mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 20:59:12 -07:00
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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user