diff --git a/src/transcription/app.py b/src/transcription/app.py
index d1df106..f061fe8 100644
--- a/src/transcription/app.py
+++ b/src/transcription/app.py
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import status
from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
from .api.errors import register_error_handlers
from .api.health import router as health_router
@@ -25,7 +26,7 @@ from .worker import worker_consumer_lifespan
async def _lifespan(app: FastAPI):
configure_logging()
- settings = get_settings()
+ settings = getattr(app.state, "settings", None) or get_settings()
app.state.settings = settings
app.state.services = ServiceBundle()
app.state.runtime = initialize_database_runtime(settings=settings)
@@ -52,6 +53,13 @@ async def _lifespan(app: FastAPI):
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
+ settings = get_settings()
+ app.state.settings = settings
+ app.mount(
+ "/uploads",
+ StaticFiles(directory=settings.upload_dir, check_dir=False),
+ name="uploads",
+ )
@app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse:
diff --git a/src/transcription/ui/components/__init__.py b/src/transcription/ui/components/__init__.py
index 8dd4dc4..01d5877 100644
--- a/src/transcription/ui/components/__init__.py
+++ b/src/transcription/ui/components/__init__.py
@@ -2,5 +2,6 @@
from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_navigation_header
+from transcription.ui.components.document_panzoom import render_document_panzoom
-__all__ = ["NAV_ITEMS", "render_navigation_header"]
+__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
diff --git a/src/transcription/ui/components/document_panzoom.py b/src/transcription/ui/components/document_panzoom.py
new file mode 100644
index 0000000..ff60304
--- /dev/null
+++ b/src/transcription/ui/components/document_panzoom.py
@@ -0,0 +1,236 @@
+"""Panzoom-backed document preview component."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from pathlib import Path
+from urllib.parse import quote
+from uuid import uuid4
+
+from nicegui import ui
+
+from transcription.config import get_settings
+from transcription.models import Document
+
+PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/panzoom@4.6.2/dist/panzoom.min.js"
+UPLOADS_URL_PREFIX = "/uploads"
+
+
+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"
+
+
+@lru_cache(maxsize=1)
+def _register_panzoom_assets() -> None:
+ ui.add_head_html(
+ f'',
+ shared=True,
+ )
+ ui.add_head_html(
+ """
+
+ """,
+ shared=True,
+ )
+
+
+def _attach_panzoom(host_id: str) -> None:
+ ui.run_javascript(
+ f"""
+ (function() {{
+ if (!window.Panzoom) return;
+ window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
+ const host = document.getElementById({host_id!r});
+ if (!host) return;
+ const target = host.querySelector('[data-panzoom-target]');
+ const media = host.querySelector('[data-panzoom-media]');
+ if (!target) return;
+
+ const computeFitScale = () => {{
+ const hostRect = host.getBoundingClientRect();
+ if (hostRect.width <= 0 || hostRect.height <= 0) return 1;
+
+ if (media && media.tagName === 'IMG') {{
+ if (media.naturalWidth <= 0 || media.naturalHeight <= 0) return 1;
+ return Math.min(
+ hostRect.width / media.naturalWidth,
+ hostRect.height / media.naturalHeight
+ );
+ }}
+
+ if (target.tagName === 'IFRAME') {{
+ const targetRect = target.getBoundingClientRect();
+ if (targetRect.width <= 0 || targetRect.height <= 0) return 1;
+ return hostRect.height / targetRect.height;
+ }}
+
+ return 1;
+ }};
+
+ const initPanzoom = () => {{
+ if (window.__transcriptionPanzoom[{host_id!r}]) {{
+ window.__transcriptionPanzoom[{host_id!r}].destroy();
+ }}
+
+ if (host.__transcriptionWheelHandler) {{
+ host.removeEventListener('wheel', host.__transcriptionWheelHandler);
+ host.__transcriptionWheelHandler = null;
+ }}
+
+ let fitScale = computeFitScale();
+
+ if (!Number.isFinite(fitScale) || fitScale <= 0) {{
+ fitScale = 1;
+ }}
+
+ const startScale = fitScale;
+ const minScale = 0.1;
+ const instance = Panzoom(target, {{
+ maxScale: 32,
+ minScale: minScale,
+ startScale: startScale,
+ step: 0.18,
+ contain: 'inside',
+ roundPixels: true,
+ overflow: 'hidden',
+ }});
+
+ window.__transcriptionPanzoom[{host_id!r}] = instance;
+
+ requestAnimationFrame(() => {{
+ requestAnimationFrame(() => {{
+ instance.reset({{ animate: false, force: true }});
+ }});
+ }});
+
+ const wheelHandler = (event) => instance.zoomWithWheel(event);
+ host.__transcriptionWheelHandler = wheelHandler;
+ host.addEventListener('wheel', wheelHandler, {{ passive: false }});
+ }};
+
+ if (media && media.tagName === 'IMG' && !media.complete) {{
+ media.addEventListener('load', initPanzoom, {{ once: true }});
+ return;
+ }}
+
+ if ((media && media.tagName === 'IMG' && (host.clientWidth <= 0 || host.clientHeight <= 0)) ||
+ (target.tagName === 'IFRAME' && (host.clientWidth <= 0 || host.clientHeight <= 0))) {{
+ requestAnimationFrame(initPanzoom);
+ return;
+ }}
+
+ initPanzoom();
+ }})();
+ """
+ )
+
+
+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 bg-blue-grey-10 text-grey-1 q-pa-md"):
+ with ui.row().classes("w-full items-center justify-between q-gutter-sm"):
+ with ui.column().classes("q-gutter-none"):
+ ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
+ ui.label(document.filename).classes("text-caption text-grey-4")
+
+ with ui.button_group().props("flat outline"):
+ ui.button(
+ "Zoom In",
+ icon="add",
+ on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomIn()"),
+ )
+ ui.button(
+ "Zoom Out",
+ icon="remove",
+ on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomOut()"),
+ )
+ ui.button(
+ "Reset",
+ icon="restart_alt",
+ on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.reset()"),
+ )
+
+ ui.label("Scroll, pinch, or use the buttons to inspect the document.").classes(
+ "text-caption text-grey-4 q-mt-sm"
+ )
+
+ with (
+ ui.element("div")
+ .classes("w-full document-panzoom-host bg-blue-grey-9 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'
"
+ )
+
+ _attach_panzoom(host_id)
diff --git a/src/transcription/ui/components/job_detail.py b/src/transcription/ui/components/job_detail.py
index a3f219c..7abcc12 100644
--- a/src/transcription/ui/components/job_detail.py
+++ b/src/transcription/ui/components/job_detail.py
@@ -7,6 +7,7 @@ from nicegui import ui
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
+from transcription.ui.components.document_panzoom import render_document_panzoom
def _status_chip_classes(status: str) -> str:
@@ -35,6 +36,9 @@ def _render_document_section(document: Document) -> None:
_metadata_row("Filename", document.filename)
_metadata_row("File path", document.file_path)
+ ui.separator().classes("q-my-md bg-blue-grey-7")
+ render_document_panzoom(document=document)
+
def _render_transcript_section(transcript: Transcript | None) -> None:
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py
new file mode 100644
index 0000000..44850b9
--- /dev/null
+++ b/tests/ui/conftest.py
@@ -0,0 +1,79 @@
+"""Shared fixtures for UI integration tests."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from pathlib import Path
+from uuid import UUID
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from transcription.app import create_app
+from transcription.config import Settings
+from transcription.config import _settings
+from transcription.db import get_session
+from transcription.models import Document
+from transcription.models import Job
+from transcription.models import JobStatus
+from transcription.models import Transcript
+
+
+@pytest.fixture
+def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
+ """Provide a real application and test client backed by in-memory SQLite."""
+ settings = Settings(
+ openrouter_api_key="test-key",
+ database_url="sqlite:///:memory:",
+ environment="test",
+ upload_dir=tmp_path / "uploads",
+ prompt_dir=tmp_path / "prompts",
+ )
+ _settings.set(settings)
+
+ app = create_app()
+ with TestClient(app) as client:
+ yield app, client
+
+
+@pytest.fixture
+def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
+ """Return a helper for inserting a document/job/transcript trio."""
+ app, _ = app_client
+
+ def _seed(
+ *,
+ filename: str = "sample.pdf",
+ status: JobStatus = JobStatus.TRANSCRIBED,
+ transcript_text: str | None = "Sample transcript text",
+ error_detail: str | None = None,
+ ) -> UUID:
+ async def _insert() -> UUID:
+ async with get_session(session_factory=app.state.runtime.session_factory) as session:
+ document = Document(filename=filename, file_path=f"uploads/{filename}")
+ session.add(document)
+ await session.flush()
+
+ job = Job(document_id=document.id, status=status, retry_count=0)
+ session.add(job)
+ await session.flush()
+
+ if transcript_text is not None or error_detail is not None:
+ session.add(
+ Transcript(
+ job_id=job.id,
+ provider="openrouter",
+ prompt_name="transcribe_document",
+ text=transcript_text,
+ error_detail=error_detail,
+ )
+ )
+
+ await session.commit()
+ return job.id
+
+ return asyncio.run(_insert())
+
+ return _seed