""" PodcastItLater Shared UI Components. Common UI components and utilities shared across web pages. """ # : lib # : dep ludic import Biz.PodcastItLater.Core as Core 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 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=is_active("home"), ), PageLayout._render_nav_item( "Public Feed", "/public", "globe", is_active=is_active("public"), ), PageLayout._render_nav_item( "Pricing", "/pricing", "stars", is_active=is_active("pricing"), ), PageLayout._render_nav_item( "Manage Account", "/account", "person-circle", is_active=is_active("account"), ), PageLayout._render_admin_dropdown(is_active) 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") 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 AccountPageAttrs(Attrs): """Attributes for AccountPage component.""" user: dict[str, typing.Any] usage: dict[str, int] limits: dict[str, int | None] portal_url: str | None class AccountPage(Component[AnyChildren, AccountPageAttrs]): """Account management page component.""" @override def render(self) -> PageLayout: user = self.attrs["user"] usage = self.attrs["usage"] limits = self.attrs["limits"] portal_url = self.attrs["portal_url"] plan_tier = user.get("plan_tier", "free") is_paid = plan_tier == "paid" article_limit = limits.get("articles_per_period") article_usage = usage.get("articles", 0) limit_text = ( "Unlimited" if article_limit is None else str(article_limit) ) usage_percent = 0 if article_limit: usage_percent = min(100, int((article_usage / article_limit) * 100)) progress_style = ( {"width": f"{usage_percent}%"} if article_limit else {"width": "0%"} ) return PageLayout( html.div( html.div( html.div( html.div( html.div( html.h2( html.i( classes=[ "bi", "bi-person-circle", "me-2", ], ), "My Account", classes=["card-title", "mb-4"], ), # User Info Section html.div( html.h5("Profile", classes=["mb-3"]), html.div( html.strong("Email: "), html.span(user.get("email", "")), html.button( "Change", classes=[ "btn", "btn-sm", "btn-outline-secondary", "ms-2", "py-0", ], hx_get="/settings/email/edit", hx_target="closest div", hx_swap="outerHTML", ), classes=[ "mb-2", "d-flex", "align-items-center", ], ), html.p( html.strong("Member since: "), user.get("created_at", "").split("T")[ 0 ], classes=["mb-4"], ), classes=["mb-5"], ), # Subscription Section html.div( html.h5("Subscription", classes=["mb-3"]), html.div( html.div( html.strong("Current Plan"), html.span( plan_tier.title(), classes=[ "badge", "bg-success" if is_paid else "bg-secondary", "ms-2", ], ), classes=[ "d-flex", "align-items-center", "mb-3", ], ), # Usage Stats html.div( html.p( "Usage this period:", classes=["mb-2", "text-muted"], ), html.div( html.div( f"{article_usage} / " f"{limit_text}", classes=["mb-1"], ), html.div( html.div( classes=[ "progress-bar", ], role="progressbar", # type: ignore[call-arg] style=progress_style, # type: ignore[arg-type] ), classes=[ "progress", "mb-3", ], style={"height": "10px"}, ) if article_limit else html.div(), classes=["mb-3"], ), ), # Actions html.div( html.form( html.button( html.i( classes=[ "bi", "bi-credit-card", "me-2", ], ), "Manage Subscription", type="submit", classes=[ "btn", "btn-outline-primary", ], ), method="post", action=portal_url, ) if is_paid and portal_url else html.a( html.i( classes=[ "bi", "bi-star-fill", "me-2", ], ), "Upgrade to Pro", href="/pricing", classes=["btn", "btn-primary"], ), classes=["d-flex", "gap-2"], ), classes=[ "card", "card-body", "bg-light", ], ), classes=["mb-5"], ), # Logout Section html.div( html.form( html.button( html.i( classes=[ "bi", "bi-box-arrow-right", "me-2", ], ), "Log Out", type="submit", classes=[ "btn", "btn-outline-danger", ], ), action="/logout", method="post", ), classes=["border-top", "pt-4"], ), # Delete Account Section html.div( html.h5( "Danger Zone", classes=["text-danger", "mb-3"], ), html.div( html.h6("Delete Account"), html.p( "Once you delete your account, " "there is no going back. " "Please be certain.", classes=["card-text"], ), html.button( html.i( classes=[ "bi", "bi-trash", "me-2", ], ), "Delete Account", hx_delete="/account", hx_confirm=( "Are you absolutely sure you " "want to delete your account? " "This action cannot be undone." ), classes=["btn", "btn-danger"], ), classes=[ "card", "card-body", "border-danger", ], ), classes=["mt-5", "pt-4", "border-top"], ), classes=["card-body", "p-4"], ), classes=["card", "shadow-sm"], ), classes=["col-lg-8", "mx-auto"], ), classes=["row"], ), ), user=user, current_page="account", page_title="Account - PodcastItLater", error=None, meta_tags=[], ) 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( html.div( 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"], ), ), user=user, current_page="pricing", page_title="Pricing - PodcastItLater", error=None, meta_tags=[], )