summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Web.py
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
-rw-r--r--Biz/PodcastItLater/Web.py182
1 files changed, 161 insertions, 21 deletions
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index c20675f..f63046c 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -20,6 +20,7 @@ import Biz.EmailAgent
import Biz.PodcastItLater.Admin as Admin
import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
+import Biz.PodcastItLater.Episode as Episode
import Biz.PodcastItLater.UI as UI
import html as html_module
import httpx
@@ -521,7 +522,11 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
html.div(
html.div(
html.h5(
- episode["title"],
+ html.a(
+ episode["title"],
+ href=f"/episode/{episode['id']}",
+ classes=["text-decoration-none"],
+ ),
classes=["card-title", "mb-2"],
),
# Show author if available
@@ -576,28 +581,39 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
# RSS feed link with copy-to-clipboard
html.div(
html.div(
- html.i(classes=["bi", "bi-rss-fill", "me-2"]),
- html.strong("Subscribe in your podcast app: "),
- html.a(
- rss_url or "",
- href="#",
- id="rss-link",
- on_click=f"navigator.clipboard.writeText('{rss_url}'); "
- "document.getElementById('copy-feedback').classList.remove('d-none'); " # noqa: E501
- "setTimeout(() => document.getElementById('copy-feedback').classList.add('d-none'), 2000); " # noqa: E501
- "return false;",
- classes=[
- "text-decoration-none",
- "text-truncate",
- "d-inline-block",
- ],
+ html.label(
+ html.i(classes=["bi", "bi-rss-fill", "me-2"]),
+ "Subscribe in your podcast app:",
+ classes=["form-label", "fw-bold"],
),
- html.span(
- " ✓ Copied!",
- id="copy-feedback",
- classes=["text-success", "small", "d-none"],
+ html.div(
+ html.button(
+ html.i(classes=["bi", "bi-copy", "me-1"]),
+ "Copy",
+ type="button",
+ id="rss-copy-button",
+ on_click=f"navigator.clipboard.writeText('{rss_url}'); " # noqa: E501
+ "const btn = document.getElementById('rss-copy-button'); " # noqa: E501
+ "const originalHTML = btn.innerHTML; "
+ "btn.innerHTML = '<i class=\"bi bi-check me-1\"></i>Copied!'; " # noqa: E501
+ "btn.classList.remove('btn-outline-secondary'); "
+ "btn.classList.add('btn-success'); "
+ "setTimeout(() => {{ "
+ "btn.innerHTML = originalHTML; "
+ "btn.classList.remove('btn-success'); "
+ "btn.classList.add('btn-outline-secondary'); "
+ "}}, 2000);",
+ classes=["btn", "btn-outline-secondary"],
+ ),
+ html.input(
+ type="text",
+ value=rss_url or "",
+ readonly=True,
+ on_focus="this.select()",
+ classes=["form-control"],
+ ),
+ classes=["input-group", "mb-3"],
),
- classes=["mb-3"],
),
)
if rss_url
@@ -1246,6 +1262,43 @@ def rss_feed(request: Request, token: str) -> Response: # noqa: ARG001
return Response(f"Error generating feed: {e}", status_code=500)
+@app.get("/episode/{episode_id}")
+def episode_detail(
+ request: Request,
+ episode_id: int,
+) -> Episode.EpisodeDetailPage | Response:
+ """Display individual episode page (public, no auth required)."""
+ try:
+ # Get episode from database
+ episode = Core.Database.get_episode_by_id(episode_id)
+
+ if not episode:
+ return Response("Episode not found", status_code=404)
+
+ # Get creator email if episode has user_id
+ creator_email = None
+ if episode.get("user_id"):
+ creator = Core.Database.get_user_by_id(episode["user_id"])
+ creator_email = creator["email"] if creator else None
+
+ # Check if current user is logged in
+ user_id = request.session.get("user_id")
+ user = None
+ if user_id:
+ user = Core.Database.get_user_by_id(user_id)
+
+ return Episode.EpisodeDetailPage(
+ episode=episode,
+ creator_email=creator_email,
+ user=user,
+ base_url=BASE_URL,
+ )
+
+ except (ValueError, KeyError) as e:
+ logger.exception("Error loading episode")
+ return Response(f"Error loading episode: {e}", status_code=500)
+
+
@app.get("/status")
def queue_status(request: Request) -> QueueStatus:
"""Return HTMX endpoint for live queue updates."""
@@ -1957,6 +2010,92 @@ class TestJobCancellation(BaseWebTest):
)
+class TestEpisodeDetailPage(BaseWebTest):
+ """Test episode detail page functionality."""
+
+ def setUp(self) -> None:
+ """Set up test client with user and episode."""
+ super().setUp()
+
+ # Create user and episode
+ self.user_id, self.token = Core.Database.create_user(
+ "creator@example.com",
+ status="active",
+ )
+ self.episode_id = Core.Database.create_episode(
+ title="Test Episode",
+ audio_url="https://example.com/audio.mp3",
+ duration=300,
+ content_length=5000,
+ user_id=self.user_id,
+ author="Test Author",
+ original_url="https://example.com/article",
+ )
+
+ def test_episode_page_loads(self) -> None:
+ """Episode page should load successfully."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test Episode", response.text)
+ self.assertIn("Test Author", response.text)
+
+ def test_episode_not_found(self) -> None:
+ """Non-existent episode should return 404."""
+ response = self.client.get("/episode/99999")
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_audio_player_present(self) -> None:
+ """Audio player should be present on episode page."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("<audio", response.text)
+ self.assertIn("controls", response.text)
+ self.assertIn("https://example.com/audio.mp3", response.text)
+
+ def test_share_button_present(self) -> None:
+ """Share button should be present."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("Share Episode", response.text)
+ self.assertIn("navigator.clipboard.writeText", response.text)
+
+ def test_original_article_link(self) -> None:
+ """Original article link should be present."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("View original article", response.text)
+ self.assertIn("https://example.com/article", response.text)
+
+ def test_signup_banner_for_non_authenticated(self) -> None:
+ """Non-authenticated users should see signup banner."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("This episode was created by", response.text)
+ self.assertIn("creator@example.com", response.text)
+ self.assertIn("Sign Up", response.text)
+
+ def test_no_signup_banner_for_authenticated(self) -> None:
+ """Authenticated users should not see signup banner."""
+ # Login
+ self.client.post("/login", data={"email": "creator@example.com"})
+
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertNotIn("This episode was created by", response.text)
+
+ def test_episode_links_from_home_page(self) -> None:
+ """Episode titles on home page should link to detail page."""
+ # Login to see episodes
+ self.client.post("/login", data={"email": "creator@example.com"})
+
+ response = self.client.get("/")
+
+ self.assertIn(f'href="/episode/{self.episode_id}"', response.text)
+ self.assertIn("Test Episode", response.text)
+
+
def test() -> None:
"""Run all tests for the web module."""
Test.run(
@@ -1968,6 +2107,7 @@ def test() -> None:
TestRSSFeed,
TestAdminInterface,
TestJobCancellation,
+ TestEpisodeDetailPage,
],
)