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/Web.py | 718 +++++++++++++++++++++++++++++----------------- 1 file changed, 447 insertions(+), 271 deletions(-) (limited to 'Biz/PodcastItLater/Web.py') 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( - '
' + '
' "Your account is pending approval. " - 'Please email ' + 'Please email ' "ben@bensima.com " 'or message @bensima on x.com ' + 'target="_blank" class="alert-link">@bensima on x.com ' "to get approved.
", status_code=403, ) @@ -676,7 +842,7 @@ def _handle_test_login(email: str, request: Request) -> Response: request.session["permanent"] = True return Response( - '
✓ Logged in (dev mode)
', + '
✓ Logged in (dev mode)
', 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 = ( - '
' + '
' "Account created, currently pending. " - 'Email ben@bensima.com ' + 'Email ben@bensima.com ' 'or message @bensima ' + 'target="_blank" class="alert-link">@bensima ' "to get your account activated.
" ) @@ -720,7 +887,7 @@ def _handle_production_login(email: str) -> Response: send_magic_link(email, magic_token) return Response( - f'
✓ Magic link sent to {email}. ' + f'
✓ Magic link sent to {email}. ' f"Check your email!
", status_code=200, ) @@ -735,7 +902,7 @@ def login(request: Request, data: FormData) -> Response: if not email: return Response( - '
Email is required
', + '
Email is required
', 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'
Error: {e!s}
', + f'
Error: {e!s}
', 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") -- cgit v1.2.3