fix keyboard, finish 05
This commit is contained in:
215
src/stores/discover.ts
Normal file
215
src/stores/discover.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Discover store for PodTUI
|
||||
* Manages trending/popular podcasts and category filtering
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
|
||||
export interface DiscoverCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: "all", name: "All", icon: "*" },
|
||||
{ id: "technology", name: "Technology", icon: ">" },
|
||||
{ id: "science", name: "Science", icon: "~" },
|
||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||
{ id: "news", name: "News", icon: "!" },
|
||||
{ id: "business", name: "Business", icon: "$" },
|
||||
{ id: "health", name: "Health", icon: "+" },
|
||||
{ id: "education", name: "Education", icon: "?" },
|
||||
{ id: "sports", name: "Sports", icon: "#" },
|
||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||
{ id: "arts", name: "Arts", icon: "@" },
|
||||
]
|
||||
|
||||
/** Mock trending podcasts */
|
||||
const TRENDING_PODCASTS: Podcast[] = [
|
||||
{
|
||||
id: "trend-1",
|
||||
title: "AI Today",
|
||||
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||
feedUrl: "https://example.com/aitoday.rss",
|
||||
author: "Tech Futures",
|
||||
categories: ["Technology", "Science"],
|
||||
imageUrl: undefined,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-2",
|
||||
title: "The History Hour",
|
||||
description: "Fascinating stories from history that shaped our world today.",
|
||||
feedUrl: "https://example.com/historyhour.rss",
|
||||
author: "History Channel",
|
||||
categories: ["Education", "History"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-3",
|
||||
title: "Comedy Gold",
|
||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedygold.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-4",
|
||||
title: "Market Watch",
|
||||
description: "Daily financial news, stock analysis, and investing tips.",
|
||||
feedUrl: "https://example.com/marketwatch.rss",
|
||||
author: "Finance Daily",
|
||||
categories: ["Business", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
{
|
||||
id: "trend-5",
|
||||
title: "Science Weekly",
|
||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
||||
feedUrl: "https://example.com/scienceweekly.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-6",
|
||||
title: "True Crime Files",
|
||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-7",
|
||||
title: "Wellness Journey",
|
||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
||||
feedUrl: "https://example.com/wellness.rss",
|
||||
author: "Health Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-8",
|
||||
title: "Sports Talk Live",
|
||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
||||
feedUrl: "https://example.com/sportstalk.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-9",
|
||||
title: "Creative Minds",
|
||||
description: "Interviews with artists, designers, and creative professionals.",
|
||||
feedUrl: "https://example.com/creativeminds.rss",
|
||||
author: "Arts Weekly",
|
||||
categories: ["Arts", "Culture"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "trend-10",
|
||||
title: "Dev Talk",
|
||||
description: "Software development, programming tutorials, and tech career advice.",
|
||||
feedUrl: "https://example.com/devtalk.rss",
|
||||
author: "Code Academy",
|
||||
categories: ["Technology", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
]
|
||||
|
||||
/** Create discover store */
|
||||
export function createDiscoverStore() {
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
||||
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory()
|
||||
if (category === "all") {
|
||||
return podcasts()
|
||||
}
|
||||
|
||||
return podcasts().filter((p) => {
|
||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
||||
return cats.some((c) => c.includes(category.replace("-", " ")))
|
||||
})
|
||||
}
|
||||
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId)
|
||||
} else {
|
||||
subscribe(podcastId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh trending podcasts (mock) */
|
||||
const refresh = async () => {
|
||||
setIsLoading(true)
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
// In real app, would fetch from API
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
filteredPodcasts,
|
||||
categories: DISCOVER_CATEGORIES,
|
||||
|
||||
// Actions
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton discover store */
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
||||
|
||||
export function useDiscoverStore() {
|
||||
if (!discoverStoreInstance) {
|
||||
discoverStoreInstance = createDiscoverStore()
|
||||
}
|
||||
return discoverStoreInstance
|
||||
}
|
||||
422
src/stores/feed.ts
Normal file
422
src/stores/feed.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Feed store for PodTUI
|
||||
* Manages feed data, sources, and filtering
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||
import type { PodcastSource, SourceType } from "../types/source"
|
||||
import { DEFAULT_SOURCES } from "../types/source"
|
||||
|
||||
/** Storage keys */
|
||||
const STORAGE_KEYS = {
|
||||
feeds: "podtui_feeds",
|
||||
sources: "podtui_sources",
|
||||
}
|
||||
|
||||
/** Create initial mock feeds for demonstration */
|
||||
function createMockFeeds(): Feed[] {
|
||||
const now = new Date()
|
||||
return [
|
||||
{
|
||||
id: "1",
|
||||
podcast: {
|
||||
id: "p1",
|
||||
title: "The Daily Tech News",
|
||||
description: "Your daily dose of technology news and insights from around the world. We cover the latest in AI, software, hardware, and digital culture.",
|
||||
feedUrl: "https://example.com/tech.rss",
|
||||
author: "Tech Media Inc",
|
||||
categories: ["Technology", "News"],
|
||||
lastUpdated: now,
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: createMockEpisodes("p1", 25),
|
||||
visibility: "public" as FeedVisibility,
|
||||
sourceId: "rss",
|
||||
lastUpdated: now,
|
||||
isPinned: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
podcast: {
|
||||
id: "p2",
|
||||
title: "Code & Coffee",
|
||||
description: "Weekly discussions about programming, software development, and the developer lifestyle. Best enjoyed with your morning coffee.",
|
||||
feedUrl: "https://example.com/code.rss",
|
||||
author: "Developer Collective",
|
||||
categories: ["Technology", "Programming"],
|
||||
lastUpdated: new Date(Date.now() - 86400000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: createMockEpisodes("p2", 50),
|
||||
visibility: "private" as FeedVisibility,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(Date.now() - 86400000),
|
||||
isPinned: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
podcast: {
|
||||
id: "p3",
|
||||
title: "Science Explained",
|
||||
description: "Breaking down complex scientific topics for curious minds. From quantum physics to biology, we make science accessible.",
|
||||
feedUrl: "https://example.com/science.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(Date.now() - 172800000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: createMockEpisodes("p3", 120),
|
||||
visibility: "public" as FeedVisibility,
|
||||
sourceId: "itunes",
|
||||
lastUpdated: new Date(Date.now() - 172800000),
|
||||
isPinned: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
podcast: {
|
||||
id: "p4",
|
||||
title: "History Uncovered",
|
||||
description: "Deep dives into fascinating historical events and figures you never learned about in school.",
|
||||
feedUrl: "https://example.com/history.rss",
|
||||
author: "History Channel",
|
||||
categories: ["History", "Education"],
|
||||
lastUpdated: new Date(Date.now() - 259200000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: createMockEpisodes("p4", 80),
|
||||
visibility: "public" as FeedVisibility,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(Date.now() - 259200000),
|
||||
isPinned: true,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
podcast: {
|
||||
id: "p5",
|
||||
title: "Startup Stories",
|
||||
description: "Founders share their journey from idea to exit. Learn from their successes and failures.",
|
||||
feedUrl: "https://example.com/startup.rss",
|
||||
author: "Entrepreneur Media",
|
||||
categories: ["Business", "Technology"],
|
||||
lastUpdated: new Date(Date.now() - 345600000),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: createMockEpisodes("p5", 45),
|
||||
visibility: "private" as FeedVisibility,
|
||||
sourceId: "itunes",
|
||||
lastUpdated: new Date(Date.now() - 345600000),
|
||||
isPinned: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Create mock episodes for a podcast */
|
||||
function createMockEpisodes(podcastId: string, count: number): Episode[] {
|
||||
const episodes: Episode[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
episodes.push({
|
||||
id: `${podcastId}-ep-${i + 1}`,
|
||||
podcastId,
|
||||
title: `Episode ${count - i}: Sample Episode Title`,
|
||||
description: `This is the description for episode ${count - i}. It contains interesting content about various topics.`,
|
||||
audioUrl: `https://example.com/audio/${podcastId}/${i + 1}.mp3`,
|
||||
duration: 1800 + Math.random() * 3600, // 30-90 minutes
|
||||
pubDate: new Date(Date.now() - i * 604800000), // Weekly episodes
|
||||
episodeNumber: count - i,
|
||||
})
|
||||
}
|
||||
return episodes
|
||||
}
|
||||
|
||||
/** Load feeds from localStorage */
|
||||
function loadFeeds(): Feed[] {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return createMockFeeds()
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Convert date strings
|
||||
return parsed.map((feed: Feed) => ({
|
||||
...feed,
|
||||
lastUpdated: new Date(feed.lastUpdated),
|
||||
podcast: {
|
||||
...feed.podcast,
|
||||
lastUpdated: new Date(feed.podcast.lastUpdated),
|
||||
},
|
||||
episodes: feed.episodes.map((ep: Episode) => ({
|
||||
...ep,
|
||||
pubDate: new Date(ep.pubDate),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return createMockFeeds()
|
||||
}
|
||||
|
||||
/** Save feeds to localStorage */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Load sources from localStorage */
|
||||
function loadSources(): PodcastSource[] {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return [...DEFAULT_SOURCES]
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.sources)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return [...DEFAULT_SOURCES]
|
||||
}
|
||||
|
||||
/** Save sources to localStorage */
|
||||
function saveSources(sources: PodcastSource[]): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
export function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
})
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()]
|
||||
const f = filter()
|
||||
|
||||
// Filter by visibility
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility)
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId)
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned)
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase()
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title)
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length)
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
||||
return sortDir * (aLatest - bLatest)
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
||||
}
|
||||
})
|
||||
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []
|
||||
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by publication date (newest first)
|
||||
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime())
|
||||
|
||||
return allEpisodes
|
||||
}
|
||||
|
||||
/** Add a new feed */
|
||||
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = "public") => {
|
||||
const newFeed: Feed = {
|
||||
id: crypto.randomUUID(),
|
||||
podcast,
|
||||
episodes: [],
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed]
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
return newFeed
|
||||
}
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource]
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
return newSource
|
||||
}
|
||||
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId)
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
|
||||
)
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId)
|
||||
}
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId()
|
||||
return id ? getFeed(id) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
addFeed,
|
||||
removeFeed,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton feed store */
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null
|
||||
|
||||
export function useFeedStore() {
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore()
|
||||
}
|
||||
return feedStoreInstance
|
||||
}
|
||||
239
src/stores/search.ts
Normal file
239
src/stores/search.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Search store for PodTUI
|
||||
* Manages search state, history, and results
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
|
||||
const STORAGE_KEY = "podtui_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export interface SearchState {
|
||||
query: string
|
||||
isSearching: boolean
|
||||
results: SearchResult[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** Mock search results for demonstration */
|
||||
const MOCK_PODCASTS: Podcast[] = [
|
||||
{
|
||||
id: "search-1",
|
||||
title: "Tech Talk Daily",
|
||||
description: "Daily technology news and analysis from Silicon Valley experts.",
|
||||
feedUrl: "https://example.com/techtalk.rss",
|
||||
author: "Tech Media Group",
|
||||
categories: ["Technology", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-2",
|
||||
title: "The Science Hour",
|
||||
description: "Weekly deep dives into the latest scientific discoveries and research.",
|
||||
feedUrl: "https://example.com/sciencehour.rss",
|
||||
author: "Science Network",
|
||||
categories: ["Science", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-3",
|
||||
title: "History Lessons",
|
||||
description: "Fascinating stories from history that shaped our world.",
|
||||
feedUrl: "https://example.com/historylessons.rss",
|
||||
author: "History Channel",
|
||||
categories: ["History", "Education"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-4",
|
||||
title: "Business Insights",
|
||||
description: "Expert analysis on business trends, markets, and entrepreneurship.",
|
||||
feedUrl: "https://example.com/businessinsights.rss",
|
||||
author: "Business Weekly",
|
||||
categories: ["Business", "Finance"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-5",
|
||||
title: "True Crime Stories",
|
||||
description: "In-depth investigations into real criminal cases and mysteries.",
|
||||
feedUrl: "https://example.com/truecrime.rss",
|
||||
author: "Crime Network",
|
||||
categories: ["True Crime", "Documentary"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-6",
|
||||
title: "Comedy Hour",
|
||||
description: "Stand-up comedy, sketches, and hilarious conversations.",
|
||||
feedUrl: "https://example.com/comedyhour.rss",
|
||||
author: "Laugh Factory",
|
||||
categories: ["Comedy", "Entertainment"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-7",
|
||||
title: "Mindful Living",
|
||||
description: "Meditation, wellness, and mental health tips for a better life.",
|
||||
feedUrl: "https://example.com/mindful.rss",
|
||||
author: "Wellness Media",
|
||||
categories: ["Health", "Self-Help"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
{
|
||||
id: "search-8",
|
||||
title: "Sports Central",
|
||||
description: "Coverage of all major sports, analysis, and athlete interviews.",
|
||||
feedUrl: "https://example.com/sportscentral.rss",
|
||||
author: "Sports Network",
|
||||
categories: ["Sports", "News"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
]
|
||||
|
||||
/** Load search history from localStorage */
|
||||
function loadHistory(): string[] {
|
||||
if (typeof localStorage === "undefined") return []
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search history to localStorage */
|
||||
function saveHistory(history: string[]): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Create search store */
|
||||
export function createSearchStore() {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [isSearching, setIsSearching] = createSignal(false)
|
||||
const [results, setResults] = createSignal<SearchResult[]>([])
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
||||
|
||||
/** Perform search (mock implementation) */
|
||||
const search = async (searchQuery: string): Promise<void> => {
|
||||
const q = searchQuery.trim()
|
||||
if (!q) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(q)
|
||||
setIsSearching(true)
|
||||
setError(null)
|
||||
|
||||
// Add to history
|
||||
addToHistory(q)
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise((r) => setTimeout(r, 300 + Math.random() * 500))
|
||||
|
||||
try {
|
||||
// Mock search - filter by query
|
||||
const queryLower = q.toLowerCase()
|
||||
const matchingPodcasts = MOCK_PODCASTS.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(queryLower) ||
|
||||
p.description.toLowerCase().includes(queryLower) ||
|
||||
p.categories?.some((c) => c.toLowerCase().includes(queryLower)) ||
|
||||
p.author?.toLowerCase().includes(queryLower)
|
||||
)
|
||||
|
||||
// Convert to search results
|
||||
const searchResults: SearchResult[] = matchingPodcasts.map((podcast, i) => ({
|
||||
sourceId: i % 2 === 0 ? "itunes" : "rss",
|
||||
podcast,
|
||||
score: 1 - i * 0.1, // Mock relevance score
|
||||
}))
|
||||
|
||||
setResults(searchResults)
|
||||
} catch (e) {
|
||||
setError("Search failed. Please try again.")
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Add query to history */
|
||||
const addToHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
// Remove duplicates and add to front
|
||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
|
||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
|
||||
saveHistory(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear search history */
|
||||
const clearHistory = () => {
|
||||
setHistory([])
|
||||
saveHistory([])
|
||||
}
|
||||
|
||||
/** Remove single history item */
|
||||
const removeFromHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.filter((h) => h !== q)
|
||||
saveHistory(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear results */
|
||||
const clearResults = () => {
|
||||
setResults([])
|
||||
setQuery("")
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
query,
|
||||
isSearching,
|
||||
results,
|
||||
error,
|
||||
history,
|
||||
selectedSources,
|
||||
|
||||
// Actions
|
||||
search,
|
||||
setQuery,
|
||||
clearResults,
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
setSelectedSources,
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton search store */
|
||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
|
||||
|
||||
export function useSearchStore() {
|
||||
if (!searchStoreInstance) {
|
||||
searchStoreInstance = createSearchStore()
|
||||
}
|
||||
return searchStoreInstance
|
||||
}
|
||||
Reference in New Issue
Block a user