summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Admin.py
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-09 14:28:39 -0500
committerBen Sima <ben@bsima.me>2025-11-09 14:28:39 -0500
commitc76c83987fae48c995e605d947aea72d513ee7cd (patch)
tree751f57da62b123218cdc7380aff9befd5317ea92 /Biz/PodcastItLater/Admin.py
parent253fdc93cb3e79983de69e0875c69efa77660aac (diff)
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 <amp@ampcode.com>
Diffstat (limited to 'Biz/PodcastItLater/Admin.py')
-rw-r--r--Biz/PodcastItLater/Admin.py267
1 files changed, 95 insertions, 172 deletions
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,