4 Commits
Author SHA1 Message Date
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
10 changed files with 172 additions and 27 deletions
+32 -1
View File
@@ -1,14 +1,45 @@
"""UI page registration exports.""" """UI page registration exports."""
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from nicegui import app as nicegui_app
from nicegui import ui from nicegui import ui
from transcription.ui.pages.jobs_page import register_page as register_jobs_page 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 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: def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app.""" """Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_upload_page() register_upload_page()
register_jobs_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,6 @@
"""Reusable UI component exports."""
from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_navigation_header
__all__ = ["NAV_ITEMS", "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)
+15 -15
View File
@@ -22,27 +22,27 @@ def _status_chip_classes(status: str) -> str:
def _metadata_row(label: str, value: str) -> None: def _metadata_row(label: str, value: str) -> None:
with ui.row().classes("w-full items-start justify-between no-wrap q-gutter-x-md"): with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-7 text-uppercase") ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
ui.label(value).classes("text-body2 text-right") ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
def _render_document_section(document: Document) -> None: 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.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"): with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Filename", document.filename) _metadata_row("Filename", document.filename)
_metadata_row("File path", document.file_path) _metadata_row("File path", document.file_path)
def _render_transcript_section(transcript: Transcript | None) -> None: 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.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: 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 return
with ui.column().classes("w-full q-gutter-y-xs"): with ui.column().classes("w-full q-gutter-y-xs"):
@@ -51,13 +51,13 @@ def _render_transcript_section(transcript: Transcript | None) -> None:
_metadata_row("Created", transcript.created_at.isoformat()) _metadata_row("Created", transcript.created_at.isoformat())
if transcript.text: if transcript.text:
ui.separator().classes("q-my-sm") ui.separator().classes("q-my-sm bg-blue-grey-7")
with ui.card().classes("w-full bg-grey-1 q-pa-sm"): with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
ui.markdown(transcript.text) ui.markdown(transcript.text).classes("text-grey-1")
return return
if transcript.error_detail: 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"): 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("Failure detail").classes("text-caption text-uppercase")
ui.label(transcript.error_detail).classes("text-body2") ui.label(transcript.error_detail).classes("text-body2")
@@ -67,11 +67,11 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
"""Render all sections for the job detail page.""" """Render all sections for the job detail page."""
status_text = job.status.value status_text = job.status.value
with ui.column().classes("w-full max-w-4xl q-gutter-md"): 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.row().classes("w-full items-center justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"): with ui.column().classes("q-gutter-none"):
ui.label("Job overview").classes("text-h6 text-weight-bold") 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 = ( status_chip_classes = (
"q-px-sm q-py-xs rounded-borders " "q-px-sm q-py-xs rounded-borders "
"text-weight-medium text-capitalize " "text-weight-medium text-capitalize "
@@ -79,7 +79,7 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
) )
ui.label(status_text).classes(status_chip_classes) 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"): with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Created", job.created_at.isoformat()) _metadata_row("Created", job.created_at.isoformat())
_metadata_row("Updated", job.updated_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 Document
from transcription.models import Job from transcription.models import Job
from transcription.models import Transcript 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 show_error
from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.error_presenter import summarize_error
from transcription.ui.components.job_detail import render_job_detail from transcription.ui.components.job_detail import render_job_detail
@@ -59,6 +60,7 @@ def register_page() -> None:
@ui.page("/jobs") @ui.page("/jobs")
async def jobs_page() -> None: async def jobs_page() -> None:
render_navigation_header(current_path="/jobs")
ui.label("Transcription Jobs") ui.label("Transcription Jobs")
status = ui.label("Ready") status = ui.label("Ready")
@@ -82,6 +84,7 @@ def register_page() -> None:
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None: async def job_detail_page(job_id: str) -> None:
render_navigation_header(current_path="/jobs")
ui.label("Job Detail") ui.label("Job Detail")
try: try:
parsed_id = UUID(job_id) 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.app_state import resolve_session_factory
from transcription.db import get_session from transcription.db import get_session
from transcription.services.store import create_upload_job 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.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier from transcription.worker import resolve_worker_notifier
@@ -17,6 +18,7 @@ def register_page() -> None:
@ui.page("/upload", title="Upload Document") @ui.page("/upload", title="Upload Document")
def upload_page(request: Request) -> None: def upload_page(request: Request) -> None:
render_navigation_header(current_path="/upload")
session_factory = resolve_session_factory(request.app.state) session_factory = resolve_session_factory(request.app.state)
async def submit_upload(filename: str, file_bytes: bytes): 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) notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker) 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;
}
+21 -5
View File
@@ -1,11 +1,14 @@
"""Tests for the jobs page route.""" """Tests for the jobs page route."""
from uuid import UUID
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from transcription.ui import register_pages from transcription.ui import register_pages
from transcription.ui.pages import jobs_page from transcription.ui.pages import jobs_page
from transcription.ui.pages.jobs_page import JobTableRow
@pytest.fixture @pytest.fixture
@@ -13,7 +16,16 @@ def client(monkeypatch):
"""Provide a minimal app client with jobs data patched for rendering.""" """Provide a minimal app client with jobs data patched for rendering."""
async def _fetch_jobs_stub(): 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) monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
@@ -27,11 +39,15 @@ def client(monkeypatch):
class TestPageRendering: class TestPageRendering:
"""Verify the jobs page is available and includes the main controls.""" """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.""" """GET /ui/jobs returns the page shell and jobs controls."""
response = client.get("/ui/jobs") 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 response.status_code == 200
assert "Transcription Jobs" in response.text assert "No jobs yet." in response.text
assert "Refresh" in response.text
assert "Back to upload" in response.text
+3 -2
View File
@@ -25,8 +25,9 @@ class TestPageRegistration:
*, *,
mount_path: str, mount_path: str,
show_welcome_message: bool, show_welcome_message: bool,
dark: bool,
) -> None: ) -> 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_upload_page", _record_upload)
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs) monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
@@ -35,4 +36,4 @@ class TestPageRegistration:
app = FastAPI() app = FastAPI()
register_pages(app) 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 response.status_code == 200
assert "Upload Document" in response.text assert "Upload Document" in response.text
assert "Select document file" in response.text assert "Select document file" in response.text
assert "View jobs" in response.text assert "Jobs" in response.text