From 989a1a5de2419373e4932b11c2f2f5877a2fb959 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 13 Nov 2025 18:16:29 -0500 Subject: 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 --- Biz/PodcastItLater/Admin.py | 140 ++++-------- Biz/PodcastItLater/Core.py | 2 +- Biz/PodcastItLater/UI.py | 230 ++++++++++++++++++++ Biz/PodcastItLater/Web.py | 504 ++++++++++++++------------------------------ 4 files changed, 426 insertions(+), 450 deletions(-) (limited to 'Biz/PodcastItLater') 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,88 +339,54 @@ 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.""" @@ -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, ) -- cgit v1.2.3