summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Web.py
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-09 14:28:39 -0500
committerBen Sima <ben@bsima.me>2025-11-09 14:28:39 -0500
commitc76c83987fae48c995e605d947aea72d513ee7cd (patch)
tree751f57da62b123218cdc7380aff9befd5317ea92 /Biz/PodcastItLater/Web.py
parent253fdc93cb3e79983de69e0875c69efa77660aac (diff)
feat(PodcastItLater): Apply Bootstrap 5 UI and fix dev login
- Apply Bootstrap 5 CSS and icons to all pages (Web.py, Admin.py) - Convert all components to use Bootstrap classes instead of inline styles - Add dev mode banner showing demo@example.com for instant login - Implement secure demo account (demo@example.com) with auto-approval - Fix HTMX loading issue when load_styles=False - Update Database.create_user() to accept optional status parameter - Add Bootstrap tables, cards, badges, and button groups throughout - All tests passing Completes task t-144drAE Amp-Thread-ID: https://ampcode.com/threads/T-8feaca83-dcc2-46cb-8f71-d0785960a2f7 Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
-rw-r--r--Biz/PodcastItLater/Web.py718
1 files changed, 447 insertions, 271 deletions
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")