generated from john/python-template
GLobal theme cleanup
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -261,4 +279,92 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user