diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 7612644..8b7d9f6 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -181,6 +181,32 @@ class TranscriptionService(ServiceBase): result = await _session.exec(query) return result.all() + async def list_sources_detail( + self, + *, + document_id: UUID | None = None, + job_id: UUID | None = None, + session: AsyncSession | None = None, + ) -> Sequence[Source]: + """List source pages with document/job link context for UI rendering.""" + async with self._session_scope(session) as _session: + query = select(Source).options( + selectinload(Source.document), + selectinload(Source.job_sources), + ) + if document_id is not None: + query = query.where(Source.document_id == document_id) + + result = await _session.exec(query) + sources = list(result.all()) + + if job_id is not None: + sources = [ + source for source in sources if any(job_source.job_id == job_id for job_source in source.job_sources) + ] + + return sources + async def create_job_source( self, job_source: JobSource, diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index 8d13563..e1f17c2 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -10,6 +10,7 @@ from transcription.ui.pages.people_page import register_page as register_people_ 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 +from transcription.ui.theme import apply_archival_theme _THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered" @@ -18,6 +19,7 @@ def _register_global_styles(app: FastAPI) -> None: if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False): return + apply_archival_theme() ui.add_css(read_css("theme.css"), shared=True) setattr(app.state, _THEME_REGISTERED_STATE_KEY, True) diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index a67c1cc..e0c18bb 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -4,8 +4,6 @@ from __future__ import annotations from nicegui import ui -from transcription.ui.resources import read_css - NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( ("Documents", "/documents", "description"), ("People", "/people", "group"), @@ -49,11 +47,10 @@ def _normalize_path(current_path: str | None) -> str: def render_app_shell(*, current_path: str | None = None) -> None: """Render the shared application shell header.""" - ui.add_css(read_css("components/app_shell.css")) normalized_path = _normalize_path(current_path) with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): - with ui.element("a").props('href="/ui/homepage"').style( + with ui.element("a").props('href="/homepage"').style( "display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;" ).classes("app-shell__brand no-wrap"): ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index faecbc6..d7fe9aa 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -27,7 +27,7 @@ from transcription.ui.components.primitives import ( ) from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table from transcription.ui.components.viewers import dark_room_viewer -from transcription.ui.theme import apply_archival_theme, page_header +from transcription.ui.theme import page_header from ...db.session import SessionFactoryDep @@ -39,7 +39,6 @@ def register_page() -> None: @ui.page("/documents/new") async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") @@ -113,7 +112,6 @@ def register_page() -> None: @ui.page("/documents") async def documents_page(session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") @@ -150,19 +148,18 @@ def register_page() -> None: @ui.page("/documents/{document_id}") async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") parsed_doc_id = _parse_uuid(document_id) if parsed_doc_id is None: - ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4") return try: document = await document_service.read_document_detail(document_id=parsed_doc_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-red-800 p-4") + ui.label("Document not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.read") @@ -192,19 +189,18 @@ def register_page() -> None: @ui.page("/documents/{document_id}/jobs") async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") parsed_doc_id = _parse_uuid(document_id) if parsed_doc_id is None: - ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4") return try: document = await document_service.read_document_detail(document_id=parsed_doc_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-red-800 p-4") + ui.label("Document not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.jobs") @@ -233,23 +229,22 @@ def register_page() -> None: @ui.page("/documents/{document_id}/sources") async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: _ = session_factory - return RedirectResponse(url=f"/ui/sources?document_id={document_id}") + return RedirectResponse(url=f"/sources?document_id={document_id}") @ui.page("/documents/{document_id}/edit") async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") parsed_doc_id = _parse_uuid(document_id) if parsed_doc_id is None: - ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4") return try: document = await document_service.read_document_detail(document_id=parsed_doc_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-red-800 p-4") + ui.label("Document not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.edit.read") @@ -327,19 +322,18 @@ def register_page() -> None: @ui.page("/documents/{document_id}/delete") async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") parsed_doc_id = _parse_uuid(document_id) if parsed_doc_id is None: - ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4") return try: document = await document_service.read_document_detail(document_id=parsed_doc_id) except DocumentError: - ui.label("Document not found").classes("text-h6 text-red-800 p-4") + ui.label("Document not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="documents.delete.read") @@ -352,7 +346,7 @@ def register_page() -> None: ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary") if document.sources or document.jobs: - ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2") + ui.label("Delete is blocked because related records exist.").classes("text-xs ui-text-danger font-bold mt-2") deps = [cat for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))] if present] ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted") ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic") @@ -362,7 +356,7 @@ def register_page() -> None: ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props("flat text-xs") return - ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium") + ui.label("This action permanently deletes the document.").classes("text-xs ui-text-danger font-medium") async def submit_delete() -> None: try: @@ -397,22 +391,22 @@ def _render_document_form_fields( *, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None ) -> dict[str, Any]: with archival_card(extra_classes="gap-3"): - name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined bg-white").classes("w-full") - type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined bg-white").classes("w-full") + name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined").classes("w-full ui-form-surface") + type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined").classes("w-full ui-form-surface") with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"): date_input = ui.input( label="Exact date (YYYY-MM-DD)", value=document.document_date.isoformat() if document and document.document_date else "", - ).props('outlined bg-white type="date"') - date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw if document and document.document_date_raw else "").props("outlined bg-white") + ).props('outlined type="date"').classes("ui-form-surface") + date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw if document and document.document_date_raw else "").props("outlined").classes("ui-form-surface") - location_input = ui.input(label="Document location", value=document.location_created if document and document.location_created else "").props("outlined bg-white").classes("w-full") - archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined bg-white").classes("w-full") - notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined bg-white autogrow").classes("w-full") + location_input = ui.input(label="Document location", value=document.location_created if document and document.location_created else "").props("outlined").classes("w-full ui-form-surface") + archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined").classes("w-full ui-form-surface") + notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined autogrow").classes("w-full ui-form-surface") author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {str(p.id): p.full_name for p in people} - author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined bg-white").classes("w-full") + author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined").classes("w-full ui-form-surface") return { "name": name_input, diff --git a/src/transcription/ui/pages/home_page.py b/src/transcription/ui/pages/home_page.py index 8e1007a..b408cf1 100644 --- a/src/transcription/ui/pages/home_page.py +++ b/src/transcription/ui/pages/home_page.py @@ -13,7 +13,6 @@ from transcription.ui.homepage_store import latest_homepage_image from transcription.ui.homepage_store import read_homepage_markdown from transcription.ui.homepage_store import save_homepage_markdown from transcription.ui.homepage_store import store_homepage_image -from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import page_header @@ -56,7 +55,6 @@ def register_page() -> None: @ui.page("/homepage", title="VibeScribe Home") def homepage_page() -> None: - apply_archival_theme() render_navigation_header(current_path="/homepage") with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): @@ -75,7 +73,6 @@ def register_page() -> None: @ui.page("/homepage/edit", title="Edit Homepage") def homepage_edit_page() -> None: - apply_archival_theme() render_navigation_header(current_path="/homepage") preview_image = [latest_homepage_image()] diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index c6a954c..5beda4b 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -28,7 +28,7 @@ from transcription.ui.components.primitives import ( section_header_row, ) from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table -from transcription.ui.theme import apply_archival_theme, page_header +from transcription.ui.theme import page_header from transcription.worker import resolve_worker_notifier from ...db.session import SessionFactoryDep @@ -39,7 +39,6 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs") async def jobs_page(session_factory: SessionFactoryDep) -> None: - apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") @@ -71,7 +70,6 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs/new") async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() documents_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/jobs") @@ -87,16 +85,16 @@ def register_page() -> None: # noqa: PLR0915 with archival_card(extra_classes="gap-3"): document_options = {str(doc.id): doc.name for doc in documents} - document_select = ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full") + document_select = ui.select(document_options, label="Target Document").props("outlined").classes("w-full ui-form-surface") requested_document_id = request.query_params.get("document_id") if requested_document_id in document_options: document_select.value = requested_document_id with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): - provider_input = ui.input(label="Provider").props("outlined bg-white") - model_input = ui.input(label="Model").props("outlined bg-white") - prompt_input = ui.input(label="Prompt").props("outlined bg-white") + provider_input = ui.input(label="Provider").props("outlined").classes("ui-form-surface") + model_input = ui.input(label="Model").props("outlined").classes("ui-form-surface") + prompt_input = ui.input(label="Prompt").props("outlined").classes("ui-form-surface") _render_upload_section(uploaded_files) @@ -138,19 +136,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs/{job_id}") async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") parsed_job_id = _parse_uuid(job_id) if parsed_job_id is None: - ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-red-800 p-4") + ui.label("Job not found").classes("text-h6 ui-text-danger p-4") return with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): @@ -162,19 +159,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs/{job_id}/cancel") async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") parsed_job_id = _parse_uuid(job_id) if parsed_job_id is None: - ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-red-800 p-4") + ui.label("Job not found").classes("text-h6 ui-text-danger p-4") return with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): @@ -211,19 +207,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs/{job_id}/resubmit") async def job_resubmit_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") parsed_job_id = _parse_uuid(job_id) if parsed_job_id is None: - ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-red-800 p-4") + ui.label("Job not found").classes("text-h6 ui-text-danger p-4") return non_transcribed_count = sum(1 for js in job.job_sources if js.status != JobSourceStatus.TRANSCRIBED) @@ -263,19 +258,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/jobs/{job_id}/delete") async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") parsed_job_id = _parse_uuid(job_id) if parsed_job_id is None: - ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4") return try: job = await jobs_service.read_job(job_id=parsed_job_id) except ValueError: - ui.label("Job not found").classes("text-h6 text-red-800 p-4") + ui.label("Job not found").classes("text-h6 ui-text-danger p-4") return with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): @@ -285,7 +279,7 @@ def register_page() -> None: # noqa: PLR0915 ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary") if job.status == JobStatus.PROCESSING: - ui.label("Delete is blocked while the job is processing.").classes("text-xs text-red-800 font-bold mt-2") + ui.label("Delete is blocked while the job is processing.").classes("text-xs ui-text-danger font-bold mt-2") 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"): ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").classes( @@ -296,7 +290,7 @@ def register_page() -> None: # noqa: PLR0915 ) return - 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 ui-text-danger font-medium") if job.job_sources: ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted") @@ -329,7 +323,7 @@ def _render_no_documents_card() -> None: with archival_card(extra_classes="p-6 text-center"): render_empty_state( "No documents available. Create a Document before creating a Job.", - extra_classes="text-red-800 font-medium mb-4", + extra_classes="ui-text-danger font-medium mb-4", ) with ui.row().classes("justify-center gap-2"): ui.button( @@ -378,7 +372,7 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None: 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( - "text-xs text-red-800" + "text-xs ui-text-danger" ) async def on_upload(event) -> None: diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 60fb1c2..96789d8 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -25,7 +25,7 @@ from transcription.ui.components.primitives import ( ) from transcription.ui.components.table.people import PersonTableRow, render_people_table from transcription.ui.components.viewers import dark_room_viewer -from transcription.ui.theme import apply_archival_theme, page_header +from transcription.ui.theme import page_header from ...db.session import SessionFactoryDep @@ -35,7 +35,6 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people") async def people_page(session_factory: SessionFactoryDep) -> None: - apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") @@ -72,7 +71,6 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people/new") async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") draft_person_id = uuid4() @@ -124,19 +122,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people/{person_id}") async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") parsed_person_id = _parse_uuid(person_id) if parsed_person_id is None: - ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-red-800 p-4") + ui.label("Person not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.read") @@ -166,19 +163,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people/{person_id}/edit") async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") parsed_person_id = _parse_uuid(person_id) if parsed_person_id is None: - ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-red-800 p-4") + ui.label("Person not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.edit.read") @@ -235,19 +231,18 @@ def register_page() -> None: # noqa: PLR0915 @ui.page("/people/{person_id}/delete") async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None: - apply_archival_theme() people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") parsed_person_id = _parse_uuid(person_id) if parsed_person_id is None: - ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") + ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4") return try: person = await people_service.read_person_detail(parsed_person_id) except DocumentError: - ui.label("Person not found").classes("text-h6 text-red-800 p-4") + ui.label("Person not found").classes("text-h6 ui-text-danger p-4") return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="people.delete.read") @@ -262,9 +257,9 @@ def register_page() -> None: # noqa: PLR0915 if person.document_people: ui.label( f"This will also remove {len(person.document_people)} linked document relationship(s)." - ).classes("text-xs text-red-800 font-bold mt-2") + ).classes("text-xs ui-text-danger font-bold mt-2") - 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 ui-text-danger font-medium") async def submit_delete() -> None: try: @@ -299,28 +294,28 @@ def _render_person_form_fields( ) -> dict[str, Any]: with archival_card(extra_classes="gap-3"): with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): - full_name_input = ui.input(label="Full name", value=person.full_name if person else "").props("outlined bg-white") - display_name_input = ui.input(label="Display name", value=person.display_name if person and person.display_name else "").props("outlined bg-white") - maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "").props("outlined bg-white") + full_name_input = ui.input(label="Full name", value=person.full_name if person else "").props("outlined").classes("ui-form-surface") + display_name_input = ui.input(label="Display name", value=person.display_name if person and person.display_name else "").props("outlined").classes("ui-form-surface") + maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "").props("outlined").classes("ui-form-surface") with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): birth_date_input = ui.input( label="Birth date (YYYY-MM-DD)", value=person.birth_date.isoformat() if person and person.birth_date else "", - ).props('outlined bg-white type="date"') - birth_date_raw_input = ui.input(label="Birth date (approximate)", value=person.birth_date_raw if person and person.birth_date_raw else "").props("outlined bg-white") - birth_place_input = ui.input(label="Birth place", value=person.birth_place if person and person.birth_place else "").props("outlined bg-white") + ).props('outlined type="date"').classes("ui-form-surface") + birth_date_raw_input = ui.input(label="Birth date (approximate)", value=person.birth_date_raw if person and person.birth_date_raw else "").props("outlined").classes("ui-form-surface") + birth_place_input = ui.input(label="Birth place", value=person.birth_place if person and person.birth_place else "").props("outlined").classes("ui-form-surface") with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): death_date_input = ui.input( label="Death date (YYYY-MM-DD)", value=person.death_date.isoformat() if person and person.death_date else "", - ).props('outlined bg-white type="date"') - death_date_raw_input = ui.input(label="Death date (approximate)", value=person.death_date_raw if person and person.death_date_raw else "").props("outlined bg-white") - death_place_input = ui.input(label="Death place", value=person.death_place if person and person.death_place else "").props("outlined bg-white") + ).props('outlined type="date"').classes("ui-form-surface") + death_date_raw_input = ui.input(label="Death date (approximate)", value=person.death_date_raw if person and person.death_date_raw else "").props("outlined").classes("ui-form-surface") + death_place_input = ui.input(label="Death place", value=person.death_place if person and person.death_place else "").props("outlined").classes("ui-form-surface") - biography_input = ui.textarea(label="Biography", value=person.biography if person and person.biography else "").props("outlined bg-white autogrow").classes("w-full") - portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "").props("outlined bg-white").classes("w-full") + biography_input = ui.textarea(label="Biography", value=person.biography if person and person.biography else "").props("outlined autogrow").classes("w-full ui-form-surface") + portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "").props("outlined").classes("w-full ui-form-surface") _bind_portrait_file_picker( portrait_path_input, diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index 2b84522..033cc30 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -6,33 +6,25 @@ from typing import Optional from uuid import UUID from nicegui import ui -from sqlalchemy.orm import selectinload -from sqlmodel import select -from transcription.db import session_scope -from transcription.db.models import Source +from transcription.services.transcription import TranscriptionNotFoundError +from transcription.services.transcription import TranscriptionService +from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card +from transcription.ui.components.error_presenter import show_error +from transcription.ui.components.primitives import section_header_row +from transcription.ui.theme import page_header - -def _render_header_nav(current_path: str = "/ui/sources") -> None: - """Render inline header navigation bar.""" - with ui.row().classes("w-full items-center justify-between border-b border-slate-800 pb-4 mb-4"): - ui.label("VibeScribe").classes("text-xl font-bold text-amber-500 tracking-wider") - with ui.row().classes("gap-4"): - ui.link("Documents", "/ui/documents").classes("text-slate-300 hover:text-amber-400 font-medium") - ui.link("Jobs", "/ui/jobs").classes("text-slate-300 hover:text-amber-400 font-medium") - ui.link("Sources", "/ui/sources").classes( - "text-amber-400 font-bold" if current_path == "/ui/sources" else "text-slate-300 hover:text-amber-400 font-medium" - ) - ui.link("People", "/ui/people").classes("text-slate-300 hover:text-amber-400 font-medium") - ui.link("Upload", "/ui/upload").classes("text-slate-300 hover:text-amber-400 font-medium") +from ...db.session import SessionFactoryDep async def sources_page( + session_factory: SessionFactoryDep, document_id: Optional[str] = None, job_id: Optional[str] = None, ) -> None: """Render the master or context-filtered sources page.""" + sources_service = TranscriptionService(session_factory=session_factory) parsed_doc_id: Optional[UUID] = None parsed_job_id: Optional[UUID] = None @@ -48,22 +40,11 @@ async def sources_page( except ValueError: pass - async with session_scope() as session: - statement = select(Source).options( - selectinload(Source.document), - selectinload(Source.job_sources), - ) - - if parsed_doc_id: - statement = statement.where(Source.document_id == parsed_doc_id) - - result = await session.exec(statement) - sources = list(result.all()) - - if parsed_job_id: - sources = [ - src for src in sources if any(js.job_id == parsed_job_id for js in src.job_sources) - ] + try: + sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.list") + return header_title = "Source Asset Records" if parsed_doc_id: @@ -71,22 +52,16 @@ async def sources_page( elif parsed_job_id: header_title = "Sources for Job" - with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - _render_header_nav(current_path="/ui/sources") + render_navigation_header(current_path="/sources") - with ui.row().classes("w-full items-center justify-between mb-2"): - with ui.column().classes("gap-1"): - ui.label(header_title).classes("text-2xl font-bold text-slate-100") - ui.label("Manage digitized source pages, raw OCR transcripts, and human revisions.").classes( - "text-sm text-slate-400" - ) + with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): + with section_header_row(): + page_header(header_title, subtitle="Manage digitized source pages, raw OCR transcripts, and human revisions.") if not sources: with archival_card(title="No Source Assets Found"): - ui.label("No source images or pages match the current filter criteria.").classes( - "text-sm text-slate-400 mb-4" - ) - ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/ui/upload")).props("color=amber-6") + ui.label("No source images or pages match the current filter criteria.").classes("text-sm ui-text-muted mb-4") + ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/upload")).classes("ui-btn-primary") return columns = [ @@ -112,43 +87,64 @@ async def sources_page( ] with archival_card(extra_classes="p-0 overflow-hidden"): - table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full bg-transparent text-slate-200") + table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full ui-table") table.add_slot( "body-cell-actions", r""" - - + + """, ) - table.on("view_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}")) - table.on("delete_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}/delete")) + table.on("view_source", lambda e: ui.navigate.to(f"/sources/{e.args}")) + table.on("delete_source", lambda e: ui.navigate.to(f"/sources/{e.args}/delete")) -async def source_detail_page(source_id: str) -> None: +async def source_detail_page(source_id: str, session_factory: SessionFactoryDep) -> None: """Render individual source detail preview and revision workspace.""" + _ = session_factory + + render_navigation_header(current_path="/sources") + with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - _render_header_nav(current_path="/ui/sources") with archival_card(title=f"Source Record: {source_id}"): - ui.label("Source Preview & Revision Workspace").classes("text-slate-300") + ui.label("Source Preview & Revision Workspace").classes("ui-text-muted") -async def source_delete_page(source_id: str) -> None: +async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None: """Render source deletion confirmation workspace.""" - async with session_scope() as session: - source = await session.get(Source, UUID(source_id)) - is_linked = bool(source and source.job_sources) + sources_service = TranscriptionService(session_factory=session_factory) + parsed_source_id: UUID | None = None + try: + parsed_source_id = UUID(source_id) + except ValueError: + parsed_source_id = None + + is_linked = False + if parsed_source_id is not None: + try: + source = await sources_service.read_source_detail(parsed_source_id) + is_linked = bool(source.job_sources) + except TranscriptionNotFoundError: + is_linked = False + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.delete.read") + return + + render_navigation_header(current_path="/sources") with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - _render_header_nav(current_path="/ui/sources") with archival_card(title="Delete Source Confirmation"): + if parsed_source_id is None: + ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4") + return if is_linked: - ui.label("Cannot delete source linked to active jobs.").classes("text-red-400") + ui.label("Cannot delete source linked to active jobs.").classes("ui-text-danger") else: - ui.label(f"Are you sure you want to delete source {source_id}?").classes("text-slate-300") + ui.label(f"Are you sure you want to delete source {source_id}?").classes("ui-text-muted") def register_page() -> None: diff --git a/src/transcription/ui/pages/upload_page.py b/src/transcription/ui/pages/upload_page.py index 1c6d292..c510569 100644 --- a/src/transcription/ui/pages/upload_page.py +++ b/src/transcription/ui/pages/upload_page.py @@ -17,4 +17,4 @@ def register_page() -> None: def upload_page(request: Request) -> RedirectResponse: _ = request render_navigation_header(current_path="/upload") - return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + return RedirectResponse(url="/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT) diff --git a/src/transcription/ui/static/components/app_shell.css b/src/transcription/ui/static/components/app_shell.css deleted file mode 100644 index 73bfcb7..0000000 --- a/src/transcription/ui/static/components/app_shell.css +++ /dev/null @@ -1,86 +0,0 @@ -.app-shell { - min-height: 64px; - padding: 0.75rem 2rem; - border-bottom: 1px solid var(--theme-border); - color: var(--theme-text); - background: var(--theme-surface-raised); -} - -.app-shell__inner { - width: 100%; - display: grid; - grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); - align-items: center; - gap: 1.5rem; -} - -.app-shell__brand, -.app-shell__actions { - align-items: center; -} - -.app-shell__brand { - gap: 0.75rem; - color: var(--theme-text); - font-family: Georgia, serif; - font-weight: 700; -} - -.app-shell__brand-mark { - width: 34px; - height: 34px; - display: grid; - place-items: center; - color: var(--theme-inverse-text); - background: transparent; /* Removes the dark box */ - font-family: "Trebuchet MS", sans-serif; - font-size: 0.72rem; -} - -.app-shell__nav { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.app-shell__nav-item { - min-height: 40px; - color: var(--theme-text-muted); -} - -.app-shell__nav-item--active { - color: var(--theme-primary-hover); - border-bottom: 3px solid var(--theme-primary); -} - -.app-shell__actions { - justify-content: flex-end; - gap: 0.75rem; -} - -.app-shell__save-state { - color: var(--theme-text-muted); - font-size: 0.82rem; - font-weight: 700; -} - -@media (max-width: 700px) { - .app-shell { - padding-inline: 0.75rem; - } - - .app-shell__inner { - grid-template-columns: 1fr auto; - } - - .app-shell__nav { - grid-column: 1 / -1; - grid-row: 2; - justify-content: center; - } - - .app-shell__brand-name, - .app-shell__save-state { - display: none; - } -} \ No newline at end of file diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css index 895a01b..ae80223 100644 --- a/src/transcription/ui/static/theme.css +++ b/src/transcription/ui/static/theme.css @@ -15,6 +15,7 @@ --theme-primary: var(--palette-blue-slate); --theme-primary-hover: var(--palette-carbon-black); --theme-secondary: var(--palette-cool-steel); + --theme-danger: var(--palette-carbon-black); --theme-focus: var(--palette-cool-steel); --theme-inverse-text: var(--palette-platinum); --theme-viewer: var(--palette-carbon-black); @@ -209,6 +210,23 @@ input:focus-visible, color: var(--theme-secondary); } +.ui-text-danger { + color: var(--theme-danger); +} + +.ui-form-surface .q-field__control { + background: var(--theme-surface-raised); +} + +.ui-form-surface .q-field__native, +.ui-form-surface .q-field__input { + color: var(--theme-text); +} + +.ui-form-surface .q-field__marginal { + color: var(--theme-text-muted); +} + .ui-page-header { display: flex; flex-direction: column; @@ -261,4 +279,92 @@ input:focus-visible, .ui-table-body { color: var(--theme-text); +} + +/* App Shell */ +.app-shell { + min-height: 64px; + padding: 0.75rem 2rem; + border-bottom: 1px solid var(--theme-border); + color: var(--theme-text); + background: var(--theme-surface-raised); +} + +.app-shell__inner { + width: 100%; + display: grid; + grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); + align-items: center; + gap: 1.5rem; +} + +.app-shell__brand, +.app-shell__actions { + align-items: center; +} + +.app-shell__brand { + gap: 0.75rem; + color: var(--theme-text); + font-family: Georgia, serif; + font-weight: 700; +} + +.app-shell__brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + color: var(--theme-inverse-text); + background: transparent; + font-family: "Trebuchet MS", sans-serif; + font-size: 0.72rem; +} + +.app-shell__nav { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.app-shell__nav-item { + min-height: 40px; + color: var(--theme-text-muted); +} + +.app-shell__nav-item--active { + color: var(--theme-primary-hover); + border-bottom: 3px solid var(--theme-primary); +} + +.app-shell__actions { + justify-content: flex-end; + gap: 0.75rem; +} + +.app-shell__save-state { + color: var(--theme-text-muted); + font-size: 0.82rem; + font-weight: 700; +} + +@media (max-width: 700px) { + .app-shell { + padding-inline: 0.75rem; + } + + .app-shell__inner { + grid-template-columns: 1fr auto; + } + + .app-shell__nav { + grid-column: 1 / -1; + grid-row: 2; + justify-content: center; + } + + .app-shell__brand-name, + .app-shell__save-state { + display: none; + } } \ No newline at end of file diff --git a/tests/ui/test_navigation_and_mounts.py b/tests/ui/test_navigation_and_mounts.py index 2391d1c..c3e6c0f 100644 --- a/tests/ui/test_navigation_and_mounts.py +++ b/tests/ui/test_navigation_and_mounts.py @@ -12,7 +12,7 @@ class TestNavigationAndMounts: [ ("/", 307, "/ui/homepage"), ("/ui", 307, "/ui/homepage"), - ("/ui/upload", 307, "/ui/jobs/new"), + ("/ui/upload", 307, "/jobs/new"), ], ) def test_entrypoint_redirects(self, app_client, url: str, expected_status: int, expected_redirect: str): diff --git a/tests/ui/test_upload_page.py b/tests/ui/test_upload_page.py index 631ea55..d6e5620 100644 --- a/tests/ui/test_upload_page.py +++ b/tests/ui/test_upload_page.py @@ -31,7 +31,7 @@ class TestPageRendering: assert response.status_code == 200 assert "Home" in response.text assert "Edit Home Page" in response.text - assert '/ui/homepage' in response.text + assert '/homepage' in response.text def test_homepage_edit_page_renders(self, app_client): """GET /ui/homepage/edit renders the edit page.""" @@ -48,4 +48,4 @@ class TestPageRendering: response = client.get("/ui/upload", follow_redirects=False) assert response.status_code == 307 - assert response.headers["location"] == "/ui/jobs/new" + assert response.headers["location"] == "/jobs/new"