diff --git a/src/transcription/services/jobs.py b/src/transcription/services/jobs.py index e287b38..8d94321 100644 --- a/src/transcription/services/jobs.py +++ b/src/transcription/services/jobs.py @@ -35,7 +35,10 @@ class JobService(ServiceBase): async with self._session_scope(session) as _session: query = ( select(Job) - .options(selectinload(Job.document)) # pyright: ignore[reportArgumentType] + .options( + selectinload(Job.document), # pyright: ignore[reportArgumentType] + selectinload(Job.transcripts), # pyright: ignore[reportArgumentType] + ) .where(Job.id == job_id) .execution_options(populate_existing=True) ) diff --git a/src/transcription/ui/components/document_panzoom.py b/src/transcription/ui/components/document_panzoom.py index 00772c3..bd10965 100644 --- a/src/transcription/ui/components/document_panzoom.py +++ b/src/transcription/ui/components/document_panzoom.py @@ -16,30 +16,41 @@ PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/panzoom@4.6.2/dist/panzoom.min.j UPLOADS_URL_PREFIX = "/uploads" -def _document_url(document: Document) -> str: - file_path = Path(document.file_path) - upload_dir = get_settings().upload_dir +def render_document_panzoom(*, document: Document) -> None: + """Render a document preview with pan and zoom interactions.""" + _register_panzoom_assets() - relative_path: Path - try: - relative_path = file_path.resolve().relative_to(upload_dir.resolve()) - except ValueError: - parts = file_path.parts - if "uploads" in parts: - uploads_index = parts.index("uploads") - relative_path = Path(*parts[uploads_index + 1 :]) - else: - relative_path = Path(file_path.name) + host_id = f"document-panzoom-{uuid4().hex}" + document_url = _document_url(document) + document_kind = _document_kind(document) - encoded_relative_path = "/".join(quote(part) for part in relative_path.parts) - return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}" + with ui.card().classes("w-full q-pa-md"): + with ui.row().classes("w-full items-center justify-between no-wrap"): + ui.label("Document preview").classes("text-subtitle1 text-weight-medium") + ui.label(document.filename).classes("text-caption text-grey-4 ellipsis").style( + "max-width: 60%; text-align: right;" + ) + with ( + ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md") + # .style(f"height: {height};") + ) as host: + host.props(f"id={host_id}") + with ui.element("div").classes("document-panzoom-surface"): + if document_kind == "pdf": + ui.html( + f'" + ) + else: + ui.html( + f'{document.filename}" + ) -def _document_kind(document: Document) -> str: - suffix = Path(document.file_path).suffix.lower() - if suffix == ".pdf": - return "pdf" - return "image" + _attach_panzoom(host_id) @lru_cache(maxsize=1) @@ -87,6 +98,32 @@ def _register_panzoom_assets() -> None: ) +def _document_url(document: Document) -> str: + file_path = Path(document.file_path) + upload_dir = get_settings().upload_dir + + relative_path: Path + try: + relative_path = file_path.resolve().relative_to(upload_dir.resolve()) + except ValueError: + parts = file_path.parts + if "uploads" in parts: + uploads_index = parts.index("uploads") + relative_path = Path(*parts[uploads_index + 1 :]) + else: + relative_path = Path(file_path.name) + + encoded_relative_path = "/".join(quote(part) for part in relative_path.parts) + return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}" + + +def _document_kind(document: Document) -> str: + suffix = Path(document.file_path).suffix.lower() + if suffix == ".pdf": + return "pdf" + return "image" + + def _attach_panzoom(host_id: str) -> None: ui.run_javascript( f""" @@ -172,41 +209,3 @@ def _attach_panzoom(host_id: str) -> None: }})(); """ ) - - -def render_document_panzoom(*, document: Document, height: str = "640px") -> None: - """Render a document preview with pan and zoom interactions.""" - _register_panzoom_assets() - - host_id = f"document-panzoom-{uuid4().hex}" - document_url = _document_url(document) - document_kind = _document_kind(document) - - with ui.card().classes("w-full q-pa-md"): - with ui.row().classes("w-full items-center justify-between no-wrap"): - ui.label("Document preview").classes("text-subtitle1 text-weight-medium") - ui.label(document.filename).classes("text-caption text-grey-4 ellipsis").style( - "max-width: 60%; text-align: right;" - ) - - with ( - ui.element("div") - .classes("w-full document-panzoom-host rounded-borders q-mt-md") - .style(f"height: {height};") as host - ): - host.props(f"id={host_id}") - with ui.element("div").classes("document-panzoom-surface"): - if document_kind == "pdf": - ui.html( - f'" - ) - else: - ui.html( - f'{document.filename}" - ) - - _attach_panzoom(host_id) diff --git a/src/transcription/ui/components/transcript.py b/src/transcription/ui/components/transcript.py new file mode 100644 index 0000000..463b243 --- /dev/null +++ b/src/transcription/ui/components/transcript.py @@ -0,0 +1,53 @@ +"""Reusable transcript UI components.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from nicegui import ui + +from transcription.models import Transcript + + +def render_transcript_revision_row( + *, + transcript: Transcript, + initially_expanded: bool = False, + classes: str = "w-full", +) -> Any: + """Render one collapsible row for a single transcript revision.""" + status_label = "Failed" if transcript.error_detail else "Transcribed" + header = f"Revision {transcript.revision} | {status_label}" + caption = f"{transcript.provider} | {transcript.prompt_name} | {_format_created_at(transcript.created_at)}" + + expansion = ui.expansion(text=header, caption=caption, value=initially_expanded, group="group").classes( + f"{classes} rounded-borders bg-blue-grey-10" + ) + + with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"): + _metadata_row(label="Provider", value=transcript.provider) + _metadata_row(label="Prompt", value=transcript.prompt_name) + _metadata_row(label="Created", value=_format_created_at(transcript.created_at)) + + if transcript.text: + with ui.card().classes("w-full q-pa-sm"): + ui.markdown(transcript.text) + + if transcript.error_detail: + with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"): + ui.label("Failure detail").classes("text-caption text-uppercase") + ui.label(transcript.error_detail).classes("text-body2") + + return expansion + + +def _format_created_at(value: datetime) -> str: + """Return a compact UTC-like timestamp for row captions.""" + return value.strftime("%Y-%m-%d %H:%M:%S %Z") + + +def _metadata_row(*, label: str, value: str) -> None: + with ui.row().classes("w-md items-start justify-between q-gutter-x-md"): + ui.label(label).classes("text-caption text-grey-5 text-uppercase") + ui.label(value).classes("text-body2 text-right text-grey-1 break-all") diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index ba8af5b..d91c5e8 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -8,13 +8,14 @@ from fastapi import Request from nicegui import ui from transcription.app_state import resolve_session_factory +from transcription.models import JobStatus from transcription.services.jobs import JobService -from transcription.services.transcription import TranscriptionService from transcription.ui.components.app_shell import render_navigation_header -from transcription.ui.components.job_detail import render_job_detail from transcription.ui.components.table.jobs import render_jobs_table +from ..components.document_panzoom import render_document_panzoom from ..components.table.jobs import JobTableRow +from ..components.transcript import render_transcript_revision_row def register_page() -> None: @@ -48,22 +49,22 @@ def register_page() -> None: async def job_detail_page(job_id: str, request: Request) -> None: session_factory = resolve_session_factory(request.app.state) jobs_service = JobService(session_factory=session_factory) - transcription_service = TranscriptionService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - ui.button(icon="arrow_back", on_click=ui.navigate.back) - try: - parsed_id = UUID(job_id) - except ValueError: - ui.label("Invalid job id") - ui.link("Back to jobs", "/jobs") - return - try: - job = await jobs_service.read_job(job_id=parsed_id) - except ValueError: - ui.label("Job not found") - ui.link("Back to jobs", "/jobs") - return + job = await jobs_service.read_job(job_id=UUID(job_id)) - transcripts = list(await transcription_service.list_transcripts_by_job(parsed_id)) - render_job_detail(job=job, document=job.document, transcripts=transcripts) + with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter: + with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"): + render_document_panzoom(document=job.document) + with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"): + with ui.row(): + ui.button(icon="arrow_back", on_click=ui.navigate.back) + with ui.row().classes("w-full items-center justify-between"): + ui.label(f"{job.id}").classes("text-h6 text-weight-bold") + match job.status: + case JobStatus.TRANSCRIBED: + ui.chip(job.status.value.upper(), color="green", text_color="white").props("outline") + case _: + ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") + for i, transcript in enumerate(job.transcripts): + render_transcript_revision_row(transcript=transcript, initially_expanded=(i == 0))