GLobal theme cleanup

This commit is contained in:
Jim Lancaster
2026-08-06 15:12:20 -05:00
parent 75f263c2b6
commit 5090e238ff
13 changed files with 252 additions and 231 deletions
@@ -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,
+2
View File
@@ -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)
+1 -4
View File
@@ -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")
+20 -26
View File
@@ -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,
-3
View File
@@ -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()]
+17 -23
View File
@@ -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:
+20 -25
View File
@@ -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,
+56 -60
View File
@@ -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"""
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="amber-6" @click="() => $parent.$emit('view_source', props.row.id)" />
<q-btn flat round dense icon="delete" color="red-5" @click="() => $parent.$emit('delete_source', props.row.id)" />
<q-btn flat round dense icon="visibility" color="primary" @click="() => $parent.$emit('view_source', props.row.id)" />
<q-btn flat round dense icon="delete" color="negative" @click="() => $parent.$emit('delete_source', props.row.id)" />
</q-td>
""",
)
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:
+1 -1
View File
@@ -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)
@@ -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;
}
}
+106
View File
@@ -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;
}
}
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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"