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["descending"] = default_descending
# Quasar props to enforce flat, archival styling
# Styling table headers with Library Green (#2D5A4C) and rows with subtle borders
table = (
ui.table(
rows=rows,
@@ -64,9 +66,29 @@ def build_table(
row_key="id",
pagination=pagination,
)
.classes(classes)
.props('table-style="table-layout: fixed; width: 100%;"')
.classes(
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm {classes}"
)
.props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
'header-cell-class="bg-[#2D5A4C] text-white font-bold text-xs uppercase tracking-wider" '
'table-class="text-xs text-[#333333]"'
)
)
# Custom CSS rules for row hover effects matching Archival Cream
ui.add_head_html("""
<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))
if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
@@ -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 transcription.ui.components.cards import archival_card
from .common import build_table
@@ -40,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"status": row.status,
"status": row.status.upper(),
"filename": row.filename,
"retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created),
@@ -55,15 +56,16 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row."""
if not rows:
ui.label("No jobs yet.")
with archival_card(extra_classes="p-8 text-center"):
ui.label("No active or historical processing jobs found.").classes("text-xs text-[#6B6A65]")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
{"name": "status", "label": "Status", "field": "status", "sortable": True},
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold text-[#2D5A4C]"},
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
@@ -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")
+227 -130
View File
@@ -17,7 +17,15 @@ from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.components.typography import page_header
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from ...db.session import SessionFactoryDep
@@ -29,19 +37,25 @@ def register_page() -> None:
@ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Create document").classes("text-h5 text-weight-medium")
ui.label("Document name is required.").classes("text-body2 vibe-text-muted")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
name_input = ui.input(label="Document name").props("outlined")
document_type_input = ui.input(label="Document type").props("outlined")
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined")
location_input = ui.input(label="Document location").props("outlined")
archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
@@ -53,10 +67,12 @@ def register_page() -> None:
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props(
"outlined"
author_select = (
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-body2")
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
return_to = request.query_params.get("return_to")
@@ -120,20 +136,24 @@ def register_page() -> None:
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save document", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.row().classes("w-full items-center justify-between"):
ui.label("Documents").classes("text-h5 text-weight-medium")
ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
)
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
page_header("Archival Documents")
ui.button(
"Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("bg-[#2D5A4C] text-white")
try:
documents = sorted(
@@ -145,151 +165,187 @@ def register_page() -> None:
show_error(exc, title="Load failed", operation="documents.list")
return
if not documents:
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
# Format documents into read-model rows for the table renderer
rows = [
DocumentTableRow(
id=doc.id,
name=doc.name,
document_type=doc.document_type or "",
archive_identifier=doc.archive_identifier or "",
created_at=doc.created_at.strftime("%b %d, %Y"),
)
return
with ui.column().classes("w-full gap-2"):
for document in documents:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
icon="open_in_new",
).props("flat")
for doc in documents
]
render_documents_table(rows)
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
ui.label(document.name).classes("text-h5 text-weight-medium")
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
author_link = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None),
(
item
for item in document.document_people
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
),
None,
)
ui.label(f"Author: {author_link.person.full_name if author_link and author_link.person is not None else 'not set'}").classes(
"text-body2"
)
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Delete document",
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button(
"Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
).props("outline color=negative")
).props("outlined color=negative text-xs")
with ui.column().classes("w-full gap-1"):
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
ui.label(f"Location created: {document.location_created or 'not set'}")
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
ui.label(f"Notes: {document.notes or 'not set'}")
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
if not document.document_people:
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
else:
for link in document.document_people:
person = link.person
person_label = person.full_name if person is not None else "Unknown person"
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
ui.separator()
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
# High-Density Bento Grid Layout
with ui.grid().classes("w-full grid-cols-12 gap-4"):
# ZONE 1: Source Image Viewer (Cols 1-5)
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"Sources",
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat")
).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",
).props('unelevated color="primary"')
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
).classes("bg-[#2D5A4C] text-white text-xs")
ui.separator()
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
"flat"
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1")
ui.label(document.notes or "No notes added.").classes(
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]"
)
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
ui.label("No linked people yet.").classes("text-xs text-[#6B6A65] italic")
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes(
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
):
ui.label(person_label).classes("text-xs font-semibold text-[#333333]")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs text-[#2D5A4C] font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("text-[#2D5A4C]")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2")
).classes("bg-[#2D5A4C] text-white text-xs")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
return
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
'unelevated color="primary"'
)
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).props("flat")
ui.button(
"Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("bg-[#2D5A4C] text-white")
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
with archival_card(extra_classes="p-6 text-center"):
ui.label("No transcription processing jobs created yet.").classes("text-xs text-[#6B6A65]")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.card().classes("w-full"):
with archival_card(extra_classes="p-3"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
"flat"
)
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono text-[#333333]")
ui.button(
"Open Job",
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new",
).props("flat dense").classes("text-xs text-[#2D5A4C]")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -298,37 +354,60 @@ def register_page() -> None:
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
ui.label("Edit document").classes("text-h5 text-weight-medium")
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Document Record", subtitle="Document name and document type are required.")
name_input = ui.input(label="Document name", value=document.name).props("outlined")
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
document_type_input = (
ui.input(label="Document type", value=document.document_type or "")
.props("outlined bg-white")
.classes("w-full")
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props("outlined")
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")
).props('outlined bg-white type="date"')
date_raw_input = (
ui.input(label="Approximate date", value=document.document_date_raw or "")
.props("outlined bg-white")
)
location_input = (
ui.input(label="Document location", value=document.location_created or "")
.props("outlined bg-white")
.classes("w-full")
)
archive_input = (
ui.input(label="Archive identifier", value=document.archive_identifier or "")
.props("outlined bg-white")
.classes("w-full")
)
notes_input = (
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
)
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"}
@@ -345,13 +424,17 @@ def register_page() -> None:
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(
author_select = (
ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
).props("outlined")
ui.link("Create new person", "/people/new").classes("text-body2")
)
.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()
@@ -421,52 +504,64 @@ def register_page() -> None:
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
ui.label("Delete document").classes("text-h5 text-weight-medium")
ui.label(f"Document: {document.name}").classes("text-subtitle1")
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Document")
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
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")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the document.").classes("text-negative")
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
@@ -489,10 +584,12 @@ def register_page() -> None:
ui.notify("Document deleted", type="positive")
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2"):
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("unelevated color=negative")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
+109 -76
View File
@@ -2,10 +2,10 @@
from __future__ import annotations
from fastapi import Request
from pathlib import Path
from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import JobStatus
@@ -15,8 +15,13 @@ from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.store import create_job_for_document
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.ui.components.typography import page_header
from transcription.ui.theme import apply_archival_theme
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
@@ -28,9 +33,19 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
page_header("Transcription Pipeline Jobs")
with ui.row().classes("items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
"bg-[#2D5A4C] text-white"
)
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
@ui.refreshable
async def render_table() -> None:
jobs = [
@@ -46,53 +61,57 @@ def register_page() -> None: # noqa: PLR0915
]
render_jobs_table(jobs)
with ui.row().classes("w-full items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
'unelevated color="primary"'
)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
await render_table()
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
ui.label("Create job").classes("text-h5 text-weight-medium")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
documents = await documents_service.list_documents()
if not documents:
with archival_card(extra_classes="p-6 text-center"):
ui.label("No documents available. Create a Document before creating a Job.").classes(
"text-body1 text-warning"
"text-xs text-red-800 font-medium mb-4"
)
with ui.row():
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).props('unelevated color="primary"')
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
).classes("bg-[#2D5A4C] text-white")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
return
uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"):
document_options = {str(document.id): document.name for document in documents}
document_select = ui.select(document_options, label="Document").props("outlined")
document_select = (
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
)
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
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")
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-body2 vibe-text-muted")
).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-body2 vibe-text-muted")
ui.label("No files uploaded yet.").classes("text-xs text-[#6B6A65] italic")
return
def remove_file(index: int) -> None:
@@ -111,14 +130,20 @@ def register_page() -> None: # noqa: PLR0915
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1"):
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"):
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 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"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
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()
@@ -130,7 +155,7 @@ def register_page() -> None: # noqa: PLR0915
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')
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
@@ -167,97 +192,106 @@ def register_page() -> None: # noqa: PLR0915
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"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
'unelevated color="primary"'
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")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-negative")
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full gap-3"):
with ui.row().classes("w-full items-center justify-between"):
ui.button(icon="arrow_back", on_click=ui.navigate.back)
match job.status:
case JobStatus.TRANSCRIBED:
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Job {job.id}").classes("text-h6 text-weight-bold")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Document Links").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
ui.button(
"Document",
"Delete Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
icon="delete",
).props("outlined color=negative text-xs")
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 archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3")
with ui.column().classes("w-full gap-2"):
ui.button(
"View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).props("flat")
).classes("bg-[#2D5A4C] text-white text-xs w-full")
ui.button(
"Sources",
"View Linked 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")
).props("flat text-xs").classes("text-[#2D5A4C] w-full")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-negative")
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
ui.label("Delete job").classes("text-h5 text-weight-medium")
ui.label(f"Job: {job.id}").classes("text-subtitle1")
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs text-[#6B6A65] italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the job.").classes("text-negative")
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs text-[#6B6A65]")
async def submit_delete() -> None:
try:
@@ -276,9 +310,8 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Job deleted", type="positive")
ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2"):
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"'
"unelevated color=negative"
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
+169 -121
View File
@@ -9,17 +9,23 @@ from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.config import Settings
from transcription.config import get_settings
from transcription.config import Settings, get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.store import UploadError
from transcription.services.store import store_person_portrait
from transcription.services.documents import (
DocumentError,
DocumentService,
PersonDeleteBlockedError,
)
from transcription.services.store import UploadError, store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.typography import page_header
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from ...db.session import SessionFactoryDep
@@ -63,8 +69,8 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted")
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
def _resolve_portrait_src(path: str | None) -> str | None:
@@ -95,14 +101,18 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.row().classes("w-full items-center justify-between"):
ui.label("People").classes("text-h5 text-weight-medium")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
'unelevated color="primary"'
)
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("bg-[#2D5A4C] text-white")
try:
people = sorted(
@@ -114,45 +124,46 @@ def register_page() -> None: # noqa: PLR0915
show_error(exc, title="Load failed", operation="people.list")
return
if not people:
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for person in people:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
icon="open_in_new",
).props("flat")
# Format person records into read-model rows for the table renderer
rows = [
PersonTableRow(
id=person.id,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
)
for person in people
]
render_people_table(rows)
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
ui.label("Create person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.")
full_name_input = ui.input(label="Full name").props("outlined")
display_name_input = ui.input(label="Display name").props("outlined")
maiden_name_input = ui.input(label="Maiden name").props("outlined")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name").props("outlined bg-white")
display_name_input = ui.input(label="Display name").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
birth_place_input = ui.input(label="Birth place").props("outlined")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
death_place_input = ui.input(label="Death place").props("outlined")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
death_place_input = ui.input(label="Death place").props("outlined bg-white")
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_create() -> None:
@@ -191,130 +202,157 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Person created", type="positive")
ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
ui.label(person.full_name).classes("text-h5 text-weight-medium")
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Delete person",
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
).props("outline color=negative")
).props("outlined color=negative text-xs")
with ui.column().classes("w-full gap-1"):
ui.label(f"Full name: {person.full_name}")
ui.label(f"Display name: {person.display_name or 'not set'}")
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
ui.label(f"Birth place: {person.birth_place or 'not set'}")
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
ui.label(f"Death place: {person.death_place or 'not set'}")
ui.label(f"Biography: {person.biography or 'not set'}")
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
portrait_src = _resolve_portrait_src(person.portrait_path)
if portrait_src is not None:
ui.image(portrait_src).classes("w-40 rounded shadow")
else:
ui.label("No portrait image set.").classes("text-body2 vibe-text-muted")
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
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")
ui.separator()
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
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-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"):
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 items-center justify-between"):
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
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, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat")
).props("flat dense text-xs").classes("text-[#2D5A4C]")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
ui.label("Edit person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.")
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
"outlined"
)
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
"outlined"
)
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
biography_input = (
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
)
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_edit() -> None:
@@ -357,43 +395,53 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Person updated", type="positive")
ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
ui.label("Delete person").classes("text-h5 text-weight-medium")
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the person.").classes("text-negative")
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
@@ -416,10 +464,10 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Person deleted", type="positive")
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2"):
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("unelevated color=negative")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
+82 -57
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
from uuid import UUID
from urllib.parse import urlencode
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.db.models import JobSource, Source
from transcription.services.documents import DocumentError, DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import (
TranscriptionNotFoundError,
TranscriptionService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.components.typography import page_header
from transcription.ui.theme import apply_archival_theme
from ...db.session import SessionFactoryDep
@@ -28,6 +33,7 @@ def register_page() -> None:
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
@@ -60,97 +66,116 @@ def register_page() -> None:
else:
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
if document_name is not None:
ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium")
header_title = f"Sources: {document_name}"
elif job_label is not None:
ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium")
header_title = f"Sources for Job {job_label}"
else:
ui.label("Sources").classes("text-h5 text-weight-medium")
header_title = "Archival Source Media"
page_header(header_title)
with ui.row().classes("w-full items-center gap-2"):
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
if not sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for source in sources:
detail_path = _source_detail_path(source_id=source.id, document_id=document_id, job_id=job_id)
with ui.card().classes("w-full") as card:
card.on(
"click",
lambda _=None, route=detail_path: ui.navigate.to(route),
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"bg-[#2D5A4C] text-white text-xs"
)
card.classes("cursor-pointer")
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
ui.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props(
"flat"
# Format source records into read-model rows for the table renderer
rows = [
SourceTableRow(
id=source.id,
page_number=source.page_number,
upload_name=source.upload_name,
filename=source.filename,
document_id=source.document_id,
)
for source in sources
]
render_sources_table(rows)
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-negative")
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-negative")
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
return
back_path = _back_path_from_query(request.query_params)
ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
if back_path is not None:
back_label = "Back to Document" if "document_id" in request.query_params else "Back to Job" if "job_id" in request.query_params else "Back to Sources"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
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")
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
with ui.column().classes("w-full gap-3"):
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
render_document_panzoom(source=source)
with ui.column().classes("gap-1"):
ui.label(f"Page number: {source.page_number}").classes("text-body2")
ui.label(f"Upload name: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2")
ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2")
ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2")
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
with archival_card(title="Source Metadata"):
metadata_row("Page Number:", str(source.page_number))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Stored Filename:", source.filename)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
ui.separator()
ui.label("Transcription text").classes("text-subtitle1 text-weight-medium")
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full")
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
"w-full text-xs font-mono"
)
ui.label("Revision text").classes("text-subtitle1 text-weight-medium")
revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow")
revision_input.classes("w-full")
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "")
.props("outlined autogrow bg-white")
.classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
@@ -167,8 +192,8 @@ def register_page() -> None:
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"')
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("bg-[#2D5A4C] text-white text-xs")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
+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]")