summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-13 18:16:29 -0500
committerBen Sima <ben@bsima.me>2025-11-13 18:16:29 -0500
commit989a1a5de2419373e4932b11c2f2f5877a2fb959 (patch)
tree9115042272f624246efa4b3d8cd78920f66a2134
parent83a1777d7869ed6da5e78c878dcc35c95a06c5cf (diff)
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
-rw-r--r--Biz/PodcastItLater/Admin.py140
-rw-r--r--Biz/PodcastItLater/Core.py2
-rw-r--r--Biz/PodcastItLater/UI.py230
-rw-r--r--Biz/PodcastItLater/Web.py504
4 files changed, 426 insertions, 450 deletions
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py
index d7a0a4e..6ee255e 100644
--- a/Biz/PodcastItLater/Admin.py
+++ b/Biz/PodcastItLater/Admin.py
@@ -13,7 +13,6 @@ Admin pages and functionality for managing users and queue items.
# : dep pytest-mock
import Biz.PodcastItLater.Core as Core
import Biz.PodcastItLater.UI as UI
-import ludic.catalog.layouts as layouts
import ludic.html as html
# i need to import these unused because bild cannot get local transitive python
@@ -36,6 +35,7 @@ class AdminUsersAttrs(Attrs):
"""Attributes for AdminUsers component."""
users: list[dict[str, typing.Any]]
+ user: dict[str, typing.Any] | None
class StatusBadgeAttrs(Attrs):
@@ -295,45 +295,19 @@ class AdminUsers(Component[AnyChildren, AdminUsersAttrs]):
"""Admin view for managing users."""
@override
- def render(self) -> html.html:
+ def render(self) -> UI.PageLayout:
users = self.attrs["users"]
+ user = self.attrs.get("user")
- 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 - User Management"),
- UI.create_htmx_script(),
- ),
- html.body(
- UI.create_bootstrap_styles(),
- # Auto dark mode CSS (must come after Bootstrap)
- UI.create_auto_dark_mode_style(),
- html.div(
- html.h1(
- "PodcastItLater - User Management",
- classes=["mb-4"],
- ),
- html.div(
- html.a(
- html.i(classes=["bi", "bi-arrow-left", "me-2"]),
- "Back to Admin",
- href="/admin",
- classes=["btn", "btn-outline-primary", "mb-3"],
- ),
- ),
- self._render_users_table(users),
- id="admin-users-content",
- classes=["container", "my-4"],
- ),
+ return UI.PageLayout(
+ html.h2(
+ "User Management",
+ classes=["mb-4"],
),
+ self._render_users_table(users),
+ user=user,
+ current_page="admin-users",
+ error=None,
)
@staticmethod
@@ -365,89 +339,55 @@ class AdminViewAttrs(Attrs):
queue_items: list[dict[str, typing.Any]]
episodes: list[dict[str, typing.Any]]
status_counts: dict[str, int]
+ user: dict[str, typing.Any] | None
class AdminView(Component[AnyChildren, AdminViewAttrs]):
"""Admin view showing all queue items and episodes in tables."""
@override
- def render(self) -> html.html:
+ def render(self) -> UI.PageLayout:
queue_items = self.attrs["queue_items"]
episodes = self.attrs["episodes"]
status_counts = self.attrs.get("status_counts", {})
+ user = self.attrs.get("user")
- 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 - Admin Queue Status"),
- UI.create_htmx_script(),
- ),
- html.body(
- UI.create_bootstrap_styles(),
- # Auto dark mode CSS (must come after Bootstrap)
- UI.create_auto_dark_mode_style(),
- html.div(
- AdminView._render_content(
- queue_items,
- episodes,
- status_counts,
- ),
- id="admin-content",
- hx_get="/admin",
- hx_trigger="every 10s",
- hx_swap="innerHTML",
- hx_target="#admin-content",
- classes=["container", "my-4"],
+ return UI.PageLayout(
+ html.div(
+ AdminView.render_content(
+ queue_items,
+ episodes,
+ status_counts,
),
+ id="admin-content",
+ hx_get="/admin",
+ hx_trigger="every 10s",
+ hx_swap="innerHTML",
+ hx_target="#admin-content",
),
+ user=user,
+ current_page="admin",
+ error=None,
)
@staticmethod
- def _render_content(
+ def render_content(
queue_items: list[dict[str, typing.Any]],
episodes: list[dict[str, typing.Any]],
status_counts: dict[str, int],
) -> html.div:
"""Render the main content of the admin page."""
return html.div(
- html.h1(
- "PodcastItLater Admin - Queue Status",
+ html.h2(
+ "Admin Queue Status",
classes=["mb-4"],
),
- AdminView.render_navigation(),
AdminView.render_status_summary(status_counts),
AdminView.render_queue_table(queue_items),
AdminView.render_episodes_table(episodes),
)
@staticmethod
- def render_navigation() -> html.div:
- """Render navigation links."""
- return html.div(
- html.a(
- html.i(classes=["bi", "bi-arrow-left", "me-2"]),
- "Back to Home",
- href="/",
- classes=["btn", "btn-outline-primary", "btn-sm", "me-2"],
- ),
- html.a(
- html.i(classes=["bi", "bi-people", "me-2"]),
- "Manage Users",
- href="/admin/users",
- classes=["btn", "btn-outline-secondary", "btn-sm"],
- ),
- classes=["mb-3"],
- )
-
- @staticmethod
def render_status_summary(status_counts: dict[str, int]) -> html.div:
"""Render status summary section."""
return html.div(
@@ -568,17 +508,10 @@ def admin_queue_status(request: Request) -> AdminView | Response | html.div:
# Check if this is an HTMX request for auto-update
if request.headers.get("HX-Request") == "true":
# Return just the content div for HTMX updates
- AdminView(
- queue_items=all_queue_items,
- episodes=all_episodes,
- status_counts=status_counts,
- )
- content = layouts.Stack(
- html.h1("PodcastItLater Admin - Queue Status"),
- AdminView.render_navigation(),
- AdminView.render_status_summary(status_counts),
- AdminView.render_queue_table(all_queue_items),
- AdminView.render_episodes_table(all_episodes),
+ content = AdminView.render_content(
+ all_queue_items,
+ all_episodes,
+ status_counts,
)
return html.div(
content,
@@ -591,6 +524,7 @@ def admin_queue_status(request: Request) -> AdminView | Response | html.div:
queue_items=all_queue_items,
episodes=all_episodes,
status_counts=status_counts,
+ user=user,
)
@@ -696,7 +630,7 @@ def admin_users(request: Request) -> AdminUsers | Response:
rows = cursor.fetchall()
users = [dict(row) for row in rows]
- return AdminUsers(users=users)
+ return AdminUsers(users=users, user=user)
def update_user_status(
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index 15aff86..f32e81b 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -39,7 +39,7 @@ TITLE_TRUNCATE_LENGTH = 50
ERROR_TRUNCATE_LENGTH = 50
# Admin whitelist
-ADMIN_EMAILS = ["ben@bensima.com"]
+ADMIN_EMAILS = ["ben@bensima.com", "demo@example.com"]
def is_admin(user: dict[str, typing.Any] | None) -> bool:
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(),
+ ),
+ )
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index b7870a1..c20675f 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -691,172 +691,37 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
return html.div()
@override
- def render(self) -> html.html:
+ def render(self) -> UI.PageLayout | html.html:
queue_items = self.attrs["queue_items"]
episodes = self.attrs["episodes"]
user = self.attrs.get("user")
+ 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"),
- html.script(
- src="https://unpkg.com/htmx.org@1.9.10",
- integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
- crossorigin="anonymous",
- ),
- ),
- html.body(
- 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');",
- ),
- # Auto dark mode CSS (must come after Bootstrap)
- UI.create_auto_dark_mode_style(),
- # Bootstrap container
- html.div(
- # Header
- 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"],
- ),
- # Error alert
- html.div(
- html.div(
- html.i(
- classes=[
- "bi",
- "bi-exclamation-triangle-fill",
- "me-2",
- ],
- ),
- self.attrs.get("error", "") or "",
- classes=[
- "alert",
- "alert-danger",
- "d-flex",
- "align-items-center",
- ],
- role="alert", # type: ignore[call-arg]
- ),
- )
- if self.attrs.get("error")
- else html.div(),
- # User info navbar
- html.nav(
- html.div(
- # Hamburger toggle button (right side)
- 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",
- ),
- # Collapsible content
- html.div(
- # Navigation links
- html.ul(
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-person-circle",
- "me-1",
- ],
- ),
- "Manage Account",
- href="/account",
- classes=["nav-link"],
- ),
- classes=["nav-item"],
- ),
- html.li(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-gear-fill",
- "me-1",
- ],
- ),
- "Admin Queue",
- href="/admin",
- classes=["nav-link"],
- ),
- classes=["nav-item"],
- )
- if 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",
- ],
- )
- if user
- else LoginForm(error=self.attrs.get("error")),
- # Plan info callout (only for logged in users)
- self._render_plan_callout(user) if user else html.div(),
- # Main content (only if logged in)
- html.div(
- SubmitForm(),
- html.div(
- QueueStatus(items=queue_items),
- EpisodeList(
- episodes=episodes,
- rss_url=f"{BASE_URL}/feed/{user['token']}.xml"
- if user
- else None,
- ),
- id="dashboard-content",
- hx_get="/dashboard-updates",
- hx_trigger="every 3s, queue-updated from:body",
- hx_swap="innerHTML",
- ),
- )
- if user
- else html.div(),
- classes=["container", "px-3", "px-md-4"],
- style={"max-width": "900px"},
- ),
- # Bootstrap JS bundle
- html.script(
- src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js",
- integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL",
- crossorigin="anonymous",
+ if not user:
+ return UI.PageLayout(
+ LoginForm(error=error),
+ user=None,
+ current_page="home",
+ error=error,
+ )
+
+ return UI.PageLayout(
+ self._render_plan_callout(user),
+ SubmitForm(),
+ html.div(
+ QueueStatus(items=queue_items),
+ EpisodeList(
+ episodes=episodes,
+ rss_url=f"{BASE_URL}/feed/{user['token']}.xml",
),
+ id="dashboard-content",
+ hx_get="/dashboard-updates",
+ hx_trigger="every 3s, queue-updated from:body",
+ hx_swap="innerHTML",
),
+ user=user,
+ current_page="home",
+ error=error,
)
@@ -1063,7 +928,7 @@ def verify_magic_link(request: Request) -> Response:
@app.get("/account")
-def account_page(request: Request) -> html.html | RedirectResponse:
+def account_page(request: Request) -> UI.PageLayout | RedirectResponse:
"""Account management page."""
user_id = request.session.get("user_id")
if not user_id:
@@ -1079,212 +944,159 @@ def account_page(request: Request) -> html.html | RedirectResponse:
subscription_status = user.get("subscription_status", "")
cancel_at_period_end = user.get("cancel_at_period_end", 0) == 1
- return html.html(
- html.head(
- html.meta(charset="utf-8"),
- html.meta(
- name="viewport",
- content="width=device-width, initial-scale=1",
+ return UI.PageLayout(
+ html.h2(
+ html.i(
+ classes=["bi", "bi-person-circle", "me-2"],
),
- html.meta(
- name="color-scheme",
- content="light dark",
+ "Account Management",
+ classes=["mb-4"],
+ ),
+ html.div(
+ html.h4(
+ html.i(classes=["bi", "bi-envelope-fill", "me-2"]),
+ "Account Information",
+ classes=["card-header", "bg-transparent"],
),
- html.title("Account - PodcastItLater"),
- html.script(
- src="https://unpkg.com/htmx.org@1.9.10",
- integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
- crossorigin="anonymous",
+ html.div(
+ html.div(
+ html.strong("Email: "),
+ user["email"],
+ classes=["mb-2"],
+ ),
+ html.div(
+ html.strong("Account Created: "),
+ user["created_at"],
+ classes=["mb-2"],
+ ),
+ classes=["card-body"],
),
+ classes=["card", "mb-4"],
),
- html.body(
- 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');",
+ html.div(
+ html.h4(
+ html.i(
+ classes=["bi", "bi-credit-card-fill", "me-2"],
+ ),
+ "Subscription",
+ classes=["card-header", "bg-transparent"],
),
- UI.create_auto_dark_mode_style(),
html.div(
html.div(
- html.h1(
- html.i(
- classes=["bi", "bi-person-circle", "me-2"],
- ),
- "Account Management",
- classes=["mb-4"],
+ html.strong("Plan: "),
+ tier_info["name"],
+ f" ({tier_info['price']})",
+ classes=["mb-2"],
+ ),
+ html.div(
+ html.strong("Status: "),
+ subscription_status.title()
+ if subscription_status
+ else "Active",
+ classes=["mb-2"],
+ )
+ if tier == "paid"
+ else html.div(),
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-info-circle",
+ "me-1",
+ ],
),
- html.div(
- html.a(
- html.i(
- classes=["bi", "bi-arrow-left", "me-1"],
- ),
- "Back to Dashboard",
- href="/",
+ "Your subscription will cancel at the end "
+ "of the billing period.",
+ classes=[
+ "alert",
+ "alert-warning",
+ "mt-2",
+ "mb-2",
+ ],
+ )
+ if cancel_at_period_end
+ else html.div(),
+ html.div(
+ html.strong("Features: "),
+ tier_info["description"],
+ classes=["mb-3"],
+ ),
+ html.div(
+ html.a(
+ html.i(
classes=[
- "btn",
- "btn-outline-secondary",
- "mb-4",
+ "bi",
+ "bi-arrow-up-circle",
+ "me-1",
],
),
- ),
- # Account info section
- html.div(
- html.h4(
- html.i(classes=["bi", "bi-envelope-fill", "me-2"]),
- "Account Information",
- classes=["card-header", "bg-transparent"],
- ),
- html.div(
- html.div(
- html.strong("Email: "),
- user["email"],
- classes=["mb-2"],
- ),
- html.div(
- html.strong("Account Created: "),
- user["created_at"],
- classes=["mb-2"],
- ),
- classes=["card-body"],
- ),
- classes=["card", "mb-4"],
- ),
- # Subscription section
- html.div(
- html.h4(
+ "Upgrade to Paid Plan",
+ href="#",
+ hx_post="/billing/checkout",
+ hx_vals='{"tier": "paid"}',
+ classes=[
+ "btn",
+ "btn-success",
+ "me-2",
+ ],
+ )
+ if tier == "free"
+ else html.form(
+ html.button(
html.i(
- classes=["bi", "bi-credit-card-fill", "me-2"],
- ),
- "Subscription",
- classes=["card-header", "bg-transparent"],
- ),
- html.div(
- html.div(
- html.strong("Plan: "),
- tier_info["name"],
- f" ({tier_info['price']})",
- classes=["mb-2"],
- ),
- html.div(
- html.strong("Status: "),
- subscription_status.title()
- if subscription_status
- else "Active",
- classes=["mb-2"],
- )
- if tier == "paid"
- else html.div(),
- html.div(
- html.i(
- classes=[
- "bi",
- "bi-info-circle",
- "me-1",
- ],
- ),
- "Your subscription will cancel at the end "
- "of the billing period.",
- classes=[
- "alert",
- "alert-warning",
- "mt-2",
- "mb-2",
- ],
- )
- if cancel_at_period_end
- else html.div(),
- html.div(
- html.strong("Features: "),
- tier_info["description"],
- classes=["mb-3"],
- ),
- # Subscription actions
- html.div(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-arrow-up-circle",
- "me-1",
- ],
- ),
- "Upgrade to Paid Plan",
- href="#",
- hx_post="/billing/checkout",
- hx_vals='{"tier": "paid"}',
- classes=[
- "btn",
- "btn-success",
- "me-2",
- ],
- )
- if tier == "free"
- else html.form(
- html.button(
- html.i(
- classes=[
- "bi",
- "bi-gear-fill",
- "me-1",
- ],
- ),
- "Manage Subscription",
- type="submit",
- classes=[
- "btn",
- "btn-primary",
- "me-2",
- ],
- ),
- method="post",
- action="/billing/portal",
- ),
- ),
- classes=["card-body"],
- ),
- classes=["card", "mb-4"],
- ),
- # Actions section
- html.div(
- html.h4(
- html.i(classes=["bi", "bi-sliders", "me-2"]),
- "Actions",
- classes=["card-header", "bg-transparent"],
- ),
- html.div(
- html.a(
- html.i(
- classes=[
- "bi",
- "bi-box-arrow-right",
- "me-1",
- ],
- ),
- "Logout",
- href="/logout",
classes=[
- "btn",
- "btn-outline-secondary",
- "mb-2",
- "me-2",
+ "bi",
+ "bi-gear-fill",
+ "me-1",
],
),
- classes=["card-body"],
+ "Manage Subscription",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-primary",
+ "me-2",
+ ],
),
- classes=["card", "mb-4"],
+ method="post",
+ action="/billing/portal",
),
- classes=["mb-4"],
),
- classes=["container", "my-5", "px-3", "px-md-4"],
- style={"max-width": "900px"},
+ classes=["card-body"],
),
- html.script(
- src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js",
- integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL",
- crossorigin="anonymous",
+ classes=["card", "mb-4"],
+ ),
+ html.div(
+ html.h4(
+ html.i(classes=["bi", "bi-sliders", "me-2"]),
+ "Actions",
+ classes=["card-header", "bg-transparent"],
),
+ html.div(
+ html.a(
+ html.i(
+ classes=[
+ "bi",
+ "bi-box-arrow-right",
+ "me-1",
+ ],
+ ),
+ "Logout",
+ href="/logout",
+ classes=[
+ "btn",
+ "btn-outline-secondary",
+ "mb-2",
+ "me-2",
+ ],
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-4"],
),
+ user=user,
+ current_page="account",
+ error=None,
)