""" 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", ) def is_admin(user: dict[str, typing.Any] | None) -> bool: """Check if user is an admin based on email whitelist.""" if not user: return False admin_emails = ["ben@bensima.com", "admin@example.com"] return user.get("email", "").lower() in [ email.lower() for email in admin_emails ] class PageLayoutAttrs(Attrs): """Attributes for PageLayout component.""" user: dict[str, typing.Any] | None current_page: str error: str | None page_title: str | None meta_tags: list[html.meta] | None class PageLayout(Component[AnyChildren, PageLayoutAttrs]): """Reusable page layout with header and navbar.""" @staticmethod def _render_nav_item( label: str, href: str, icon: str, is_active: bool, ) -> html.li: return html.li( html.a( html.i(classes=["bi", f"bi-{icon}", "me-1"]), label, href=href, classes=[ "nav-link", "active" if is_active else "", ], ), classes=["nav-item"], ) @staticmethod def _render_admin_dropdown( is_active_func: typing.Callable[[str], bool], ) -> html.li: is_active = is_active_func("admin") or is_active_func("admin-users") return 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 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"], ), ), html.li( html.a( html.i(classes=["bi", "bi-graph-up", "me-2"]), "Metrics", href="/admin/metrics", classes=["dropdown-item"], ), ), classes=["dropdown-menu"], aria_labelledby="adminDropdown", ), classes=["nav-item", "dropdown"], ) @staticmethod def _render_navbar( user: dict[str, typing.Any] | None, current_page: str, ) -> html.nav: """Render navigation bar.""" 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( PageLayout._render_nav_item( "Home", "/", "house-fill", is_active("home"), ), PageLayout._render_nav_item( "Public Feed", "/public", "globe", is_active("public"), ), PageLayout._render_nav_item( "Pricing", "/pricing", "stars", is_active("pricing"), ), PageLayout._render_nav_item( "Manage Account", "/account", "person-circle", is_active("account"), ), PageLayout._render_admin_dropdown(is_active) if user and 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") page_title = self.attrs.get("page_title") or "PodcastItLater" meta_tags = self.attrs.get("meta_tags") or [] 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(page_title), *meta_tags, 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(), ), ) class PricingPageAttrs(Attrs): """Attributes for PricingPage component.""" user: dict[str, typing.Any] | None class PricingPage(Component[AnyChildren, PricingPageAttrs]): """Pricing page component.""" @override def render(self) -> PageLayout: user = self.attrs.get("user") current_tier = user.get("plan_tier", "free") if user else "free" return PageLayout( user=user, current_page="pricing", page_title="Pricing - PodcastItLater", error=None, meta_tags=[], children=[ html.div( html.h2("Simple Pricing", classes=["text-center", "mb-5"]), html.div( # Free Tier html.div( html.div( html.div( html.h3("Free", classes=["card-title"]), html.h4( "$0", classes=[ "card-subtitle", "mb-3", "text-muted", ], ), html.p( "10 articles total", classes=["card-text"], ), html.ul( html.li("Convert 10 articles"), html.li("Basic features"), classes=["list-unstyled", "mb-4"], ), html.button( "Current Plan", classes=[ "btn", "btn-outline-primary", "w-100", ], disabled=True, ) if current_tier == "free" else html.div(), classes=["card-body"], ), classes=["card", "mb-4", "shadow-sm", "h-100"], ), classes=["col-md-6"], ), # Paid Tier html.div( html.div( html.div( html.h3( "Unlimited", classes=["card-title"], ), html.h4( "$12/mo", classes=[ "card-subtitle", "mb-3", "text-muted", ], ), html.p( "Unlimited articles", classes=["card-text"], ), html.ul( html.li("Unlimited conversions"), html.li("Priority processing"), html.li("Support independent software"), classes=["list-unstyled", "mb-4"], ), html.form( html.button( "Upgrade Now", type="submit", classes=[ "btn", "btn-primary", "w-100", ], ), action="/upgrade", method="POST", ) if user and current_tier == "free" else ( html.button( "Current Plan", classes=[ "btn", "btn-success", "w-100", ], disabled=True, ) if user and current_tier == "paid" else html.a( "Login to Upgrade", href="/", classes=[ "btn", "btn-primary", "w-100", ], ) ), classes=["card-body"], ), classes=[ "card", "mb-4", "shadow-sm", "border-primary", "h-100", ], ), classes=["col-md-6"], ), classes=["row"], ), classes=["container", "py-3"], ), ], )