generated from john/python-template
UI style refresh with Gemini's help
This commit is contained in:
@@ -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
|
||||
@@ -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]")
|
||||
@@ -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")
|
||||
@@ -17,7 +17,15 @@ from transcription.services.documents import DocumentDeleteBlockedError
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.data_display import archival_badge
|
||||
from transcription.ui.components.data_display import metadata_row
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.table.documents import DocumentTableRow
|
||||
from transcription.ui.components.table.documents import render_documents_table
|
||||
from transcription.ui.components.typography import page_header
|
||||
from transcription.ui.components.viewers import dark_room_viewer
|
||||
from transcription.ui.theme import apply_archival_theme
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
@@ -29,267 +37,315 @@ def register_page() -> None:
|
||||
|
||||
@ui.page("/documents/new")
|
||||
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
ui.label("Create document").classes("text-h5 text-weight-medium")
|
||||
ui.label("Document name is required.").classes("text-body2 vibe-text-muted")
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Create Document", subtitle="Document name is required.")
|
||||
|
||||
name_input = ui.input(label="Document name").props("outlined")
|
||||
document_type_input = ui.input(label="Document type").props("outlined")
|
||||
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"')
|
||||
date_raw_input = ui.input(label="Approximate date").props("outlined")
|
||||
location_input = ui.input(label="Document location").props("outlined")
|
||||
archive_input = ui.input(label="Archive identifier").props("outlined")
|
||||
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
author_options = (
|
||||
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
|
||||
| {str(person.id): person.full_name for person in people}
|
||||
)
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
|
||||
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
|
||||
|
||||
def on_author_change(event) -> None:
|
||||
selected = str(event.value or "").strip()
|
||||
if selected == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
|
||||
|
||||
author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props(
|
||||
"outlined"
|
||||
)
|
||||
ui.link("Create new person", "/people/new").classes("text-body2")
|
||||
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
|
||||
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
|
||||
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
|
||||
|
||||
return_to = request.query_params.get("return_to")
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
author_options = (
|
||||
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
|
||||
| {str(person.id): person.full_name for person in people}
|
||||
)
|
||||
|
||||
async def submit_create() -> None:
|
||||
candidate_name = (name_input.value or "").strip()
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
def on_author_change(event) -> None:
|
||||
selected = str(event.value or "").strip()
|
||||
if selected == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (date_input.value or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
author_select = (
|
||||
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
|
||||
.props("outlined bg-white")
|
||||
.classes("w-full")
|
||||
)
|
||||
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
|
||||
|
||||
return_to = request.query_params.get("return_to")
|
||||
|
||||
async def submit_create() -> None:
|
||||
candidate_name = (name_input.value or "").strip()
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
|
||||
candidate = Document(
|
||||
name=candidate_name,
|
||||
document_type=(document_type_input.value or "").strip() or None,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||
location_created=(location_input.value or "").strip() or None,
|
||||
notes=(notes_input.value or "").strip() or None,
|
||||
archive_identifier=(archive_input.value or "").strip() or None,
|
||||
)
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (date_input.value or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
created = await document_service.create_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create failed", operation="documents.create")
|
||||
return
|
||||
|
||||
selected_author = (author_select.value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
if selected_author:
|
||||
try:
|
||||
parsed_person_id = UUID(selected_author)
|
||||
except ValueError:
|
||||
ui.notify("Selected author is invalid.", type="warning")
|
||||
return
|
||||
candidate = Document(
|
||||
name=candidate_name,
|
||||
document_type=(document_type_input.value or "").strip() or None,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||
location_created=(location_input.value or "").strip() or None,
|
||||
notes=(notes_input.value or "").strip() or None,
|
||||
archive_identifier=(archive_input.value or "").strip() or None,
|
||||
)
|
||||
|
||||
try:
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=created.id,
|
||||
person_id=parsed_person_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
created = await document_service.create_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Author link failed", operation="documents.create.link_author")
|
||||
show_error(exc, title="Create failed", operation="documents.create")
|
||||
return
|
||||
|
||||
ui.notify("Document created", type="positive")
|
||||
if return_to == "jobs_new":
|
||||
ui.navigate.to(f"/jobs/new?document_id={created.id}")
|
||||
return
|
||||
ui.navigate.to(f"/documents/{created.id}")
|
||||
selected_author = (author_select.value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
if selected_author:
|
||||
try:
|
||||
parsed_person_id = UUID(selected_author)
|
||||
except ValueError:
|
||||
ui.notify("Selected author is invalid.", type="warning")
|
||||
return
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back")
|
||||
try:
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=created.id,
|
||||
person_id=parsed_person_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Author link failed", operation="documents.create.link_author")
|
||||
return
|
||||
|
||||
ui.notify("Document created", type="positive")
|
||||
if return_to == "jobs_new":
|
||||
ui.navigate.to(f"/jobs/new?document_id={created.id}")
|
||||
return
|
||||
ui.navigate.to(f"/documents/{created.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Save document", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
|
||||
|
||||
@ui.page("/documents")
|
||||
async def documents_page(session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label("Documents").classes("text-h5 text-weight-medium")
|
||||
ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
||||
page_header("Archival Documents")
|
||||
ui.button(
|
||||
"Create new document",
|
||||
on_click=lambda: ui.navigate.to("/documents/new"),
|
||||
icon="note_add",
|
||||
).classes("bg-[#2D5A4C] text-white")
|
||||
|
||||
try:
|
||||
documents = sorted(
|
||||
await document_service.list_documents(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.list")
|
||||
return
|
||||
try:
|
||||
documents = sorted(
|
||||
await document_service.list_documents(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.list")
|
||||
return
|
||||
|
||||
if not documents:
|
||||
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
|
||||
ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for document in documents:
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
# Format documents into read-model rows for the table renderer
|
||||
rows = [
|
||||
DocumentTableRow(
|
||||
id=doc.id,
|
||||
name=doc.name,
|
||||
document_type=doc.document_type or "",
|
||||
archive_identifier=doc.archive_identifier or "",
|
||||
created_at=doc.created_at.strftime("%b %d, %Y"),
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
render_documents_table(rows)
|
||||
|
||||
@ui.page("/documents/{document_id}")
|
||||
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.read")
|
||||
return
|
||||
|
||||
ui.label(document.name).classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
|
||||
author_link = next(
|
||||
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None),
|
||||
(
|
||||
item
|
||||
for item in document.document_people
|
||||
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
ui.label(f"Author: {author_link.person.full_name if author_link and author_link.person is not None else 'not set'}").classes(
|
||||
"text-body2"
|
||||
)
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button(
|
||||
"Delete document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outline color=negative")
|
||||
# Main Bento Grid Wrapper
|
||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||
# Header Bar
|
||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
||||
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
|
||||
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
|
||||
ui.label(f"Location created: {document.location_created or 'not set'}")
|
||||
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
|
||||
ui.label(f"Notes: {document.notes or 'not set'}")
|
||||
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
|
||||
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button(
|
||||
"Edit Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
||||
icon="edit",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
ui.button(
|
||||
"Delete",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outlined color=negative text-xs")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
|
||||
if not document.document_people:
|
||||
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
|
||||
else:
|
||||
for link in document.document_people:
|
||||
person = link.person
|
||||
person_label = person.full_name if person is not None else "Unknown person"
|
||||
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
|
||||
# High-Density Bento Grid Layout
|
||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||
# ZONE 1: Source Image Viewer (Cols 1-5)
|
||||
with ui.column().classes("col-span-12 lg:col-span-5"):
|
||||
source_path = document.sources[0].file_path if document.sources else None
|
||||
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
|
||||
with ui.row().classes("w-full justify-between items-center mt-2"):
|
||||
ui.button(
|
||||
"View All Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
||||
icon="description",
|
||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
||||
ui.button(
|
||||
"+ Add Source",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
||||
icon="description",
|
||||
).props("flat")
|
||||
ui.button(
|
||||
"+ Add Source",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).props('unelevated color="primary"')
|
||||
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
|
||||
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Archival Metadata"):
|
||||
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
|
||||
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
|
||||
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
|
||||
metadata_row("Location Created:", document.location_created or "Not set")
|
||||
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
|
||||
"flat"
|
||||
)
|
||||
ui.button(
|
||||
"+ Add Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).props('unelevated color="primary"')
|
||||
ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2")
|
||||
with ui.column().classes("w-full mt-2"):
|
||||
ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1")
|
||||
ui.label(document.notes or "No notes added.").classes(
|
||||
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]"
|
||||
)
|
||||
|
||||
with archival_card(title="System Logistics"):
|
||||
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
||||
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
||||
|
||||
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
|
||||
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||
with archival_card(title="Related People"):
|
||||
if not document.document_people:
|
||||
ui.label("No linked people yet.").classes("text-xs text-[#6B6A65] italic")
|
||||
else:
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for link in document.document_people:
|
||||
person_label = link.person.full_name if link.person is not None else "Unknown person"
|
||||
with ui.row().classes(
|
||||
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
||||
):
|
||||
ui.label(person_label).classes("text-xs font-semibold text-[#333333]")
|
||||
archival_badge(link.role.value)
|
||||
|
||||
with archival_card(title="Pipeline Jobs"):
|
||||
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs text-[#2D5A4C] font-bold")
|
||||
|
||||
with ui.row().classes("w-full gap-2 mt-2"):
|
||||
ui.button(
|
||||
"View Jobs",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
|
||||
icon="work_history",
|
||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
||||
ui.button(
|
||||
"+ Add Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
|
||||
@ui.page("/documents/{document_id}/jobs")
|
||||
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.jobs")
|
||||
return
|
||||
|
||||
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
||||
page_header(f"Jobs for {document.name}")
|
||||
with ui.row().classes("gap-2"):
|
||||
ui.button(
|
||||
"Back to Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||
icon="arrow_back",
|
||||
).props("flat")
|
||||
ui.button(
|
||||
"Create Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).classes("bg-[#2D5A4C] text-white")
|
||||
|
||||
if not document.jobs:
|
||||
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
if not document.jobs:
|
||||
with archival_card(extra_classes="p-6 text-center"):
|
||||
ui.label("No transcription processing jobs created yet.").classes("text-xs text-[#6B6A65]")
|
||||
return
|
||||
|
||||
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
|
||||
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
|
||||
"flat"
|
||||
)
|
||||
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||
with archival_card(extra_classes="p-3"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
archival_badge(job.status.value)
|
||||
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono text-[#333333]")
|
||||
ui.button(
|
||||
"Open Job",
|
||||
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat dense").classes("text-xs text-[#2D5A4C]")
|
||||
|
||||
@ui.page("/documents/{document_id}/sources")
|
||||
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
||||
@@ -298,201 +354,242 @@ def register_page() -> None:
|
||||
|
||||
@ui.page("/documents/{document_id}/edit")
|
||||
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.edit.read")
|
||||
return
|
||||
|
||||
ui.label("Edit document").classes("text-h5 text-weight-medium")
|
||||
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Edit Document Record", subtitle="Document name and document type are required.")
|
||||
|
||||
name_input = ui.input(label="Document name", value=document.name).props("outlined")
|
||||
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
|
||||
date_input = ui.input(
|
||||
label="Exact date (YYYY-MM-DD)",
|
||||
value=document.document_date.isoformat() if document.document_date else "",
|
||||
).props("outlined")
|
||||
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined")
|
||||
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
|
||||
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
|
||||
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
author_options = (
|
||||
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
|
||||
| {str(person.id): person.full_name for person in people}
|
||||
)
|
||||
existing_author = next(
|
||||
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
|
||||
None,
|
||||
)
|
||||
author_value = str(existing_author.person_id) if existing_author is not None else ""
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
|
||||
document_type_input = (
|
||||
ui.input(label="Document type", value=document.document_type or "")
|
||||
.props("outlined bg-white")
|
||||
.classes("w-full")
|
||||
)
|
||||
|
||||
def on_author_change(event) -> None:
|
||||
selected = str(event.value or "").strip()
|
||||
if selected == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
date_input = ui.input(
|
||||
label="Exact date (YYYY-MM-DD)",
|
||||
value=document.document_date.isoformat() if document.document_date else "",
|
||||
).props('outlined bg-white type="date"')
|
||||
date_raw_input = (
|
||||
ui.input(label="Approximate date", value=document.document_date_raw or "")
|
||||
.props("outlined bg-white")
|
||||
)
|
||||
|
||||
author_select = ui.select(
|
||||
author_options,
|
||||
label="Author (Person)",
|
||||
value=author_value,
|
||||
on_change=on_author_change,
|
||||
).props("outlined")
|
||||
ui.link("Create new person", "/people/new").classes("text-body2")
|
||||
location_input = (
|
||||
ui.input(label="Document location", value=document.location_created or "")
|
||||
.props("outlined bg-white")
|
||||
.classes("w-full")
|
||||
)
|
||||
archive_input = (
|
||||
ui.input(label="Archive identifier", value=document.archive_identifier or "")
|
||||
.props("outlined bg-white")
|
||||
.classes("w-full")
|
||||
)
|
||||
notes_input = (
|
||||
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
|
||||
)
|
||||
|
||||
async def submit_edit() -> None:
|
||||
candidate_name = (name_input.value or "").strip()
|
||||
candidate_type = (document_type_input.value or "").strip()
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
if not candidate_type:
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
author_options = (
|
||||
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
|
||||
| {str(person.id): person.full_name for person in people}
|
||||
)
|
||||
existing_author = next(
|
||||
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
|
||||
None,
|
||||
)
|
||||
author_value = str(existing_author.person_id) if existing_author is not None else ""
|
||||
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (date_input.value or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
def on_author_change(event) -> None:
|
||||
selected = str(event.value or "").strip()
|
||||
if selected == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
|
||||
author_select = (
|
||||
ui.select(
|
||||
author_options,
|
||||
label="Author (Person)",
|
||||
value=author_value,
|
||||
on_change=on_author_change,
|
||||
)
|
||||
.props("outlined bg-white")
|
||||
.classes("w-full")
|
||||
)
|
||||
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
|
||||
|
||||
async def submit_edit() -> None:
|
||||
candidate_name = (name_input.value or "").strip()
|
||||
candidate_type = (document_type_input.value or "").strip()
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
if not candidate_type:
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
|
||||
candidate = Document(
|
||||
id=document.id,
|
||||
name=candidate_name,
|
||||
document_type=candidate_type,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||
location_created=(location_input.value or "").strip() or None,
|
||||
notes=(notes_input.value or "").strip() or None,
|
||||
archive_identifier=(archive_input.value or "").strip() or None,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (date_input.value or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
await document_service.update_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||
return
|
||||
candidate = Document(
|
||||
id=document.id,
|
||||
name=candidate_name,
|
||||
document_type=candidate_type,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||
location_created=(location_input.value or "").strip() or None,
|
||||
notes=(notes_input.value or "").strip() or None,
|
||||
archive_identifier=(archive_input.value or "").strip() or None,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
|
||||
selected_author = (author_select.value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
existing_author_links = [
|
||||
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
|
||||
]
|
||||
try:
|
||||
if not selected_author:
|
||||
for link in existing_author_links:
|
||||
await document_service.delete_document_person(link)
|
||||
else:
|
||||
selected_author_id = UUID(selected_author)
|
||||
if not any(link.person_id == selected_author_id for link in existing_author_links):
|
||||
try:
|
||||
await document_service.update_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||
return
|
||||
|
||||
selected_author = (author_select.value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
existing_author_links = [
|
||||
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
|
||||
]
|
||||
try:
|
||||
if not selected_author:
|
||||
for link in existing_author_links:
|
||||
await document_service.delete_document_person(link)
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=selected_author_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
else:
|
||||
selected_author_id = UUID(selected_author)
|
||||
if not any(link.person_id == selected_author_id for link in existing_author_links):
|
||||
for link in existing_author_links:
|
||||
await document_service.delete_document_person(link)
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=selected_author_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
|
||||
return
|
||||
|
||||
ui.notify("Document updated", type="positive")
|
||||
ui.navigate.to(f"/documents/{document.id}")
|
||||
ui.notify("Document updated", type="positive")
|
||||
ui.navigate.to(f"/documents/{document.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
||||
"flat"
|
||||
)
|
||||
|
||||
@ui.page("/documents/{document_id}/delete")
|
||||
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||
return
|
||||
|
||||
ui.label("Delete document").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Document: {document.name}").classes("text-subtitle1")
|
||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||
page_header("Delete Document")
|
||||
|
||||
has_sources = bool(document.sources)
|
||||
has_jobs = bool(document.jobs)
|
||||
with archival_card(extra_classes="gap-2"):
|
||||
ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]")
|
||||
|
||||
if has_sources or has_jobs:
|
||||
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
|
||||
categories: list[str] = []
|
||||
if has_sources:
|
||||
categories.append("Sources")
|
||||
if has_jobs:
|
||||
categories.append("Jobs")
|
||||
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
|
||||
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||
has_sources = bool(document.sources)
|
||||
has_jobs = bool(document.jobs)
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||
return
|
||||
if has_sources or has_jobs:
|
||||
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
|
||||
categories: list[str] = []
|
||||
if has_sources:
|
||||
categories.append("Sources")
|
||||
if has_jobs:
|
||||
categories.append("Jobs")
|
||||
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs text-[#6B6A65]")
|
||||
ui.label("Remove related records first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
|
||||
|
||||
ui.label("This action permanently deletes the document.").classes("text-negative")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await document_service.delete_document(document)
|
||||
except DocumentDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/documents/{document.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Document not found.", type="warning")
|
||||
ui.navigate.to("/documents")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||
ui.button(
|
||||
"Back to Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||
icon="arrow_back",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||
"flat text-xs"
|
||||
)
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
|
||||
ui.notify("Document deleted", type="positive")
|
||||
ui.navigate.to("/documents")
|
||||
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Delete document permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props('unelevated color="negative"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await document_service.delete_document(document)
|
||||
except DocumentDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/documents/{document.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Document not found.", type="warning")
|
||||
ui.navigate.to("/documents")
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
|
||||
ui.notify("Document deleted", type="positive")
|
||||
ui.navigate.to("/documents")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button(
|
||||
"Delete document permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props("unelevated color=negative")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
||||
"flat"
|
||||
)
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import JobStatus
|
||||
@@ -15,8 +15,13 @@ from transcription.services.jobs import JobDeleteBlockedError
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.store import create_job_for_document
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.data_display import archival_badge
|
||||
from transcription.ui.components.data_display import metadata_row
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.table.jobs import render_jobs_table
|
||||
from transcription.ui.components.typography import page_header
|
||||
from transcription.ui.theme import apply_archival_theme
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
@@ -28,257 +33,285 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
@ui.page("/jobs")
|
||||
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
filename=job.filename,
|
||||
retry_count=job.retry_count,
|
||||
date_created=job.date_created.isoformat(),
|
||||
date_updated=job.date_updated.isoformat(),
|
||||
)
|
||||
for job in await jobs_service.list_jobs()
|
||||
]
|
||||
render_jobs_table(jobs)
|
||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
||||
page_header("Transcription Pipeline Jobs")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
||||
"bg-[#2D5A4C] text-white"
|
||||
)
|
||||
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
await render_table()
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
filename=job.filename,
|
||||
retry_count=job.retry_count,
|
||||
date_created=job.date_created.isoformat(),
|
||||
date_updated=job.date_updated.isoformat(),
|
||||
)
|
||||
for job in await jobs_service.list_jobs()
|
||||
]
|
||||
render_jobs_table(jobs)
|
||||
|
||||
await render_table()
|
||||
|
||||
@ui.page("/jobs/new")
|
||||
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
documents_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
ui.label("Create job").classes("text-h5 text-weight-medium")
|
||||
|
||||
documents = await documents_service.list_documents()
|
||||
if not documents:
|
||||
ui.label("No documents available. Create a Document before creating a Job.").classes(
|
||||
"text-body1 text-warning"
|
||||
)
|
||||
with ui.row():
|
||||
ui.button(
|
||||
"Create document",
|
||||
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
||||
icon="note_add",
|
||||
).props('unelevated color="primary"')
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||
return
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
|
||||
|
||||
uploaded_files: list[tuple[str, bytes]] = []
|
||||
|
||||
document_options = {str(document.id): document.name for document in documents}
|
||||
document_select = ui.select(document_options, label="Document").props("outlined")
|
||||
requested_document_id = request.query_params.get("document_id")
|
||||
if requested_document_id in document_options:
|
||||
document_select.value = requested_document_id
|
||||
provider_input = ui.input(label="Provider").props("outlined")
|
||||
model_input = ui.input(label="Model").props("outlined")
|
||||
prompt_input = ui.input(label="Prompt").props("outlined")
|
||||
|
||||
ui.label("Source files").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(
|
||||
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||
).classes("text-body2 vibe-text-muted")
|
||||
|
||||
@ui.refreshable
|
||||
def render_upload_list() -> None:
|
||||
if not uploaded_files:
|
||||
ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
def remove_file(index: int) -> None:
|
||||
if 0 <= index < len(uploaded_files):
|
||||
removed_name, _ = uploaded_files.pop(index)
|
||||
ui.notify(f"Removed {removed_name}", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
def clear_files() -> None:
|
||||
uploaded_files.clear()
|
||||
ui.notify("Cleared queued files", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ordered_uploads = sorted(
|
||||
enumerate(uploaded_files),
|
||||
key=lambda item: Path(item[1][0]).name.casefold(),
|
||||
)
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
for index, (filename, _) in ordered_uploads:
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(Path(filename).name).classes("text-body2")
|
||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense")
|
||||
|
||||
with ui.row().classes("w-full justify-end"):
|
||||
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
|
||||
|
||||
async def on_upload(event) -> None:
|
||||
payload = await event.file.read()
|
||||
uploaded_files.append((event.file.name, payload))
|
||||
ui.notify(f"Added {event.file.name}", type="positive")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ui.upload(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
label="Select source files or a folder",
|
||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
|
||||
|
||||
render_upload_list()
|
||||
|
||||
async def submit_create() -> None:
|
||||
selected_document = document_select.value
|
||||
if not selected_document:
|
||||
ui.notify("Document is required.", type="warning")
|
||||
return
|
||||
if not uploaded_files:
|
||||
ui.notify("At least one source file is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
document_id = UUID(str(selected_document))
|
||||
except ValueError:
|
||||
ui.notify("Selected document id is invalid.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
async with session_scope(session_factory=session_factory) as session:
|
||||
result = await create_job_for_document(
|
||||
document_id=document_id,
|
||||
uploads=uploaded_files,
|
||||
provider=(provider_input.value or None),
|
||||
model=(model_input.value or None),
|
||||
prompt_name=(prompt_input.value or None),
|
||||
session=session,
|
||||
documents = await documents_service.list_documents()
|
||||
if not documents:
|
||||
with archival_card(extra_classes="p-6 text-center"):
|
||||
ui.label("No documents available. Create a Document before creating a Job.").classes(
|
||||
"text-xs text-red-800 font-medium mb-4"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||
with ui.row().classes("justify-center gap-2"):
|
||||
ui.button(
|
||||
"Create document",
|
||||
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
||||
icon="note_add",
|
||||
).classes("bg-[#2D5A4C] text-white")
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||
return
|
||||
|
||||
resolve_worker_notifier(request.app.state).notify()
|
||||
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||
ui.navigate.to(f"/jobs/{result.job_id}")
|
||||
uploaded_files: list[tuple[str, bytes]] = []
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
document_options = {str(document.id): document.name for document in documents}
|
||||
document_select = (
|
||||
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
|
||||
)
|
||||
requested_document_id = request.query_params.get("document_id")
|
||||
if requested_document_id in document_options:
|
||||
document_select.value = requested_document_id
|
||||
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
provider_input = ui.input(label="Provider").props("outlined bg-white")
|
||||
model_input = ui.input(label="Model").props("outlined bg-white")
|
||||
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
|
||||
|
||||
with archival_card(title="Source Files"):
|
||||
ui.label(
|
||||
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||
).classes("text-xs text-[#6B6A65] mb-2")
|
||||
|
||||
@ui.refreshable
|
||||
def render_upload_list() -> None:
|
||||
if not uploaded_files:
|
||||
ui.label("No files uploaded yet.").classes("text-xs text-[#6B6A65] italic")
|
||||
return
|
||||
|
||||
def remove_file(index: int) -> None:
|
||||
if 0 <= index < len(uploaded_files):
|
||||
removed_name, _ = uploaded_files.pop(index)
|
||||
ui.notify(f"Removed {removed_name}", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
def clear_files() -> None:
|
||||
uploaded_files.clear()
|
||||
ui.notify("Cleared queued files", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ordered_uploads = sorted(
|
||||
enumerate(uploaded_files),
|
||||
key=lambda item: Path(item[1][0]).name.casefold(),
|
||||
)
|
||||
|
||||
with ui.column().classes("gap-1 w-full mt-2"):
|
||||
for index, (filename, _) in ordered_uploads:
|
||||
with ui.row().classes(
|
||||
"w-full items-center justify-between bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
||||
):
|
||||
ui.label(Path(filename).name).classes("text-xs font-mono text-[#333333]")
|
||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
||||
"flat round dense color=negative text-xs"
|
||||
)
|
||||
|
||||
with ui.row().classes("w-full justify-end mt-2"):
|
||||
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
|
||||
"text-xs text-red-800"
|
||||
)
|
||||
|
||||
async def on_upload(event) -> None:
|
||||
payload = await event.file.read()
|
||||
uploaded_files.append((event.file.name, payload))
|
||||
ui.notify(f"Added {event.file.name}", type="positive")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ui.upload(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
label="Select source files or a folder",
|
||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
|
||||
|
||||
render_upload_list()
|
||||
|
||||
async def submit_create() -> None:
|
||||
selected_document = document_select.value
|
||||
if not selected_document:
|
||||
ui.notify("Document is required.", type="warning")
|
||||
return
|
||||
if not uploaded_files:
|
||||
ui.notify("At least one source file is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
document_id = UUID(str(selected_document))
|
||||
except ValueError:
|
||||
ui.notify("Selected document id is invalid.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
async with session_scope(session_factory=session_factory) as session:
|
||||
result = await create_job_for_document(
|
||||
document_id=document_id,
|
||||
uploads=uploaded_files,
|
||||
provider=(provider_input.value or None),
|
||||
model=(model_input.value or None),
|
||||
prompt_name=(prompt_input.value or None),
|
||||
session=session,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||
return
|
||||
|
||||
resolve_worker_notifier(request.app.state).notify()
|
||||
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||
ui.navigate.to(f"/jobs/{result.job_id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
|
||||
"bg-[#2D5A4C] text-white"
|
||||
)
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||
|
||||
@ui.page("/jobs/{job_id}")
|
||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
try:
|
||||
parsed_job_id = UUID(job_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 text-negative")
|
||||
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-3"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||
match job.status:
|
||||
case JobStatus.TRANSCRIBED:
|
||||
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
|
||||
case _:
|
||||
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
||||
page_header(f"Job Record: {job.id}")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
archival_badge(job.status.value.upper())
|
||||
ui.button(
|
||||
"Delete Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outlined color=negative text-xs")
|
||||
|
||||
ui.label(f"Job {job.id}").classes("text-h6 text-weight-bold")
|
||||
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
|
||||
with archival_card(title="Execution Logistics"):
|
||||
metadata_row("Provider:", job.provider or "pending")
|
||||
metadata_row("Model:", job.model or "pending")
|
||||
metadata_row("Prompt:", job.prompt_name or "pending")
|
||||
metadata_row("Retry Count:", str(job.retry_count))
|
||||
metadata_row("Last Updated:", job.date_updated.isoformat())
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
|
||||
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Document Links").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||
icon="description",
|
||||
).props("flat")
|
||||
ui.button(
|
||||
"Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||
icon="description",
|
||||
).props("flat")
|
||||
ui.button(
|
||||
"Jobs",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/jobs"),
|
||||
icon="work_history",
|
||||
).props("flat")
|
||||
with archival_card(title="Document Links"):
|
||||
ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3")
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
ui.button(
|
||||
"View Linked Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||
icon="description",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs w-full")
|
||||
ui.button(
|
||||
"View Linked Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||
icon="description",
|
||||
).props("flat text-xs").classes("text-[#2D5A4C] w-full")
|
||||
|
||||
@ui.page("/jobs/{job_id}/delete")
|
||||
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
try:
|
||||
parsed_job_id = UUID(job_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 text-negative")
|
||||
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
ui.label("Delete job").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Job: {job.id}").classes("text-subtitle1")
|
||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||
page_header("Delete Processing Job")
|
||||
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
|
||||
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||
return
|
||||
with archival_card(extra_classes="gap-2"):
|
||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]")
|
||||
|
||||
ui.label("This action permanently deletes the job.").classes("text-negative")
|
||||
if job.job_sources:
|
||||
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
ui.label("Delete is blocked while the job is processing.").classes(
|
||||
"text-xs text-red-800 font-bold mt-2"
|
||||
)
|
||||
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs text-[#6B6A65] italic")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||
ui.button(
|
||||
"Back to Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
|
||||
icon="arrow_back",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||
"flat text-xs"
|
||||
)
|
||||
return
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
||||
except JobDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
return
|
||||
except ValueError:
|
||||
ui.notify("Job not found.", type="warning")
|
||||
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
||||
if job.job_sources:
|
||||
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs text-[#6B6A65]")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
||||
except JobDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
return
|
||||
except ValueError:
|
||||
ui.notify("Job not found.", type="warning")
|
||||
ui.navigate.to("/jobs")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete job failed", operation="jobs.delete")
|
||||
return
|
||||
|
||||
ui.notify("Job deleted", type="positive")
|
||||
ui.navigate.to("/jobs")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete job failed", operation="jobs.delete")
|
||||
return
|
||||
|
||||
ui.notify("Job deleted", type="positive")
|
||||
ui.navigate.to("/jobs")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
|
||||
'unelevated color="negative"'
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
|
||||
"unelevated color=negative"
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
||||
@@ -9,17 +9,23 @@ from uuid import UUID
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.config import Settings, get_settings
|
||||
from transcription.db.models import Person
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.documents import PersonDeleteBlockedError
|
||||
from transcription.services.store import UploadError
|
||||
from transcription.services.store import store_person_portrait
|
||||
from transcription.services.documents import (
|
||||
DocumentError,
|
||||
DocumentService,
|
||||
PersonDeleteBlockedError,
|
||||
)
|
||||
from transcription.services.store import UploadError, store_person_portrait
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.data_display import metadata_row
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.table.people import PersonTableRow, render_people_table
|
||||
from transcription.ui.components.typography import page_header
|
||||
from transcription.ui.components.viewers import dark_room_viewer
|
||||
from transcription.ui.theme import apply_archival_theme
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
@@ -63,8 +69,8 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
|
||||
on_upload=on_portrait_selected,
|
||||
auto_upload=True,
|
||||
label="Choose portrait file",
|
||||
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
|
||||
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted")
|
||||
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
|
||||
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
|
||||
|
||||
|
||||
def _resolve_portrait_src(path: str | None) -> str | None:
|
||||
@@ -95,331 +101,373 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
@ui.page("/people")
|
||||
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label("People").classes("text-h5 text-weight-medium")
|
||||
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
||||
page_header("Archival Entities: People")
|
||||
ui.button(
|
||||
"Create new person",
|
||||
on_click=lambda: ui.navigate.to("/people/new"),
|
||||
icon="person_add",
|
||||
).classes("bg-[#2D5A4C] text-white")
|
||||
|
||||
try:
|
||||
people = sorted(
|
||||
await people_service.list_people(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.list")
|
||||
return
|
||||
try:
|
||||
people = sorted(
|
||||
await people_service.list_people(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.list")
|
||||
return
|
||||
|
||||
if not people:
|
||||
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for person in people:
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
# Format person records into read-model rows for the table renderer
|
||||
rows = [
|
||||
PersonTableRow(
|
||||
id=person.id,
|
||||
full_name=person.full_name,
|
||||
display_name=person.display_name or "",
|
||||
maiden_name=person.maiden_name or "",
|
||||
birth_date=person.birth_date.isoformat() if person.birth_date else "",
|
||||
)
|
||||
for person in people
|
||||
]
|
||||
render_people_table(rows)
|
||||
|
||||
@ui.page("/people/new")
|
||||
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
ui.label("Create person").classes("text-h5 text-weight-medium")
|
||||
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Create Person Record", subtitle="Full name is required.")
|
||||
|
||||
full_name_input = ui.input(label="Full name").props("outlined")
|
||||
display_name_input = ui.input(label="Display name").props("outlined")
|
||||
maiden_name_input = ui.input(label="Maiden name").props("outlined")
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
full_name_input = ui.input(label="Full name").props("outlined bg-white")
|
||||
display_name_input = ui.input(label="Display name").props("outlined bg-white")
|
||||
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
|
||||
|
||||
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined type="date"')
|
||||
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
|
||||
birth_place_input = ui.input(label="Birth place").props("outlined")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
|
||||
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
|
||||
|
||||
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined type="date"')
|
||||
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
|
||||
death_place_input = ui.input(label="Death place").props("outlined")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
|
||||
death_place_input = ui.input(label="Death place").props("outlined bg-white")
|
||||
|
||||
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
|
||||
portrait_path_input = ui.input(label="Portrait path").props("outlined")
|
||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
|
||||
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
|
||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||
|
||||
async def submit_create() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
async def submit_create() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
|
||||
candidate = Person(
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
)
|
||||
candidate = Person(
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
)
|
||||
|
||||
try:
|
||||
created = await people_service.create_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create failed", operation="people.create")
|
||||
return
|
||||
try:
|
||||
created = await people_service.create_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create failed", operation="people.create")
|
||||
return
|
||||
|
||||
ui.notify("Person created", type="positive")
|
||||
ui.navigate.to(f"/people/{created.id}")
|
||||
ui.notify("Person created", type="positive")
|
||||
ui.navigate.to(f"/people/{created.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
||||
|
||||
@ui.page("/people/{person_id}")
|
||||
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.read")
|
||||
return
|
||||
|
||||
ui.label(person.full_name).classes("text-h5 text-weight-medium")
|
||||
portrait_src = _resolve_portrait_src(person.portrait_path)
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button(
|
||||
"Delete person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outline color=negative")
|
||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
||||
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
ui.label(f"Full name: {person.full_name}")
|
||||
ui.label(f"Display name: {person.display_name or 'not set'}")
|
||||
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
|
||||
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
|
||||
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
|
||||
ui.label(f"Birth place: {person.birth_place or 'not set'}")
|
||||
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
|
||||
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
|
||||
ui.label(f"Death place: {person.death_place or 'not set'}")
|
||||
ui.label(f"Biography: {person.biography or 'not set'}")
|
||||
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
|
||||
portrait_src = _resolve_portrait_src(person.portrait_path)
|
||||
if portrait_src is not None:
|
||||
ui.image(portrait_src).classes("w-40 rounded shadow")
|
||||
else:
|
||||
ui.label("No portrait image set.").classes("text-body2 vibe-text-muted")
|
||||
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
|
||||
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
|
||||
|
||||
if not person.document_people:
|
||||
ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted")
|
||||
ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
for link in person.document_people:
|
||||
document = link.document
|
||||
if document is None:
|
||||
continue
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
"Edit Person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
|
||||
icon="edit",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
ui.button(
|
||||
"Delete",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outlined color=negative text-xs")
|
||||
|
||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
||||
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
||||
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Biographical Record"):
|
||||
metadata_row("Full Name:", person.full_name)
|
||||
metadata_row("Display Name:", person.display_name or "Not set")
|
||||
metadata_row("Maiden Name:", person.maiden_name or "Not set")
|
||||
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
|
||||
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
|
||||
metadata_row("Birth Place:", person.birth_place or "Not set")
|
||||
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
|
||||
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
|
||||
metadata_row("Death Place:", person.death_place or "Not set")
|
||||
|
||||
with archival_card(title="System Logistics"):
|
||||
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
||||
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
||||
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Biography"):
|
||||
ui.label(person.biography or "No biography recorded.").classes(
|
||||
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm text-xs text-[#333333] italic w-full"
|
||||
)
|
||||
|
||||
with archival_card(title="Linked Documents"):
|
||||
if not person.document_people:
|
||||
ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic")
|
||||
ui.label("Link this person from a Document workflow.").classes("text-xs text-[#6B6A65]")
|
||||
else:
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for link in person.document_people:
|
||||
document = link.document
|
||||
if document is None:
|
||||
continue
|
||||
with ui.row().classes(
|
||||
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
||||
):
|
||||
with ui.column().classes("gap-0"):
|
||||
ui.label(document.name).classes("text-xs font-semibold text-[#333333]")
|
||||
ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
|
||||
f"/documents/{doc_id}"
|
||||
),
|
||||
icon="open_in_new",
|
||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
||||
|
||||
@ui.page("/people/{person_id}/edit")
|
||||
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.edit.read")
|
||||
return
|
||||
|
||||
ui.label("Edit person").classes("text-h5 text-weight-medium")
|
||||
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Edit Person Record", subtitle="Full name is required.")
|
||||
|
||||
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
|
||||
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
|
||||
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
|
||||
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
||||
"outlined bg-white"
|
||||
)
|
||||
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
|
||||
|
||||
birth_date_input = ui.input(
|
||||
label="Birth date (YYYY-MM-DD)",
|
||||
value=person.birth_date.isoformat() if person.birth_date else "",
|
||||
).props('outlined type="date"')
|
||||
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
|
||||
"outlined"
|
||||
)
|
||||
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
birth_date_input = ui.input(
|
||||
label="Birth date (YYYY-MM-DD)",
|
||||
value=person.birth_date.isoformat() if person.birth_date else "",
|
||||
).props('outlined bg-white type="date"')
|
||||
birth_date_raw_input = ui.input(
|
||||
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
||||
).props("outlined bg-white")
|
||||
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
|
||||
|
||||
death_date_input = ui.input(
|
||||
label="Death date (YYYY-MM-DD)",
|
||||
value=person.death_date.isoformat() if person.death_date else "",
|
||||
).props('outlined type="date"')
|
||||
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
|
||||
"outlined"
|
||||
)
|
||||
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
death_date_input = ui.input(
|
||||
label="Death date (YYYY-MM-DD)",
|
||||
value=person.death_date.isoformat() if person.death_date else "",
|
||||
).props('outlined bg-white type="date"')
|
||||
death_date_raw_input = ui.input(
|
||||
label="Death date (approximate)", value=person.death_date_raw or ""
|
||||
).props("outlined bg-white")
|
||||
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
|
||||
|
||||
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
|
||||
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
|
||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||
biography_input = (
|
||||
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
|
||||
)
|
||||
portrait_path_input = (
|
||||
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
|
||||
)
|
||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||
|
||||
async def submit_edit() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
async def submit_edit() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
|
||||
candidate = Person(
|
||||
id=person.id,
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
metadata_=person.metadata_,
|
||||
created_at=person.created_at,
|
||||
updated_at=person.updated_at,
|
||||
)
|
||||
candidate = Person(
|
||||
id=person.id,
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
metadata_=person.metadata_,
|
||||
created_at=person.created_at,
|
||||
updated_at=person.updated_at,
|
||||
)
|
||||
|
||||
try:
|
||||
await people_service.update_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="people.edit.save")
|
||||
return
|
||||
try:
|
||||
await people_service.update_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="people.edit.save")
|
||||
return
|
||||
|
||||
ui.notify("Person updated", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}")
|
||||
ui.notify("Person updated", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
||||
|
||||
@ui.page("/people/{person_id}/delete")
|
||||
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||
return
|
||||
|
||||
ui.label("Delete person").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
|
||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||
page_header("Delete Person Record")
|
||||
|
||||
if person.document_people:
|
||||
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
|
||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
|
||||
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
|
||||
return
|
||||
with archival_card(extra_classes="gap-2"):
|
||||
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
|
||||
|
||||
ui.label("This action permanently deletes the person.").classes("text-negative")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await people_service.delete_person(person)
|
||||
except PersonDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/people/{person.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Person not found.", type="warning")
|
||||
ui.navigate.to("/people")
|
||||
if person.document_people:
|
||||
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
|
||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
|
||||
ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
|
||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||
ui.button(
|
||||
"Back to Person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||
icon="arrow_back",
|
||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
||||
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
|
||||
"flat text-xs"
|
||||
)
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
|
||||
ui.notify("Person deleted", type="positive")
|
||||
ui.navigate.to("/people")
|
||||
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Delete person permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props('unelevated color="negative"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await people_service.delete_person(person)
|
||||
except PersonDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/people/{person.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Person not found.", type="warning")
|
||||
ui.navigate.to("/people")
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
|
||||
ui.notify("Person deleted", type="positive")
|
||||
ui.navigate.to("/people")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button(
|
||||
"Delete person permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props("unelevated color=negative")
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
||||
@@ -2,23 +2,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import JobSource
|
||||
from transcription.db.models import Source
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.db.models import JobSource, Source
|
||||
from transcription.services.documents import DocumentError, DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.transcription import TranscriptionService
|
||||
from transcription.services.transcription import TranscriptionNotFoundError
|
||||
from transcription.services.transcription import (
|
||||
TranscriptionNotFoundError,
|
||||
TranscriptionService,
|
||||
)
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.data_display import metadata_row
|
||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
||||
from transcription.ui.components.typography import page_header
|
||||
from transcription.ui.theme import apply_archival_theme
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
@@ -28,6 +33,7 @@ def register_page() -> None:
|
||||
|
||||
@ui.page("/sources")
|
||||
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
sources_service = TranscriptionService(session_factory=session_factory)
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
documents_service = DocumentService(session_factory=session_factory)
|
||||
@@ -60,115 +66,134 @@ def register_page() -> None:
|
||||
else:
|
||||
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 text-negative")
|
||||
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="sources.list")
|
||||
return
|
||||
|
||||
if document_name is not None:
|
||||
ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium")
|
||||
elif job_label is not None:
|
||||
ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium")
|
||||
else:
|
||||
ui.label("Sources").classes("text-h5 text-weight-medium")
|
||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
||||
if document_name is not None:
|
||||
header_title = f"Sources: {document_name}"
|
||||
elif job_label is not None:
|
||||
header_title = f"Sources for Job {job_label}"
|
||||
else:
|
||||
header_title = "Archival Source Media"
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
if back_path is not None:
|
||||
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
|
||||
page_header(header_title)
|
||||
|
||||
if not sources:
|
||||
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for source in sources:
|
||||
detail_path = _source_detail_path(source_id=source.id, document_id=document_id, job_id=job_id)
|
||||
with ui.card().classes("w-full") as card:
|
||||
card.on(
|
||||
"click",
|
||||
lambda _=None, route=detail_path: ui.navigate.to(route),
|
||||
if back_path is not None:
|
||||
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||
"bg-[#2D5A4C] text-white text-xs"
|
||||
)
|
||||
card.classes("cursor-pointer")
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
|
||||
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
|
||||
ui.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props(
|
||||
"flat"
|
||||
)
|
||||
|
||||
# Format source records into read-model rows for the table renderer
|
||||
rows = [
|
||||
SourceTableRow(
|
||||
id=source.id,
|
||||
page_number=source.page_number,
|
||||
upload_name=source.upload_name,
|
||||
filename=source.filename,
|
||||
document_id=source.document_id,
|
||||
)
|
||||
for source in sources
|
||||
]
|
||||
render_sources_table(rows)
|
||||
|
||||
@ui.page("/sources/{source_id}")
|
||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
apply_archival_theme()
|
||||
sources_service = TranscriptionService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/sources")
|
||||
|
||||
try:
|
||||
parsed_source_id = UUID(source_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid source id").classes("text-h6 text-negative")
|
||||
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
source = await sources_service.read_source_detail(source_id=parsed_source_id)
|
||||
except TranscriptionNotFoundError:
|
||||
ui.label("Source not found").classes("text-h6 text-negative")
|
||||
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="sources.read")
|
||||
return
|
||||
|
||||
back_path = _back_path_from_query(request.query_params)
|
||||
ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
if back_path is not None:
|
||||
back_label = "Back to Document" if "document_id" in request.query_params else "Back to Job" if "job_id" in request.query_params else "Back to Sources"
|
||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
|
||||
else:
|
||||
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back")
|
||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
||||
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
|
||||
|
||||
with ui.column().classes("w-full gap-3"):
|
||||
render_document_panzoom(source=source)
|
||||
if back_path is not None:
|
||||
back_label = (
|
||||
"Back to Document"
|
||||
if "document_id" in request.query_params
|
||||
else "Back to Job"
|
||||
if "job_id" in request.query_params
|
||||
else "Back to Sources"
|
||||
)
|
||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||
"bg-[#2D5A4C] text-white text-xs"
|
||||
)
|
||||
else:
|
||||
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
||||
"flat text-xs"
|
||||
)
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(f"Page number: {source.page_number}").classes("text-body2")
|
||||
ui.label(f"Upload name: {source.upload_name}").classes("text-body2")
|
||||
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
|
||||
ui.label(f"Document id: {source.document_id}").classes("text-body2")
|
||||
ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2")
|
||||
ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2")
|
||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
|
||||
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
|
||||
render_document_panzoom(source=source)
|
||||
|
||||
ui.separator()
|
||||
ui.label("Transcription text").classes("text-subtitle1 text-weight-medium")
|
||||
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full")
|
||||
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
|
||||
with archival_card(title="Source Metadata"):
|
||||
metadata_row("Page Number:", str(source.page_number))
|
||||
metadata_row("Upload Name:", source.upload_name)
|
||||
metadata_row("Stored Filename:", source.filename)
|
||||
metadata_row("Document ID:", str(source.document_id))
|
||||
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
|
||||
metadata_row(
|
||||
"Date Revised:",
|
||||
source.date_revised.isoformat() if source.date_revised else "Not revised",
|
||||
)
|
||||
|
||||
ui.label("Revision text").classes("text-subtitle1 text-weight-medium")
|
||||
revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow")
|
||||
revision_input.classes("w-full")
|
||||
with archival_card(title="Automated Raw Transcription"):
|
||||
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
|
||||
"w-full text-xs font-mono"
|
||||
)
|
||||
|
||||
async def save_revision() -> None:
|
||||
candidate = (revision_input.value or "").strip()
|
||||
if not candidate:
|
||||
ui.notify("Revision text is required.", type="warning")
|
||||
return
|
||||
with archival_card(title="Curated Human Transcription"):
|
||||
revision_input = (
|
||||
ui.textarea(value=source.revised_text or "")
|
||||
.props("outlined autogrow bg-white")
|
||||
.classes("w-full text-xs")
|
||||
)
|
||||
|
||||
try:
|
||||
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="sources.save_revision")
|
||||
return
|
||||
async def save_revision() -> None:
|
||||
candidate = (revision_input.value or "").strip()
|
||||
if not candidate:
|
||||
ui.notify("Revision text is required.", type="warning")
|
||||
return
|
||||
|
||||
ui.notify("Revision saved", type="positive")
|
||||
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||
try:
|
||||
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="sources.save_revision")
|
||||
return
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"')
|
||||
ui.notify("Revision saved", type="positive")
|
||||
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
ui.button("Save Revision", on_click=save_revision, icon="save").classes("bg-[#2D5A4C] text-white text-xs")
|
||||
|
||||
@ui.page("/documents/{document_id}/sources")
|
||||
async def document_sources_page(document_id: str) -> RedirectResponse:
|
||||
|
||||
@@ -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]")
|
||||
Reference in New Issue
Block a user