generated from john/python-template
UI style refresh continued
This commit is contained in:
@@ -161,7 +161,8 @@ class JobService(ServiceBase):
|
|||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
# Break ties by id so "next" is stable when two rows share close timestamps.
|
||||||
|
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
return (await _session.exec(query)).first()
|
return (await _session.exec(query)).first()
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ async def process_next_queued_job(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process the next queued job if one exists."""
|
"""Process the next queued job if one exists."""
|
||||||
job = await services.jobs.read_next_queued_job(session=session)
|
job = await services.jobs.read_next_queued_job(session=session)
|
||||||
|
|
||||||
if job is None:
|
if job is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ from transcription.ui.components.app_shell import NAV_ITEMS
|
|||||||
from transcription.ui.components.app_shell import render_app_shell
|
from transcription.ui.components.app_shell import render_app_shell
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.page_content import render_page_content
|
|
||||||
from transcription.ui.components.page_header import render_page_header
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NAV_ITEMS",
|
"NAV_ITEMS",
|
||||||
"render_app_shell",
|
"render_app_shell",
|
||||||
"render_document_panzoom",
|
"render_document_panzoom",
|
||||||
"render_navigation_header",
|
"render_navigation_header",
|
||||||
"render_page_content",
|
|
||||||
"render_page_header",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# transcription/ui/components/cards.py
|
# transcription/ui/components/cards.py
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def archival_card(title: str | None = None, extra_classes: str = ""):
|
def archival_card(title: str | None = None, extra_classes: str = ""):
|
||||||
"""Reusable container for Flat 2.0 Bento Grid cards."""
|
"""Reusable container for Flat 2.0 Bento Grid cards."""
|
||||||
with ui.card().classes(
|
with ui.card().classes(f"w-full ui-card-surface p-4 {extra_classes}") as card:
|
||||||
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm p-4 {extra_classes}"
|
|
||||||
) as card:
|
|
||||||
if title:
|
if title:
|
||||||
ui.label(title.upper()).classes(
|
ui.label(title.upper()).classes(
|
||||||
"text-xs font-bold text-[#6B6A65] tracking-wider mb-3 border-b border-[#6B6A65]/20 pb-1"
|
"text-xs font-bold ui-text-muted tracking-wider mb-3 ui-header-divider pb-1"
|
||||||
)
|
)
|
||||||
yield card
|
yield card
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
# transcription/ui/components/data_display.py
|
# transcription/ui/components/data_display.py
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
def metadata_row(label: str, value: str):
|
def metadata_row(label: str, value: str):
|
||||||
"""Render a high-density, low-contrast key-value pair."""
|
"""Render a high-density, low-contrast key-value pair."""
|
||||||
with ui.row().classes("justify-between w-full border-b border-[#6B6A65]/10 pb-1 text-xs"):
|
with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
|
||||||
ui.label(label).classes("text-[#6B6A65]")
|
ui.label(label).classes("ui-text-muted")
|
||||||
ui.label(value).classes("font-semibold text-[#333333]")
|
ui.label(value).classes("font-semibold ui-text-primary")
|
||||||
|
|
||||||
|
|
||||||
def archival_badge(text: str):
|
def archival_badge(text: str):
|
||||||
"""Standardized Aged Sepia badge."""
|
"""Standardized Aged Sepia badge."""
|
||||||
return ui.badge(text, color="#E2C7A8", text_color="#333333").classes("text-[10px]")
|
return ui.badge(text, color="secondary", text_color="dark").classes("text-[10px]")
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Reusable job detail rendering helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.db.models import Job
|
|
||||||
from transcription.db.models import Source
|
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
|
||||||
from transcription.ui.components.transcript import render_original_transcription_card
|
|
||||||
from transcription.ui.components.transcript import render_revision_row
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _status_chip_classes(status: str) -> str:
|
|
||||||
if status == "queued":
|
|
||||||
return "vibe-status--queued"
|
|
||||||
if status == "processing":
|
|
||||||
return "vibe-status--processing"
|
|
||||||
if status == "transcribed":
|
|
||||||
return "vibe-status--transcribed"
|
|
||||||
if status == "failed":
|
|
||||||
return "vibe-status--failed"
|
|
||||||
return "vibe-status--default"
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_row(label: str, value: str) -> None:
|
|
||||||
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
|
||||||
ui.label(label).classes("text-caption vibe-text-muted text-uppercase w-28")
|
|
||||||
ui.label(value).classes("text-body2 text-right break-all")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_source_section(source: Source) -> None:
|
|
||||||
with ui.card().classes("w-full q-pa-md vibe-card"):
|
|
||||||
ui.label("Source").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
|
||||||
_metadata_row("Upload name", source.upload_name)
|
|
||||||
_metadata_row("Stored filename", source.filename)
|
|
||||||
_metadata_row("File path", source.file_path)
|
|
||||||
_metadata_row("Uploaded", source.date_uploaded.isoformat())
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-md")
|
|
||||||
render_document_panzoom(source=source)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_revision_section(revision: Source | None) -> None:
|
|
||||||
with ui.card().classes("w-full q-pa-md vibe-card"):
|
|
||||||
ui.label("Source revision").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
|
|
||||||
if revision is None:
|
|
||||||
ui.label("No source revision exists for this source.").classes("text-body2 vibe-text-muted")
|
|
||||||
return
|
|
||||||
|
|
||||||
render_revision_row(revision=revision, initially_expanded=True)
|
|
||||||
|
|
||||||
|
|
||||||
def render_job_detail(*, job: Job, source: Source | None, revision: Source | None) -> None:
|
|
||||||
"""Render all sections for the job detail page."""
|
|
||||||
logger.debug("Rendering job detail for job ID %s", job.id)
|
|
||||||
status_text = job.status.value
|
|
||||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
|
||||||
with ui.card().classes("w-full q-pa-lg vibe-card"):
|
|
||||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
|
||||||
with ui.column().classes("q-gutter-none"):
|
|
||||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
|
||||||
ui.label(str(job.id)).classes("text-caption vibe-text-muted")
|
|
||||||
status_chip_classes = (
|
|
||||||
"q-px-sm q-py-xs rounded-borders "
|
|
||||||
"vibe-status text-weight-medium text-capitalize "
|
|
||||||
f"{_status_chip_classes(status_text)}"
|
|
||||||
)
|
|
||||||
ui.label(status_text).classes(status_chip_classes)
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-md vibe-separator")
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
|
||||||
_metadata_row("Created", job.date_created.isoformat())
|
|
||||||
_metadata_row("Updated", job.date_updated.isoformat())
|
|
||||||
_metadata_row("Retries", str(job.retry_count))
|
|
||||||
|
|
||||||
render_original_transcription_card(job=job)
|
|
||||||
|
|
||||||
if source is not None:
|
|
||||||
_render_source_section(source)
|
|
||||||
|
|
||||||
_render_revision_section(revision)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""High-level placeholder content for a transcription workspace."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
|
|
||||||
def render_page_content(
|
|
||||||
*,
|
|
||||||
source_name: str = "document-placeholder.jpg",
|
|
||||||
raw_text: str = "AI transcription output will appear here.",
|
|
||||||
revised_text: str = "Human revision text will appear here.",
|
|
||||||
) -> None:
|
|
||||||
"""Render the primary editor workspace and supporting context sidebar."""
|
|
||||||
ui.add_css(read_css("components/page_content.css"))
|
|
||||||
|
|
||||||
with ui.element("div").classes("page-content"):
|
|
||||||
with ui.element("section").classes("page-content__editor"):
|
|
||||||
with ui.row().classes("page-content__heading"):
|
|
||||||
with ui.column().classes("gap-0"):
|
|
||||||
ui.label("Active source").classes("page-content__kicker")
|
|
||||||
ui.label("Page transcription").classes("page-content__title")
|
|
||||||
ui.badge("Page 1 of 1").classes("page-content__badge")
|
|
||||||
|
|
||||||
with ui.element("div").classes("page-content__workspace"):
|
|
||||||
with ui.element("section").classes("source-placeholder"):
|
|
||||||
with ui.row().classes("source-placeholder__toolbar"):
|
|
||||||
ui.label(source_name)
|
|
||||||
ui.icon("image", size="1.25rem")
|
|
||||||
with ui.column().classes("source-placeholder__body"):
|
|
||||||
ui.icon("description", size="4rem")
|
|
||||||
ui.label("Source preview")
|
|
||||||
|
|
||||||
with ui.column().classes("transcription-placeholder"):
|
|
||||||
with ui.element("section").classes("transcription-placeholder__section"):
|
|
||||||
ui.label("AI raw output").classes("transcription-placeholder__title")
|
|
||||||
ui.label(raw_text).classes("transcription-placeholder__text")
|
|
||||||
|
|
||||||
with ui.element("section").classes("transcription-placeholder__section"):
|
|
||||||
ui.label("Human-reviewed text").classes("transcription-placeholder__title")
|
|
||||||
ui.textarea(value=revised_text).props("outlined autogrow").classes("w-full")
|
|
||||||
|
|
||||||
with ui.element("aside").props('aria-label="Document context"').classes("page-content__sidebar"):
|
|
||||||
with ui.element("section").classes("page-content__sidebar-section"):
|
|
||||||
ui.label("People").classes("page-content__sidebar-title")
|
|
||||||
ui.label("Author · Placeholder person")
|
|
||||||
ui.label("Recipient · Placeholder person")
|
|
||||||
|
|
||||||
with ui.element("section").classes("page-content__sidebar-section"):
|
|
||||||
ui.label("AI processing").classes("page-content__sidebar-title")
|
|
||||||
ui.badge("Completed", color="positive")
|
|
||||||
ui.label("Provider · Placeholder provider")
|
|
||||||
ui.label("Model · Placeholder model")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""High-level page header for document-oriented views."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
type PageHeaderAction = Callable[[], Awaitable[None] | None]
|
|
||||||
|
|
||||||
|
|
||||||
def render_page_header(
|
|
||||||
*,
|
|
||||||
eyebrow: str = "Letter · Placeholder Collection",
|
|
||||||
title: str = "Untitled archival document",
|
|
||||||
metadata: tuple[str, ...] = ("Date unknown", "Location unknown"),
|
|
||||||
on_details: PageHeaderAction | None = None,
|
|
||||||
on_review: PageHeaderAction | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Render document identity, metadata, and page-level actions."""
|
|
||||||
ui.add_css(read_css("components/page_header.css"))
|
|
||||||
|
|
||||||
with ui.element("section").classes("page-header"):
|
|
||||||
with ui.column().classes("page-header__identity"):
|
|
||||||
ui.label(eyebrow).classes("page-header__eyebrow")
|
|
||||||
ui.label(title).classes("page-header__title")
|
|
||||||
with ui.row().classes("page-header__metadata"):
|
|
||||||
for value in metadata:
|
|
||||||
ui.label(value)
|
|
||||||
|
|
||||||
with ui.row().classes("page-header__actions"):
|
|
||||||
ui.button("Document details", icon="info", on_click=on_details).props("outline no-caps")
|
|
||||||
ui.button("Mark reviewed", icon="task_alt", on_click=on_review).props("unelevated no-caps")
|
|
||||||
@@ -57,8 +57,7 @@ def build_table(
|
|||||||
pagination["sortBy"] = default_sort_by
|
pagination["sortBy"] = default_sort_by
|
||||||
pagination["descending"] = default_descending
|
pagination["descending"] = default_descending
|
||||||
|
|
||||||
# Quasar props to enforce flat, archival styling
|
# Quasar props enforce behavior; visual styling is centralized in theme.css.
|
||||||
# Styling table headers with Library Green (#2D5A4C) and rows with subtle borders
|
|
||||||
table = (
|
table = (
|
||||||
ui.table(
|
ui.table(
|
||||||
rows=rows,
|
rows=rows,
|
||||||
@@ -66,28 +65,13 @@ def build_table(
|
|||||||
row_key="id",
|
row_key="id",
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
.classes(
|
.classes(f"w-full ui-table {classes}")
|
||||||
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm {classes}"
|
|
||||||
)
|
|
||||||
.props(
|
.props(
|
||||||
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
|
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
|
||||||
'header-cell-class="bg-[#2D5A4C] text-white font-bold text-xs uppercase tracking-wider" '
|
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
|
||||||
'table-class="text-xs text-[#333333]"'
|
'table-class="ui-table-body text-xs"'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom CSS rules for row hover effects matching Archival Cream
|
|
||||||
ui.add_head_html("""
|
|
||||||
<style>
|
|
||||||
.q-table tbody tr:hover {
|
|
||||||
background-color: #FAF9F6 !important;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.q-table td {
|
|
||||||
border-bottom: 1px solid rgba(107, 106, 101, 0.2) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
""")
|
|
||||||
|
|
||||||
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||||
if on_row_click_id is not None:
|
if on_row_click_id is not None:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
|
|||||||
"""Render documents table and open detail page when clicking a row."""
|
"""Render documents table and open detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
ui.label("No documents in repository yet.").classes("text-xs text-[#6B6A65]")
|
ui.label("No documents in repository yet.").classes("text-xs ui-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
build_table(
|
build_table(
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
|||||||
"""Render jobs table and open a detail page when clicking a row."""
|
"""Render jobs table and open a detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
ui.label("No active or historical processing jobs found.").classes("text-xs text-[#6B6A65]")
|
ui.label("No active or historical processing jobs found.").classes("text-xs ui-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
build_table(
|
build_table(
|
||||||
rows=_serialize_rows(rows),
|
rows=_serialize_rows(rows),
|
||||||
columns=[
|
columns=[
|
||||||
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
|
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
|
||||||
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold text-[#2D5A4C]"},
|
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
|
||||||
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
||||||
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||||
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
|
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
|||||||
"""Render people table and open detail page when clicking a row."""
|
"""Render people table and open detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
ui.label("No person records found in repository.").classes("text-xs text-[#6B6A65]")
|
ui.label("No person records found in repository.").classes("text-xs ui-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
build_table(
|
build_table(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
"""Render sources table and open detail page when clicking a row."""
|
"""Render sources table and open detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
ui.label("No source file records found.").classes("text-xs text-[#6B6A65]")
|
ui.label("No source file records found.").classes("text-xs ui-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
build_table(
|
build_table(
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Typography helper components for Archival and Academic layouts."""
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
# System-wide typography styles matching the UI Design Specification
|
|
||||||
STYLE_SERIF_HEADER = "font-family: 'Georgia', 'Times New Roman', serif;"
|
|
||||||
STYLE_SANS_BODY = "font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;"
|
|
||||||
|
|
||||||
|
|
||||||
def page_header(title: str, subtitle: str | None = None) -> None:
|
|
||||||
"""Render a standardized page title header using the Archival Serif font."""
|
|
||||||
with ui.column().classes("gap-0 pb-2 border-b border-[#6B6A65]/30 w-full"):
|
|
||||||
ui.label(title).style(
|
|
||||||
f"{STYLE_SERIF_HEADER} font-size: 1.75rem; font-weight: 700; color: #333333;"
|
|
||||||
)
|
|
||||||
if subtitle:
|
|
||||||
ui.label(subtitle).classes("text-xs text-[#6B6A65]")
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Reusable upload widget for document submission."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
from nicegui.binding import bindable_dataclass
|
|
||||||
from nicegui.events import UploadEventArguments
|
|
||||||
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.services.documents import UploadJobResult
|
|
||||||
from transcription.ui.components.error_presenter import show_error
|
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
|
||||||
from transcription.worker import WorkerNotifier
|
|
||||||
|
|
||||||
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
|
|
||||||
|
|
||||||
|
|
||||||
@bindable_dataclass
|
|
||||||
class UploadWidgetState:
|
|
||||||
"""Simple state container for upload feedback."""
|
|
||||||
|
|
||||||
loading: bool = False
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
|
|
||||||
"""Render upload controls and common status/error handling."""
|
|
||||||
state = UploadWidgetState()
|
|
||||||
status_label = ui.label("Upload a document to start transcription.")
|
|
||||||
status_label.bind_text(state, "message")
|
|
||||||
|
|
||||||
async def on_upload(event: UploadEventArguments) -> None:
|
|
||||||
if state.loading:
|
|
||||||
ui.notify("Upload already in progress. Please wait.", type="warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
state.loading = True
|
|
||||||
status_label.text = "Uploading..."
|
|
||||||
try:
|
|
||||||
payload = await event.file.read()
|
|
||||||
result = await submitter(event.file.name, payload)
|
|
||||||
job_id = result.job_id
|
|
||||||
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
|
|
||||||
status_label.text = state.message
|
|
||||||
if notifier is not None:
|
|
||||||
notifier.notify()
|
|
||||||
ui.notify(state.message, type="positive")
|
|
||||||
except AppError as exc:
|
|
||||||
state.message = summarize_error(exc, operation="upload.submit")
|
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
|
||||||
show_error(exc, title="Upload failed", operation="upload.submit")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
state.message = summarize_error(exc, operation="upload.submit")
|
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
|
||||||
show_error(exc, title="Upload failed", operation="upload.submit")
|
|
||||||
finally:
|
|
||||||
state.loading = False
|
|
||||||
|
|
||||||
ui.upload(
|
|
||||||
on_upload=on_upload,
|
|
||||||
auto_upload=True,
|
|
||||||
label="Select document file",
|
|
||||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
|
|
||||||
@@ -10,19 +10,17 @@ def dark_room_viewer(
|
|||||||
container_height: str = "500px",
|
container_height: str = "500px",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Isolated high-contrast container for image inspection with pan and zoom capabilities."""
|
"""Isolated high-contrast container for image inspection with pan and zoom capabilities."""
|
||||||
with ui.card().classes(
|
with ui.card().classes("ui-bg-viewer ui-border-viewer rounded-sm p-3 flex flex-col justify-between w-full"):
|
||||||
"bg-[#2B2D2C] border border-[#333333] rounded-sm p-3 flex flex-col justify-between w-full"
|
|
||||||
):
|
|
||||||
# Viewer Header Bar
|
# Viewer Header Bar
|
||||||
with ui.row().classes("w-full justify-between items-center mb-2 text-[#FAF9F6] text-xs"):
|
with ui.row().classes("w-full justify-between items-center mb-2 ui-text-inverse text-xs"):
|
||||||
ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider")
|
ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider")
|
||||||
ui.label(count_label).classes("text-[#E2C7A8]")
|
ui.label(count_label).classes("ui-text-accent")
|
||||||
|
|
||||||
# Interactive Pan/Zoom Canvas Area
|
# Interactive Pan/Zoom Canvas Area
|
||||||
if image_path:
|
if image_path:
|
||||||
# Container with fixed height and hidden overflow for contained panning/zooming
|
# Container with fixed height and hidden overflow for contained panning/zooming
|
||||||
with ui.element("div").classes(
|
with ui.element("div").classes(
|
||||||
"relative w-full overflow-hidden border border-[#333333] bg-black/50 rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
|
"relative w-full overflow-hidden border ui-border-viewer ui-bg-viewer-overlay rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||||
).style(f"height: {container_height};") as viewport:
|
).style(f"height: {container_height};") as viewport:
|
||||||
|
|
||||||
# Image element targeted by client-side pan/zoom JS
|
# Image element targeted by client-side pan/zoom JS
|
||||||
@@ -89,7 +87,7 @@ def dark_room_viewer(
|
|||||||
ui.run_javascript(js_pan_zoom)
|
ui.run_javascript(js_pan_zoom)
|
||||||
|
|
||||||
# Control Toolbar
|
# Control Toolbar
|
||||||
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t border-[#333333]"):
|
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
|
||||||
ui.button(
|
ui.button(
|
||||||
icon="zoom_in",
|
icon="zoom_in",
|
||||||
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
|
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
|
||||||
@@ -108,6 +106,6 @@ def dark_room_viewer(
|
|||||||
else:
|
else:
|
||||||
# Fallback state when no image source is linked
|
# Fallback state when no image source is linked
|
||||||
with ui.column().classes(
|
with ui.column().classes(
|
||||||
"w-full flex-grow items-center justify-center border border-[#333333] bg-black/40 rounded-sm p-8"
|
"w-full flex-grow items-center justify-center border ui-border-viewer ui-bg-viewer-overlay-soft rounded-sm p-8"
|
||||||
).style(f"min-height: {container_height};"):
|
).style(f"min-height: {container_height};"):
|
||||||
ui.label("No source media available for inspection.").classes("text-[#6B6A65] text-xs italic")
|
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
|
||||||
@@ -23,9 +23,9 @@ from transcription.ui.components.data_display import metadata_row
|
|||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.table.documents import DocumentTableRow
|
from transcription.ui.components.table.documents import DocumentTableRow
|
||||||
from transcription.ui.components.table.documents import render_documents_table
|
from transcription.ui.components.table.documents import render_documents_table
|
||||||
from transcription.ui.components.typography import page_header
|
|
||||||
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
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ def register_page() -> None:
|
|||||||
.props("outlined bg-white")
|
.props("outlined bg-white")
|
||||||
.classes("w-full")
|
.classes("w-full")
|
||||||
)
|
)
|
||||||
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
|
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
|
||||||
|
|
||||||
return_to = request.query_params.get("return_to")
|
return_to = request.query_params.get("return_to")
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ def register_page() -> None:
|
|||||||
ui.navigate.to(f"/documents/{created.id}")
|
ui.navigate.to(f"/documents/{created.id}")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save document", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
|
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/documents")
|
@ui.page("/documents")
|
||||||
@@ -147,13 +147,13 @@ def register_page() -> None:
|
|||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
|
||||||
page_header("Archival Documents")
|
page_header("Archival Documents")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Create new document",
|
"Create new document",
|
||||||
on_click=lambda: ui.navigate.to("/documents/new"),
|
on_click=lambda: ui.navigate.to("/documents/new"),
|
||||||
icon="note_add",
|
icon="note_add",
|
||||||
).classes("bg-[#2D5A4C] text-white")
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
documents = sorted(
|
documents = sorted(
|
||||||
@@ -211,7 +211,7 @@ def register_page() -> None:
|
|||||||
# Main Bento Grid Wrapper
|
# Main Bento Grid Wrapper
|
||||||
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"):
|
||||||
# Header Bar
|
# Header Bar
|
||||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
|
||||||
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
|
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
|
||||||
|
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
@@ -219,7 +219,7 @@ def register_page() -> None:
|
|||||||
"Edit Document",
|
"Edit Document",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
||||||
icon="edit",
|
icon="edit",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Delete",
|
"Delete",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||||
@@ -237,12 +237,12 @@ def register_page() -> None:
|
|||||||
"View All Sources",
|
"View All Sources",
|
||||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
||||||
icon="description",
|
icon="description",
|
||||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
"+ Add Source",
|
"+ Add Source",
|
||||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
icon="add",
|
icon="add",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
|
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
@@ -254,45 +254,45 @@ def register_page() -> None:
|
|||||||
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
||||||
|
|
||||||
with ui.column().classes("w-full mt-2"):
|
with ui.column().classes("w-full mt-2"):
|
||||||
ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1")
|
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
|
||||||
ui.label(document.notes or "No notes added.").classes(
|
ui.label(document.notes or "No notes added.").classes(
|
||||||
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]"
|
"p-2 ui-note-box text-xs"
|
||||||
)
|
)
|
||||||
|
|
||||||
with archival_card(title="System Logistics"):
|
with archival_card(title="System Logistics"):
|
||||||
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
|
||||||
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
|
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
|
||||||
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||||
with archival_card(title="Related People"):
|
with archival_card(title="Related People"):
|
||||||
if not document.document_people:
|
if not document.document_people:
|
||||||
ui.label("No linked people yet.").classes("text-xs text-[#6B6A65] italic")
|
ui.label("No linked people yet.").classes("text-xs ui-text-muted italic")
|
||||||
else:
|
else:
|
||||||
with ui.column().classes("w-full gap-2"):
|
with ui.column().classes("w-full gap-2"):
|
||||||
for link in document.document_people:
|
for link in document.document_people:
|
||||||
person_label = link.person.full_name if link.person is not None else "Unknown person"
|
person_label = link.person.full_name if link.person is not None else "Unknown person"
|
||||||
with ui.row().classes(
|
with ui.row().classes(
|
||||||
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
"w-full justify-between items-center ui-row-surface p-2"
|
||||||
):
|
):
|
||||||
ui.label(person_label).classes("text-xs font-semibold text-[#333333]")
|
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
|
||||||
archival_badge(link.role.value)
|
archival_badge(link.role.value)
|
||||||
|
|
||||||
with archival_card(title="Pipeline Jobs"):
|
with archival_card(title="Pipeline Jobs"):
|
||||||
with ui.row().classes("w-full justify-between items-center mb-2"):
|
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||||
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs text-[#2D5A4C] font-bold")
|
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-2 mt-2"):
|
with ui.row().classes("w-full gap-2 mt-2"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Jobs",
|
"View Jobs",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
|
||||||
icon="work_history",
|
icon="work_history",
|
||||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
"+ Add Job",
|
"+ Add Job",
|
||||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
icon="add",
|
icon="add",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
@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:
|
||||||
@@ -316,7 +316,7 @@ def register_page() -> None:
|
|||||||
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"):
|
||||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
|
||||||
page_header(f"Jobs for {document.name}")
|
page_header(f"Jobs for {document.name}")
|
||||||
with ui.row().classes("gap-2"):
|
with ui.row().classes("gap-2"):
|
||||||
ui.button(
|
ui.button(
|
||||||
@@ -328,11 +328,11 @@ def register_page() -> None:
|
|||||||
"Create Job",
|
"Create Job",
|
||||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
icon="add",
|
icon="add",
|
||||||
).classes("bg-[#2D5A4C] text-white")
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
if not document.jobs:
|
if not document.jobs:
|
||||||
with archival_card(extra_classes="p-6 text-center"):
|
with archival_card(extra_classes="p-6 text-center"):
|
||||||
ui.label("No transcription processing jobs created yet.").classes("text-xs text-[#6B6A65]")
|
ui.label("No transcription processing jobs created yet.").classes("text-xs ui-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||||
@@ -340,12 +340,12 @@ def register_page() -> None:
|
|||||||
with ui.row().classes("w-full items-center justify-between"):
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
archival_badge(job.status.value)
|
archival_badge(job.status.value)
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono text-[#333333]")
|
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Open Job",
|
"Open Job",
|
||||||
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
|
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||||
icon="open_in_new",
|
icon="open_in_new",
|
||||||
).props("flat dense").classes("text-xs text-[#2D5A4C]")
|
).props("flat dense").classes("text-xs ui-link-primary")
|
||||||
|
|
||||||
@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:
|
||||||
@@ -434,7 +434,7 @@ def register_page() -> None:
|
|||||||
.props("outlined bg-white")
|
.props("outlined bg-white")
|
||||||
.classes("w-full")
|
.classes("w-full")
|
||||||
)
|
)
|
||||||
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
|
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
|
||||||
|
|
||||||
async def submit_edit() -> None:
|
async def submit_edit() -> None:
|
||||||
candidate_name = (name_input.value or "").strip()
|
candidate_name = (name_input.value or "").strip()
|
||||||
@@ -505,7 +505,7 @@ def register_page() -> None:
|
|||||||
ui.navigate.to(f"/documents/{document.id}")
|
ui.navigate.to(f"/documents/{document.id}")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
|
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
||||||
"flat"
|
"flat"
|
||||||
)
|
)
|
||||||
@@ -535,7 +535,7 @@ def register_page() -> None:
|
|||||||
page_header("Delete Document")
|
page_header("Delete Document")
|
||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]")
|
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
has_sources = bool(document.sources)
|
has_sources = bool(document.sources)
|
||||||
has_jobs = bool(document.jobs)
|
has_jobs = bool(document.jobs)
|
||||||
@@ -547,15 +547,15 @@ def register_page() -> None:
|
|||||||
categories.append("Sources")
|
categories.append("Sources")
|
||||||
if has_jobs:
|
if has_jobs:
|
||||||
categories.append("Jobs")
|
categories.append("Jobs")
|
||||||
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs text-[#6B6A65]")
|
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
|
||||||
ui.label("Remove related records first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
|
ui.label("Remove related records first, then retry deletion.").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(
|
ui.button(
|
||||||
"Back to Document",
|
"Back to Document",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||||
icon="arrow_back",
|
icon="arrow_back",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||||
"flat text-xs"
|
"flat text-xs"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ from transcription.ui.components.data_display import archival_badge
|
|||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
from transcription.ui.components.typography import page_header
|
|
||||||
from transcription.ui.theme import apply_archival_theme
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
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
|
||||||
@@ -38,11 +38,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
|
||||||
page_header("Transcription Pipeline Jobs")
|
page_header("Transcription Pipeline Jobs")
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
||||||
"bg-[#2D5A4C] text-white"
|
"ui-btn-primary"
|
||||||
)
|
)
|
||||||
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
"Create document",
|
"Create document",
|
||||||
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
||||||
icon="note_add",
|
icon="note_add",
|
||||||
).classes("bg-[#2D5A4C] text-white")
|
).classes("ui-btn-primary")
|
||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -106,12 +106,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
with archival_card(title="Source Files"):
|
with archival_card(title="Source Files"):
|
||||||
ui.label(
|
ui.label(
|
||||||
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||||
).classes("text-xs text-[#6B6A65] mb-2")
|
).classes("text-xs ui-text-muted mb-2")
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_upload_list() -> None:
|
def render_upload_list() -> None:
|
||||||
if not uploaded_files:
|
if not uploaded_files:
|
||||||
ui.label("No files uploaded yet.").classes("text-xs text-[#6B6A65] italic")
|
ui.label("No files uploaded yet.").classes("text-xs ui-text-muted italic")
|
||||||
return
|
return
|
||||||
|
|
||||||
def remove_file(index: int) -> None:
|
def remove_file(index: int) -> None:
|
||||||
@@ -133,9 +133,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
with ui.column().classes("gap-1 w-full mt-2"):
|
with ui.column().classes("gap-1 w-full mt-2"):
|
||||||
for index, (filename, _) in ordered_uploads:
|
for index, (filename, _) in ordered_uploads:
|
||||||
with ui.row().classes(
|
with ui.row().classes(
|
||||||
"w-full items-center justify-between bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
"w-full items-center justify-between ui-row-surface p-2"
|
||||||
):
|
):
|
||||||
ui.label(Path(filename).name).classes("text-xs font-mono text-[#333333]")
|
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
||||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
||||||
"flat round dense color=negative text-xs"
|
"flat round dense color=negative text-xs"
|
||||||
)
|
)
|
||||||
@@ -194,7 +194,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
|
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
|
||||||
"bg-[#2D5A4C] text-white"
|
"ui-btn-primary"
|
||||||
)
|
)
|
||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
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"):
|
||||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
|
||||||
page_header(f"Job Record: {job.id}")
|
page_header(f"Job Record: {job.id}")
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
archival_badge(job.status.value.upper())
|
archival_badge(job.status.value.upper())
|
||||||
@@ -236,18 +236,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
metadata_row("Last Updated:", job.date_updated.isoformat())
|
metadata_row("Last Updated:", job.date_updated.isoformat())
|
||||||
|
|
||||||
with archival_card(title="Document Links"):
|
with archival_card(title="Document Links"):
|
||||||
ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3")
|
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
|
||||||
with ui.column().classes("w-full gap-2"):
|
with ui.column().classes("w-full gap-2"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Linked Document",
|
"View Linked Document",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||||
icon="description",
|
icon="description",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs w-full")
|
).classes("ui-btn-primary text-xs w-full")
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Linked Sources",
|
"View Linked Sources",
|
||||||
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||||
icon="description",
|
icon="description",
|
||||||
).props("flat text-xs").classes("text-[#2D5A4C] w-full")
|
).props("flat text-xs").classes("ui-link-primary w-full")
|
||||||
|
|
||||||
@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:
|
||||||
@@ -271,19 +271,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
page_header("Delete Processing Job")
|
page_header("Delete Processing Job")
|
||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]")
|
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(
|
ui.label("Delete is blocked while the job is processing.").classes(
|
||||||
"text-xs text-red-800 font-bold mt-2"
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
)
|
)
|
||||||
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs text-[#6B6A65] 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(
|
ui.button(
|
||||||
"Back to Job",
|
"Back to Job",
|
||||||
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
|
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
|
||||||
icon="arrow_back",
|
icon="arrow_back",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||||
"flat text-xs"
|
"flat text-xs"
|
||||||
)
|
)
|
||||||
@@ -291,7 +291,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
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 text-red-800 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 text-[#6B6A65]")
|
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ from transcription.ui.components.cards import archival_card
|
|||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
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.typography import page_header
|
|
||||||
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
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
|
|||||||
auto_upload=True,
|
auto_upload=True,
|
||||||
label="Choose portrait file",
|
label="Choose portrait file",
|
||||||
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
|
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
|
||||||
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
|
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_portrait_src(path: str | None) -> str | None:
|
def _resolve_portrait_src(path: str | None) -> str | None:
|
||||||
@@ -106,13 +106,13 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
|
||||||
page_header("Archival Entities: People")
|
page_header("Archival Entities: People")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Create new person",
|
"Create new person",
|
||||||
on_click=lambda: ui.navigate.to("/people/new"),
|
on_click=lambda: ui.navigate.to("/people/new"),
|
||||||
icon="person_add",
|
icon="person_add",
|
||||||
).classes("bg-[#2D5A4C] text-white")
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
people = sorted(
|
people = sorted(
|
||||||
@@ -203,7 +203,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.navigate.to(f"/people/{created.id}")
|
ui.navigate.to(f"/people/{created.id}")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
|
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/people/{person_id}")
|
@ui.page("/people/{person_id}")
|
||||||
@@ -230,7 +230,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
portrait_src = _resolve_portrait_src(person.portrait_path)
|
portrait_src = _resolve_portrait_src(person.portrait_path)
|
||||||
|
|
||||||
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"):
|
||||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
|
||||||
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
||||||
|
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
@@ -238,7 +238,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
"Edit Person",
|
"Edit Person",
|
||||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
|
||||||
icon="edit",
|
icon="edit",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Delete",
|
"Delete",
|
||||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||||
@@ -262,19 +262,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
metadata_row("Death Place:", person.death_place or "Not set")
|
metadata_row("Death Place:", person.death_place or "Not set")
|
||||||
|
|
||||||
with archival_card(title="System Logistics"):
|
with archival_card(title="System Logistics"):
|
||||||
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
|
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
with archival_card(title="Biography"):
|
with archival_card(title="Biography"):
|
||||||
ui.label(person.biography or "No biography recorded.").classes(
|
ui.label(person.biography or "No biography recorded.").classes(
|
||||||
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm text-xs text-[#333333] italic w-full"
|
"p-2 ui-note-box text-xs w-full"
|
||||||
)
|
)
|
||||||
|
|
||||||
with archival_card(title="Linked Documents"):
|
with archival_card(title="Linked Documents"):
|
||||||
if not person.document_people:
|
if not person.document_people:
|
||||||
ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic")
|
ui.label("No linked documents yet.").classes("text-xs ui-text-muted italic")
|
||||||
ui.label("Link this person from a Document workflow.").classes("text-xs text-[#6B6A65]")
|
ui.label("Link this person from a Document workflow.").classes("text-xs ui-text-muted")
|
||||||
else:
|
else:
|
||||||
with ui.column().classes("w-full gap-2"):
|
with ui.column().classes("w-full gap-2"):
|
||||||
for link in person.document_people:
|
for link in person.document_people:
|
||||||
@@ -282,18 +282,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
if document is None:
|
if document is None:
|
||||||
continue
|
continue
|
||||||
with ui.row().classes(
|
with ui.row().classes(
|
||||||
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
|
"w-full justify-between items-center ui-row-surface p-2"
|
||||||
):
|
):
|
||||||
with ui.column().classes("gap-0"):
|
with ui.column().classes("gap-0"):
|
||||||
ui.label(document.name).classes("text-xs font-semibold text-[#333333]")
|
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
|
||||||
ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]")
|
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Open",
|
"Open",
|
||||||
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
|
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
|
||||||
f"/documents/{doc_id}"
|
f"/documents/{doc_id}"
|
||||||
),
|
),
|
||||||
icon="open_in_new",
|
icon="open_in_new",
|
||||||
).props("flat dense text-xs").classes("text-[#2D5A4C]")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
|
|
||||||
@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:
|
||||||
@@ -396,7 +396,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.navigate.to(f"/people/{person.id}")
|
ui.navigate.to(f"/people/{person.id}")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
|
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/people/{person_id}/delete")
|
@ui.page("/people/{person_id}/delete")
|
||||||
@@ -424,18 +424,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
page_header("Delete Person Record")
|
page_header("Delete Person Record")
|
||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
|
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
if person.document_people:
|
if person.document_people:
|
||||||
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
|
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
|
||||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
|
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
|
||||||
ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
|
ui.label("Remove document links first, then retry deletion.").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(
|
ui.button(
|
||||||
"Back to Person",
|
"Back to Person",
|
||||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||||
icon="arrow_back",
|
icon="arrow_back",
|
||||||
).classes("bg-[#2D5A4C] text-white text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
|
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
|
||||||
"flat text-xs"
|
"flat text-xs"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ from transcription.ui.components.data_display import metadata_row
|
|||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
||||||
from transcription.ui.components.typography import page_header
|
|
||||||
from transcription.ui.theme import apply_archival_theme
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ def register_page() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
|
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
|
||||||
if document_name is not None:
|
if document_name is not None:
|
||||||
header_title = f"Sources: {document_name}"
|
header_title = f"Sources: {document_name}"
|
||||||
elif job_label is not None:
|
elif job_label is not None:
|
||||||
@@ -89,7 +89,7 @@ def register_page() -> None:
|
|||||||
if back_path is not None:
|
if back_path is not None:
|
||||||
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||||
"bg-[#2D5A4C] text-white text-xs"
|
"ui-btn-primary text-xs"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format source records into read-model rows for the table renderer
|
# Format source records into read-model rows for the table renderer
|
||||||
@@ -129,7 +129,7 @@ def register_page() -> None:
|
|||||||
back_path = _back_path_from_query(request.query_params)
|
back_path = _back_path_from_query(request.query_params)
|
||||||
|
|
||||||
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"):
|
||||||
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
|
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
|
||||||
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
|
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
|
||||||
|
|
||||||
if back_path is not None:
|
if back_path is not None:
|
||||||
@@ -141,7 +141,7 @@ def register_page() -> None:
|
|||||||
else "Back to Sources"
|
else "Back to Sources"
|
||||||
)
|
)
|
||||||
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||||
"bg-[#2D5A4C] text-white text-xs"
|
"ui-btn-primary text-xs"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
||||||
@@ -193,7 +193,7 @@ def register_page() -> None:
|
|||||||
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save Revision", on_click=save_revision, icon="save").classes("bg-[#2D5A4C] text-white text-xs")
|
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/sources")
|
@ui.page("/documents/{document_id}/sources")
|
||||||
async def document_sources_page(document_id: str) -> RedirectResponse:
|
async def document_sources_page(document_id: str) -> RedirectResponse:
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
.page-content {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
|
||||||
align-items: start;
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
color: var(--theme-text);
|
|
||||||
background: var(--theme-surface-raised);
|
|
||||||
box-shadow: var(--theme-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__editor {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__heading {
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__kicker {
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__title,
|
|
||||||
.page-content__sidebar-title {
|
|
||||||
color: var(--theme-text);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__title {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__badge {
|
|
||||||
color: var(--theme-text);
|
|
||||||
background: var(--theme-surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(280px, 0.85fr) minmax(320px, 1.15fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder {
|
|
||||||
min-height: 440px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
border: 1px solid var(--theme-viewer-border);
|
|
||||||
background: var(--theme-viewer);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder__toolbar {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 48px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
color: var(--theme-inverse-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder__body {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--theme-viewer-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder {
|
|
||||||
min-width: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__section {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__title {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__text {
|
|
||||||
min-height: 160px;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
background: var(--theme-surface);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar {
|
|
||||||
min-width: 0;
|
|
||||||
border-left: 1px solid var(--theme-border);
|
|
||||||
background: var(--theme-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar-section {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--theme-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar-title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.page-content {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
border-top: 1px solid var(--theme-border);
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.page-content__workspace,
|
|
||||||
.page-content__sidebar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__editor,
|
|
||||||
.page-content__sidebar-section {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
.page-header {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
padding: 2rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__identity {
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__eyebrow {
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__title {
|
|
||||||
color: var(--theme-text);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__metadata {
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__metadata > * + *::before {
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
content: "·";
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.page-header {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__title {
|
|
||||||
font-size: 1.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__actions,
|
|
||||||
.page-header__actions .q-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -102,4 +102,135 @@ input:focus-visible,
|
|||||||
[tabindex="0"]:focus-visible {
|
[tabindex="0"]:focus-visible {
|
||||||
outline: 3px solid var(--theme-focus);
|
outline: 3px solid var(--theme-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Semantic utility classes for incremental migration away from inline hex styles. */
|
||||||
|
.ui-text-primary {
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-muted {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-inverse {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-page {
|
||||||
|
background: var(--theme-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface {
|
||||||
|
background: var(--theme-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface-raised {
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface-muted {
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer {
|
||||||
|
background: var(--theme-viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer-overlay {
|
||||||
|
background: color-mix(in srgb, var(--theme-viewer) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer-overlay-soft {
|
||||||
|
background: color-mix(in srgb, var(--theme-viewer) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-border-subtle {
|
||||||
|
border-color: var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-border-viewer {
|
||||||
|
border-color: var(--theme-viewer-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-header-divider {
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card-surface {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-row-surface {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-note-box {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-primary {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-primary:hover {
|
||||||
|
background: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-secondary {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-secondary:hover {
|
||||||
|
color: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-link-primary {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-link-primary:hover {
|
||||||
|
color: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-accent {
|
||||||
|
color: var(--theme-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table .q-table tbody tr:hover {
|
||||||
|
background: var(--theme-surface) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table .q-table td {
|
||||||
|
border-bottom: 1px solid var(--theme-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table-header {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table-body {
|
||||||
|
color: var(--theme-text);
|
||||||
}
|
}
|
||||||
+9
-5
@@ -15,7 +15,6 @@ from transcription.config import Settings
|
|||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.db.engine import get_database_url
|
from transcription.db.engine import get_database_url
|
||||||
from transcription.db.engine import get_engine
|
from transcription.db.engine import get_engine
|
||||||
from transcription.db.operations import create_all
|
|
||||||
from transcription.db.session import dispose_session_factory
|
from transcription.db.session import dispose_session_factory
|
||||||
from transcription.db.session import get_session_factory
|
from transcription.db.session import get_session_factory
|
||||||
from transcription.db.session import session_scope
|
from transcription.db.session import session_scope
|
||||||
@@ -42,8 +41,15 @@ async def default_settings():
|
|||||||
"""Provide default settings for tests."""
|
"""Provide default settings for tests."""
|
||||||
settings = get_settings(database_url="sqlite:///:memory:")
|
settings = get_settings(database_url="sqlite:///:memory:")
|
||||||
db_url = get_database_url(settings)
|
db_url = get_database_url(settings)
|
||||||
await create_all(engine=get_engine(database_url=db_url))
|
engine = get_engine(database_url=db_url)
|
||||||
return settings
|
|
||||||
|
# Cached in-memory engines persist across tests; reset schema per test for isolation.
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(SQLModel.metadata.drop_all)
|
||||||
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
yield settings
|
||||||
|
await dispose_session_factory(db_url)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -53,8 +59,6 @@ async def async_session(default_settings: Settings):
|
|||||||
async with session_scope(database_url=db_url) as async_session:
|
async with session_scope(database_url=db_url) as async_session:
|
||||||
yield async_session
|
yield async_session
|
||||||
|
|
||||||
await dispose_session_factory(db_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def default_session_factory(default_settings: Settings):
|
def default_session_factory(default_settings: Settings):
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from transcription.config import Settings
|
|||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.providers.base import TranscriptionResult
|
from transcription.providers.base import TranscriptionResult
|
||||||
|
from transcription.services import ServiceBundle
|
||||||
from transcription.services.store import create_upload_job
|
from transcription.services.store import create_upload_job
|
||||||
from transcription.worker import process_next_queued_job
|
from transcription.services.workflows import advance_job
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -58,7 +59,11 @@ class TestPipelineSuccessFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
services = ServiceBundle()
|
||||||
|
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
||||||
|
processed = queued_job is not None
|
||||||
|
if queued_job is not None:
|
||||||
|
await advance_job(job=queued_job, services=services, session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
@@ -98,7 +103,11 @@ class TestPipelineFailureFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
services = ServiceBundle()
|
||||||
|
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
||||||
|
processed = queued_job is not None
|
||||||
|
if queued_job is not None:
|
||||||
|
await advance_job(job=queued_job, services=services, session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -100,8 +103,13 @@ class TestJobService:
|
|||||||
document = Document(id=uuid4(), name="ordered-doc")
|
document = Document(id=uuid4(), name="ordered-doc")
|
||||||
await document_service.create_document(document=document)
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
first = Job(document_id=document.id, status=JobStatus.QUEUED)
|
created_at = datetime.now(UTC)
|
||||||
second = Job(document_id=document.id, status=JobStatus.QUEUED)
|
first = Job(document_id=document.id, status=JobStatus.QUEUED, date_created=created_at)
|
||||||
|
second = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
date_created=created_at + timedelta(microseconds=1),
|
||||||
|
)
|
||||||
await job_service.create_job(job=first)
|
await job_service.create_job(job=first)
|
||||||
await job_service.create_job(job=second)
|
await job_service.create_job(job=second)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ class TestDocumentsPageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Documents" in response.text
|
assert "Documents" in response.text
|
||||||
assert "Create new document" in response.text
|
assert "Create new document" in response.text
|
||||||
assert "No documents yet." in response.text
|
assert "No documents in repository yet." in response.text
|
||||||
assert "Create your first document" in response.text
|
|
||||||
|
|
||||||
def test_document_create_page_renders_fields(self, app_client):
|
def test_document_create_page_renders_fields(self, app_client):
|
||||||
"""GET /ui/documents/new renders document-create form fields."""
|
"""GET /ui/documents/new renders document-create form fields."""
|
||||||
@@ -40,7 +39,7 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get("/ui/documents/new")
|
response = client.get("/ui/documents/new")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Create document" in response.text
|
assert "Create Document" in response.text
|
||||||
assert "Document name is required." in response.text
|
assert "Document name is required." in response.text
|
||||||
assert "Document name" in response.text
|
assert "Document name" in response.text
|
||||||
assert "Document type" in response.text
|
assert "Document type" in response.text
|
||||||
@@ -69,7 +68,7 @@ class TestDocumentsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Seeded Document" in response.text
|
assert "Seeded Document" in response.text
|
||||||
assert "Type: letter" in response.text
|
assert "letter" in response.text
|
||||||
|
|
||||||
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
|
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
|
||||||
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
|
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
|
||||||
@@ -97,24 +96,30 @@ class TestDocumentsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Zenna Letter" in response.text
|
assert "Zenna Letter" in response.text
|
||||||
assert "Document type: letter" in response.text
|
assert "Type: letter" in response.text
|
||||||
assert "Author: not set" in response.text
|
assert "Author:" in response.text
|
||||||
assert "Exact date: 1885-07-13" in response.text
|
assert "Not set" in response.text
|
||||||
assert "Approximate date: c. 1885" in response.text
|
assert "Exact Date:" in response.text
|
||||||
assert "Location created: Ohio" in response.text
|
assert "1885-07-13" in response.text
|
||||||
assert "Archive identifier: BOX-1-FOLDER-2" in response.text
|
assert "Approx. Date:" in response.text
|
||||||
assert "Notes: Family archive" in response.text
|
assert "c. 1885" in response.text
|
||||||
assert "Created at (read-only):" in response.text
|
assert "Location Created:" in response.text
|
||||||
assert "Updated at (read-only):" in response.text
|
assert "Ohio" in response.text
|
||||||
|
assert "Archive Identifier:" in response.text
|
||||||
|
assert "BOX-1-FOLDER-2" in response.text
|
||||||
|
assert "Archival Notes:" in response.text
|
||||||
|
assert "Family archive" in response.text
|
||||||
|
assert "Created:" in response.text
|
||||||
|
assert "Updated:" in response.text
|
||||||
assert "No linked people yet." in response.text
|
assert "No linked people yet." in response.text
|
||||||
assert "0 source(s) linked" in response.text
|
assert "0 Source(s) Linked" in response.text
|
||||||
assert "0 job(s) linked" in response.text
|
assert "0 Active Jobs" in response.text
|
||||||
assert "+ Add Source" in response.text
|
assert "+ Add Source" in response.text
|
||||||
assert "+ Add Job" in response.text
|
assert "+ Add Job" in response.text
|
||||||
assert "Sources" in response.text
|
assert "Sources" in response.text
|
||||||
assert "Jobs" in response.text
|
assert "Jobs" in response.text
|
||||||
assert "Edit document" in response.text
|
assert "Edit Document" in response.text
|
||||||
assert "Delete document" in response.text
|
assert "Delete" in response.text
|
||||||
|
|
||||||
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
|
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
|
||||||
"""GET /ui/documents/{document_id} shows related records when present."""
|
"""GET /ui/documents/{document_id} shows related records when present."""
|
||||||
@@ -158,10 +163,11 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get(f"/ui/documents/{document_id}")
|
response = client.get(f"/ui/documents/{document_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Jane Doe (author)" in response.text
|
assert "Jane Doe" in response.text
|
||||||
assert "Author: Jane Doe" in response.text
|
assert "author" in response.text
|
||||||
assert "1 source(s) linked" in response.text
|
assert "Author:" in response.text
|
||||||
assert "1 job(s) linked" in response.text
|
assert "1 Source(s) Linked" in response.text
|
||||||
|
assert "1 Active Jobs" in response.text
|
||||||
|
|
||||||
def test_document_jobs_page_filters_to_document_context(self, app_client):
|
def test_document_jobs_page_filters_to_document_context(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -223,7 +229,7 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get(f"/ui/sources?document_id={document_id}")
|
response = client.get(f"/ui/sources?document_id={document_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Sources for Target" in response.text
|
assert "Sources: Target" in response.text
|
||||||
assert "Back to Document" in response.text
|
assert "Back to Document" in response.text
|
||||||
assert "target_page.png" in response.text
|
assert "target_page.png" in response.text
|
||||||
assert "other_page.png" not in response.text
|
assert "other_page.png" not in response.text
|
||||||
@@ -267,7 +273,7 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get(f"/ui/documents/{document_id}/edit")
|
response = client.get(f"/ui/documents/{document_id}/edit")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Edit document" in response.text
|
assert "Edit Document Record" in response.text
|
||||||
assert "Document name and document type are required." in response.text
|
assert "Document name and document type are required." in response.text
|
||||||
assert "Document name" in response.text
|
assert "Document name" in response.text
|
||||||
assert "Document type" in response.text
|
assert "Document type" in response.text
|
||||||
@@ -298,7 +304,7 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get(f"/ui/documents/{document_id}/delete")
|
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete document" in response.text
|
assert "Delete Document" in response.text
|
||||||
assert "This action permanently deletes the document." in response.text
|
assert "This action permanently deletes the document." in response.text
|
||||||
assert "Delete document permanently" in response.text
|
assert "Delete document permanently" in response.text
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TestPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Create job" in response.text
|
assert "Create job" in response.text
|
||||||
assert "No jobs yet." in response.text
|
assert "No active or historical processing jobs found." in response.text
|
||||||
|
|
||||||
def test_job_create_page_requires_existing_documents(self, app_client):
|
def test_job_create_page_requires_existing_documents(self, app_client):
|
||||||
"""GET /ui/jobs/new shows guidance when no Documents exist."""
|
"""GET /ui/jobs/new shows guidance when no Documents exist."""
|
||||||
@@ -30,7 +30,7 @@ class TestPageRendering:
|
|||||||
response = client.get("/ui/jobs/new")
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Create job" in response.text
|
assert "Create Processing Job" in response.text
|
||||||
assert "No documents available. Create a Document before creating a Job." in response.text
|
assert "No documents available. Create a Document before creating a Job." in response.text
|
||||||
assert "Create document" in response.text
|
assert "Create document" in response.text
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class TestPageRendering:
|
|||||||
response = client.get("/ui/jobs/new")
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Create job" in response.text
|
assert "Create Processing Job" in response.text
|
||||||
assert "Seeded Document" in response.text
|
assert "Seeded Document" in response.text
|
||||||
assert "Files are processed alphabetically by original filename." in response.text
|
assert "Files are processed alphabetically by original filename." in response.text
|
||||||
assert "No files uploaded yet." in response.text
|
assert "No files uploaded yet." in response.text
|
||||||
@@ -81,9 +81,9 @@ class TestPageRendering:
|
|||||||
assert "Provider:" in response.text
|
assert "Provider:" in response.text
|
||||||
assert "Model:" in response.text
|
assert "Model:" in response.text
|
||||||
assert "Prompt:" in response.text
|
assert "Prompt:" in response.text
|
||||||
assert "Retry count:" in response.text
|
assert "Retry Count:" in response.text
|
||||||
assert "Last updated:" in response.text
|
assert "Last Updated:" in response.text
|
||||||
assert "Document Links" in response.text
|
assert "document links" in response.text.lower()
|
||||||
assert "Sources" in response.text
|
assert "Sources" in response.text
|
||||||
assert "Jobs" in response.text
|
assert "Jobs" in response.text
|
||||||
assert "Delete job" not in response.text
|
assert "Delete job" not in response.text
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class TestPeoplePageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "People" in response.text
|
assert "People" in response.text
|
||||||
assert "Create new person" in response.text
|
assert "Create new person" in response.text
|
||||||
assert "No people yet." in response.text
|
assert "No person records found in repository." in response.text
|
||||||
|
|
||||||
def test_people_page_lists_seeded_people(self, app_client):
|
def test_people_page_lists_seeded_people(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -41,7 +41,7 @@ class TestPeoplePageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Ada Lovelace" in response.text
|
assert "Ada Lovelace" in response.text
|
||||||
assert "Display name: Ada" in response.text
|
assert "Ada" in response.text
|
||||||
|
|
||||||
def test_person_create_page_renders_fields(self, app_client):
|
def test_person_create_page_renders_fields(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -49,7 +49,7 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get("/ui/people/new")
|
response = client.get("/ui/people/new")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Create person" in response.text
|
assert "Create Person Record" in response.text
|
||||||
assert "Full name is required." in response.text
|
assert "Full name is required." in response.text
|
||||||
assert "Birth date (YYYY-MM-DD)" in response.text
|
assert "Birth date (YYYY-MM-DD)" in response.text
|
||||||
assert "Death date (YYYY-MM-DD)" in response.text
|
assert "Death date (YYYY-MM-DD)" in response.text
|
||||||
@@ -85,15 +85,17 @@ class TestPeoplePageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Grace Hopper" in response.text
|
assert "Grace Hopper" in response.text
|
||||||
assert "Full name: Grace Hopper" in response.text
|
assert "Full Name:" in response.text
|
||||||
assert "Display name: Grace" in response.text
|
assert "Display Name:" in response.text
|
||||||
assert "Maiden name: Murray" in response.text
|
assert "Maiden Name:" in response.text
|
||||||
assert "Birth date: 1906-12-09" in response.text
|
assert "Birth Date:" in response.text
|
||||||
assert "Death date: 1992-01-01" in response.text
|
assert "1906-12-09" in response.text
|
||||||
assert "Biography: Computer pioneer" in response.text
|
assert "Death Date:" in response.text
|
||||||
assert "Portrait path: /images/grace.jpg" in response.text
|
assert "1992-01-01" in response.text
|
||||||
assert "Created at (read-only):" in response.text
|
assert "biography" in response.text.lower()
|
||||||
assert "Updated at (read-only):" in response.text
|
assert "Computer pioneer" in response.text
|
||||||
|
assert "Created:" in response.text
|
||||||
|
assert "Updated:" in response.text
|
||||||
assert "No linked documents yet." in response.text
|
assert "No linked documents yet." in response.text
|
||||||
assert "Link this person from a Document workflow." in response.text
|
assert "Link this person from a Document workflow." in response.text
|
||||||
|
|
||||||
@@ -115,7 +117,6 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get(f"/ui/people/{person_id}")
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Portrait path: portraits/person/seeded.png" in response.text
|
|
||||||
assert "/uploads/portraits/person/seeded.png" in response.text
|
assert "/uploads/portraits/person/seeded.png" in response.text
|
||||||
|
|
||||||
def test_person_detail_page_renders_linked_documents(self, app_client):
|
def test_person_detail_page_renders_linked_documents(self, app_client):
|
||||||
@@ -145,7 +146,8 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get(f"/ui/people/{person_id}")
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Linked Document (author)" in response.text
|
assert "Linked Document" in response.text
|
||||||
|
assert "Role: author" in response.text
|
||||||
|
|
||||||
def test_person_detail_page_handles_invalid_id(self, app_client):
|
def test_person_detail_page_handles_invalid_id(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -179,7 +181,7 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get(f"/ui/people/{person_id}/edit")
|
response = client.get(f"/ui/people/{person_id}/edit")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Edit person" in response.text
|
assert "Edit Person Record" in response.text
|
||||||
assert "Full name is required." in response.text
|
assert "Full name is required." in response.text
|
||||||
assert "Full name" in response.text
|
assert "Full name" in response.text
|
||||||
assert "Save changes" in response.text
|
assert "Save changes" in response.text
|
||||||
@@ -200,8 +202,8 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get(f"/ui/people/{person_id}/delete")
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete person" in response.text
|
assert "Delete Person Record" in response.text
|
||||||
assert "This action permanently deletes the person." in response.text
|
assert "This action permanently deletes the person record." in response.text
|
||||||
assert "Delete person permanently" in response.text
|
assert "Delete person permanently" in response.text
|
||||||
|
|
||||||
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
|
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TestSourcesPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Sources" in response.text
|
assert "Sources" in response.text
|
||||||
assert "No sources added yet." in response.text
|
assert "No source file records found." in response.text
|
||||||
|
|
||||||
def test_sources_page_lists_seeded_sources(self, app_client):
|
def test_sources_page_lists_seeded_sources(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -49,9 +49,8 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get("/ui/sources")
|
response = client.get("/ui/sources")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Page 1: page_one.png" in response.text
|
assert "page_one.png" in response.text
|
||||||
assert "stored_page_one.png" in response.text
|
assert "stored_page_one.png" in response.text
|
||||||
assert "Open source detail" in response.text
|
|
||||||
|
|
||||||
def test_sources_page_filters_to_document_context(self, app_client):
|
def test_sources_page_filters_to_document_context(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -90,11 +89,10 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get(f"/ui/sources?document_id={document_id}")
|
response = client.get(f"/ui/sources?document_id={document_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Sources for Target" in response.text
|
assert "Sources: Target" in response.text
|
||||||
assert "Back to Document" in response.text
|
assert "Back to Document" in response.text
|
||||||
assert "target_page.png" in response.text
|
assert "target_page.png" in response.text
|
||||||
assert "other_page.png" not in response.text
|
assert "other_page.png" not in response.text
|
||||||
assert "Open source detail" in response.text
|
|
||||||
|
|
||||||
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
|
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -106,7 +104,6 @@ class TestSourcesPageRendering:
|
|||||||
assert "Sources for Job" in response.text
|
assert "Sources for Job" in response.text
|
||||||
assert "Back to Job" in response.text
|
assert "Back to Job" in response.text
|
||||||
assert "job-page.png" in response.text
|
assert "job-page.png" in response.text
|
||||||
assert "Open source detail" in response.text
|
|
||||||
|
|
||||||
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -133,11 +130,11 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get(f"/ui/sources/{source_id}")
|
response = client.get(f"/ui/sources/{source_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Source detail-source.png" in response.text
|
assert "Source Page 1: detail-source.png" in response.text
|
||||||
assert "Back to Sources" in response.text
|
assert "Back to Sources" in response.text
|
||||||
assert "Transcription text" in response.text
|
assert "automated raw transcription" in response.text.lower()
|
||||||
assert "original transcription text" in response.text
|
assert "original transcription text" in response.text
|
||||||
assert "Revision text" in response.text
|
assert "curated human transcription" in response.text.lower()
|
||||||
assert "human revision text" in response.text
|
assert "human revision text" in response.text
|
||||||
assert "Page number:" in response.text
|
assert "Page Number:" in response.text
|
||||||
assert "Stored filename:" in response.text
|
assert "Stored Filename:" in response.text
|
||||||
|
|||||||
Reference in New Issue
Block a user