diff --git a/src/transcription/app.py b/src/transcription/app.py index f18e972..d1bb172 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -86,11 +86,11 @@ def create_app(settings: Settings | None = None) -> FastAPI: @app.get("/", include_in_schema=False) async def root_redirect() -> RedirectResponse: - return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT) @app.get("/ui", include_in_schema=False) async def ui_redirect() -> RedirectResponse: - return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT) @app.get("/healthz") def health() -> dict[str, str]: diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index bdf5bf2..8d13563 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from nicegui import ui +from transcription.ui.pages.home_page import register_page as register_home_page from transcription.ui.pages.documents_page import register_page as register_documents_page from transcription.ui.pages.jobs_page import register_page as register_jobs_page from transcription.ui.pages.people_page import register_page as register_people_page @@ -25,6 +26,7 @@ def _register_global_styles(app: FastAPI) -> None: def register_pages(app: FastAPI) -> None: """Register all NiceGUI pages and mount them onto the FastAPI app.""" _register_global_styles(app) + register_home_page() register_upload_page() register_documents_page() register_people_page() diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index 90c83fd..a67c1cc 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -43,7 +43,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) - def _normalize_path(current_path: str | None) -> str: normalized = (current_path or "").strip() if not normalized: - return "/jobs" + return "/homepage" return normalized.rstrip("/") or "/" @@ -53,7 +53,9 @@ def render_app_shell(*, current_path: str | None = None) -> None: normalized_path = _normalize_path(current_path) with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): - with ui.row().classes("app-shell__brand no-wrap"): + with ui.element("a").props('href="/ui/homepage"').style( + "display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;" + ).classes("app-shell__brand no-wrap"): ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") ui.label("VibeScribe").classes("app-shell__brand-name") diff --git a/src/transcription/ui/homepage_store.py b/src/transcription/ui/homepage_store.py new file mode 100644 index 0000000..2993b63 --- /dev/null +++ b/src/transcription/ui/homepage_store.py @@ -0,0 +1,62 @@ +"""File-backed storage helpers for the homepage content.""" + +from __future__ import annotations + +from pathlib import Path + +HOME_PAGE_DIR = Path(__file__).resolve().parents[3] / "data" / "homepage" +HOME_PAGE_MARKDOWN_PATH = HOME_PAGE_DIR / "homepage.md" +SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"} + + +def ensure_homepage_storage() -> None: + """Create the homepage storage directory when needed.""" + HOME_PAGE_DIR.mkdir(parents=True, exist_ok=True) + + +def read_homepage_markdown() -> str: + """Read the saved homepage markdown text.""" + ensure_homepage_storage() + if not HOME_PAGE_MARKDOWN_PATH.exists(): + return "" + return HOME_PAGE_MARKDOWN_PATH.read_text(encoding="utf-8") + + +def save_homepage_markdown(markdown_text: str) -> None: + """Persist the homepage markdown text.""" + ensure_homepage_storage() + HOME_PAGE_MARKDOWN_PATH.write_text(markdown_text, encoding="utf-8") + + +def store_homepage_image(*, filename: str, file_bytes: bytes) -> Path: + """Persist an uploaded homepage image in the shared homepage folder.""" + ensure_homepage_storage() + + safe_name = Path(filename).name + if not safe_name: + msg = "Homepage image filename is required" + raise ValueError(msg) + + stored_path = HOME_PAGE_DIR / safe_name + stored_path.write_bytes(file_bytes) + return stored_path + + +def list_homepage_images() -> list[Path]: + """List stored homepage images in the order they were last updated.""" + ensure_homepage_storage() + + image_paths = [ + path + for path in HOME_PAGE_DIR.iterdir() + if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES + ] + return sorted(image_paths, key=lambda path: (path.stat().st_mtime, path.name)) + + +def latest_homepage_image() -> Path | None: + """Return the most recently updated homepage image, if one exists.""" + image_paths = list_homepage_images() + if not image_paths: + return None + return image_paths[-1] \ No newline at end of file diff --git a/src/transcription/ui/pages/home_page.py b/src/transcription/ui/pages/home_page.py new file mode 100644 index 0000000..8e1007a --- /dev/null +++ b/src/transcription/ui/pages/home_page.py @@ -0,0 +1,110 @@ +"""Homepage registration and handlers.""" + +from __future__ import annotations + +from nicegui import ui + +from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.cards import archival_card +from transcription.ui.components.primitives import render_empty_state +from transcription.ui.components.primitives import section_header_row +from transcription.ui.components.viewers import dark_room_viewer +from transcription.ui.homepage_store import latest_homepage_image +from transcription.ui.homepage_store import read_homepage_markdown +from transcription.ui.homepage_store import save_homepage_markdown +from transcription.ui.homepage_store import store_homepage_image +from transcription.ui.theme import apply_archival_theme +from transcription.ui.theme import page_header + + +def _render_homepage_view(*, markdown_text: str, image_path) -> None: + with ui.grid().classes("w-full grid-cols-12 gap-4"): + with ui.column().classes("col-span-12 lg:col-span-4"): + dark_room_viewer(str(image_path) if image_path else None, count_label="Homepage Image") + + with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"): + if markdown_text: + ui.markdown(markdown_text) + else: + render_empty_state("No homepage text saved yet.") + + with ui.column().classes("col-span-12 lg:col-span-3"): + ui.element("div") + + +def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) -> None: + with ui.grid().classes("w-full grid-cols-12 gap-4"): + with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with archival_card(title="Homepage Image"): + ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props( + 'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"' + ).classes("w-full") + render_image_panel() + + with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"): + markdown_input[0] = ui.textarea( + label="Homepage markdown", + value=read_homepage_markdown(), + ).props("outlined autogrow").classes("w-full") + + with ui.column().classes("col-span-12 lg:col-span-3"): + ui.element("div") + + +def register_page() -> None: + """Register the homepage routes.""" + + @ui.page("/homepage", title="VibeScribe Home") + def homepage_page() -> None: + apply_archival_theme() + render_navigation_header(current_path="/homepage") + + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + with section_header_row(): + page_header("Home") + ui.button( + "Edit Home Page", + on_click=lambda: ui.navigate.to("/homepage/edit"), + icon="edit", + ).classes("ui-btn-primary text-xs") + + _render_homepage_view( + markdown_text=read_homepage_markdown().strip(), + image_path=latest_homepage_image(), + ) + + @ui.page("/homepage/edit", title="Edit Homepage") + def homepage_edit_page() -> None: + apply_archival_theme() + render_navigation_header(current_path="/homepage") + + preview_image = [latest_homepage_image()] + markdown_input = [None] + + @ui.refreshable + def render_image_panel() -> None: + dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image") + + async def on_upload(event) -> None: + payload = await event.file.read() + preview_image[0] = store_homepage_image(filename=event.file.name, file_bytes=payload) + ui.notify(f"Uploaded {event.file.name}", type="positive") + render_image_panel.refresh() + + async def save_homepage() -> None: + save_homepage_markdown((markdown_input[0].value if markdown_input[0] is not None else "") or "") + ui.notify("Homepage saved", type="positive") + ui.navigate.to("/homepage") + + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + with section_header_row(): + page_header("Edit Home Page") + with ui.row().classes("items-center gap-2"): + ui.button("Save", on_click=save_homepage, icon="save").classes("ui-btn-primary text-xs") + ui.button("Cancel", on_click=lambda: ui.navigate.to("/homepage"), icon="close").props("flat") + + _render_homepage_editor( + render_image_panel=render_image_panel, + markdown_input=markdown_input, + on_upload=on_upload, + ) diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py index cde900e..cc92b35 100644 --- a/tests/ui/test_pages_registration.py +++ b/tests/ui/test_pages_registration.py @@ -11,12 +11,14 @@ class TestPageRegistration: """Mounted UI routes respond successfully when the full app is created.""" _, client = app_client + homepage_response = client.get("/ui/homepage") upload_response = client.get("/ui/upload", follow_redirects=False) documents_response = client.get("/ui/documents") people_response = client.get("/ui/people") sources_response = client.get("/ui/sources") jobs_response = client.get("/ui/jobs") + assert homepage_response.status_code == 200 assert upload_response.status_code == 307 assert documents_response.status_code == 200 assert people_response.status_code == 200 diff --git a/tests/ui/test_upload_page.py b/tests/ui/test_upload_page.py index acb5198..631ea55 100644 --- a/tests/ui/test_upload_page.py +++ b/tests/ui/test_upload_page.py @@ -13,15 +13,34 @@ class TestPageRendering: response = client.get("/", follow_redirects=False) assert response.status_code == 307 - assert response.headers["location"] == "/ui" + assert response.headers["location"] == "/ui/homepage" - def test_ui_redirects_to_documents(self, app_client): - """GET /ui redirects to the documents page.""" + def test_ui_redirects_to_homepage(self, app_client): + """GET /ui redirects to the homepage.""" _, client = app_client response = client.get("/ui", follow_redirects=False) assert response.status_code == 307 - assert response.headers["location"] == "/ui/documents" + assert response.headers["location"] == "/ui/homepage" + + def test_homepage_page_renders(self, app_client): + """GET /ui/homepage renders the homepage page.""" + _, client = app_client + response = client.get("/ui/homepage") + + assert response.status_code == 200 + assert "Home" in response.text + assert "Edit Home Page" in response.text + assert '/ui/homepage' in response.text + + def test_homepage_edit_page_renders(self, app_client): + """GET /ui/homepage/edit renders the edit page.""" + _, client = app_client + response = client.get("/ui/homepage/edit") + + assert response.status_code == 200 + assert "Edit Home Page" in response.text + assert "Homepage markdown" in response.text def test_upload_page_renders_expected_controls(self, app_client): """GET /ui/upload redirects to the job-create flow."""