summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Web.py
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-17 13:29:40 -0500
committerBen Sima <ben@bensima.com>2025-12-17 13:29:40 -0500
commitab01b34bf563990e0f491ada646472aaade97610 (patch)
tree5e46a1a157bb846b0c3a090a83153c788da2b977 /Biz/PodcastItLater/Web.py
parente112d3ce07fa24f31a281e521a554cc881a76c7b (diff)
parent337648981cc5a55935116141341521f4fce83214 (diff)
Merge Ava deployment changes
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
-rw-r--r--Biz/PodcastItLater/Web.py220
1 files changed, 205 insertions, 15 deletions
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 30b5236..c472819 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -18,7 +18,7 @@ Provides ludic + htmx interface and RSS feed generation.
# : dep stripe
# : dep sqids
import Biz.EmailAgent
-import Biz.PodcastItLater.Admin as Admin
+import Biz.PodcastItLater.Admin.Core as Admin
import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
import Biz.PodcastItLater.Episode as Episode
@@ -28,7 +28,6 @@ import httpx
import logging
import ludic.html as html
import Omni.App as App
-import Omni.Log as Log
import Omni.Test as Test
import os
import pathlib
@@ -57,7 +56,9 @@ from typing import override
from unittest.mock import patch
logger = logging.getLogger(__name__)
-Log.setup(logger)
+logging.basicConfig(
+ level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s"
+)
# Configuration
@@ -86,9 +87,10 @@ def decode_episode_id(sqid: str) -> int | None:
# Authentication configuration
MAGIC_LINK_MAX_AGE = 3600 # 1 hour
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
-EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@podcastitlater.com")
+EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@bensima.com")
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.mailgun.org")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
# Initialize serializer for magic links
magic_link_serializer = URLSafeTimedSerializer(
@@ -167,7 +169,8 @@ def send_magic_link(email: str, token: str) -> None:
# Create email body
magic_link = f"{BASE_URL}/auth/verify?token={token}"
- body_text_path.write_text(f"""
+ body_text_path.write_text(
+ f"""
Hello,
Click this link to login to PodcastItLater:
@@ -179,7 +182,9 @@ If you didn't request this, please ignore this email.
Best,
PodcastItLater
-""")
+""",
+ encoding="utf-8",
+ )
try:
Biz.EmailAgent.send_email(
@@ -189,6 +194,7 @@ PodcastItLater
password=SMTP_PASSWORD,
subject=subject,
body_text=body_text_path,
+ port=SMTP_PORT,
)
finally:
# Clean up temporary file
@@ -900,20 +906,126 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
error = self.attrs.get("error")
if not user:
- # Show public feed with login form for logged-out users
+ # Marketing landing page for logged-out users
return UI.PageLayout(
- LoginForm(error=error),
+ # Hero section
html.div(
- html.h4(
- html.i(classes=["bi", "bi-broadcast", "me-2"]),
- "Public Feed",
- classes=["mb-3", "mt-4"],
+ html.h1(
+ "Your Reading List, as a Podcast",
+ classes=["display-5", "fw-bold", "mb-3"],
),
html.p(
- "Featured articles converted to audio. "
- "Sign up to create your own personal feed!",
- classes=["text-muted", "mb-3"],
+ "Natural-sounding audio for your articles. "
+ "Delivered to your podcast app. "
+ "Listen while commuting, cooking, or working out.",
+ classes=["lead", "text-muted", "mb-4"],
+ ),
+ html.a(
+ "Start Listening",
+ href="#login",
+ classes=["btn", "btn-primary", "btn-lg"],
),
+ classes=["text-center", "py-5", "mb-5"],
+ ),
+ # How it works section
+ html.div(
+ html.h3(
+ "How It Works",
+ classes=["text-center", "mb-4"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "1",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("Paste any article URL"),
+ html.p(
+ "Copy the link to any article you want to listen to.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "2",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("We convert it to audio"),
+ html.p(
+ "Our AI reads the article in a natural, human-sounding voice and creates a podcast episode.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "3",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("Listen in your podcast app"),
+ html.p(
+ "Subscribe to your personal RSS feed in any podcast app.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ classes=["row"],
+ ),
+ classes=["mb-5"],
+ ),
+ # Social proof section
+ html.div(
+ html.h4(
+ "Join others who've converted their reading backlog",
+ classes=["text-center", "mb-4"],
+ ),
+ classes=["mb-4"],
+ ),
+ # Login form
+ html.div(
+ LoginForm(error=error),
+ id="login",
+ classes=["mb-5"],
+ ),
+ # Recent episodes
+ html.div(
EpisodeList(
episodes=episodes,
rss_url=None,
@@ -1033,6 +1145,83 @@ def pricing(request: Request) -> UI.PricingPage:
)
+@app.get("/feedback")
+def feedback_form(request: Request) -> UI.FeedbackPage:
+ """Display feedback form."""
+ user_id = request.session.get("user_id")
+ user = Core.Database.get_user_by_id(user_id) if user_id else None
+ campaign_id = request.query_params.get("campaign")
+
+ return UI.FeedbackPage(
+ user=user,
+ campaign_id=campaign_id,
+ success=False,
+ )
+
+
+@app.post("/feedback")
+async def submit_feedback(request: Request) -> UI.FeedbackPage:
+ """Submit feedback form."""
+ import secrets
+
+ user_id = request.session.get("user_id")
+ user = Core.Database.get_user_by_id(user_id) if user_id else None
+
+ form_data = await request.form()
+
+ email = form_data.get("email")
+ source = form_data.get("source")
+ campaign_id = form_data.get("campaign_id")
+ rating_str = form_data.get("rating")
+ feedback_text = form_data.get("feedback_text")
+ use_case = form_data.get("use_case")
+
+ rating = int(str(rating_str)) if rating_str else None
+
+ feedback_id = secrets.token_urlsafe(16)
+
+ Core.Database.create_feedback(
+ feedback_id=feedback_id,
+ email=str(email) if email else None,
+ source=str(source) if source else None,
+ campaign_id=str(campaign_id) if campaign_id else None,
+ rating=rating,
+ feedback_text=str(feedback_text) if feedback_text else None,
+ use_case=str(use_case) if use_case else None,
+ )
+
+ logger.info(
+ "Received feedback %s from %s", feedback_id, email or "anonymous"
+ )
+
+ return UI.FeedbackPage(
+ user=user,
+ campaign_id=None,
+ success=True,
+ )
+
+
+@app.get("/api/feedback")
+def api_feedback(request: Request) -> Response:
+ """Return feedback entries as JSON for agent tools."""
+ import json
+
+ limit_str = request.query_params.get("limit", "20")
+ since = request.query_params.get("since")
+
+ try:
+ limit = min(100, max(1, int(limit_str)))
+ except ValueError:
+ limit = 20
+
+ feedback = Core.Database.get_feedback(limit=limit, since=since)
+
+ return Response(
+ json.dumps(feedback),
+ media_type="application/json",
+ )
+
+
@app.post("/upgrade")
def upgrade(request: Request) -> RedirectResponse:
"""Start upgrade checkout flow."""
@@ -1832,6 +2021,7 @@ def cancel_queue_item(request: Request, job_id: int) -> Response:
app.delete("/queue/{job_id}")(Admin.delete_queue_item)
app.get("/admin/users")(Admin.admin_users)
app.get("/admin/metrics")(Admin.admin_metrics)
+app.get("/admin/feedback")(Admin.admin_feedback)
app.post("/admin/users/{user_id}/status")(Admin.update_user_status)
app.post("/admin/episode/{episode_id}/toggle-public")(
Admin.toggle_episode_public,