generated from john/python-template
UI updates, changes sync'd to UI docs
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user