7 Commits
Author SHA1 Message Date
John Lancaster 5719debbaa messing with zoom/reset 2026-06-29 08:06:02 -05:00
John Lancaster e7c7ab71b4 zooming fix 2026-06-28 22:20:48 -05:00
John Lancaster ca5c9f787f started panzoom thing 2026-06-28 22:10:06 -05:00
John Lancaster 8064821503 app_shell tweak 2026-06-28 15:19:32 -05:00
John Lancaster 593388ef3a test updates 2026-06-28 15:18:31 -05:00
John Lancaster 83ee7b31e0 styling 2026-06-28 15:18:06 -05:00
John Lancaster 455a01d7c4 started app shell 2026-06-28 14:58:29 -05:00
13 changed files with 500 additions and 28 deletions
+9 -1
View File
@@ -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:
+32 -1
View File
@@ -1,14 +1,45 @@
"""UI page registration exports."""
from pathlib import Path
from fastapi import FastAPI
from nicegui import app as nicegui_app
from nicegui import ui
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
_THEME_COLORS: dict[str, str] = {
"primary": "#6f97e8",
"secondary": "#92b5f5",
"accent": "#7fc0de",
"dark": "#22304a",
"dark_page": "#1a2538",
"positive": "#86c8ad",
"negative": "#d98a9a",
"info": "#7ebdda",
"warning": "#e2c083",
}
def _register_global_styles(app: FastAPI) -> None:
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return
nicegui_app.colors(**_THEME_COLORS)
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
if css_path.exists():
ui.add_css(css_path, shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
@@ -0,0 +1,7 @@
"""Reusable UI component exports."""
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_document_panzoom", "render_navigation_header"]
@@ -0,0 +1,59 @@
"""Reusable app shell primitives for page-level layout."""
from __future__ import annotations
from nicegui import ui
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Upload", "/upload", "upload_file"),
("Jobs", "/jobs", "work_history"),
)
def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs":
return current_path == "/jobs" or current_path.startswith("/jobs/")
return current_path == item_path
def _button_props(*, icon: str, is_active: bool) -> str:
if is_active:
return f"icon={icon} no-caps unelevated color=primary text-color=white"
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
def _button_classes(*, is_active: bool) -> str:
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
if is_active:
return f"{base}"
return f"{base}"
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
is_active = _is_active_path(current_path=current_path, item_path=path)
button = ui.button(
label,
icon=icon,
on_click=lambda _=None, route=path: ui.navigate.to(route),
)
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
def _normalize_path(current_path: str | None) -> str:
normalized = (current_path or "").strip()
if not normalized:
return "/upload"
return normalized.rstrip("/") or "/"
def render_navigation_header(*, current_path: str | None = None) -> None:
"""Render a shared app header with links for top-level pages."""
normalized_path = _normalize_path(current_path)
with (
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
):
for label, path, icon in NAV_ITEMS:
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
@@ -0,0 +1,235 @@
"""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/[email protected]/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'<script src="{PANGOZOOM_CDN_URL}"></script>',
shared=True,
)
ui.add_head_html(
"""
<style>
.document-panzoom-host {
overflow: hidden;
touch-action: none;
}
.document-panzoom-surface {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.document-panzoom-media {
width: auto;
height: auto;
display: block;
max-width: none;
max-height: none;
user-select: none;
-webkit-user-drag: none;
}
.document-panzoom-iframe {
width: 100%;
height: 100%;
border: 0;
pointer-events: none;
background: white;
}
</style>
""",
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.01;
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(() => {{
instance.reset({{ animate: false, force: true }});
instance.setOptions({{ contain: undefined }});
}});
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'<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)
+19 -15
View File
@@ -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:
@@ -22,27 +23,30 @@ def _status_chip_classes(status: str) -> str:
def _metadata_row(label: str, value: str) -> None:
with ui.row().classes("w-full items-start justify-between no-wrap q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-7 text-uppercase")
ui.label(value).classes("text-body2 text-right")
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
def _render_document_section(document: Document) -> None:
with ui.card().classes("w-full bg-grey-1 q-pa-md"):
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
ui.label("Document").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
ui.separator().classes("q-my-sm bg-blue-grey-7")
with ui.column().classes("w-full q-gutter-y-xs"):
_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 q-pa-md"):
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
ui.separator().classes("q-my-sm bg-blue-grey-7")
if transcript is None:
ui.label("Transcript not available yet.").classes("text-body2 text-grey-8")
ui.label("Transcript not available yet.").classes("text-body2 text-grey-3")
return
with ui.column().classes("w-full q-gutter-y-xs"):
@@ -51,13 +55,13 @@ def _render_transcript_section(transcript: Transcript | None) -> None:
_metadata_row("Created", transcript.created_at.isoformat())
if transcript.text:
ui.separator().classes("q-my-sm")
with ui.card().classes("w-full bg-grey-1 q-pa-sm"):
ui.markdown(transcript.text)
ui.separator().classes("q-my-sm bg-blue-grey-7")
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
ui.markdown(transcript.text).classes("text-grey-1")
return
if transcript.error_detail:
ui.separator().classes("q-my-sm")
ui.separator().classes("q-my-sm bg-blue-grey-7")
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")
@@ -67,11 +71,11 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
"""Render all sections for the job detail page."""
status_text = job.status.value
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
with ui.card().classes("w-full q-pa-lg"):
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-lg"):
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"):
ui.label("Job overview").classes("text-h6 text-weight-bold")
ui.label(str(job.id)).classes("text-caption text-grey-7")
ui.label(str(job.id)).classes("text-caption text-grey-5")
status_chip_classes = (
"q-px-sm q-py-xs rounded-borders "
"text-weight-medium text-capitalize "
@@ -79,7 +83,7 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
)
ui.label(status_text).classes(status_chip_classes)
ui.separator().classes("q-my-md")
ui.separator().classes("q-my-md bg-blue-grey-7")
with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Created", job.created_at.isoformat())
_metadata_row("Updated", job.updated_at.isoformat())
+3
View File
@@ -13,6 +13,7 @@ from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
from transcription.ui.components.job_detail import render_job_detail
@@ -59,6 +60,7 @@ def register_page() -> None:
@ui.page("/jobs")
async def jobs_page() -> None:
render_navigation_header(current_path="/jobs")
ui.label("Transcription Jobs")
status = ui.label("Ready")
@@ -82,6 +84,7 @@ def register_page() -> None:
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None:
render_navigation_header(current_path="/jobs")
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
+2 -3
View File
@@ -8,6 +8,7 @@ from nicegui import ui
from transcription.app_state import resolve_session_factory
from transcription.db import get_session
from transcription.services.store import create_upload_job
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier
@@ -17,6 +18,7 @@ def register_page() -> None:
@ui.page("/upload", title="Upload Document")
def upload_page(request: Request) -> None:
render_navigation_header(current_path="/upload")
session_factory = resolve_session_factory(request.app.state)
async def submit_upload(filename: str, file_bytes: bytes):
@@ -29,6 +31,3 @@ def register_page() -> None:
notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
with ui.row():
ui.link("View jobs", "/jobs")
+30
View File
@@ -0,0 +1,30 @@
:root {
/* Soft blue-night palette tokens */
--ctp-rosewater: #f2dde5;
--ctp-flamingo: #edcfd8;
--ctp-pink: #dcc7de;
--ctp-mauve: #a9bde5;
--ctp-red: #d98a9a;
--ctp-maroon: #d39aa5;
--ctp-peach: #d7af8c;
--ctp-yellow: #e2c083;
--ctp-green: #86c8ad;
--ctp-teal: #77bfbe;
--ctp-sky: #7ebdda;
--ctp-sapphire: #74aed0;
--ctp-blue: #92b5f5;
--ctp-lavender: #6f97e8;
--ctp-text: #d8e2f5;
--ctp-subtext1: #bfcae0;
--ctp-subtext0: #a9b6cf;
--ctp-overlay2: #95a3bf;
--ctp-overlay1: #7c8ca9;
--ctp-overlay0: #657490;
--ctp-surface2: #4d5f7c;
--ctp-surface1: #394a65;
--ctp-surface0: #2a3954;
--ctp-base: #1f2b42;
--ctp-mantle: #1a2538;
--ctp-crust: #141e30;
}
+79
View File
@@ -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
+21 -5
View File
@@ -1,11 +1,14 @@
"""Tests for the jobs page route."""
from uuid import UUID
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.ui import register_pages
from transcription.ui.pages import jobs_page
from transcription.ui.pages.jobs_page import JobTableRow
@pytest.fixture
@@ -13,7 +16,16 @@ def client(monkeypatch):
"""Provide a minimal app client with jobs data patched for rendering."""
async def _fetch_jobs_stub():
return []
return [
JobTableRow(
id=UUID("00000000-0000-0000-0000-000000000001"),
status="queued",
filename="sample.pdf",
retry_count=2,
created_at="2026-01-01T12:00:00+00:00",
updated_at="2026-01-01T12:01:00+00:00",
)
]
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
@@ -27,11 +39,15 @@ def client(monkeypatch):
class TestPageRendering:
"""Verify the jobs page is available and includes the main controls."""
def test_jobs_page_renders_expected_controls(self, client):
def test_jobs_page_renders_expected_controls(self, client, monkeypatch):
"""GET /ui/jobs returns the page shell and jobs controls."""
response = client.get("/ui/jobs")
async def _fetch_jobs_empty():
return []
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_empty)
response = client.get("/ui/jobs")
assert response.status_code == 200
assert "Transcription Jobs" in response.text
assert "Refresh" in response.text
assert "Back to upload" in response.text
assert "No jobs yet." in response.text
+3 -2
View File
@@ -25,8 +25,9 @@ class TestPageRegistration:
*,
mount_path: str,
show_welcome_message: bool,
dark: bool,
) -> None:
calls.append(f"run_with:{mount_path}:{show_welcome_message}")
calls.append(f"run_with:{mount_path}:{show_welcome_message}:{dark}")
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
@@ -35,4 +36,4 @@ class TestPageRegistration:
app = FastAPI()
register_pages(app)
assert calls == ["upload", "jobs", "run_with:/ui:False"]
assert calls == ["upload", "jobs", "run_with:/ui:False:True"]
+1 -1
View File
@@ -52,4 +52,4 @@ class TestPageRendering:
assert response.status_code == 200
assert "Upload Document" in response.text
assert "Select document file" in response.text
assert "View jobs" in response.text
assert "Jobs" in response.text