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'{document.filename}" + ) + + _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