generated from john/python-template
global theming
This commit is contained in:
@@ -5,12 +5,12 @@ from fastapi import APIRouter
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
"""Return a simple health status payload."""
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz")
|
@router.get("/healthz")
|
||||||
def healthz_route() -> dict[str, str]:
|
def healthz_route() -> dict[str, str]:
|
||||||
"""Route wrapper for health status payload."""
|
"""Route wrapper for health status payload."""
|
||||||
return healthz()
|
return healthz()
|
||||||
|
|
||||||
|
|
||||||
|
def healthz() -> dict[str, str]:
|
||||||
|
"""Return a simple health status payload."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from fastapi import FastAPI
|
|||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
@@ -25,8 +24,7 @@ from .db import dispose_database_runtime
|
|||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
from .services.jobs import JobService
|
from .services.jobs import JobService
|
||||||
from .ui import register_pages
|
from .ui.pages import register_pages
|
||||||
from .ui.theme import THEME_COLORS
|
|
||||||
from .worker import worker_consumer_lifespan
|
from .worker import worker_consumer_lifespan
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -98,8 +96,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(health_router)
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
app.include_router(health_router)
|
|
||||||
ui.colors(**THEME_COLORS)
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
"""UI page registration exports."""
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.pages.documents import register_pages as register_documents_pages
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
|
||||||
from transcription.ui.pages.people_page import register_page as register_people_page
|
|
||||||
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
|
||||||
|
|
||||||
|
|
||||||
def _register_global_styles(app: FastAPI) -> None:
|
|
||||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
|
||||||
return
|
|
||||||
|
|
||||||
ui.add_css(read_css("theme.css"), 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_documents_pages()
|
|
||||||
register_people_page()
|
|
||||||
register_sources_page()
|
|
||||||
register_jobs_page()
|
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.pages.documents import register_pages as register_documents_pages
|
||||||
|
|
||||||
|
from ..theme import register_global_styles
|
||||||
|
from .jobs_page import register_page as register_jobs_page
|
||||||
|
from .people_page import register_page as register_people_page
|
||||||
|
from .sources_page import register_page as register_sources_page
|
||||||
|
from .upload_page import register_page as register_upload_page
|
||||||
|
|
||||||
|
__all__ = ["register_pages"]
|
||||||
|
|
||||||
|
|
||||||
|
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_documents_pages()
|
||||||
|
register_people_page()
|
||||||
|
register_sources_page()
|
||||||
|
register_jobs_page()
|
||||||
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from transcription.ui.components.primitives import destructive_button
|
|||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
from transcription.ui.theme import apply_archival_theme
|
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/jobs")
|
@ui.page("/jobs")
|
||||||
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
@@ -68,12 +67,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/jobs/new")
|
@ui.page("/jobs/new")
|
||||||
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
documents_service = DocumentService(session_factory=session_factory)
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
|
page_header(
|
||||||
|
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
|
||||||
|
)
|
||||||
|
|
||||||
documents = await documents_service.list_documents()
|
documents = await documents_service.list_documents()
|
||||||
if not documents:
|
if not documents:
|
||||||
@@ -88,7 +89,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
||||||
icon="note_add",
|
icon="note_add",
|
||||||
).classes("ui-btn-primary")
|
).classes("ui-btn-primary")
|
||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
uploaded_files: list[tuple[str, bytes]] = []
|
uploaded_files: list[tuple[str, bytes]] = []
|
||||||
@@ -136,18 +139,16 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.column().classes("gap-1 w-full mt-2"):
|
with ui.column().classes("gap-1 w-full mt-2"):
|
||||||
for index, (filename, _) in ordered_uploads:
|
for index, (filename, _) in ordered_uploads:
|
||||||
with ui.row().classes(
|
with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
|
||||||
"w-full items-center justify-between ui-row-surface p-2"
|
|
||||||
):
|
|
||||||
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
||||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
||||||
"flat round dense color=negative text-xs"
|
"flat round dense color=negative text-xs"
|
||||||
)
|
)
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end mt-2"):
|
with ui.row().classes("w-full justify-end mt-2"):
|
||||||
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
|
ui.button("Clear files", on_click=clear_files, icon="clear_all").props(
|
||||||
"text-xs text-red-800"
|
"flat dense"
|
||||||
)
|
).classes("text-xs text-red-800")
|
||||||
|
|
||||||
async def on_upload(event) -> None:
|
async def on_upload(event) -> None:
|
||||||
payload = await event.file.read()
|
payload = await event.file.read()
|
||||||
@@ -204,7 +205,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/jobs/{job_id}/delete")
|
@ui.page("/jobs/{job_id}/delete")
|
||||||
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
@@ -282,7 +283,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label("Delete is blocked while the job is processing.").classes(
|
ui.label("Delete is blocked while the job is processing.").classes(
|
||||||
"text-xs text-red-800 font-bold mt-2"
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
)
|
)
|
||||||
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
|
ui.label("Wait for processing to complete, then retry delete.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"Back to Job",
|
"Back to Job",
|
||||||
@@ -296,7 +299,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
||||||
if job.job_sources:
|
if job.job_sources:
|
||||||
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
|
ui.label("Related JobSource links will be removed as part of delete.").classes(
|
||||||
|
"text-xs ui-text-muted"
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ from uuid import UUID
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.config import Settings, get_settings
|
from transcription.config import Settings
|
||||||
|
from transcription.config import get_settings
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
from transcription.services.documents import (
|
from transcription.services.documents import DocumentError
|
||||||
DocumentError,
|
from transcription.services.documents import DocumentService
|
||||||
DocumentService,
|
from transcription.services.documents import PersonDeleteBlockedError
|
||||||
PersonDeleteBlockedError,
|
from transcription.services.store import UploadError
|
||||||
)
|
from transcription.services.store import store_person_portrait
|
||||||
from transcription.services.store import UploadError, store_person_portrait
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
@@ -25,9 +25,9 @@ from transcription.ui.components.error_presenter import show_error
|
|||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.people import PersonTableRow, render_people_table
|
from transcription.ui.components.table.people import PersonTableRow
|
||||||
|
from transcription.ui.components.table.people import render_people_table
|
||||||
from transcription.ui.components.viewers import dark_room_viewer
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
from transcription.ui.theme import apply_archival_theme
|
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -104,7 +104,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people")
|
@ui.page("/people")
|
||||||
async def people_page(session_factory: SessionFactoryDep) -> None:
|
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/new")
|
@ui.page("/people/new")
|
||||||
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/{person_id}")
|
@ui.page("/people/{person_id}")
|
||||||
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -271,9 +271,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
with archival_card(title="Biography"):
|
with archival_card(title="Biography"):
|
||||||
ui.label(person.biography or "No biography recorded.").classes(
|
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
|
||||||
"p-2 ui-note-box text-xs w-full"
|
|
||||||
)
|
|
||||||
|
|
||||||
with archival_card(title="Linked Documents"):
|
with archival_card(title="Linked Documents"):
|
||||||
if not person.document_people:
|
if not person.document_people:
|
||||||
@@ -285,9 +283,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document = link.document
|
document = link.document
|
||||||
if document is None:
|
if document is None:
|
||||||
continue
|
continue
|
||||||
with ui.row().classes(
|
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
|
||||||
"w-full justify-between items-center ui-row-surface p-2"
|
|
||||||
):
|
|
||||||
with ui.column().classes("gap-0"):
|
with ui.column().classes("gap-0"):
|
||||||
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
|
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
|
||||||
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
|
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
|
||||||
@@ -301,7 +297,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/{person_id}/edit")
|
@ui.page("/people/{person_id}/edit")
|
||||||
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -329,7 +325,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
||||||
"outlined bg-white"
|
"outlined bg-white"
|
||||||
)
|
)
|
||||||
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
|
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
birth_date_input = ui.input(
|
birth_date_input = ui.input(
|
||||||
@@ -339,7 +337,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
birth_date_raw_input = ui.input(
|
birth_date_raw_input = ui.input(
|
||||||
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
||||||
).props("outlined bg-white")
|
).props("outlined bg-white")
|
||||||
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
|
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
death_date_input = ui.input(
|
death_date_input = ui.input(
|
||||||
@@ -349,13 +349,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
death_date_raw_input = ui.input(
|
death_date_raw_input = ui.input(
|
||||||
label="Death date (approximate)", value=person.death_date_raw or ""
|
label="Death date (approximate)", value=person.death_date_raw or ""
|
||||||
).props("outlined bg-white")
|
).props("outlined bg-white")
|
||||||
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
|
death_place_input = ui.input(label="Death place", value=person.death_place or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
biography_input = (
|
biography_input = (
|
||||||
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
|
ui.textarea(label="Biography", value=person.biography or "")
|
||||||
|
.props("outlined bg-white autogrow")
|
||||||
|
.classes("w-full")
|
||||||
)
|
)
|
||||||
portrait_path_input = (
|
portrait_path_input = (
|
||||||
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
|
ui.input(label="Portrait path", value=person.portrait_path or "")
|
||||||
|
.props("outlined bg-white")
|
||||||
|
.classes("w-full")
|
||||||
)
|
)
|
||||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||||
|
|
||||||
@@ -401,11 +407,13 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
|
||||||
@ui.page("/people/{person_id}/delete")
|
@ui.page("/people/{person_id}/delete")
|
||||||
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -431,21 +439,27 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
if person.document_people:
|
if person.document_people:
|
||||||
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
|
ui.label("Delete is blocked because linked documents exist.").classes(
|
||||||
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
|
)
|
||||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
|
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
|
||||||
ui.label("Remove document links first, then retry deletion.").classes("text-xs ui-text-muted italic")
|
ui.label("Remove document links first, then retry deletion.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"Back to Person",
|
"Back to Person",
|
||||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||||
icon="arrow_back",
|
icon="arrow_back",
|
||||||
).classes("ui-btn-primary text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
|
ui.button(
|
||||||
"flat text-xs"
|
"Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description"
|
||||||
)
|
).props("flat text-xs")
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
|
ui.label("This action permanently deletes the person record.").classes(
|
||||||
|
"text-xs text-red-800 font-medium"
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -475,4 +489,6 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
icon="delete_forever",
|
icon="delete_forever",
|
||||||
variant="solid",
|
variant="solid",
|
||||||
)
|
)
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,21 +9,20 @@ from fastapi import Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db.models import JobSource, Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentError, DocumentService
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
from transcription.services.transcription import (
|
from transcription.services.transcription import TranscriptionNotFoundError
|
||||||
TranscriptionNotFoundError,
|
from transcription.services.transcription import TranscriptionService
|
||||||
TranscriptionService,
|
|
||||||
)
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
from transcription.ui.components.table.sources import SourceTableRow
|
||||||
from transcription.ui.theme import apply_archival_theme
|
from transcription.ui.components.table.sources import render_sources_table
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -34,7 +33,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/sources")
|
@ui.page("/sources")
|
||||||
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
documents_service = DocumentService(session_factory=session_factory)
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
@@ -56,7 +55,7 @@ def register_page() -> None:
|
|||||||
document = await documents_service.read_document_detail(document_id=document_id)
|
document = await documents_service.read_document_detail(document_id=document_id)
|
||||||
document_name = document.name
|
document_name = document.name
|
||||||
back_path = f"/documents/{document.id}"
|
back_path = f"/documents/{document.id}"
|
||||||
sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
|
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
elif job_id is not None:
|
elif job_id is not None:
|
||||||
job = await jobs_service.read_job(job_id=job_id)
|
job = await jobs_service.read_job(job_id=job_id)
|
||||||
job_label = str(job.id)
|
job_label = str(job.id)
|
||||||
@@ -65,7 +64,9 @@ def register_page() -> None:
|
|||||||
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
|
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
|
||||||
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
|
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
else:
|
else:
|
||||||
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
|
sources = sorted(
|
||||||
|
await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)
|
||||||
|
)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
return
|
return
|
||||||
@@ -89,9 +90,9 @@ def register_page() -> None:
|
|||||||
|
|
||||||
if back_path is not None:
|
if back_path is not None:
|
||||||
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
ui.button(
|
||||||
"ui-btn-primary text-xs"
|
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
|
||||||
)
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
# Format source records into read-model rows for the table renderer
|
# Format source records into read-model rows for the table renderer
|
||||||
rows = [
|
rows = [
|
||||||
@@ -108,7 +109,6 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/sources/{source_id}")
|
@ui.page("/sources/{source_id}")
|
||||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
apply_archival_theme()
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
@@ -131,7 +131,9 @@ def register_page() -> None:
|
|||||||
|
|
||||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
|
page_header(
|
||||||
|
f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}"
|
||||||
|
)
|
||||||
|
|
||||||
if back_path is not None:
|
if back_path is not None:
|
||||||
back_label = (
|
back_label = (
|
||||||
@@ -141,9 +143,9 @@ def register_page() -> None:
|
|||||||
if "job_id" in request.query_params
|
if "job_id" in request.query_params
|
||||||
else "Back to Sources"
|
else "Back to Sources"
|
||||||
)
|
)
|
||||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
ui.button(
|
||||||
"ui-btn-primary text-xs"
|
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
|
||||||
)
|
).classes("ui-btn-primary text-xs")
|
||||||
else:
|
else:
|
||||||
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
||||||
"flat text-xs"
|
"flat text-xs"
|
||||||
@@ -167,9 +169,9 @@ def register_page() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with archival_card(title="Automated Raw Transcription"):
|
with archival_card(title="Automated Raw Transcription"):
|
||||||
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
|
ui.textarea(value=_source_transcription_text(source) or "").props(
|
||||||
"w-full text-xs font-mono"
|
"outlined autogrow readonly bg-white"
|
||||||
)
|
).classes("w-full text-xs font-mono")
|
||||||
|
|
||||||
with archival_card(title="Curated Human Transcription"):
|
with archival_card(title="Curated Human Transcription"):
|
||||||
revision_input = (
|
revision_input = (
|
||||||
@@ -194,7 +196,9 @@ def register_page() -> None:
|
|||||||
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
|
ui.button("Save Revision", on_click=save_revision, icon="save").classes(
|
||||||
|
"ui-btn-primary text-xs"
|
||||||
|
)
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/sources")
|
@ui.page("/documents/{document_id}/sources")
|
||||||
async def document_sources_page(document_id: str) -> RedirectResponse:
|
async def document_sources_page(document_id: str) -> RedirectResponse:
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from nicegui import app as nicegui_app
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from .resources import read_css
|
||||||
|
|
||||||
|
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||||
|
|
||||||
|
|
||||||
# Runtime bridge for Quasar color slots. Visual ownership remains in theme.css tokens/classes.
|
# Runtime bridge for Quasar color slots. Visual ownership remains in theme.css tokens/classes.
|
||||||
THEME_COLORS = {
|
THEME_COLORS = {
|
||||||
"primary": "#5e6572",
|
"primary": "#5e6572",
|
||||||
@@ -12,17 +19,20 @@ THEME_COLORS = {
|
|||||||
"warning": "#a9b4c2",
|
"warning": "#a9b4c2",
|
||||||
}
|
}
|
||||||
|
|
||||||
_THEME_APPLIED = False
|
|
||||||
|
|
||||||
|
def register_global_styles(app: FastAPI) -> None:
|
||||||
def apply_archival_theme() -> None:
|
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||||
"""Apply runtime color slots once; visual styling is defined in theme.css."""
|
|
||||||
global _THEME_APPLIED
|
|
||||||
if _THEME_APPLIED:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.colors(**THEME_COLORS)
|
theme_css = read_css("theme.css")
|
||||||
_THEME_APPLIED = True
|
try:
|
||||||
|
ui.add_css(theme_css, shared=True)
|
||||||
|
except RuntimeError:
|
||||||
|
# NiceGUI can retain stale slot state across test app factories.
|
||||||
|
ui.add_head_html(f"<style>{theme_css}</style>", shared=True)
|
||||||
|
|
||||||
|
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||||
|
nicegui_app.colors(**THEME_COLORS)
|
||||||
|
|
||||||
|
|
||||||
def page_header(title: str, subtitle: str | None = None) -> None:
|
def page_header(title: str, subtitle: str | None = None) -> None:
|
||||||
|
|||||||
+4
-12
@@ -8,19 +8,11 @@ from transcription import __main__ as entrypoint
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
def test_main_uses_cli_factory_import_string(monkeypatch):
|
||||||
"""Non-reload execution keeps the parsed settings instance in the app."""
|
"""Startup uses an importable factory so Uvicorn owns app creation."""
|
||||||
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
||||||
application = object()
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def create_app(*, settings: object) -> object:
|
|
||||||
assert settings is expected_settings
|
|
||||||
return application
|
|
||||||
|
|
||||||
expected_settings = settings
|
|
||||||
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
||||||
monkeypatch.setattr(entrypoint, "create_app", create_app)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
entrypoint.uvicorn,
|
entrypoint.uvicorn,
|
||||||
"run",
|
"run",
|
||||||
@@ -30,8 +22,8 @@ def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
|||||||
entrypoint.main()
|
entrypoint.main()
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"application": application,
|
"application": "transcription.__main__:create_cli_app",
|
||||||
"factory": False,
|
"factory": True,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"log_level": "debug",
|
"log_level": "debug",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from transcription.ui import register_pages
|
from transcription.ui.pages import register_pages
|
||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user