Add podcast subscriptions with My Podcasts tab

- Subscribe/unsubscribe button on podcast cards and episode list header
- "My Podcasts" tab shows subscribed podcasts, opens first if subscriptions exist
- Subscriptions persisted in localStorage
- Tab shows subscription count
- Empty state guides to search/trending
This commit is contained in:
Jure
2026-03-21 14:53:20 +01:00
parent 04498aeb21
commit 5a8250e7cf
4 changed files with 130 additions and 32 deletions

View File

@@ -3,6 +3,23 @@ import type { PodcastShow, PodcastEpisode } from "../../types/podcast";
import { getEpisodes } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
function SubscribeButton({ show }: { show: PodcastShow }) {
const subscribed = usePodcastStore((s) => s.isSubscribed(show.feedUrl));
const { subscribe, unsubscribe } = usePodcastStore.getState();
return (
<button
onClick={() => subscribed ? unsubscribe(show.feedUrl) : subscribe(show)}
className={`shrink-0 text-[11px] px-3 py-1 rounded-sm border transition-colors ${
subscribed
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{subscribed ? "subscribed" : "+ subscribe"}
</button>
);
}
function formatDuration(seconds: number): string {
if (!seconds) return "";
const h = Math.floor(seconds / 3600);
@@ -60,9 +77,14 @@ export function EpisodeList({ show, onBack }: EpisodeListProps) {
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div className="min-w-0">
<h2 className="text-[15px] text-text font-semibold">{show.title}</h2>
<div className="text-[12px] text-text-muted">{show.author}</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-[15px] text-text font-semibold">{show.title}</h2>
<div className="text-[12px] text-text-muted">{show.author}</div>
</div>
<SubscribeButton show={show} />
</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}

View File

@@ -1,4 +1,5 @@
import type { PodcastShow } from "../../types/podcast";
import { usePodcastStore } from "../../stores/podcast";
interface PodcastCardProps {
show: PodcastShow;
@@ -6,25 +7,27 @@ interface PodcastCardProps {
}
export function PodcastCard({ show, onClick }: PodcastCardProps) {
const subscribed = usePodcastStore((s) => s.isSubscribed(show.feedUrl));
const { subscribe, unsubscribe } = usePodcastStore.getState();
return (
<button
onClick={onClick}
className="flex items-start gap-3 p-3 rounded-sm bg-bg-raised border border-border hover:bg-bg-hover transition-colors text-left w-full"
>
{show.artworkUrl ? (
<img
src={show.artworkUrl}
alt=""
className="w-16 h-16 rounded-sm object-cover shrink-0 bg-bg"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-16 h-16 rounded-sm bg-bg flex items-center justify-center shrink-0 text-2xl text-text-dim">
P
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start gap-3 p-3 rounded-sm bg-bg-raised border border-border hover:bg-bg-hover transition-colors text-left w-full">
<button onClick={onClick} className="shrink-0">
{show.artworkUrl ? (
<img
src={show.artworkUrl}
alt=""
className="w-16 h-16 rounded-sm object-cover bg-bg"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-16 h-16 rounded-sm bg-bg flex items-center justify-center text-2xl text-text-dim">
P
</div>
)}
</button>
<button onClick={onClick} className="min-w-0 flex-1 text-left">
<div className="text-[13px] text-text font-medium truncate">{show.title}</div>
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
{show.description && (
@@ -32,7 +35,21 @@ export function PodcastCard({ show, onClick }: PodcastCardProps) {
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
</div>
)}
</div>
</button>
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (subscribed) unsubscribe(show.feedUrl);
else subscribe(show);
}}
className={`shrink-0 text-[10px] px-2 py-1 rounded-sm border transition-colors ${
subscribed
? "border-accent/40 text-accent"
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
}`}
>
{subscribed ? "subscribed" : "+ subscribe"}
</button>
</div>
);
}

View File

@@ -1,20 +1,24 @@
import { useState, useEffect, useCallback } from "react";
import type { PodcastShow } from "../../types/podcast";
import { searchPodcasts, getTrending } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
import { PodcastCard } from "./PodcastCard";
import { EpisodeList } from "./EpisodeList";
type Tab = "trending" | "search";
type Tab = "subscriptions" | "trending" | "search";
export function PodcastsView() {
const [tab, setTab] = useState<Tab>("trending");
const subscriptions = usePodcastStore((s) => s.subscriptions);
const hasSubscriptions = subscriptions.length > 0;
const [tab, setTab] = useState<Tab>(hasSubscriptions ? "subscriptions" : "trending");
const [query, setQuery] = useState("");
const [shows, setShows] = useState<PodcastShow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedShow, setSelectedShow] = useState<PodcastShow | null>(null);
// Load trending on mount
// Load trending on mount if no subscriptions
useEffect(() => {
if (hasSubscriptions) return;
setLoading(true);
getTrending().then((results) => {
setShows(results);
@@ -47,6 +51,8 @@ export function PodcastsView() {
return <EpisodeList show={selectedShow} onBack={() => setSelectedShow(null)} />;
}
const displayShows = tab === "subscriptions" ? subscriptions : shows;
return (
<div className="h-full flex flex-col">
{/* Header */}
@@ -74,7 +80,7 @@ export function PodcastsView() {
{/* Tabs */}
<div className="flex gap-4">
{(["trending", "search"] as Tab[]).map((t) => (
{(["subscriptions", "trending", "search"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => handleTabChange(t)}
@@ -84,7 +90,7 @@ export function PodcastsView() {
: "text-text-muted border-transparent hover:text-text"
}`}
>
{t === "trending" ? "Trending" : "Search Results"}
{t === "subscriptions" ? `My Podcasts${subscriptions.length > 0 ? ` (${subscriptions.length})` : ""}` : t === "trending" ? "Trending" : "Search Results"}
</button>
))}
</div>
@@ -92,15 +98,32 @@ export function PodcastsView() {
{/* Results */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
{tab === "subscriptions" ? (
subscriptions.length === 0 ? (
<div className="text-center py-12">
<p className="text-text-dim text-[13px] mb-2">No subscriptions yet.</p>
<p className="text-text-dim text-[11px] opacity-60">Search for a podcast or browse trending to subscribe.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{subscriptions.map((show, i) => (
<PodcastCard
key={show.feedUrl ?? i}
show={show}
onClick={() => setSelectedShow(show)}
/>
))}
</div>
)
) : loading ? (
<div className="text-text-dim text-[12px]">Loading...</div>
) : shows.length === 0 ? (
) : displayShows.length === 0 ? (
<div className="text-text-dim text-[12px]">
{tab === "search" ? "No results. Try a different search." : "No trending podcasts found."}
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{shows.map((show, i) => (
{displayShows.map((show, i) => (
<PodcastCard
key={show.podcastIndexId ?? i}
show={show}