UI style refresh with Gemini's help

This commit is contained in:
Jim Lancaster
2026-08-03 13:54:24 -05:00
parent 47aef0e26e
commit 4f6e1fd913
15 changed files with 1604 additions and 857 deletions
@@ -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
+15
View File
@@ -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
@@ -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]")
@@ -57,6 +57,8 @@ def build_table(
pagination["sortBy"] = default_sort_by pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending pagination["descending"] = default_descending
# Quasar props to enforce flat, archival styling
# Styling table headers with Library Green (#2D5A4C) and rows with subtle borders
table = ( table = (
ui.table( ui.table(
rows=rows, rows=rows,
@@ -64,10 +66,30 @@ def build_table(
row_key="id", row_key="id",
pagination=pagination, pagination=pagination,
) )
.classes(classes) .classes(
.props('table-style="table-layout: fixed; width: 100%;"') 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("""
<style>
.q-table tbody tr:hover {
background-color: #FAF9F6 !important;
cursor: pointer;
}
.q-table td {
border-bottom: 1px solid rgba(107, 106, 101, 0.2) !important;
}
</style>
""")
logger.info("Table built with %d rows and %d columns", len(rows), len(columns)) logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None: if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id) _bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table return table
@@ -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}"),
)
@@ -11,6 +11,7 @@ from uuid import UUID
from nicegui import ui from nicegui import ui
from transcription.ui.components.cards import archival_card
from .common import build_table from .common import build_table
@@ -40,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [ return [
{ {
"id": str(row.id), "id": str(row.id),
"status": row.status, "status": row.status.upper(),
"filename": row.filename, "filename": row.filename,
"retry_count": row.retry_count, "retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created), "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: def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row.""" """Render jobs table and open a detail page when clicking a row."""
if not rows: 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 return
build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True}, {"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
{"name": "status", "label": "Status", "field": "status", "sortable": True}, {"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold text-[#2D5A4C]"},
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True}, {"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True}, {"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
{"name": "date_updated", "label": "Updated", "field": "date_updated", "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, default_descending=True,
classes="app-table w-full", classes="app-table w-full",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
) )
@@ -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}"),
)
@@ -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}"),
)
@@ -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]")
+113
View File
@@ -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")
+426 -329
View File
@@ -17,7 +17,15 @@ from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.ui.components.app_shell import render_navigation_header 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.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 from ...db.session import SessionFactoryDep
@@ -29,267 +37,315 @@ def register_page() -> None:
@ui.page("/documents/new") @ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None: async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
ui.label("Create document").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
ui.label("Document name is required.").classes("text-body2 vibe-text-muted") page_header("Create Document", subtitle="Document name is required.")
name_input = ui.input(label="Document name").props("outlined") with archival_card(extra_classes="gap-3"):
document_type_input = ui.input(label="Document type").props("outlined") name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"') document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
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}
)
def on_author_change(event) -> None: with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
selected = str(event.value or "").strip() date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
if selected == CREATE_NEW_PERSON_OPTION: date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
ui.navigate.to("/people/new")
author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props( location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
"outlined" 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")
ui.link("Create new person", "/people/new").classes("text-body2")
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: def on_author_change(event) -> None:
candidate_name = (name_input.value or "").strip() selected = str(event.value or "").strip()
if not candidate_name: if selected == CREATE_NEW_PERSON_OPTION:
ui.notify("Document name is required.", type="warning") ui.navigate.to("/people/new")
return
parsed_date: date | None = None author_select = (
candidate_date_text = (date_input.value or "").strip() ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
if candidate_date_text: .props("outlined bg-white")
try: .classes("w-full")
parsed_date = date.fromisoformat(candidate_date_text) )
except ValueError: ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
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 return
candidate = Document( parsed_date: date | None = None
name=candidate_name, candidate_date_text = (date_input.value or "").strip()
document_type=(document_type_input.value or "").strip() or None, if candidate_date_text:
document_date=parsed_date, try:
document_date_raw=(date_raw_input.value or "").strip() or None, parsed_date = date.fromisoformat(candidate_date_text)
location_created=(location_input.value or "").strip() or None, except ValueError:
notes=(notes_input.value or "").strip() or None, ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
archive_identifier=(archive_input.value or "").strip() or None, return
)
try: candidate = Document(
created = await document_service.create_document(candidate) name=candidate_name,
except Exception as exc: # noqa: BLE001 document_type=(document_type_input.value or "").strip() or None,
show_error(exc, title="Create failed", operation="documents.create") document_date=parsed_date,
return document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
selected_author = (author_select.value or "").strip() notes=(notes_input.value or "").strip() or None,
if selected_author == CREATE_NEW_PERSON_OPTION: archive_identifier=(archive_input.value or "").strip() or None,
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
try: try:
await document_service.create_document_person( created = await document_service.create_document(candidate)
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001 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 return
ui.notify("Document created", type="positive") selected_author = (author_select.value or "").strip()
if return_to == "jobs_new": if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to(f"/jobs/new?document_id={created.id}") ui.navigate.to("/people/new")
return return
ui.navigate.to(f"/documents/{created.id}") 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"): try:
ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"') await document_service.create_document_person(
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back") 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") @ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None: async def documents_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
with ui.row().classes("w-full items-center justify-between"): with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
ui.label("Documents").classes("text-h5 text-weight-medium") with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props( page_header("Archival Documents")
'unelevated color="primary"' ui.button(
) "Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("bg-[#2D5A4C] text-white")
try: try:
documents = sorted( documents = sorted(
await document_service.list_documents(), await document_service.list_documents(),
key=lambda item: item.created_at, key=lambda item: item.created_at,
reverse=True, reverse=True,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.list") show_error(exc, title="Load failed", operation="documents.list")
return return
if not documents: # Format documents into read-model rows for the table renderer
ui.label("No documents yet.").classes("text-body1 vibe-text-muted") rows = [
ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props( DocumentTableRow(
'unelevated color="primary"' id=doc.id,
) name=doc.name,
return document_type=doc.document_type or "",
archive_identifier=doc.archive_identifier or "",
with ui.column().classes("w-full gap-2"): created_at=doc.created_at.strftime("%b %d, %Y"),
for document in documents: )
with ui.card().classes("w-full"): for doc in documents
with ui.row().classes("w-full items-center justify-between"): ]
with ui.column().classes("gap-1"): render_documents_table(rows)
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")
@ui.page("/documents/{document_id}") @ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: try:
parsed_document_id = UUID(document_id) parsed_document_id = UUID(document_id)
except ValueError: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read") show_error(exc, title="Load failed", operation="documents.read")
return 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( 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, 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"): # Main Bento Grid Wrapper
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props( with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
'unelevated color="primary"' # Header Bar
) with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
ui.button( page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
"Delete document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("w-full gap-1"): with ui.row().classes("items-center gap-2"):
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}") ui.button(
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}") "Edit Document",
ui.label(f"Location created: {document.location_created or 'not set'}") on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}") icon="edit",
ui.label(f"Notes: {document.notes or 'not set'}") ).classes("bg-[#2D5A4C] text-white text-xs")
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2") ui.button(
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2") "Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
).props("outlined color=negative text-xs")
ui.separator() # High-Density Bento Grid Layout
ui.label("Related people").classes("text-subtitle1 text-weight-medium") with ui.grid().classes("w-full grid-cols-12 gap-4"):
if not document.document_people: # ZONE 1: Source Image Viewer (Cols 1-5)
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted") with ui.column().classes("col-span-12 lg:col-span-5"):
else: source_path = document.sources[0].file_path if document.sources else None
for link in document.document_people: dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
person = link.person with ui.row().classes("w-full justify-between items-center mt-2"):
person_label = person.full_name if person is not None else "Unknown person" ui.button(
ui.label(f"{person_label} ({link.role.value})").classes("text-body2") "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() # ZONE 2: Metadata & Archival Attributes (Cols 6-8)
ui.label("Sources").classes("text-subtitle1 text-weight-medium") with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with ui.row().classes("w-full items-center gap-2"): with archival_card(title="Archival Metadata"):
ui.button( metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
"Sources", metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), metadata_row("Approx. Date:", document.document_date_raw or "Not set")
icon="description", metadata_row("Location Created:", document.location_created or "Not set")
).props("flat") metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
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")
ui.separator() with ui.column().classes("w-full mt-2"):
ui.label("Jobs").classes("text-subtitle1 text-weight-medium") ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1")
with ui.row().classes("w-full items-center gap-2"): ui.label(document.notes or "No notes added.").classes(
ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props( "p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]"
"flat" )
)
ui.button( with archival_card(title="System Logistics"):
"+ Add Job", ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
icon="add",
).props('unelevated color="primary"') # ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2") 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") @ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: try:
parsed_document_id = UUID(document_id) parsed_document_id = UUID(document_id)
except ValueError: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs") show_error(exc, title="Load failed", operation="documents.jobs")
return return
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center gap-2"): with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") page_header(f"Jobs for {document.name}")
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props( with ui.row().classes("gap-2"):
'unelevated color="primary"' 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: if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted") with archival_card(extra_classes="p-6 text-center"):
return 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): for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.card().classes("w-full"): with archival_card(extra_classes="p-3"):
with ui.row().classes("w-full items-center justify-between"): with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2") with ui.row().classes("items-center gap-2"):
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props( archival_badge(job.status.value)
"flat" 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") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: 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") @ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: try:
parsed_document_id = UUID(document_id) parsed_document_id = UUID(document_id)
except ValueError: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read") show_error(exc, title="Load failed", operation="documents.edit.read")
return return
ui.label("Edit document").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted") 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") with archival_card(extra_classes="gap-3"):
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined") name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
date_input = ui.input( document_type_input = (
label="Exact date (YYYY-MM-DD)", ui.input(label="Document type", value=document.document_type or "")
value=document.document_date.isoformat() if document.document_date else "", .props("outlined bg-white")
).props("outlined") .classes("w-full")
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 ""
def on_author_change(event) -> None: with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
selected = str(event.value or "").strip() date_input = ui.input(
if selected == CREATE_NEW_PERSON_OPTION: label="Exact date (YYYY-MM-DD)",
ui.navigate.to("/people/new") 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( location_input = (
author_options, ui.input(label="Document location", value=document.location_created or "")
label="Author (Person)", .props("outlined bg-white")
value=author_value, .classes("w-full")
on_change=on_author_change, )
).props("outlined") archive_input = (
ui.link("Create new person", "/people/new").classes("text-body2") 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: people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
candidate_name = (name_input.value or "").strip() author_options = (
candidate_type = (document_type_input.value or "").strip() {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
if not candidate_name: | {str(person.id): person.full_name for person in people}
ui.notify("Document name is required.", type="warning") )
return existing_author = next(
if not candidate_type: (item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
ui.notify("Document type is required.", type="warning") None,
return )
author_value = str(existing_author.person_id) if existing_author is not None else ""
parsed_date: date | None = None def on_author_change(event) -> None:
candidate_date_text = (date_input.value or "").strip() selected = str(event.value or "").strip()
if candidate_date_text: if selected == CREATE_NEW_PERSON_OPTION:
try: ui.navigate.to("/people/new")
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError: author_select = (
ui.notify("Exact date must use YYYY-MM-DD.", type="warning") 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 return
candidate = Document( parsed_date: date | None = None
id=document.id, candidate_date_text = (date_input.value or "").strip()
name=candidate_name, if candidate_date_text:
document_type=candidate_type, try:
document_date=parsed_date, parsed_date = date.fromisoformat(candidate_date_text)
document_date_raw=(date_raw_input.value or "").strip() or None, except ValueError:
location_created=(location_input.value or "").strip() or None, ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
notes=(notes_input.value or "").strip() or None, return
archive_identifier=(archive_input.value or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
)
try: candidate = Document(
await document_service.update_document(candidate) id=document.id,
except Exception as exc: # noqa: BLE001 name=candidate_name,
show_error(exc, title="Save failed", operation="documents.edit.save") document_type=candidate_type,
return 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() try:
if selected_author == CREATE_NEW_PERSON_OPTION: await document_service.update_document(candidate)
ui.navigate.to("/people/new") except Exception as exc: # noqa: BLE001
return show_error(exc, title="Save failed", operation="documents.edit.save")
existing_author_links = [ return
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
] selected_author = (author_select.value or "").strip()
try: if selected_author == CREATE_NEW_PERSON_OPTION:
if not selected_author: ui.navigate.to("/people/new")
for link in existing_author_links: return
await document_service.delete_document_person(link) existing_author_links = [
else: link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
selected_author_id = UUID(selected_author) ]
if not any(link.person_id == selected_author_id for link in existing_author_links): try:
if not selected_author:
for link in existing_author_links: for link in existing_author_links:
await document_service.delete_document_person(link) await document_service.delete_document_person(link)
await document_service.create_document_person( else:
DocumentPerson( selected_author_id = UUID(selected_author)
document_id=document.id, if not any(link.person_id == selected_author_id for link in existing_author_links):
person_id=selected_author_id, for link in existing_author_links:
role=DocumentPersonRole.AUTHOR, 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
except Exception as exc: # noqa: BLE001 show_error(exc, title="Author update failed", operation="documents.edit.link_author")
show_error(exc, title="Author update failed", operation="documents.edit.link_author") return
return
ui.notify("Document updated", type="positive") ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}") ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"') 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") ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@ui.page("/documents/{document_id}/delete") @ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: try:
parsed_document_id = UUID(document_id) parsed_document_id = UUID(document_id)
except ValueError: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read") show_error(exc, title="Load failed", operation="documents.delete.read")
return return
ui.label("Delete document").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
ui.label(f"Document: {document.name}").classes("text-subtitle1") page_header("Delete Document")
has_sources = bool(document.sources) with archival_card(extra_classes="gap-2"):
has_jobs = bool(document.jobs) ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]")
if has_sources or has_jobs: has_sources = bool(document.sources)
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium") has_jobs = bool(document.jobs)
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")
with ui.row().classes("w-full items-center gap-2"): if has_sources or has_jobs:
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history") categories: list[str] = []
return 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") with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
async def submit_delete() -> None: "Back to Document",
try: on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
await document_service.delete_document(document) icon="arrow_back",
except DocumentDeleteBlockedError as exc: ).classes("bg-[#2D5A4C] text-white text-xs")
ui.notify(exc.message, type="warning") ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
ui.navigate.to(f"/documents/{document.id}/delete") "flat text-xs"
return )
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Document not found.", type="warning")
ui.navigate.to("/documents")
return 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.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2"): async def submit_delete() -> None:
ui.button( try:
"Delete document permanently", await document_service.delete_document(document)
on_click=submit_delete, except DocumentDeleteBlockedError as exc:
icon="delete_forever", ui.notify(exc.message, type="warning")
).props('unelevated color="negative"') ui.navigate.to(f"/documents/{document.id}/delete")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") 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"
)
+234 -201
View File
@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.db.models import JobStatus 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.jobs import JobService
from transcription.services.store import create_job_for_document from transcription.services.store import create_job_for_document
from transcription.ui.components.app_shell import render_navigation_header 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.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table 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 transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -28,257 +33,285 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs") @ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None: async def jobs_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
@ui.refreshable with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
async def render_table() -> None: with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
jobs = [ page_header("Transcription Pipeline Jobs")
JobTableRow( with ui.row().classes("items-center gap-2"):
id=job.id, ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
status=job.status.value, "bg-[#2D5A4C] text-white"
filename=job.filename, )
retry_count=job.retry_count, ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
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.row().classes("w-full items-center gap-2"): @ui.refreshable
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props( async def render_table() -> None:
'unelevated color="primary"' jobs = [
) JobTableRow(
ui.button("Refresh", on_click=render_table.refresh, icon="refresh") id=job.id,
await render_table() 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") @ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None: async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
documents_service = DocumentService(session_factory=session_factory) documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
ui.label("Create job").classes("text-h5 text-weight-medium")
documents = await documents_service.list_documents() with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
if not documents: page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
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
uploaded_files: list[tuple[str, bytes]] = [] documents = await documents_service.list_documents()
if not documents:
document_options = {str(document.id): document.name for document in documents} with archival_card(extra_classes="p-6 text-center"):
document_select = ui.select(document_options, label="Document").props("outlined") ui.label("No documents available. Create a Document before creating a Job.").classes(
requested_document_id = request.query_params.get("document_id") "text-xs text-red-800 font-medium mb-4"
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,
) )
except Exception as exc: # noqa: BLE001 with ui.row().classes("justify-center gap-2"):
show_error(exc, title="Create job failed", operation="jobs.create") 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 return
resolve_worker_notifier(request.app.state).notify() uploaded_files: list[tuple[str, bytes]] = []
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"): with archival_card(extra_classes="gap-3"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props( document_options = {str(document.id): document.name for document in documents}
'unelevated color="primary"' document_select = (
) ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back") )
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}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: try:
parsed_job_id = UUID(job_id) parsed_job_id = UUID(job_id)
except ValueError: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
with ui.column().classes("w-full gap-3"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between"): with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
ui.button(icon="arrow_back", on_click=ui.navigate.back) page_header(f"Job Record: {job.id}")
match job.status: with ui.row().classes("items-center gap-2"):
case JobStatus.TRANSCRIBED: archival_badge(job.status.value.upper())
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline") ui.button(
case _: "Delete Job",
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") 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"): with archival_card(title="Document Links"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2") ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2") with ui.column().classes("w-full gap-2"):
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2") ui.button(
ui.label(f"Retry count: {job.retry_count}").classes("text-body2") "View Linked Document",
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2") on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
ui.separator() ).classes("bg-[#2D5A4C] text-white text-xs w-full")
ui.label("Document Links").classes("text-subtitle1 text-weight-medium") ui.button(
with ui.row().classes("w-full items-center gap-2"): "View Linked Sources",
ui.button( on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
"Document", icon="description",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"), ).props("flat text-xs").classes("text-[#2D5A4C] w-full")
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")
@ui.page("/jobs/{job_id}/delete") @ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None: async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: try:
parsed_job_id = UUID(job_id) parsed_job_id = UUID(job_id)
except ValueError: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
ui.label("Delete job").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
ui.label(f"Job: {job.id}").classes("text-subtitle1") page_header("Delete Processing Job")
if job.status == JobStatus.PROCESSING: with archival_card(extra_classes="gap-2"):
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium") ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]")
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
ui.label("This action permanently deletes the job.").classes("text-negative") if job.status == JobStatus.PROCESSING:
if job.job_sources: ui.label("Delete is blocked while the job is processing.").classes(
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2") "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: ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
try: if job.job_sources:
await jobs_service.delete_job_with_guardrails(job_id=job.id) ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs text-[#6B6A65]")
except JobDeleteBlockedError as exc:
ui.notify(exc.message, type="warning") async def submit_delete() -> None:
return try:
except ValueError: await jobs_service.delete_job_with_guardrails(job_id=job.id)
ui.notify("Job not found.", type="warning") 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") 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")
+289 -241
View File
@@ -9,17 +9,23 @@ from uuid import UUID
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.config import Settings from transcription.config import Settings, get_settings
from transcription.config import get_settings
from transcription.db.models import Person from transcription.db.models import Person
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError from transcription.services.documents import (
from transcription.services.documents import DocumentService DocumentError,
from transcription.services.documents import PersonDeleteBlockedError DocumentService,
from transcription.services.store import UploadError PersonDeleteBlockedError,
from transcription.services.store import store_person_portrait )
from transcription.services.store import UploadError, store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header 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.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 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, on_upload=on_portrait_selected,
auto_upload=True, auto_upload=True,
label="Choose portrait file", label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"') ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted") ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
def _resolve_portrait_src(path: str | None) -> str | None: def _resolve_portrait_src(path: str | None) -> str | None:
@@ -95,331 +101,373 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people") @ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None: async def people_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
with ui.row().classes("w-full items-center justify-between"): with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
ui.label("People").classes("text-h5 text-weight-medium") with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props( page_header("Archival Entities: People")
'unelevated color="primary"' ui.button(
) "Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("bg-[#2D5A4C] text-white")
try: try:
people = sorted( people = sorted(
await people_service.list_people(), await people_service.list_people(),
key=lambda item: item.created_at, key=lambda item: item.created_at,
reverse=True, reverse=True,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.list") show_error(exc, title="Load failed", operation="people.list")
return return
if not people: # Format person records into read-model rows for the table renderer
ui.label("No people yet.").classes("text-body1 vibe-text-muted") rows = [
return PersonTableRow(
id=person.id,
with ui.column().classes("w-full gap-2"): full_name=person.full_name,
for person in people: display_name=person.display_name or "",
with ui.card().classes("w-full"): maiden_name=person.maiden_name or "",
with ui.row().classes("w-full items-center justify-between"): birth_date=person.birth_date.isoformat() if person.birth_date else "",
with ui.column().classes("gap-1"): )
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium") for person in people
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2") ]
ui.button( render_people_table(rows)
"Open",
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/people/new") @ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None: async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
ui.label("Create person").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
ui.label("Full name is required.").classes("text-body2 vibe-text-muted") page_header("Create Person Record", subtitle="Full name is required.")
full_name_input = ui.input(label="Full name").props("outlined") with archival_card(extra_classes="gap-3"):
display_name_input = ui.input(label="Display name").props("outlined") with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
maiden_name_input = ui.input(label="Maiden name").props("outlined") 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"') with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined") birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_place_input = ui.input(label="Birth place").props("outlined") 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"') with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined") death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_place_input = ui.input(label="Death place").props("outlined") 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") biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined") 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)) _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_create() -> None: async def submit_create() -> None:
full_name = (full_name_input.value or "").strip() full_name = (full_name_input.value or "").strip()
if not full_name: if not full_name:
ui.notify("Full name is required.", type="warning") ui.notify("Full name is required.", type="warning")
return return
try: try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date") death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc: except ValueError as exc:
ui.notify(str(exc), type="warning") ui.notify(str(exc), type="warning")
return return
candidate = Person( candidate = Person(
full_name=full_name, full_name=full_name,
display_name=(display_name_input.value or "").strip() or None, display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None, maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date, birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None, birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None, birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date, death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None, death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None, death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None, biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None, portrait_path=(portrait_path_input.value or "").strip() or None,
) )
try: try:
created = await people_service.create_person(candidate) created = await people_service.create_person(candidate)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="people.create") show_error(exc, title="Create failed", operation="people.create")
return return
ui.notify("Person created", type="positive") ui.notify("Person created", type="positive")
ui.navigate.to(f"/people/{created.id}") ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"') 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") ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}") @ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None: async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: try:
parsed_person_id = UUID(person_id) parsed_person_id = UUID(person_id)
except ValueError: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read") show_error(exc, title="Load failed", operation="people.read")
return 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"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props( with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
'unelevated color="primary"' page_header(person.full_name, subtitle=f"Person ID: {person.id}")
)
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 gap-1"): with ui.row().classes("items-center gap-2"):
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")
ui.button( ui.button(
"Open", "Edit Person",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"), on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="open_in_new", icon="edit",
).props("flat") ).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") @ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: try:
parsed_person_id = UUID(person_id) parsed_person_id = UUID(person_id)
except ValueError: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read") show_error(exc, title="Load failed", operation="people.edit.read")
return return
ui.label("Edit person").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
ui.label("Full name is required.").classes("text-body2 vibe-text-muted") page_header("Edit Person Record", subtitle="Full name is required.")
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined") with archival_card(extra_classes="gap-3"):
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined") with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined") 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( with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
label="Birth date (YYYY-MM-DD)", birth_date_input = ui.input(
value=person.birth_date.isoformat() if person.birth_date else "", label="Birth date (YYYY-MM-DD)",
).props('outlined type="date"') value=person.birth_date.isoformat() if person.birth_date else "",
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props( ).props('outlined bg-white type="date"')
"outlined" birth_date_raw_input = ui.input(
) label="Birth date (approximate)", value=person.birth_date_raw or ""
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined") ).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( with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
label="Death date (YYYY-MM-DD)", death_date_input = ui.input(
value=person.death_date.isoformat() if person.death_date else "", label="Death date (YYYY-MM-DD)",
).props('outlined type="date"') value=person.death_date.isoformat() if person.death_date else "",
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props( ).props('outlined bg-white type="date"')
"outlined" death_date_raw_input = ui.input(
) label="Death date (approximate)", value=person.death_date_raw or ""
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined") ).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") biography_input = (
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined") ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request)) )
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: async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip() full_name = (full_name_input.value or "").strip()
if not full_name: if not full_name:
ui.notify("Full name is required.", type="warning") ui.notify("Full name is required.", type="warning")
return return
try: try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date") death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc: except ValueError as exc:
ui.notify(str(exc), type="warning") ui.notify(str(exc), type="warning")
return return
candidate = Person( candidate = Person(
id=person.id, id=person.id,
full_name=full_name, full_name=full_name,
display_name=(display_name_input.value or "").strip() or None, display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None, maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date, birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None, birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None, birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date, death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None, death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None, death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None, biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None, portrait_path=(portrait_path_input.value or "").strip() or None,
metadata_=person.metadata_, metadata_=person.metadata_,
created_at=person.created_at, created_at=person.created_at,
updated_at=person.updated_at, updated_at=person.updated_at,
) )
try: try:
await people_service.update_person(candidate) await people_service.update_person(candidate)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="people.edit.save") show_error(exc, title="Save failed", operation="people.edit.save")
return return
ui.notify("Person updated", type="positive") ui.notify("Person updated", type="positive")
ui.navigate.to(f"/people/{person.id}") ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"') 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") ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete") @ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None: async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: try:
parsed_person_id = UUID(person_id) parsed_person_id = UUID(person_id)
except ValueError: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read") show_error(exc, title="Load failed", operation="people.delete.read")
return return
ui.label("Delete person").classes("text-h5 text-weight-medium") with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
ui.label(f"Person: {person.full_name}").classes("text-subtitle1") page_header("Delete Person Record")
if person.document_people: with archival_card(extra_classes="gap-2"):
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium") ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
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
ui.label("This action permanently deletes the person.").classes("text-negative") if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
async def submit_delete() -> None: ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
try: ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
await people_service.delete_person(person) with ui.row().classes("w-full items-center gap-2 mt-4"):
except PersonDeleteBlockedError as exc: ui.button(
ui.notify(exc.message, type="warning") "Back to Person",
ui.navigate.to(f"/people/{person.id}/delete") on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
return icon="arrow_back",
except DocumentError as exc: ).classes("bg-[#2D5A4C] text-white text-xs")
if exc.category == ErrorCategory.NOT_FOUND: ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
ui.notify("Person not found.", type="warning") "flat text-xs"
ui.navigate.to("/people") )
return 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.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2"): async def submit_delete() -> None:
ui.button( try:
"Delete person permanently", await people_service.delete_person(person)
on_click=submit_delete, except PersonDeleteBlockedError as exc:
icon="delete_forever", ui.notify(exc.message, type="warning")
).props('unelevated color="negative"') ui.navigate.to(f"/people/{person.id}/delete")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back") 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")
+102 -77
View File
@@ -2,23 +2,28 @@
from __future__ import annotations from __future__ import annotations
from uuid import UUID
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import UUID
from fastapi import Request from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from transcription.db.models import JobSource from transcription.db.models import JobSource, Source
from transcription.db.models import Source from transcription.services.documents import DocumentError, DocumentService
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService from transcription.services.transcription import (
from transcription.services.transcription import TranscriptionNotFoundError TranscriptionNotFoundError,
TranscriptionService,
)
from transcription.ui.components.app_shell import render_navigation_header 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.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error 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 from ...db.session import SessionFactoryDep
@@ -28,6 +33,7 @@ def register_page() -> None:
@ui.page("/sources") @ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None: async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory) sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory) documents_service = DocumentService(session_factory=session_factory)
@@ -60,115 +66,134 @@ def register_page() -> None:
else: else:
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number))) sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
except DocumentError: 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 return
except ValueError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list") show_error(exc, title="Load failed", operation="sources.list")
return return
if document_name is not None: with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium") with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
elif job_label is not None: if document_name is not None:
ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium") header_title = f"Sources: {document_name}"
else: elif job_label is not None:
ui.label("Sources").classes("text-h5 text-weight-medium") header_title = f"Sources for Job {job_label}"
else:
header_title = "Archival Source Media"
with ui.row().classes("w-full items-center gap-2"): page_header(header_title)
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")
if not sources: if back_path is not None:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted") back_label = "Back to Document" if document_id is not None else "Back to Job"
return ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"bg-[#2D5A4C] text-white text-xs"
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),
) )
card.classes("cursor-pointer")
with ui.row().classes("w-full items-center justify-between"): # Format source records into read-model rows for the table renderer
with ui.column().classes("gap-1"): rows = [
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium") SourceTableRow(
ui.label(f"Stored filename: {source.filename}").classes("text-body2") id=source.id,
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted") page_number=source.page_number,
ui.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props( upload_name=source.upload_name,
"flat" filename=source.filename,
) document_id=source.document_id,
)
for source in sources
]
render_sources_table(rows)
@ui.page("/sources/{source_id}") @ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory) sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
try: try:
parsed_source_id = UUID(source_id) parsed_source_id = UUID(source_id)
except ValueError: 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 return
try: try:
source = await sources_service.read_source_detail(source_id=parsed_source_id) source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read") show_error(exc, title="Load failed", operation="sources.read")
return return
back_path = _back_path_from_query(request.query_params) 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"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
if back_path is not None: with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
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" page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
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 gap-3"): if back_path is not None:
render_document_panzoom(source=source) 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"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
ui.label(f"Page number: {source.page_number}").classes("text-body2") with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
ui.label(f"Upload name: {source.upload_name}").classes("text-body2") with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
ui.label(f"Stored filename: {source.filename}").classes("text-body2") render_document_panzoom(source=source)
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")
ui.separator() with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
ui.label("Transcription text").classes("text-subtitle1 text-weight-medium") with archival_card(title="Source Metadata"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full") 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") with archival_card(title="Automated Raw Transcription"):
revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow") ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
revision_input.classes("w-full") "w-full text-xs font-mono"
)
async def save_revision() -> None: with archival_card(title="Curated Human Transcription"):
candidate = (revision_input.value or "").strip() revision_input = (
if not candidate: ui.textarea(value=source.revised_text or "")
ui.notify("Revision text is required.", type="warning") .props("outlined autogrow bg-white")
return .classes("w-full text-xs")
)
try: async def save_revision() -> None:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate) candidate = (revision_input.value or "").strip()
except Exception as exc: # noqa: BLE001 if not candidate:
show_error(exc, title="Save failed", operation="sources.save_revision") ui.notify("Revision text is required.", type="warning")
return return
ui.notify("Revision saved", type="positive") try:
ui.navigate.to(request.url.path + _back_query(request.query_params)) 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.notify("Revision saved", type="positive")
ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"') 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") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse: async def document_sources_page(document_id: str) -> RedirectResponse:
+35
View File
@@ -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]")