diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-17 13:29:40 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-17 13:29:40 -0500 |
| commit | ab01b34bf563990e0f491ada646472aaade97610 (patch) | |
| tree | 5e46a1a157bb846b0c3a090a83153c788da2b977 /Biz/PodcastItLater/Web.py | |
| parent | e112d3ce07fa24f31a281e521a554cc881a76c7b (diff) | |
| parent | 337648981cc5a55935116141341521f4fce83214 (diff) | |
Merge Ava deployment changes
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 220 |
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, |
