UI updates, changes sync'd to UI docs

This commit is contained in:
Jim Lancaster
2026-08-02 18:20:38 -05:00
parent 9653060c2a
commit 0ab7ad50f2
22 changed files with 447 additions and 431 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz")
def health() -> dict[str, str]:
+136 -16
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
from datetime import date
from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
@@ -21,12 +24,98 @@ from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Create document").classes("text-h5 text-weight-medium")
ui.label("Document name is required.").classes("text-body2 vibe-text-muted")
name_input = ui.input(label="Document name").props("outlined")
document_type_input = ui.input(label="Document type").props("outlined")
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined")
location_input = ui.input(label="Document location").props("outlined")
archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
author_select = ui.select(author_options, label="Author (Person)", value="").props("outlined")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (name_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
name=candidate_name,
document_type=(document_type_input.value or "").strip() or None,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
)
try:
created = await document_service.create_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (author_select.value or "").strip()
if selected_author:
try:
parsed_person_id = UUID(selected_author)
except ValueError:
ui.notify("Selected author is invalid.", type="warning")
return
try:
await document_service.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author link failed", operation="documents.create.link_author")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back")
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Documents").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center justify-between"):
ui.label("Documents").classes("text-h5 text-weight-medium")
ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
)
try:
documents = sorted(
@@ -40,6 +129,9 @@ def register_page() -> None:
if not documents:
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
)
return
with ui.column().classes("w-full gap-2"):
@@ -77,6 +169,13 @@ def register_page() -> None:
ui.label(document.name).classes("text-h5 text-weight-medium")
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
author_link = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None),
None,
)
ui.label(f"Author: {author_link.person.full_name if author_link and author_link.person is not None else 'not set'}").classes(
"text-body2"
)
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
@@ -111,40 +210,29 @@ def register_page() -> None:
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"View sources",
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
icon="description",
).props("flat")
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Add sources",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
).props('unelevated color="primary"')
else:
for source in sorted(document.sources, key=lambda item: item.page_number):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
ui.separator()
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
"flat"
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Create job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
else:
with ui.column().classes("w-full gap-1"):
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -254,6 +342,14 @@ def register_page() -> None:
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
author_value = str(existing_author.person_id) if existing_author is not None else ""
author_select = ui.select(author_options, label="Author (Person)", value=author_value).props("outlined")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
@@ -293,6 +389,30 @@ def register_page() -> None:
show_error(exc, title="Save failed", operation="documents.edit.save")
return
selected_author = (author_select.value or "").strip()
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
try:
if not selected_author:
for link in existing_author_links:
await document_service.delete_document_person(link)
else:
selected_author_id = UUID(selected_author)
if not any(link.person_id == selected_author_id for link in existing_author_links):
for link in existing_author_links:
await document_service.delete_document_person(link)
await document_service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
+40 -143
View File
@@ -8,26 +8,19 @@ from uuid import UUID
from nicegui import ui
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.store import create_job_for_document
from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
from ..components.document_panzoom import render_document_panzoom
from ..components.table.jobs import JobTableRow
from ..components.transcript import render_original_transcription_card
from ..components.transcript import render_revision_row
def register_page() -> None: # noqa: PLR0915
@@ -72,6 +65,11 @@ def register_page() -> None: # noqa: PLR0915
"text-body1 text-warning"
)
with ui.row():
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).props('unelevated color="primary"')
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
return
@@ -131,13 +129,7 @@ def register_page() -> None: # noqa: PLR0915
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Upload folder",
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
render_upload_list()
@@ -182,9 +174,8 @@ def register_page() -> None: # noqa: PLR0915
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
transcription_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
@@ -199,126 +190,42 @@ def register_page() -> None: # noqa: PLR0915
ui.label("Job not found").classes("text-h6 text-negative")
return
source = _resolve_primary_source(job)
with ui.column().classes("w-full gap-3"):
with ui.row().classes("w-full items-center justify-between"):
ui.button(icon="arrow_back", on_click=ui.navigate.back)
match job.status:
case JobStatus.TRANSCRIBED:
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
if source is not None:
render_document_panzoom(source=source)
else:
ui.label("No source preview is available for this job.").classes("text-body2 vibe-text-muted")
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
with ui.row():
ui.button(icon="arrow_back", on_click=ui.navigate.back)
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
match job.status:
case JobStatus.TRANSCRIBED:
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Job {job.id}").classes("text-h6 text-weight-bold")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete job",
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
render_original_transcription_card(job=job)
@ui.refreshable
async def render_revision_panel() -> None:
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
refreshed_source = _resolve_primary_source(refreshed_job)
if refreshed_source is None:
ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted")
return
current_revision_text = refreshed_source.revised_text
default_revision_text = current_revision_text or ""
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
editor.classes("w-full")
async def delete_source() -> None:
try:
await transcription_service.delete_source_from_job_context(
job_id=parsed_job_id,
source_id=refreshed_source.id,
)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(f"/jobs/{parsed_job_id}")
async def confirm_delete_source() -> None:
delete_dialog.close()
await delete_source()
async def save_revision() -> None:
candidate = (editor.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await transcription_service.upsert_revision_for_source(
source_id=refreshed_source.id,
text=candidate,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="jobs.save_revision")
return
ui.notify("Revision saved", type="positive")
await render_revision_panel.refresh()
with ui.row().classes("w-full justify-end gap-2"):
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
ui.label("If related job history exists, deletion may be blocked.").classes(
"text-body2 vibe-text-muted"
)
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=delete_dialog.close)
ui.button(
"Delete source",
on_click=confirm_delete_source,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
ui.button(
"Create revision" if current_revision_text is None else "Update revision",
on_click=save_revision,
icon="save",
).props('unelevated color="primary"')
if current_revision_text is None:
ui.label("No source revision exists for this source.").classes("text-body2 vibe-text-muted")
return
render_revision_row(
revision=refreshed_source,
initially_expanded=True,
)
await render_revision_panel()
ui.separator()
ui.label("Document Links").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).props("flat")
ui.button(
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/sources"),
icon="description",
).props("flat")
ui.button(
"Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/jobs"),
icon="work_history",
).props("flat")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
@@ -375,13 +282,3 @@ def register_page() -> None: # noqa: PLR0915
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
def _resolve_primary_source(job: Job) -> Source | None:
if not job.job_sources:
return None
for job_source in job.job_sources:
if job_source.source is not None:
return job_source.source
return None
+42 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import date
from urllib.parse import quote
from uuid import UUID
from nicegui import ui
@@ -29,6 +30,35 @@ def _parse_optional_date(value: str | None, *, label: str) -> date | None:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input) -> None:
async def on_portrait_selected(event) -> None:
portrait_path_input.value = event.file.name
ui.notify("Portrait filename selected. Edit the path if needed.", type="info")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
ui.label("This picker fills the filename from your selected image.").classes("text-body2 vibe-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
candidate = (path or "").strip()
if not candidate:
return None
normalized = candidate.replace("\\", "/")
lowered = normalized.casefold()
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@@ -82,16 +112,17 @@ def register_page() -> None: # noqa: PLR0915
display_name_input = ui.input(label="Display name").props("outlined")
maiden_name_input = ui.input(label="Maiden name").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
birth_place_input = ui.input(label="Birth place").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
death_place_input = ui.input(label="Death place").props("outlined")
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
@@ -166,6 +197,7 @@ def register_page() -> None: # noqa: PLR0915
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Full name: {person.full_name}")
ui.label(f"Display name: {person.display_name or 'not set'}")
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
@@ -176,6 +208,11 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Death place: {person.death_place or 'not set'}")
ui.label(f"Biography: {person.biography or 'not set'}")
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
portrait_src = _resolve_portrait_src(person.portrait_path)
if portrait_src is not None:
ui.image(portrait_src).classes("w-40 rounded shadow")
else:
ui.label("No portrait image set.").classes("text-body2 vibe-text-muted")
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
@@ -230,7 +267,7 @@ def register_page() -> None: # noqa: PLR0915
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props("outlined")
).props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
"outlined"
)
@@ -239,7 +276,7 @@ def register_page() -> None: # noqa: PLR0915
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props("outlined")
).props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
"outlined"
)
@@ -247,6 +284,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()