summaryrefslogtreecommitdiff
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
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>
-rw-r--r--.tasks/tasks.jsonl9
-rw-r--r--Biz/PodcastItLater/Admin.py267
-rw-r--r--Biz/PodcastItLater/Core.py21
-rw-r--r--Biz/PodcastItLater/Web.py718
4 files changed, 568 insertions, 447 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl
index 0ac3fa8..b9fa72d 100644
--- a/.tasks/tasks.jsonl
+++ b/.tasks/tasks.jsonl
@@ -23,3 +23,12 @@
{"taskCreatedAt":"2025-11-09T13:05:18.543055749Z","taskDependencies":[],"taskId":"t-PqMBuS","taskNamespace":"Omni/Task.hs","taskParent":"t-PpXWsU","taskStatus":"Done","taskTitle":"Protect production database from tests","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:05:18.602787251Z"}
{"taskCreatedAt":"2025-11-09T13:05:18.64074361Z","taskDependencies":[],"taskId":"t-PqN0Uu","taskNamespace":"Omni/Task.hs","taskParent":"t-PpXWsU","taskStatus":"Done","taskTitle":"Add migration support for old task format","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:05:18.703048004Z"}
{"taskCreatedAt":"2025-11-09T14:22:32.038937583Z","taskDependencies":[{"depId":"t-PpZlbL","depType":"DiscoveredFrom"}],"taskId":"t-Uumhrq","taskNamespace":"Omni/Task.hs","taskParent":null,"taskStatus":"Open","taskTitle":"Investigate and implement prettier tree drawing with box characters","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T14:22:32.038937583Z"}
+{"taskCreatedAt":"2025-11-09T16:48:40.260201423Z","taskDependencies":[],"taskId":"t-143KQl2","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskStatus":"Open","taskTitle":"PodcastItLater: Path to Paid Product","taskType":"Epic","taskUpdatedAt":"2025-11-09T16:48:40.260201423Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.076581674Z","taskDependencies":[],"taskId":"t-144drAE","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Done","taskTitle":"Adopt Bootstrap CSS for UI improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T17:00:05.424532832Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.237113366Z","taskDependencies":[],"taskId":"t-144e7lF","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Add Stripe integration for billing","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:47.237113366Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.388960509Z","taskDependencies":[],"taskId":"t-144eKR1","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Implement usage tracking and limits","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:47.388960509Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.589181852Z","taskDependencies":[],"taskId":"t-144fAWn","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Add email notifications (transactional)","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:47.589181852Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.737218185Z","taskDependencies":[],"taskId":"t-144gds4","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Migrate from SQLite to PostgreSQL","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:47.737218185Z"}
+{"taskCreatedAt":"2025-11-09T16:48:47.887102357Z","taskDependencies":[],"taskId":"t-144gQry","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Create basic admin dashboard","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:47.887102357Z"}
+{"taskCreatedAt":"2025-11-09T16:48:48.072927212Z","taskDependencies":[],"taskId":"t-144hCMJ","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Complete comprehensive test suite","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T16:48:48.072927212Z"}
+{"taskCreatedAt":"2025-11-09T17:48:34.522286485Z","taskDependencies":[],"taskId":"t-17Z0069","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-143KQl2","taskStatus":"Open","taskTitle":"Fix Recent Episodes refresh to prepend instead of reload (interrupts audio playback)","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T17:48:34.522286485Z"}
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,
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index 0ca945c..b7b6ed9 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -576,13 +576,21 @@ class Database: # noqa: PLR0904
)
@staticmethod
- def create_user(email: str) -> tuple[int, str]:
+ def create_user(email: str, status: str = "pending") -> tuple[int, str]:
"""Create a new user and return (user_id, token).
+ Args:
+ email: User email address
+ status: Initial status (pending, active, or disabled)
+
Raises:
ValueError: If user ID cannot be retrieved after insert or if user
- not found.
+ not found, or if status is invalid.
"""
+ if status not in {"pending", "active", "disabled"}:
+ msg = f"Invalid status: {status}"
+ raise ValueError(msg)
+
# Generate a secure token for RSS feed access
token = secrets.token_urlsafe(32)
with Database.get_connection() as conn:
@@ -590,14 +598,19 @@ class Database: # noqa: PLR0904
try:
cursor.execute(
"INSERT INTO users (email, token, status) VALUES (?, ?, ?)",
- (email, token, "pending"),
+ (email, token, status),
)
conn.commit()
user_id = cursor.lastrowid
if user_id is None:
msg = "Failed to get user ID after insert"
raise ValueError(msg)
- logger.info("Created user %s with email %s", user_id, email)
+ logger.info(
+ "Created user %s with email %s (status: %s)",
+ user_id,
+ email,
+ status,
+ )
except sqlite3.IntegrityError:
# User already exists
cursor.execute(
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index ee63b60..bd4b973 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -20,7 +20,6 @@ import Biz.PodcastItLater.Admin as Admin
import Biz.PodcastItLater.Core as Core
import html as html_module
import httpx
-import ludic.catalog.layouts as layouts
import ludic.catalog.pages as pages
import ludic.html as html
import Omni.App as App
@@ -213,46 +212,73 @@ class LoginForm(Component[AnyChildren, LoginFormAttrs]):
@override
def render(self) -> html.div:
error = self.attrs.get("error")
+ is_dev_mode = App.from_env() == App.Area.Test
+
return html.div(
- html.h2("Login / Register"),
- html.form(
+ # Dev mode banner
+ html.div(
html.div(
- html.label("Email:", for_="email"),
- html.input(
- type="email",
- id="email",
- name="email",
- placeholder="your@email.com",
- required=True,
- style={
- "width": "100%",
- "padding": "8px",
- "margin": "4px 0",
- },
- ),
- ),
- html.button(
- "Continue",
- type="submit",
- style={
- "padding": "10px 20px",
- "background": "#007cba",
- "color": "white",
- "border": "none",
- "cursor": "pointer",
- },
+ html.i(classes=["bi", "bi-info-circle", "me-2"]),
+ html.strong("Dev/Test Mode: "),
+ "Use ",
+ html.code("demo@example.com", classes=["text-dark"]),
+ " for instant login",
+ classes=[
+ "alert",
+ "alert-info",
+ "d-flex",
+ "align-items-center",
+ "mb-3",
+ ],
),
- hx_post="/login",
- hx_target="#login-result",
- hx_swap="innerHTML",
- ),
+ )
+ if is_dev_mode
+ else html.div(),
html.div(
- error or "",
- id="login-result",
- style={"margin-top": "10px", "color": "#dc3545"}
- if error
- else {"margin-top": "10px"},
+ html.div(
+ html.h4(
+ html.i(classes=["bi", "bi-envelope-fill", "me-2"]),
+ "Login / Register",
+ classes=["card-title", "mb-3"],
+ ),
+ html.form(
+ html.div(
+ html.label(
+ "Email address",
+ for_="email",
+ classes=["form-label"],
+ ),
+ html.input(
+ type="email",
+ id="email",
+ name="email",
+ placeholder="your@email.com",
+ required=True,
+ classes=["form-control", "mb-3"],
+ ),
+ ),
+ html.button(
+ html.i(
+ classes=["bi", "bi-arrow-right-circle", "me-2"],
+ ),
+ "Continue",
+ type="submit",
+ classes=["btn", "btn-primary", "w-100"],
+ ),
+ hx_post="/login",
+ hx_target="#login-result",
+ hx_swap="innerHTML",
+ ),
+ html.div(
+ error or "",
+ id="login-result",
+ classes=["mt-3"],
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card"],
),
+ classes=["mb-4"],
)
@@ -262,45 +288,53 @@ class SubmitForm(Component[AnyChildren, Attrs]):
@override
def render(self) -> html.div:
return html.div(
- html.h2("Submit Article"),
- html.form(
+ html.div(
html.div(
- html.label("Article URL:", for_="url"),
- html.input(
- type="url",
- id="url",
- name="url",
- placeholder="https://example.com/article",
- required=True,
- style={
- "width": "100%",
- "padding": "8px",
- "margin": "4px 0",
- },
- on_focus="this.select()",
+ html.h4(
+ html.i(classes=["bi", "bi-file-earmark-plus", "me-2"]),
+ "Submit Article",
+ classes=["card-title", "mb-3"],
),
+ html.form(
+ html.div(
+ html.label(
+ "Article URL",
+ for_="url",
+ classes=["form-label"],
+ ),
+ html.div(
+ html.input(
+ type="url",
+ id="url",
+ name="url",
+ placeholder="https://example.com/article",
+ required=True,
+ classes=["form-control"],
+ on_focus="this.select()",
+ ),
+ html.button(
+ html.i(classes=["bi", "bi-send-fill"]),
+ type="submit",
+ classes=["btn", "btn-primary"],
+ ),
+ classes=["input-group", "mb-3"],
+ ),
+ ),
+ hx_post="/submit",
+ hx_target="#submit-result",
+ hx_swap="innerHTML",
+ hx_on=(
+ "htmx:afterRequest: "
+ "if(event.detail.successful) "
+ "document.getElementById('url').value = ''"
+ ),
+ ),
+ html.div(id="submit-result", classes=["mt-2"]),
+ classes=["card-body"],
),
- html.button(
- "Submit",
- type="submit",
- style={
- "padding": "10px 20px",
- "background": "#007cba",
- "color": "white",
- "border": "none",
- "cursor": "pointer",
- },
- ),
- hx_post="/submit",
- hx_target="#submit-result",
- hx_swap="innerHTML",
- hx_on=(
- "htmx:afterRequest: "
- "if(event.detail.successful) "
- "document.getElementById('url').value = ''"
- ),
+ classes=["card"],
),
- html.div(id="submit-result", style={"margin-top": "10px"}),
+ classes=["mb-4"],
)
@@ -318,103 +352,145 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]):
items = self.attrs["items"]
if not items:
return html.div(
- html.h3("Queue Status"),
- html.p("No items in queue"),
+ html.h4(
+ html.i(classes=["bi", "bi-list-check", "me-2"]),
+ "Queue Status",
+ classes=["mb-3"],
+ ),
+ html.p("No items in queue", classes=["text-muted"]),
)
+ # Map status to Bootstrap badge classes
+ status_classes = {
+ "pending": "bg-warning text-dark",
+ "processing": "bg-primary",
+ "error": "bg-danger",
+ "cancelled": "bg-secondary",
+ }
+
+ status_icons = {
+ "pending": "bi-clock",
+ "processing": "bi-arrow-repeat",
+ "error": "bi-exclamation-triangle",
+ "cancelled": "bi-x-circle",
+ }
+
queue_items = []
for item in items:
- status_color = {
- "pending": "#ffa500",
- "processing": "#007cba",
- "error": "#dc3545",
- "cancelled": "#6c757d",
- }.get(item["status"], "#6c757d")
+ badge_class = status_classes.get(item["status"], "bg-secondary")
+ icon_class = status_icons.get(item["status"], "bi-question-circle")
queue_items.append(
html.div(
html.div(
- html.strong(f"#{item['id']} "),
- html.span(
- item["status"].upper(),
- style={
- "color": status_color,
- "font-weight": "bold",
- },
+ html.div(
+ html.strong(f"#{item['id']}", classes=["me-2"]),
+ html.span(
+ html.i(classes=["bi", icon_class, "me-1"]),
+ item["status"].upper(),
+ classes=["badge", badge_class],
+ ),
+ classes=[
+ "d-flex",
+ "align-items-center",
+ "justify-content-between",
+ ],
+ ),
+ # Add title and author if available
+ *(
+ [
+ html.div(
+ html.strong(
+ item["title"],
+ classes=["d-block"],
+ ),
+ html.small(
+ f"by {item['author']}",
+ classes=["text-muted"],
+ )
+ if item.get("author")
+ else html.span(),
+ classes=["mt-2"],
+ ),
+ ]
+ if item.get("title")
+ else []
+ ),
+ html.small(
+ html.i(classes=["bi", "bi-link-45deg", "me-1"]),
+ item["url"][: Core.URL_TRUNCATE_LENGTH]
+ + (
+ "..."
+ if len(item["url"]) > Core.URL_TRUNCATE_LENGTH
+ else ""
+ ),
+ classes=["text-muted", "d-block", "mt-2"],
+ ),
+ html.small(
+ html.i(classes=["bi", "bi-calendar", "me-1"]),
+ f"Created: {item['created_at']}",
+ classes=["text-muted", "d-block", "mt-1"],
+ ),
+ *(
+ [
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-exclamation-circle",
+ "me-1",
+ ],
+ ),
+ f"Error: {item['error_message']}",
+ classes=[
+ "alert",
+ "alert-danger",
+ "mt-2",
+ "mb-0",
+ "py-1",
+ "px-2",
+ "small",
+ ],
+ ),
+ ]
+ if item["error_message"]
+ else []
),
# Add cancel button for pending jobs
- html.button(
- "Cancel",
- hx_post=f"/queue/{item['id']}/cancel",
- hx_trigger="click",
- hx_on=(
- "htmx:afterRequest: "
- "if(event.detail.successful) "
- "htmx.trigger('body', 'queue-updated')"
+ html.div(
+ html.button(
+ html.i(classes=["bi", "bi-x-lg", "me-1"]),
+ "Cancel",
+ hx_post=f"/queue/{item['id']}/cancel",
+ hx_trigger="click",
+ hx_on=(
+ "htmx:afterRequest: "
+ "if(event.detail.successful) "
+ "htmx.trigger('body', 'queue-updated')"
+ ),
+ classes=[
+ "btn",
+ "btn-sm",
+ "btn-outline-danger",
+ "mt-2",
+ ],
),
- style={
- "margin-left": "10px",
- "padding": "2px 8px",
- "background": "#dc3545",
- "color": "white",
- "border": "none",
- "cursor": "pointer",
- "border-radius": "3px",
- "font-size": "12px",
- },
+ classes=["mt-2"],
)
if item["status"] == "pending"
- else "",
- style={"display": "flex", "align-items": "center"},
- ),
- html.br(),
- # Add title and author if available
- *(
- [
- html.div(
- html.strong(item["title"]),
- html.br() if item.get("author") else "",
- html.small(f"by {item['author']}")
- if item.get("author")
- else "",
- style={"margin": "5px 0"},
- ),
- ]
- if item.get("title")
- else []
- ),
- html.small(
- item["url"][: Core.URL_TRUNCATE_LENGTH]
- + (
- "..."
- if len(item["url"]) > Core.URL_TRUNCATE_LENGTH
- else ""
- ),
- ),
- html.br(),
- html.small(f"Created: {item['created_at']}"),
- *(
- [
- html.br(),
- html.small(
- f"Error: {item['error_message']}",
- style={"color": "#dc3545"},
- ),
- ]
- if item["error_message"]
- else []
+ else html.div(),
+ classes=["card-body"],
),
- style={
- "border": "1px solid #ddd",
- "padding": "10px",
- "margin": "5px 0",
- "border-radius": "4px",
- },
+ classes=["card", "mb-2"],
),
)
return html.div(
- html.h3("Queue Status"),
+ html.h4(
+ html.i(classes=["bi", "bi-list-check", "me-2"]),
+ "Queue Status",
+ classes=["mb-3"],
+ ),
*queue_items,
)
@@ -433,8 +509,12 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
episodes = self.attrs["episodes"]
if not episodes:
return html.div(
- html.h3("Recent Episodes"),
- html.p("No episodes yet"),
+ html.h4(
+ html.i(classes=["bi", "bi-broadcast", "me-2"]),
+ "Recent Episodes",
+ classes=["mb-3"],
+ ),
+ html.p("No episodes yet", classes=["text-muted"]),
)
episode_items = []
@@ -442,49 +522,71 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
duration_str = format_duration(episode.get("duration"))
episode_items.append(
html.div(
- html.h4(episode["title"]),
- # Show author if available
- html.p(
- f"by {episode['author']}",
- style={"margin": "5px 0", "font-style": "italic"},
- )
- if episode.get("author")
- else html.span(),
- html.audio(
- html.source(
- src=episode["audio_url"],
- type="audio/mpeg",
- ),
- "Your browser does not support the audio element.",
- controls=True,
- style={"width": "100%"},
- ),
- html.small(
- f"Duration: {duration_str} | "
- f"Created: {episode['created_at']}",
- ),
- # Show link to original article if available
html.div(
- html.a(
- "View original article",
- href=episode["original_url"],
- target="_blank",
- style={"color": "#007cba"},
+ html.h5(
+ episode["title"],
+ classes=["card-title", "mb-2"],
),
- style={"margin-top": "10px"},
- )
- if episode.get("original_url")
- else html.span(),
- style={
- "border": "1px solid #ddd",
- "padding": "15px",
- "margin": "10px 0",
- "border-radius": "4px",
- },
+ # Show author if available
+ html.p(
+ html.i(classes=["bi", "bi-person", "me-1"]),
+ f"by {episode['author']}",
+ classes=["text-muted", "small", "mb-3"],
+ )
+ if episode.get("author")
+ else html.div(),
+ html.audio(
+ html.source(
+ src=episode["audio_url"],
+ type="audio/mpeg",
+ ),
+ "Your browser does not support the audio element.",
+ controls=True,
+ classes=["w-100", "mb-3"],
+ ),
+ html.div(
+ html.small(
+ html.i(classes=["bi", "bi-clock", "me-1"]),
+ f"Duration: {duration_str}",
+ classes=["text-muted", "me-3"],
+ ),
+ html.small(
+ html.i(classes=["bi", "bi-calendar", "me-1"]),
+ f"Created: {episode['created_at']}",
+ classes=["text-muted"],
+ ),
+ classes=["mb-2"],
+ ),
+ # Show link to original article if available
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-link-45deg", "me-1"]),
+ "View original article",
+ href=episode["original_url"],
+ target="_blank",
+ classes=[
+ "btn",
+ "btn-sm",
+ "btn-outline-primary",
+ ],
+ ),
+ )
+ if episode.get("original_url")
+ else html.div(),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-3"],
),
)
- return html.div(html.h3("Recent Episodes"), *episode_items)
+ return html.div(
+ html.h4(
+ html.i(classes=["bi", "bi-broadcast", "me-2"]),
+ "Recent Episodes",
+ classes=["mb-3"],
+ ),
+ *episode_items,
+ )
class HomePageAttrs(Attrs):
@@ -509,90 +611,145 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
pages.Head(
title="PodcastItLater",
htmx_version="1.9.10",
- load_styles=True,
+ load_styles=False,
),
pages.Body(
- layouts.Center(
- layouts.Stack(
- html.h1("PodcastItLater"),
- html.p("Convert web articles to podcast episodes"),
+ # Add HTMX
+ html.script(
+ src="https://unpkg.com/htmx.org@1.9.10",
+ integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
+ crossorigin="anonymous",
+ ),
+ # Add Bootstrap CSS and icons (pages.Head doesn't support it)
+ 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');",
+ ),
+ # 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 card or login form
+ html.div(
html.div(
- # Show error if present
- html.div(
- self.attrs.get("error", "") or "",
- style={
- "color": "#dc3545",
- "margin-bottom": "10px",
- },
- )
- if self.attrs.get("error")
- else html.div(),
- # Show user info and logout if logged in
html.div(
- html.p(f"Logged in as: {user['email']}"),
- html.p(
- "Your RSS Feed: ",
+ html.h6(
+ "Logged in as:",
+ classes=["card-title", "mb-2"],
+ ),
+ html.p(user["email"], classes=["mb-3"]),
+ html.h6(
+ "Your RSS Feed:",
+ classes=["card-title", "mb-2"],
+ ),
+ html.div(
html.code(
f"{BASE_URL}/feed/{user['token']}.xml",
+ classes=["text-break"],
),
+ classes=["mb-3"],
),
html.div(
html.a(
- "View Queue Status",
+ html.i(
+ classes=[
+ "bi",
+ "bi-gear-fill",
+ "me-1",
+ ],
+ ),
+ "Admin Queue",
href="/admin",
- style={
- "color": "#007cba",
- "margin-right": "15px",
- },
+ classes=[
+ "btn",
+ "btn-outline-primary",
+ "btn-sm",
+ "me-2",
+ ],
)
if Core.is_admin(user)
else html.span(),
html.a(
+ html.i(
+ classes=[
+ "bi",
+ "bi-box-arrow-right",
+ "me-1",
+ ],
+ ),
"Logout",
href="/logout",
- style={"color": "#dc3545"},
- ),
- ),
- style={
- "background": "#f8f9fa",
- "padding": "15px",
- "border-radius": "4px",
- "margin-bottom": "20px",
- },
- )
- if user
- else LoginForm(error=self.attrs.get("error")),
- # Only show submit form and content if logged in
- html.div(
- SubmitForm(),
- html.div(
- QueueStatus(items=queue_items),
- EpisodeList(episodes=episodes),
- id="dashboard-content",
- hx_get="/dashboard-updates",
- hx_trigger=(
- "every 3s, queue-updated from:body"
+ classes=[
+ "btn",
+ "btn-outline-danger",
+ "btn-sm",
+ ],
),
- hx_swap="innerHTML",
),
- classes=["container"],
- )
- if user
- else html.div(),
+ classes=["card-body", "bg-light"],
+ ),
+ classes=["card", "mb-4"],
),
- html.style("""
- body {
- font-family: Arial, sans-serif;
- max-width: 800px;
- margin: 0 auto;
- padding: 20px;
- }
- h1 { color: #333; }
- .container { display: grid; gap: 20px; }
- """),
- ),
+ )
+ if user
+ else LoginForm(error=self.attrs.get("error")),
+ # Main content (only if logged in)
+ html.div(
+ SubmitForm(),
+ html.div(
+ QueueStatus(items=queue_items),
+ EpisodeList(episodes=episodes),
+ 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", "max-w-4xl"],
+ 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",
),
- htmx_version="1.9.10",
),
)
@@ -648,25 +805,34 @@ def index(request: Request) -> HomePage:
def _handle_test_login(email: str, request: Request) -> Response:
"""Handle login in test mode."""
+ # Special handling for demo account - auto-approve
+ is_demo_account = email == "demo@example.com"
+
user = Core.Database.get_user_by_email(email)
if not user:
- user_id, token = Core.Database.create_user(email)
+ # Create new user with appropriate status
+ status = "active" if is_demo_account else "pending"
+ user_id, token = Core.Database.create_user(email, status=status)
user = {
"id": user_id,
"email": email,
"token": token,
- "status": "pending",
+ "status": status,
}
+ elif is_demo_account and user.get("status") != "active":
+ # Auto-activate demo account if it exists but isn't active
+ Core.Database.update_user_status(user["id"], "active")
+ user["status"] = "active"
# Check if user is active
if user.get("status") != "active":
return Response(
- '<div style="color: #dc3545;">'
+ '<div class="alert alert-danger">'
"Your account is pending approval. "
- 'Please email <a href="mailto:ben@bensima.com">'
+ 'Please email <a href="mailto:ben@bensima.com" class="alert-link">'
"ben@bensima.com</a> "
'or message <a href="https://x.com/bensima" '
- 'target="_blank">@bensima on x.com</a> '
+ 'target="_blank" class="alert-link">@bensima on x.com</a> '
"to get approved.</div>",
status_code=403,
)
@@ -676,7 +842,7 @@ def _handle_test_login(email: str, request: Request) -> Response:
request.session["permanent"] = True
return Response(
- '<div style="color: #28a745;">✓ Logged in (dev mode)</div>',
+ '<div class="alert alert-success">✓ Logged in (dev mode)</div>',
status_code=200,
headers={"HX-Redirect": "/"},
)
@@ -685,11 +851,12 @@ def _handle_test_login(email: str, request: Request) -> Response:
def _handle_production_login(email: str) -> Response:
"""Handle login in production mode."""
pending_message = (
- '<div style="color: #ffa500;">'
+ '<div class="alert alert-warning">'
"Account created, currently pending. "
- 'Email <a href="mailto:ben@bensima.com">ben@bensima.com</a> '
+ 'Email <a href="mailto:ben@bensima.com" '
+ 'class="alert-link">ben@bensima.com</a> '
'or message <a href="https://x.com/bensima" '
- 'target="_blank">@bensima</a> '
+ 'target="_blank" class="alert-link">@bensima</a> '
"to get your account activated.</div>"
)
@@ -720,7 +887,7 @@ def _handle_production_login(email: str) -> Response:
send_magic_link(email, magic_token)
return Response(
- f'<div style="color: #28a745;">✓ Magic link sent to {email}. '
+ f'<div class="alert alert-success">✓ Magic link sent to {email}. '
f"Check your email!</div>",
status_code=200,
)
@@ -735,7 +902,7 @@ def login(request: Request, data: FormData) -> Response:
if not email:
return Response(
- '<div style="color: #dc3545;">Email is required</div>',
+ '<div class="alert alert-danger">Email is required</div>',
status_code=400,
)
@@ -748,7 +915,7 @@ def login(request: Request, data: FormData) -> Response:
except Exception as e:
logger.exception("Login error")
return Response(
- f'<div style="color: #dc3545;">Error: {e!s}</div>',
+ f'<div class="alert alert-danger">Error: {e!s}</div>',
status_code=500,
)
@@ -800,15 +967,17 @@ def submit_article(request: Request, data: FormData) -> html.div:
user_id = request.session.get("user_id")
if not user_id:
return html.div(
+ html.i(classes=["bi", "bi-exclamation-triangle", "me-2"]),
"Error: Please login first",
- style={"color": "#dc3545"},
+ classes=["alert", "alert-danger"],
)
user = Core.Database.get_user_by_id(user_id)
if not user:
return html.div(
+ html.i(classes=["bi", "bi-exclamation-triangle", "me-2"]),
"Error: Invalid session",
- style={"color": "#dc3545"},
+ classes=["alert", "alert-danger"],
)
url_raw = data.get("url", "")
@@ -816,16 +985,18 @@ def submit_article(request: Request, data: FormData) -> html.div:
if not url:
return html.div(
+ html.i(classes=["bi", "bi-exclamation-triangle", "me-2"]),
"Error: URL is required",
- style={"color": "#dc3545"},
+ classes=["alert", "alert-danger"],
)
# Basic URL validation
parsed = urllib.parse.urlparse(url)
if not parsed.scheme or not parsed.netloc:
return html.div(
+ html.i(classes=["bi", "bi-exclamation-triangle", "me-2"]),
"Error: Invalid URL format",
- style={"color": "#dc3545"},
+ classes=["alert", "alert-danger"],
)
# Extract Open Graph metadata
@@ -839,12 +1010,17 @@ def submit_article(request: Request, data: FormData) -> html.div:
author=author,
)
return html.div(
+ html.i(classes=["bi", "bi-check-circle", "me-2"]),
f"✓ Article submitted successfully! Job ID: {job_id}",
- style={"color": "#28a745", "font-weight": "bold"},
+ classes=["alert", "alert-success"],
)
except Exception as e: # noqa: BLE001
- return html.div(f"Error: {e!s}", style={"color": "#dc3545"})
+ return html.div(
+ html.i(classes=["bi", "bi-exclamation-triangle", "me-2"]),
+ f"Error: {e!s}",
+ classes=["alert", "alert-danger"],
+ )
@app.get("/feed/{token}.xml")