diff options
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 182 |
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, ], ) |
