From 83ee7b31e0511eb3e6be6b8cb8fb0aaca27ef2f4 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:18:06 -0500 Subject: [PATCH] styling --- src/transcription/ui/__init__.py | 33 ++++++++++- src/transcription/ui/components/__init__.py | 5 ++ src/transcription/ui/components/app_shell.py | 57 +++++++++++++++---- src/transcription/ui/components/job_detail.py | 30 +++++----- src/transcription/ui/pages/jobs_page.py | 4 +- src/transcription/ui/pages/upload_page.py | 2 +- src/transcription/ui/static/colors.css | 30 ++++++++++ 7 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 src/transcription/ui/static/colors.css diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index cd9fb61..0fa7e83 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -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) diff --git a/src/transcription/ui/components/__init__.py b/src/transcription/ui/components/__init__.py index 313c2a5..8dd4dc4 100644 --- a/src/transcription/ui/components/__init__.py +++ b/src/transcription/ui/components/__init__.py @@ -1 +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"] diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index fada0b7..f8e2c23 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -4,23 +4,56 @@ from __future__ import annotations from nicegui import ui +NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( + ("Upload", "/upload", "upload_file"), + ("Jobs", "/jobs", "work_history"), +) -def _nav_link_classes(*, is_active: bool) -> str: - base = "q-px-sm q-py-xs rounded-borders text-body2" + +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"{base} bg-primary text-white text-weight-medium" - return f"{base} text-primary" + 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 render_navigation_header(*, title: str, current_path: str | None = None) -> None: +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 = (current_path or "").rstrip("/") + normalized_path = _normalize_path(current_path) with ( - ui.header(elevated=True).classes("bg-white text-dark q-px-md q-py-sm"), - ui.row().classes("w-full items-center justify-between q-gutter-sm"), + 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-end sm:gap-2"), ): - ui.label(title).classes("text-subtitle1 text-weight-bold") - with ui.row().classes("items-center q-gutter-xs"): - ui.link("Upload", "/upload").classes(_nav_link_classes(is_active=normalized_path == "/upload")) - ui.link("Jobs", "/jobs").classes(_nav_link_classes(is_active=normalized_path.startswith("/jobs"))) + for label, path, icon in NAV_ITEMS: + _render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path) diff --git a/src/transcription/ui/components/job_detail.py b/src/transcription/ui/components/job_detail.py index d93d503..a3f219c 100644 --- a/src/transcription/ui/components/job_detail.py +++ b/src/transcription/ui/components/job_detail.py @@ -22,27 +22,27 @@ 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) 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 +51,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 +67,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 +79,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()) diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index adb83ab..ae346df 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -60,7 +60,7 @@ def register_page() -> None: @ui.page("/jobs") async def jobs_page() -> None: - render_navigation_header(title="Transcription", current_path="/jobs") + render_navigation_header(current_path="/jobs") ui.label("Transcription Jobs") status = ui.label("Ready") @@ -84,7 +84,7 @@ def register_page() -> None: @ui.page("/jobs/{job_id}") async def job_detail_page(job_id: str) -> None: - render_navigation_header(title="Transcription", current_path="/jobs") + render_navigation_header(current_path="/jobs") ui.label("Job Detail") try: parsed_id = UUID(job_id) diff --git a/src/transcription/ui/pages/upload_page.py b/src/transcription/ui/pages/upload_page.py index ddd3939..cc94645 100644 --- a/src/transcription/ui/pages/upload_page.py +++ b/src/transcription/ui/pages/upload_page.py @@ -18,7 +18,7 @@ def register_page() -> None: @ui.page("/upload", title="Upload Document") def upload_page(request: Request) -> None: - render_navigation_header(title="Transcription", current_path="/upload") + render_navigation_header(current_path="/upload") session_factory = resolve_session_factory(request.app.state) async def submit_upload(filename: str, file_bytes: bytes): diff --git a/src/transcription/ui/static/colors.css b/src/transcription/ui/static/colors.css new file mode 100644 index 0000000..4a529fc --- /dev/null +++ b/src/transcription/ui/static/colors.css @@ -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; + +}