mirror of
https://github.com/hoornet/vega.git
synced 2026-05-13 12:28:36 -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 { getEpisodes } from "../../lib/podcast";
|
||||||
import { usePodcastStore } from "../../stores/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 {
|
function formatDuration(seconds: number): string {
|
||||||
if (!seconds) return "";
|
if (!seconds) return "";
|
||||||
const h = Math.floor(seconds / 3600);
|
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"; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-[15px] text-text font-semibold">{show.title}</h2>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="text-[12px] text-text-muted">{show.author}</div>
|
<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 && (
|
{show.description && (
|
||||||
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
|
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
|
||||||
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}
|
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PodcastShow } from "../../types/podcast";
|
import type { PodcastShow } from "../../types/podcast";
|
||||||
|
import { usePodcastStore } from "../../stores/podcast";
|
||||||
|
|
||||||
interface PodcastCardProps {
|
interface PodcastCardProps {
|
||||||
show: PodcastShow;
|
show: PodcastShow;
|
||||||
@@ -6,25 +7,27 @@ interface PodcastCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PodcastCard({ show, onClick }: PodcastCardProps) {
|
export function PodcastCard({ show, onClick }: PodcastCardProps) {
|
||||||
|
const subscribed = usePodcastStore((s) => s.isSubscribed(show.feedUrl));
|
||||||
|
const { subscribe, unsubscribe } = usePodcastStore.getState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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">
|
||||||
onClick={onClick}
|
<button onClick={onClick} className="shrink-0">
|
||||||
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
|
||||||
{show.artworkUrl ? (
|
src={show.artworkUrl}
|
||||||
<img
|
alt=""
|
||||||
src={show.artworkUrl}
|
className="w-16 h-16 rounded-sm object-cover bg-bg"
|
||||||
alt=""
|
loading="lazy"
|
||||||
className="w-16 h-16 rounded-sm object-cover shrink-0 bg-bg"
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
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 className="w-16 h-16 rounded-sm bg-bg flex items-center justify-center shrink-0 text-2xl text-text-dim">
|
</div>
|
||||||
P
|
)}
|
||||||
</div>
|
</button>
|
||||||
)}
|
<button onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-[13px] text-text font-medium truncate">{show.title}</div>
|
<div className="text-[13px] text-text font-medium truncate">{show.title}</div>
|
||||||
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
|
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
|
||||||
{show.description && (
|
{show.description && (
|
||||||
@@ -32,7 +35,21 @@ export function PodcastCard({ show, onClick }: PodcastCardProps) {
|
|||||||
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
|
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
|
||||||
</div>
|
</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 { useState, useEffect, useCallback } from "react";
|
||||||
import type { PodcastShow } from "../../types/podcast";
|
import type { PodcastShow } from "../../types/podcast";
|
||||||
import { searchPodcasts, getTrending } from "../../lib/podcast";
|
import { searchPodcasts, getTrending } from "../../lib/podcast";
|
||||||
|
import { usePodcastStore } from "../../stores/podcast";
|
||||||
import { PodcastCard } from "./PodcastCard";
|
import { PodcastCard } from "./PodcastCard";
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
|
|
||||||
type Tab = "trending" | "search";
|
type Tab = "subscriptions" | "trending" | "search";
|
||||||
|
|
||||||
export function PodcastsView() {
|
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 [query, setQuery] = useState("");
|
||||||
const [shows, setShows] = useState<PodcastShow[]>([]);
|
const [shows, setShows] = useState<PodcastShow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedShow, setSelectedShow] = useState<PodcastShow | null>(null);
|
const [selectedShow, setSelectedShow] = useState<PodcastShow | null>(null);
|
||||||
|
|
||||||
// Load trending on mount
|
// Load trending on mount if no subscriptions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasSubscriptions) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getTrending().then((results) => {
|
getTrending().then((results) => {
|
||||||
setShows(results);
|
setShows(results);
|
||||||
@@ -47,6 +51,8 @@ export function PodcastsView() {
|
|||||||
return <EpisodeList show={selectedShow} onBack={() => setSelectedShow(null)} />;
|
return <EpisodeList show={selectedShow} onBack={() => setSelectedShow(null)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayShows = tab === "subscriptions" ? subscriptions : shows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -74,7 +80,7 @@ export function PodcastsView() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{(["trending", "search"] as Tab[]).map((t) => (
|
{(["subscriptions", "trending", "search"] as Tab[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => handleTabChange(t)}
|
onClick={() => handleTabChange(t)}
|
||||||
@@ -84,7 +90,7 @@ export function PodcastsView() {
|
|||||||
: "text-text-muted border-transparent hover:text-text"
|
: "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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,15 +98,32 @@ export function PodcastsView() {
|
|||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<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>
|
<div className="text-text-dim text-[12px]">Loading...</div>
|
||||||
) : shows.length === 0 ? (
|
) : displayShows.length === 0 ? (
|
||||||
<div className="text-text-dim text-[12px]">
|
<div className="text-text-dim text-[12px]">
|
||||||
{tab === "search" ? "No results. Try a different search." : "No trending podcasts found."}
|
{tab === "search" ? "No results. Try a different search." : "No trending podcasts found."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{shows.map((show, i) => (
|
{displayShows.map((show, i) => (
|
||||||
<PodcastCard
|
<PodcastCard
|
||||||
key={show.podcastIndexId ?? i}
|
key={show.podcastIndexId ?? i}
|
||||||
show={show}
|
show={show}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PodcastEpisode, PlaybackState } from "../types/podcast";
|
import type { PodcastShow, PodcastEpisode, PlaybackState } from "../types/podcast";
|
||||||
|
|
||||||
const STORAGE_KEY = "wrystr_podcast";
|
const STORAGE_KEY = "wrystr_podcast";
|
||||||
|
const SUBS_KEY = "wrystr_podcast_subs";
|
||||||
|
|
||||||
interface EpisodeProgress {
|
interface EpisodeProgress {
|
||||||
position: number;
|
position: number;
|
||||||
@@ -52,6 +53,7 @@ interface PodcastState {
|
|||||||
v4vIntervalId: number | null;
|
v4vIntervalId: number | null;
|
||||||
progressMap: Record<string, EpisodeProgress>;
|
progressMap: Record<string, EpisodeProgress>;
|
||||||
playCounter: number;
|
playCounter: number;
|
||||||
|
subscriptions: PodcastShow[];
|
||||||
|
|
||||||
play: (episode: PodcastEpisode) => void;
|
play: (episode: PodcastEpisode) => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@@ -69,6 +71,21 @@ interface PodcastState {
|
|||||||
setV4VSatsPerMinute: (sats: number) => void;
|
setV4VSatsPerMinute: (sats: number) => void;
|
||||||
setV4VStreaming: (streaming: boolean, intervalId?: number | null) => void;
|
setV4VStreaming: (streaming: boolean, intervalId?: number | null) => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
|
subscribe: (show: PodcastShow) => void;
|
||||||
|
unsubscribe: (feedUrl: string) => void;
|
||||||
|
isSubscribed: (feedUrl: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSubscriptions(): PodcastShow[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(SUBS_KEY) ?? "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubscriptions(subs: PodcastShow[]) {
|
||||||
|
localStorage.setItem(SUBS_KEY, JSON.stringify(subs));
|
||||||
}
|
}
|
||||||
|
|
||||||
const persisted = loadPersistedState();
|
const persisted = loadPersistedState();
|
||||||
@@ -87,6 +104,7 @@ export const usePodcastStore = create<PodcastState>((set, get) => ({
|
|||||||
v4vIntervalId: null,
|
v4vIntervalId: null,
|
||||||
progressMap: persisted.progressMap,
|
progressMap: persisted.progressMap,
|
||||||
playCounter: 0,
|
playCounter: 0,
|
||||||
|
subscriptions: loadSubscriptions(),
|
||||||
|
|
||||||
play: (episode) => {
|
play: (episode) => {
|
||||||
const position = get().loadProgress(episode.guid);
|
const position = get().loadProgress(episode.guid);
|
||||||
@@ -167,4 +185,22 @@ export const usePodcastStore = create<PodcastState>((set, get) => ({
|
|||||||
v4vIntervalId: null,
|
v4vIntervalId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
subscribe: (show) => {
|
||||||
|
const { subscriptions } = get();
|
||||||
|
if (subscriptions.some((s) => s.feedUrl === show.feedUrl)) return;
|
||||||
|
const updated = [...subscriptions, show];
|
||||||
|
set({ subscriptions: updated });
|
||||||
|
saveSubscriptions(updated);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: (feedUrl) => {
|
||||||
|
const updated = get().subscriptions.filter((s) => s.feedUrl !== feedUrl);
|
||||||
|
set({ subscriptions: updated });
|
||||||
|
saveSubscriptions(updated);
|
||||||
|
},
|
||||||
|
|
||||||
|
isSubscribed: (feedUrl) => {
|
||||||
|
return get().subscriptions.some((s) => s.feedUrl === feedUrl);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user