From 989a1a5de2419373e4932b11c2f2f5877a2fb959 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 13 Nov 2025 18:16:29 -0500 Subject: Unify navigation across PodcastItLater pages - Created reusable PageLayout component in UI.py with consistent header/navbar - Added Home link and Admin dropdown menu (Queue Status, Manage Users) - Updated all pages to use PageLayout: home, account, admin queue, admin users - Added demo@example.com to admin whitelist for testing - Added dark mode styling for table headers - Fixed component children syntax for Ludic framework - Proper type annotations instead of type: ignore comments --- Biz/PodcastItLater/UI.py | 230 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) (limited to 'Biz/PodcastItLater/UI.py') diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 99ac29b..4844d67 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -7,6 +7,11 @@ Common UI components and utilities shared across web pages. # : out podcastitlater-ui # : dep ludic import ludic.html as html +import typing +from ludic.attrs import Attrs +from ludic.components import Component +from ludic.types import AnyChildren +from typing import override def format_duration(seconds: int | None) -> str: @@ -91,6 +96,14 @@ def create_auto_dark_mode_style() -> html.style: .navbar .navbar-text { color: #dee2e6 !important; } + + /* Table header dark mode */ + .table-light { + --bs-table-bg: #343a40; + --bs-table-color: #dee2e6; + background-color: #343a40 !important; + color: #dee2e6 !important; + } } """, ) @@ -112,3 +125,220 @@ def create_bootstrap_js() -> html.script: integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL", crossorigin="anonymous", ) + + +class PageLayoutAttrs(Attrs): + """Attributes for PageLayout component.""" + + user: dict[str, typing.Any] | None + current_page: str + error: str | None + + +class PageLayout(Component[AnyChildren, PageLayoutAttrs]): + """Reusable page layout with header and navbar.""" + + @staticmethod + def _render_navbar( + user: dict[str, typing.Any] | None, + current_page: str, + ) -> html.nav: + """Render navigation bar.""" + # Import here to avoid circular dependency + import Biz.PodcastItLater.Core as Core # noqa: PLC0415 # type: ignore[import-untyped] + + def is_active(page: str) -> bool: + return current_page == page + + return html.nav( + html.div( + html.button( # type: ignore[call-arg] + html.span(classes=["navbar-toggler-icon"]), + classes=["navbar-toggler", "ms-auto"], + type="button", + data_bs_toggle="collapse", + data_bs_target="#navbarNav", + aria_controls="navbarNav", + aria_expanded="false", + aria_label="Toggle navigation", + ), + html.div( + html.ul( + html.li( + html.a( + html.i( + classes=[ + "bi", + "bi-house-fill", + "me-1", + ], + ), + "Home", + href="/", + classes=[ + "nav-link", + "active" if is_active("home") else "", + ], + ), + classes=["nav-item"], + ), + html.li( + html.a( + html.i( + classes=[ + "bi", + "bi-person-circle", + "me-1", + ], + ), + "Manage Account", + href="/account", + classes=[ + "nav-link", + "active" if is_active("account") else "", + ], + ), + classes=["nav-item"], + ), + html.li( + html.a( # type: ignore[call-arg] + html.i( + classes=[ + "bi", + "bi-gear-fill", + "me-1", + ], + ), + "Admin", + href="#", + id="adminDropdown", + role="button", + data_bs_toggle="dropdown", + aria_expanded="false", + classes=[ + "nav-link", + "dropdown-toggle", + "active" + if is_active("admin") + or is_active("admin-users") + else "", + ], + ), + html.ul( # type: ignore[call-arg] + html.li( + html.a( + html.i( + classes=[ + "bi", + "bi-list-task", + "me-2", + ], + ), + "Queue Status", + href="/admin", + classes=["dropdown-item"], + ), + ), + html.li( + html.a( + html.i( + classes=[ + "bi", + "bi-people-fill", + "me-2", + ], + ), + "Manage Users", + href="/admin/users", + classes=["dropdown-item"], + ), + ), + classes=["dropdown-menu"], + aria_labelledby="adminDropdown", + ), + classes=["nav-item", "dropdown"], + ) + if user and Core.is_admin(user) + else html.span(), + classes=["navbar-nav"], + ), + id="navbarNav", + classes=["collapse", "navbar-collapse"], + ), + classes=["container-fluid"], + ), + classes=[ + "navbar", + "navbar-expand-lg", + "bg-body-tertiary", + "rounded", + "mb-4", + ], + ) + + @override + def render(self) -> html.html: + user = self.attrs.get("user") + current_page = self.attrs.get("current_page", "") + error = self.attrs.get("error") + + return html.html( + html.head( + html.meta(charset="utf-8"), + html.meta( + name="viewport", + content="width=device-width, initial-scale=1", + ), + html.meta( + name="color-scheme", + content="light dark", + ), + html.title("PodcastItLater"), + create_htmx_script(), + ), + html.body( + create_bootstrap_styles(), + create_auto_dark_mode_style(), + html.div( + html.div( + html.h1( + "PodcastItLater", + classes=["display-4", "mb-2"], + ), + html.p( + "Convert web articles to podcast episodes", + classes=["lead", "text-muted"], + ), + classes=["text-center", "mb-4", "pt-4"], + ), + html.div( + html.div( + html.i( + classes=[ + "bi", + "bi-exclamation-triangle-fill", + "me-2", + ], + ), + error or "", + classes=[ + "alert", + "alert-danger", + "d-flex", + "align-items-center", + ], + role="alert", # type: ignore[call-arg] + ), + ) + if error + else html.div(), + self._render_navbar(user, current_page) + if user + else html.div(), + *self.children, + classes=["container", "px-3", "px-md-4"], + style={"max-width": "900px"}, + ), + create_bootstrap_js(), + ), + ) -- cgit v1.2.3