diff options
Diffstat (limited to 'Biz/PodcastItLater/UI.py')
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 509 |
1 files changed, 350 insertions, 159 deletions
diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 27f5fff..00cf5e3 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -6,6 +6,7 @@ Common UI components and utilities shared across web pages. # : out podcastitlater-ui # : dep ludic +import Biz.PodcastItLater.Core as Core import ludic.html as html import typing from ludic.attrs import Attrs @@ -90,7 +91,7 @@ def create_auto_dark_mode_style() -> html.style: /* Navbar dark mode */ .navbar.bg-body-tertiary { - background-color: #2b3035 !important; + background-color: #2b3035 !important; } .navbar .navbar-text { @@ -127,16 +128,6 @@ def create_bootstrap_js() -> html.script: ) -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.""" @@ -151,6 +142,78 @@ 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, @@ -174,151 +237,32 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ), 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"], + PageLayout._render_nav_item( + "Home", + "/", + "house-fill", + is_active=is_active("home"), ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-globe", - "me-1", - ], - ), - "Public Feed", - href="/public", - classes=[ - "nav-link", - "active" if is_active("public") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Public Feed", + "/public", + "globe", + is_active=is_active("public"), ), - html.li( - html.a( - html.i( - classes=[ - "bi", - "bi-stars", - "me-1", - ], - ), - "Pricing", - href="/pricing", - classes=[ - "nav-link", - "active" if is_active("pricing") else "", - ], - ), - classes=["nav-item"], + PageLayout._render_nav_item( + "Pricing", + "/pricing", + "stars", + is_active=is_active("pricing"), ), - 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"], + PageLayout._render_nav_item( + "Manage Account", + "/account", + "person-circle", + is_active=is_active("account"), ), - 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"], - ), - ), - 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"], - ) - if user and is_admin(user) + PageLayout._render_admin_dropdown(is_active) + if user and Core.is_admin(user) else html.span(), classes=["navbar-nav"], ), @@ -407,6 +351,253 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ) +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.p( + html.strong("Email: "), + user.get("email", ""), + classes=["mb-2"], + ), + 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", + style=progress_style, + ), + 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.""" @@ -422,12 +613,7 @@ class PricingPage(Component[AnyChildren, PricingPageAttrs]): 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.div( html.h2("Simple Pricing", classes=["text-center", "mb-5"]), html.div( @@ -547,5 +733,10 @@ class PricingPage(Component[AnyChildren, PricingPageAttrs]): ), classes=["container", "py-3"], ), - ], + ), + user=user, + current_page="pricing", + page_title="Pricing - PodcastItLater", + error=None, + meta_tags=[], ) |
