diff options
Diffstat (limited to 'Biz/PodcastItLater')
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 267 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 21 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 718 |
3 files changed, 559 insertions, 447 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, 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") |
