UI style refresh with Gemini's help

This commit is contained in:
Jim Lancaster
2026-08-03 13:54:24 -05:00
parent 47aef0e26e
commit 4f6e1fd913
15 changed files with 1604 additions and 857 deletions
+102 -77
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
from uuid import UUID
from urllib.parse import urlencode
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.db.models import JobSource, Source
from transcription.services.documents import DocumentError, DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import (
TranscriptionNotFoundError,
TranscriptionService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.components.typography import page_header
from transcription.ui.theme import apply_archival_theme
from ...db.session import SessionFactoryDep
@@ -28,6 +33,7 @@ def register_page() -> None:
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
@@ -60,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: