job detail page stuff

This commit is contained in:
John Lancaster
2026-06-29 18:33:22 -05:00
parent 67b0980664
commit 9ada09accf
4 changed files with 133 additions and 77 deletions
+4 -1
View File
@@ -35,7 +35,10 @@ class JobService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = ( query = (
select(Job) 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) .where(Job.id == job_id)
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
) )
@@ -16,30 +16,41 @@ PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.j
UPLOADS_URL_PREFIX = "/uploads" UPLOADS_URL_PREFIX = "/uploads"
def _document_url(document: Document) -> str: def render_document_panzoom(*, document: Document) -> None:
file_path = Path(document.file_path) """Render a document preview with pan and zoom interactions."""
upload_dir = get_settings().upload_dir _register_panzoom_assets()
relative_path: Path host_id = f"document-panzoom-{uuid4().hex}"
try: document_url = _document_url(document)
relative_path = file_path.resolve().relative_to(upload_dir.resolve()) document_kind = _document_kind(document)
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) with ui.card().classes("w-full q-pa-md"):
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}" 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'<iframe class="document-panzoom-iframe" '
f'src="{document_url}" title="{document.filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" '
f'src="{document_url}" alt="{document.filename}" '
"data-panzoom-target data-panzoom-media />"
)
def _document_kind(document: Document) -> str: _attach_panzoom(host_id)
suffix = Path(document.file_path).suffix.lower()
if suffix == ".pdf":
return "pdf"
return "image"
@lru_cache(maxsize=1) @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: def _attach_panzoom(host_id: str) -> None:
ui.run_javascript( ui.run_javascript(
f""" 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'<iframe class="document-panzoom-iframe" '
f'src="{document_url}" title="{document.filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" '
f'src="{document_url}" alt="{document.filename}" '
"data-panzoom-target data-panzoom-media />"
)
_attach_panzoom(host_id)
@@ -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")
+19 -18
View File
@@ -8,13 +8,14 @@ from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.app_state import resolve_session_factory from transcription.app_state import resolve_session_factory
from transcription.models import JobStatus
from transcription.services.jobs import JobService 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.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 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.table.jobs import JobTableRow
from ..components.transcript import render_transcript_revision_row
def register_page() -> None: def register_page() -> None:
@@ -48,22 +49,22 @@ def register_page() -> None:
async def job_detail_page(job_id: str, request: Request) -> None: async def job_detail_page(job_id: str, request: Request) -> None:
session_factory = resolve_session_factory(request.app.state) session_factory = resolve_session_factory(request.app.state)
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
transcription_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") 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=UUID(job_id))
job = await jobs_service.read_job(job_id=parsed_id)
except ValueError:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
transcripts = list(await transcription_service.list_transcripts_by_job(parsed_id)) with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
render_job_detail(job=job, document=job.document, transcripts=transcripts) 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))