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) result = await _session.exec(query)
return result.all() 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( async def create_job_source(
self, self,
job_source: JobSource, 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.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.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
from transcription.ui.theme import apply_archival_theme
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered" _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): if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return return
apply_archival_theme()
ui.add_css(read_css("theme.css"), shared=True) ui.add_css(read_css("theme.css"), shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, 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 nicegui import ui
from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Documents", "/documents", "description"), ("Documents", "/documents", "description"),
("People", "/people", "group"), ("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: def render_app_shell(*, current_path: str | None = None) -> None:
"""Render the shared application shell header.""" """Render the shared application shell header."""
ui.add_css(read_css("components/app_shell.css"))
normalized_path = _normalize_path(current_path) normalized_path = _normalize_path(current_path)
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): 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;" "display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;"
).classes("app-shell__brand no-wrap"): ).classes("app-shell__brand no-wrap"):
ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") 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.table.documents import DocumentTableRow, render_documents_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, page_header from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -39,7 +39,6 @@ def register_page() -> None:
@ui.page("/documents/new") @ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None: async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
@@ -113,7 +112,6 @@ def register_page() -> None:
@ui.page("/documents") @ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None: async def documents_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
@@ -150,19 +148,18 @@ def register_page() -> None:
@ui.page("/documents/{document_id}") @ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id) parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_doc_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
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 ui-text-danger p-4")
return return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read") show_error(exc, title="Load failed", operation="documents.read")
@@ -192,19 +189,18 @@ def register_page() -> None:
@ui.page("/documents/{document_id}/jobs") @ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id) parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_doc_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
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 ui-text-danger p-4")
return return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs") show_error(exc, title="Load failed", operation="documents.jobs")
@@ -233,23 +229,22 @@ def register_page() -> None:
@ui.page("/documents/{document_id}/sources") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory _ = 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") @ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id) parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_doc_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
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 ui-text-danger p-4")
return return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read") show_error(exc, title="Load failed", operation="documents.edit.read")
@@ -327,19 +322,18 @@ def register_page() -> None:
@ui.page("/documents/{document_id}/delete") @ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id) parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None: 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 return
try: try:
document = await document_service.read_document_detail(document_id=parsed_doc_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
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 ui-text-danger p-4")
return return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read") 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") ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
if document.sources or document.jobs: 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] 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(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") 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") ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props("flat text-xs")
return 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: async def submit_delete() -> None:
try: try:
@@ -397,22 +391,22 @@ def _render_document_form_fields(
*, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None *, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"): 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") 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 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").classes("w-full ui-form-surface")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"): with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input( date_input = ui.input(
label="Exact date (YYYY-MM-DD)", label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document and document.document_date else "", value=document.document_date.isoformat() if document and document.document_date else "",
).props('outlined bg-white type="date"') ).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 bg-white") 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") 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 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").classes("w-full ui-form-surface")
notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined bg-white autogrow").classes("w-full") 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_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 { return {
"name": name_input, "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 read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.homepage_store import store_homepage_image from transcription.ui.homepage_store import store_homepage_image
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
@@ -56,7 +55,6 @@ def register_page() -> None:
@ui.page("/homepage", title="VibeScribe Home") @ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None: def homepage_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage") render_navigation_header(current_path="/homepage")
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"):
@@ -75,7 +73,6 @@ def register_page() -> None:
@ui.page("/homepage/edit", title="Edit Homepage") @ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None: def homepage_edit_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage") render_navigation_header(current_path="/homepage")
preview_image = [latest_homepage_image()] preview_image = [latest_homepage_image()]
+17 -23
View File
@@ -28,7 +28,7 @@ from transcription.ui.components.primitives import (
section_header_row, section_header_row,
) )
from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table 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 transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -39,7 +39,6 @@ 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")
@@ -71,7 +70,6 @@ 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")
@@ -87,16 +85,16 @@ def register_page() -> None: # noqa: PLR0915
with archival_card(extra_classes="gap-3"): with archival_card(extra_classes="gap-3"):
document_options = {str(doc.id): doc.name for doc in documents} 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") requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options: if requested_document_id in document_options:
document_select.value = requested_document_id document_select.value = requested_document_id
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"):
provider_input = ui.input(label="Provider").props("outlined bg-white") provider_input = ui.input(label="Provider").props("outlined").classes("ui-form-surface")
model_input = ui.input(label="Model").props("outlined bg-white") model_input = ui.input(label="Model").props("outlined").classes("ui-form-surface")
prompt_input = ui.input(label="Prompt").props("outlined bg-white") prompt_input = ui.input(label="Prompt").props("outlined").classes("ui-form-surface")
_render_upload_section(uploaded_files) _render_upload_section(uploaded_files)
@@ -138,19 +136,18 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def job_detail_page(job_id: str, request: Request, 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")
parsed_job_id = _parse_uuid(job_id) parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
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"):
@@ -162,19 +159,18 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/{job_id}/cancel") @ui.page("/jobs/{job_id}/cancel")
async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def job_cancel_page(job_id: str, request: Request, 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")
parsed_job_id = _parse_uuid(job_id) parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): 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") @ui.page("/jobs/{job_id}/resubmit")
async def job_resubmit_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def job_resubmit_page(job_id: str, request: Request, 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")
parsed_job_id = _parse_uuid(job_id) parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
non_transcribed_count = sum(1 for js in job.job_sources if js.status != JobSourceStatus.TRANSCRIBED) 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") @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")
parsed_job_id = _parse_uuid(job_id) parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None: 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 return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: 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 return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): 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") ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING: 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") 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("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").classes( 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 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: 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")
@@ -329,7 +323,7 @@ def _render_no_documents_card() -> None:
with archival_card(extra_classes="p-6 text-center"): with archival_card(extra_classes="p-6 text-center"):
render_empty_state( render_empty_state(
"No documents available. Create a Document before creating a Job.", "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"): with ui.row().classes("justify-center gap-2"):
ui.button( 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"): 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("flat dense").classes(
"text-xs text-red-800" "text-xs ui-text-danger"
) )
async def on_upload(event) -> None: 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.table.people import PersonTableRow, 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, page_header from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -35,7 +35,6 @@ 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")
@@ -72,7 +71,6 @@ 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")
draft_person_id = uuid4() draft_person_id = uuid4()
@@ -124,19 +122,18 @@ 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")
parsed_person_id = _parse_uuid(person_id) parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read") 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") @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")
parsed_person_id = _parse_uuid(person_id) parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read") 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") @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")
parsed_person_id = _parse_uuid(person_id) parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None: 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 return
try: try:
person = await people_service.read_person_detail(parsed_person_id) person = await people_service.read_person_detail(parsed_person_id)
except DocumentError: 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 return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read") show_error(exc, title="Load failed", operation="people.delete.read")
@@ -262,9 +257,9 @@ def register_page() -> None: # noqa: PLR0915
if person.document_people: if person.document_people:
ui.label( ui.label(
f"This will also remove {len(person.document_people)} linked document relationship(s)." 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: async def submit_delete() -> None:
try: try:
@@ -299,28 +294,28 @@ def _render_person_form_fields(
) -> dict[str, Any]: ) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"): with archival_card(extra_classes="gap-3"):
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"):
full_name_input = ui.input(label="Full name", value=person.full_name if person 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 bg-white") 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 bg-white") 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"): 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(
label="Birth date (YYYY-MM-DD)", label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person and person.birth_date else "", value=person.birth_date.isoformat() if person and person.birth_date else "",
).props('outlined bg-white type="date"') ).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 bg-white") 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 bg-white") 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"): 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(
label="Death date (YYYY-MM-DD)", label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person and person.death_date else "", value=person.death_date.isoformat() if person and person.death_date else "",
).props('outlined bg-white type="date"') ).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 bg-white") 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 bg-white") 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") 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 bg-white").classes("w-full") 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( _bind_portrait_file_picker(
portrait_path_input, portrait_path_input,
+56 -60
View File
@@ -6,33 +6,25 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from nicegui import ui from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import select
from transcription.db import session_scope from transcription.services.transcription import TranscriptionNotFoundError
from transcription.db.models import Source 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.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
from ...db.session import SessionFactoryDep
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")
async def sources_page( async def sources_page(
session_factory: SessionFactoryDep,
document_id: Optional[str] = None, document_id: Optional[str] = None,
job_id: Optional[str] = None, job_id: Optional[str] = None,
) -> None: ) -> None:
"""Render the master or context-filtered sources page.""" """Render the master or context-filtered sources page."""
sources_service = TranscriptionService(session_factory=session_factory)
parsed_doc_id: Optional[UUID] = None parsed_doc_id: Optional[UUID] = None
parsed_job_id: Optional[UUID] = None parsed_job_id: Optional[UUID] = None
@@ -48,22 +40,11 @@ async def sources_page(
except ValueError: except ValueError:
pass pass
async with session_scope() as session: try:
statement = select(Source).options( sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id)
selectinload(Source.document), except Exception as exc: # noqa: BLE001
selectinload(Source.job_sources), show_error(exc, title="Load failed", operation="sources.list")
) return
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)
]
header_title = "Source Asset Records" header_title = "Source Asset Records"
if parsed_doc_id: if parsed_doc_id:
@@ -71,22 +52,16 @@ async def sources_page(
elif parsed_job_id: elif parsed_job_id:
header_title = "Sources for Job" header_title = "Sources for Job"
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): render_navigation_header(current_path="/sources")
_render_header_nav(current_path="/ui/sources")
with ui.row().classes("w-full items-center justify-between mb-2"): with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
with ui.column().classes("gap-1"): with section_header_row():
ui.label(header_title).classes("text-2xl font-bold text-slate-100") page_header(header_title, subtitle="Manage digitized source pages, raw OCR transcripts, and human revisions.")
ui.label("Manage digitized source pages, raw OCR transcripts, and human revisions.").classes(
"text-sm text-slate-400"
)
if not sources: if not sources:
with archival_card(title="No Source Assets Found"): with archival_card(title="No Source Assets Found"):
ui.label("No source images or pages match the current filter criteria.").classes( ui.label("No source images or pages match the current filter criteria.").classes("text-sm ui-text-muted mb-4")
"text-sm text-slate-400 mb-4" ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/upload")).classes("ui-btn-primary")
)
ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/ui/upload")).props("color=amber-6")
return return
columns = [ columns = [
@@ -112,43 +87,64 @@ async def sources_page(
] ]
with archival_card(extra_classes="p-0 overflow-hidden"): 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( table.add_slot(
"body-cell-actions", "body-cell-actions",
r""" r"""
<q-td :props="props"> <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="visibility" color="primary" @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="delete" color="negative" @click="() => $parent.$emit('delete_source', props.row.id)" />
</q-td> </q-td>
""", """,
) )
table.on("view_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}")) table.on("view_source", lambda e: ui.navigate.to(f"/sources/{e.args}"))
table.on("delete_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}/delete")) 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.""" """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"): 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}"): 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.""" """Render source deletion confirmation workspace."""
async with session_scope() as session: sources_service = TranscriptionService(session_factory=session_factory)
source = await session.get(Source, UUID(source_id)) parsed_source_id: UUID | None = None
is_linked = bool(source and source.job_sources) 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"): 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"): 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: 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: 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: def register_page() -> None:
+1 -1
View File
@@ -17,4 +17,4 @@ def register_page() -> None:
def upload_page(request: Request) -> RedirectResponse: def upload_page(request: Request) -> RedirectResponse:
_ = request _ = request
render_navigation_header(current_path="/upload") 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: var(--palette-blue-slate);
--theme-primary-hover: var(--palette-carbon-black); --theme-primary-hover: var(--palette-carbon-black);
--theme-secondary: var(--palette-cool-steel); --theme-secondary: var(--palette-cool-steel);
--theme-danger: var(--palette-carbon-black);
--theme-focus: var(--palette-cool-steel); --theme-focus: var(--palette-cool-steel);
--theme-inverse-text: var(--palette-platinum); --theme-inverse-text: var(--palette-platinum);
--theme-viewer: var(--palette-carbon-black); --theme-viewer: var(--palette-carbon-black);
@@ -209,6 +210,23 @@ input:focus-visible,
color: var(--theme-secondary); 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 { .ui-page-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -262,3 +280,91 @@ input:focus-visible,
.ui-table-body { .ui-table-body {
color: var(--theme-text); 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"), ("/", 307, "/ui/homepage"),
("/ui", 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): 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 response.status_code == 200
assert "Home" in response.text assert "Home" in response.text
assert "Edit Home Page" 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): def test_homepage_edit_page_renders(self, app_client):
"""GET /ui/homepage/edit renders the edit page.""" """GET /ui/homepage/edit renders the edit page."""
@@ -48,4 +48,4 @@ class TestPageRendering:
response = client.get("/ui/upload", follow_redirects=False) response = client.get("/ui/upload", follow_redirects=False)
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "/ui/jobs/new" assert response.headers["location"] == "/jobs/new"