""" PodcastItLater Shared UI Components. 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: """Format duration from seconds to human-readable format. Examples: 300 -> "5m" 3840 -> "1h 4m" 11520 -> "3h 12m" """ if seconds is None or seconds <= 0: return "Unknown" # Constants for time conversion seconds_per_minute = 60 minutes_per_hour = 60 seconds_per_hour = 3600 # Round up to nearest minute minutes = (seconds + seconds_per_minute - 1) // seconds_per_minute # Show as minutes only if under 60 minutes (exclusive) # 3599 seconds rounds up to 60 minutes, which we keep as "60m" if minutes <= minutes_per_hour: # If exactly 3600 seconds (already 60 full minutes without rounding) if seconds >= seconds_per_hour: return "1h" return f"{minutes}m" hours = minutes // minutes_per_hour remaining_minutes = minutes % minutes_per_hour if remaining_minutes == 0: return f"{hours}h" return f"{hours}h {remaining_minutes}m" def create_bootstrap_styles() -> html.style: """Load Bootstrap CSS and icons.""" return html.style( "@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2" "/dist/css/bootstrap.min.css');" "@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons" "@1.11.3/font/bootstrap-icons.min.css');", ) def create_auto_dark_mode_style() -> html.style: """Create CSS for automatic dark mode based on prefers-color-scheme.""" return html.style( """ /* Auto dark mode - applies Bootstrap dark theme via media query */ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; --bs-body-color: #dee2e6; --bs-body-color-rgb: 222, 226, 230; --bs-body-bg: #212529; --bs-body-bg-rgb: 33, 37, 41; --bs-emphasis-color: #fff; --bs-emphasis-color-rgb: 255, 255, 255; --bs-secondary-color: rgba(222, 226, 230, 0.75); --bs-secondary-bg: #343a40; --bs-tertiary-color: rgba(222, 226, 230, 0.5); --bs-tertiary-bg: #2b3035; --bs-heading-color: inherit; --bs-link-color: #6ea8fe; --bs-link-hover-color: #8bb9fe; --bs-link-color-rgb: 110, 168, 254; --bs-link-hover-color-rgb: 139, 185, 254; --bs-code-color: #e685b5; --bs-border-color: #495057; --bs-border-color-translucent: rgba(255, 255, 255, 0.15); } /* Navbar dark mode */ .navbar.bg-body-tertiary { background-color: #2b3035 !important; } .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; } } """, ) def create_htmx_script() -> html.script: """Load HTMX library.""" return html.script( src="https://unpkg.com/htmx.org@1.9.10", integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC", crossorigin="anonymous", ) def create_bootstrap_js() -> html.script: """Load Bootstrap JavaScript bundle.""" return html.script( src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js", 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(), ), )