From c76c83987fae48c995e605d947aea72d513ee7cd Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Sun, 9 Nov 2025 14:28:39 -0500 Subject: feat(PodcastItLater): Apply Bootstrap 5 UI and fix dev login - Apply Bootstrap 5 CSS and icons to all pages (Web.py, Admin.py) - Convert all components to use Bootstrap classes instead of inline styles - Add dev mode banner showing demo@example.com for instant login - Implement secure demo account (demo@example.com) with auto-approval - Fix HTMX loading issue when load_styles=False - Update Database.create_user() to accept optional status parameter - Add Bootstrap tables, cards, badges, and button groups throughout - All tests passing Completes task t-144drAE Amp-Thread-ID: https://ampcode.com/threads/T-8feaca83-dcc2-46cb-8f71-d0785960a2f7 Co-authored-by: Amp --- Biz/PodcastItLater/Admin.py | 267 ++++++++++++++++---------------------------- 1 file changed, 95 insertions(+), 172 deletions(-) (limited to 'Biz/PodcastItLater/Admin.py') diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py index f9ca585..5772256 100644 --- a/Biz/PodcastItLater/Admin.py +++ b/Biz/PodcastItLater/Admin.py @@ -54,31 +54,25 @@ class StatusBadge(Component[AnyChildren, StatusBadgeAttrs]): count = self.attrs.get("count", None) text = f"{status.upper()}: {count}" if count is not None else status + badge_class = self.get_status_badge_class(status) return html.span( text, - style={ - "margin-right": "20px" if count is not None else "0", - "padding": "5px 10px", - "background": self.get_status_color(status), - "color": "white", - "border-radius": "4px", - "font-weight": "bold" if count is None else "normal", - }, + classes=["badge", badge_class, "me-3" if count is not None else ""], ) @staticmethod - def get_status_color(status: str) -> str: - """Get color for status display.""" + def get_status_badge_class(status: str) -> str: + """Get Bootstrap badge class for status.""" return { - "pending": "#ffa500", - "processing": "#007cba", - "completed": "#28a745", - "active": "#28a745", - "error": "#dc3545", - "cancelled": "#6c757d", - "disabled": "#dc3545", - }.get(status, "#6c757d") + "pending": "bg-warning text-dark", + "processing": "bg-primary", + "completed": "bg-success", + "active": "bg-success", + "error": "bg-danger", + "cancelled": "bg-secondary", + "disabled": "bg-danger", + }.get(status, "bg-secondary") class TruncatedTextAttrs(Attrs): @@ -105,11 +99,8 @@ class TruncatedText(Component[AnyChildren, TruncatedTextAttrs]): return html.div( truncated, title=text, - style={ - "max-width": max_width, - "overflow": "hidden", - "text-overflow": "ellipsis", - }, + classes=["text-truncate"], + style={"max-width": max_width}, ) @@ -133,44 +124,31 @@ class ActionButtons(Component[AnyChildren, ActionButtonsAttrs]): if status != "completed": buttons.append( html.button( + html.i(classes=["bi", "bi-arrow-clockwise", "me-1"]), "Retry", hx_post=f"/queue/{job_id}/retry", hx_target="body", hx_swap="outerHTML", - style={ - "margin-right": "5px", - "padding": "5px 10px", - "background": "#28a745", - "color": "white", - "border": "none", - "cursor": "pointer", - "border-radius": "3px", - }, + classes=["btn", "btn-sm", "btn-success", "me-1"], disabled=status == "completed", ), ) buttons.append( html.button( + html.i(classes=["bi", "bi-trash", "me-1"]), "Delete", hx_delete=f"/queue/{job_id}", hx_confirm="Are you sure you want to delete this queue item?", hx_target="body", hx_swap="outerHTML", - style={ - "padding": "5px 10px", - "background": "#dc3545", - "color": "white", - "border": "none", - "cursor": "pointer", - "border-radius": "3px", - }, + classes=["btn", "btn-sm", "btn-danger"], ), ) return html.div( *buttons, - style={"display": "flex", "gap": "5px"}, + classes=["btn-group"], ) @@ -188,51 +166,33 @@ class QueueTableRow(Component[AnyChildren, QueueTableRowAttrs]): item = self.attrs["item"] return html.tr( - html.td(str(item["id"]), style={"padding": "10px"}), + html.td(str(item["id"])), html.td( TruncatedText( text=item["url"], max_length=Core.TITLE_TRUNCATE_LENGTH, max_width="300px", ), - style={"padding": "10px"}, ), html.td( TruncatedText( text=item.get("title") or "-", max_length=Core.TITLE_TRUNCATE_LENGTH, ), - style={"padding": "10px"}, - ), - html.td( - item["email"] or "-", - style={"padding": "10px"}, - ), - html.td( - StatusBadge(status=item["status"]), - style={"padding": "10px"}, - ), - html.td( - str(item.get("retry_count", 0)), - style={"padding": "10px"}, - ), - html.td( - item["created_at"], - style={"padding": "10px"}, ), + html.td(item["email"] or "-"), + html.td(StatusBadge(status=item["status"])), + html.td(str(item.get("retry_count", 0))), + html.td(html.small(item["created_at"], classes=["text-muted"])), html.td( TruncatedText( text=item["error_message"] or "-", max_length=Core.ERROR_TRUNCATE_LENGTH, ) if item["error_message"] - else "-", - style={"padding": "10px"}, - ), - html.td( - ActionButtons(job_id=item["id"], status=item["status"]), - style={"padding": "10px"}, + else html.span("-", classes=["text-muted"]), ), + html.td(ActionButtons(job_id=item["id"], status=item["status"])), ) @@ -250,37 +210,31 @@ class EpisodeTableRow(Component[AnyChildren, EpisodeTableRowAttrs]): episode = self.attrs["episode"] return html.tr( - html.td(str(episode["id"]), style={"padding": "10px"}), + html.td(str(episode["id"])), html.td( TruncatedText( text=episode["title"], max_length=Core.TITLE_TRUNCATE_LENGTH, ), - style={"padding": "10px"}, ), html.td( html.a( + html.i(classes=["bi", "bi-play-circle", "me-1"]), "Listen", href=episode["audio_url"], target="_blank", - style={"color": "#007cba"}, + classes=["btn", "btn-sm", "btn-outline-primary"], ), - style={"padding": "10px"}, ), html.td( f"{episode['duration']}s" if episode["duration"] else "-", - style={"padding": "10px"}, ), html.td( f"{episode['content_length']:,} chars" if episode["content_length"] else "-", - style={"padding": "10px"}, - ), - html.td( - episode["created_at"], - style={"padding": "10px"}, ), + html.td(html.small(episode["created_at"], classes=["text-muted"])), ) @@ -298,12 +252,9 @@ class UserTableRow(Component[AnyChildren, UserTableRowAttrs]): user = self.attrs["user"] return html.tr( - html.td(user["email"], style={"padding": "10px"}), - html.td(user["created_at"], style={"padding": "10px"}), - html.td( - StatusBadge(status=user.get("status", "pending")), - style={"padding": "10px"}, - ), + html.td(user["email"]), + html.td(html.small(user["created_at"], classes=["text-muted"])), + html.td(StatusBadge(status=user.get("status", "pending"))), html.td( html.select( html.option( @@ -326,13 +277,8 @@ class UserTableRow(Component[AnyChildren, UserTableRowAttrs]): hx_trigger="change", hx_target="body", hx_swap="outerHTML", - style={ - "padding": "5px", - "border": "1px solid #ddd", - "border-radius": "3px", - }, + classes=["form-select", "form-select-sm"], ), - style={"padding": "10px"}, ), ) @@ -340,31 +286,19 @@ class UserTableRow(Component[AnyChildren, UserTableRowAttrs]): def create_table_header(columns: list[str]) -> html.thead: """Create a table header with given column names.""" return html.thead( - html.tr(*[ - html.th( - col, - style={"padding": "10px", "text-align": "left"}, - ) - for col in columns - ]), + html.tr(*[html.th(col, scope="col") for col in columns]), + classes=["table-light"], ) -def create_admin_styles() -> html.style: - """Create common admin page styles.""" - return html.style(""" - body { - font-family: Arial, sans-serif; - max-width: 1200px; - margin: 0 auto; - padding: 20px; - } - h1, h2 { color: #333; } - table { background: white; } - thead { background: #f8f9fa; } - tbody tr:nth-child(even) { background: #f8f9fa; } - tbody tr:hover { background: #e9ecef; } - """) +def create_bootstrap_styles() -> html.style: + """Load Bootstrap CSS for admin pages.""" + 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');", + ) class AdminUsers(Component[AnyChildren, AdminUsersAttrs]): @@ -378,28 +312,27 @@ class AdminUsers(Component[AnyChildren, AdminUsersAttrs]): pages.Head( title="PodcastItLater - User Management", htmx_version="1.9.10", - load_styles=True, + load_styles=False, ), pages.Body( - layouts.Center( + create_bootstrap_styles(), + html.div( + html.h1( + "PodcastItLater - User Management", + classes=["mb-4"], + ), html.div( - layouts.Stack( - html.h1("PodcastItLater - User Management"), - html.div( - html.a( - "← Back to Admin", - href="/admin", - style={"color": "#007cba"}, - ), - style={"margin-bottom": "20px"}, - ), - self._render_users_table(users), - create_admin_styles(), + html.a( + html.i(classes=["bi", "bi-arrow-left", "me-2"]), + "Back to Admin", + href="/admin", + classes=["btn", "btn-outline-primary", "mb-3"], ), - id="admin-users-content", ), + self._render_users_table(users), + id="admin-users-content", + classes=["container", "my-4"], ), - htmx_version="1.9.10", ), ) @@ -409,7 +342,7 @@ class AdminUsers(Component[AnyChildren, AdminUsersAttrs]): ) -> html.div: """Render users table.""" return html.div( - html.h2("All Users"), + html.h2("All Users", classes=["mb-3"]), html.div( html.table( create_table_header([ @@ -419,13 +352,9 @@ class AdminUsers(Component[AnyChildren, AdminUsersAttrs]): "Actions", ]), html.tbody(*[UserTableRow(user=user) for user in users]), - style={ - "width": "100%", - "border-collapse": "collapse", - "border": "1px solid #ddd", - }, + classes=["table", "table-hover", "table-striped"], ), - style={"overflow-x": "auto"}, + classes=["table-responsive"], ), ) @@ -451,24 +380,23 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): pages.Head( title="PodcastItLater - Admin Queue Status", htmx_version="1.9.10", - load_styles=True, + load_styles=False, ), pages.Body( - layouts.Center( - 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", + create_bootstrap_styles(), + 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"], ), - htmx_version="1.9.10", ), ) @@ -477,15 +405,17 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): queue_items: list[dict[str, typing.Any]], episodes: list[dict[str, typing.Any]], status_counts: dict[str, int], - ) -> layouts.Stack: + ) -> html.div: """Render the main content of the admin page.""" - return layouts.Stack( - html.h1("PodcastItLater Admin - Queue Status"), + return html.div( + html.h1( + "PodcastItLater 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), - create_admin_styles(), ) @staticmethod @@ -493,29 +423,31 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): """Render navigation links.""" return html.div( html.a( - "← Back to Home", + html.i(classes=["bi", "bi-arrow-left", "me-2"]), + "Back to Home", href="/", - style={"color": "#007cba"}, + classes=["btn", "btn-outline-primary", "btn-sm", "me-2"], ), html.a( + html.i(classes=["bi", "bi-people", "me-2"]), "Manage Users", href="/admin/users", - style={"color": "#007cba", "margin-left": "15px"}, + classes=["btn", "btn-outline-secondary", "btn-sm"], ), - style={"margin-bottom": "20px"}, + classes=["mb-3"], ) @staticmethod def render_status_summary(status_counts: dict[str, int]) -> html.div: """Render status summary section.""" return html.div( - html.h2("Status Summary"), + html.h2("Status Summary", classes=["mb-3"]), html.div( *[ StatusBadge(status=status, count=count) for status, count in status_counts.items() ], - style={"margin-bottom": "20px"}, + classes=["mb-4"], ), ) @@ -525,7 +457,7 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): ) -> html.div: """Render queue items table.""" return html.div( - html.h2("Queue Items"), + html.h2("Queue Items", classes=["mb-3"]), html.div( html.table( create_table_header([ @@ -542,13 +474,9 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): html.tbody(*[ QueueTableRow(item=item) for item in queue_items ]), - style={ - "width": "100%", - "border-collapse": "collapse", - "border": "1px solid #ddd", - }, + classes=["table", "table-hover", "table-sm"], ), - style={"overflow-x": "auto", "margin-bottom": "30px"}, + classes=["table-responsive", "mb-5"], ), ) @@ -558,7 +486,7 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): ) -> html.div: """Render episodes table.""" return html.div( - html.h2("Completed Episodes"), + html.h2("Completed Episodes", classes=["mb-3"]), html.div( html.table( create_table_header([ @@ -572,13 +500,9 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): html.tbody(*[ EpisodeTableRow(episode=episode) for episode in episodes ]), - style={ - "width": "100%", - "border-collapse": "collapse", - "border": "1px solid #ddd", - }, + classes=["table", "table-hover", "table-sm"], ), - style={"overflow-x": "auto"}, + classes=["table-responsive"], ), ) @@ -645,7 +569,6 @@ def admin_queue_status(request: Request) -> AdminView | Response | html.div: AdminView.render_status_summary(status_counts), AdminView.render_queue_table(all_queue_items), AdminView.render_episodes_table(all_episodes), - create_admin_styles(), ) return html.div( content, -- cgit v1.2.3