summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Episode.py
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater/Episode.py')
-rw-r--r--Biz/PodcastItLater/Episode.py281
1 files changed, 281 insertions, 0 deletions
diff --git a/Biz/PodcastItLater/Episode.py b/Biz/PodcastItLater/Episode.py
new file mode 100644
index 0000000..abcff92
--- /dev/null
+++ b/Biz/PodcastItLater/Episode.py
@@ -0,0 +1,281 @@
+"""
+PodcastItLater Episode Detail Components.
+
+Components for displaying individual episode pages with media player,
+share functionality, and signup prompts for non-authenticated users.
+"""
+
+# : out podcastitlater-episode
+# : dep ludic
+import Biz.PodcastItLater.UI as UI
+import ludic.html as html
+import typing
+from ludic.attrs import Attrs
+from ludic.components import Component
+from ludic.types import AnyChildren
+from typing import override
+
+
+class EpisodePlayerAttrs(Attrs):
+ """Attributes for EpisodePlayer component."""
+
+ audio_url: str
+ title: str
+
+
+class EpisodePlayer(Component[AnyChildren, EpisodePlayerAttrs]):
+ """HTML5 audio player for episode playback."""
+
+ @override
+ def render(self) -> html.div:
+ audio_url = self.attrs["audio_url"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.h5(
+ html.i(classes=["bi", "bi-play-circle", "me-2"]),
+ "Listen",
+ classes=["card-title", "mb-3"],
+ ),
+ html.audio(
+ html.source(src=audio_url, type="audio/mpeg"),
+ "Your browser does not support the audio element.",
+ controls=True,
+ preload="metadata",
+ classes=["w-100"],
+ style={"max-width": "100%"},
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-4"],
+ ),
+ )
+
+
+class ShareButtonAttrs(Attrs):
+ """Attributes for ShareButton component."""
+
+ share_url: str
+
+
+class ShareButton(Component[AnyChildren, ShareButtonAttrs]):
+ """Button to copy episode URL to clipboard."""
+
+ @override
+ def render(self) -> html.div:
+ share_url = self.attrs["share_url"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.h5(
+ html.i(classes=["bi", "bi-share", "me-2"]),
+ "Share Episode",
+ classes=["card-title", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.button(
+ html.i(classes=["bi", "bi-copy", "me-1"]),
+ "Copy",
+ type="button",
+ id="share-button",
+ on_click=f"navigator.clipboard.writeText('{share_url}'); " # noqa: E501
+ "const btn = document.getElementById('share-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'); " # noqa: E501
+ "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=share_url,
+ readonly=True,
+ on_focus="this.select()",
+ classes=["form-control"],
+ ),
+ classes=["input-group"],
+ ),
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card"],
+ ),
+ classes=["mb-4"],
+ )
+
+
+class SignupBannerAttrs(Attrs):
+ """Attributes for SignupBanner component."""
+
+ creator_email: str
+ base_url: str
+
+
+class SignupBanner(Component[AnyChildren, SignupBannerAttrs]):
+ """Banner prompting non-authenticated users to sign up."""
+
+ @override
+ def render(self) -> html.div:
+ creator_email = self.attrs["creator_email"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-info-circle-fill",
+ "me-2",
+ ],
+ ),
+ html.strong("This episode was created by "),
+ html.code(
+ creator_email,
+ classes=["text-dark"],
+ ),
+ html.strong(" using PodcastItLater."),
+ classes=["mb-3"],
+ ),
+ html.div(
+ html.p(
+ "Want to convert your own articles "
+ "to podcast episodes?",
+ classes=["mb-2"],
+ ),
+ html.form(
+ html.div(
+ html.input(
+ type="email",
+ name="email",
+ placeholder="Enter your email to start",
+ required=True,
+ classes=["form-control"],
+ ),
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-arrow-right-circle",
+ "me-2",
+ ],
+ ),
+ "Sign Up",
+ type="submit",
+ classes=["btn", "btn-primary"],
+ ),
+ classes=["input-group"],
+ ),
+ hx_post="/login",
+ hx_target="#signup-result",
+ hx_swap="innerHTML",
+ ),
+ html.div(id="signup-result", classes=["mt-2"]),
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card", "border-primary"],
+ ),
+ classes=["mb-4"],
+ )
+
+
+class EpisodeDetailPageAttrs(Attrs):
+ """Attributes for EpisodeDetailPage component."""
+
+ episode: dict[str, typing.Any]
+ creator_email: str | None
+ user: dict[str, typing.Any] | None
+ base_url: str
+
+
+class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]):
+ """Full page view for a single episode."""
+
+ @override
+ def render(self) -> UI.PageLayout:
+ episode = self.attrs["episode"]
+ creator_email = self.attrs.get("creator_email")
+ user = self.attrs.get("user")
+ base_url = self.attrs["base_url"]
+
+ share_url = f"{base_url}/episode/{episode['id']}"
+ duration_str = UI.format_duration(episode.get("duration"))
+
+ return UI.PageLayout(
+ # Show signup banner if user is not logged in
+ SignupBanner(
+ creator_email=creator_email or "a user",
+ base_url=base_url,
+ )
+ if not user and creator_email
+ else html.div(),
+ # Episode title and metadata
+ html.div(
+ html.h2(
+ episode["title"],
+ classes=["display-6", "mb-3"],
+ ),
+ html.div(
+ html.span(
+ html.i(classes=["bi", "bi-person", "me-1"]),
+ f"by {episode['author']}",
+ classes=["text-muted", "me-3"],
+ )
+ if episode.get("author")
+ else html.span(),
+ html.span(
+ html.i(classes=["bi", "bi-clock", "me-1"]),
+ f"Duration: {duration_str}",
+ classes=["text-muted", "me-3"],
+ ),
+ html.span(
+ html.i(classes=["bi", "bi-calendar", "me-1"]),
+ f"Created: {episode['created_at']}",
+ classes=["text-muted"],
+ ),
+ classes=["mb-3"],
+ ),
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-link-45deg", "me-1"]),
+ "View original article",
+ href=episode["original_url"],
+ target="_blank",
+ rel="noopener",
+ classes=["btn", "btn-sm", "btn-outline-secondary"],
+ ),
+ )
+ if episode.get("original_url")
+ else html.div(),
+ classes=["mb-4"],
+ ),
+ # Audio player
+ EpisodePlayer(
+ audio_url=episode["audio_url"],
+ title=episode["title"],
+ ),
+ # Share button
+ ShareButton(share_url=share_url),
+ # Back to home link
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-arrow-left", "me-1"]),
+ "Back to Home",
+ href="/",
+ classes=["btn", "btn-link"],
+ ),
+ classes=["mt-4"],
+ ),
+ user=user,
+ current_page="",
+ error=None,
+ )