Files
spotizerr-dev/spotizerr-ui/src/routes/watchlist.tsx

223 lines
8.8 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import apiClient from "../lib/api-client";
import { toast } from "sonner";
import { useSettings } from "../contexts/settings-context";
import { Link } from "@tanstack/react-router";
import type { ArtistType, PlaylistType } from "../types/spotify";
import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
// --- Type Definitions ---
interface BaseWatched {
itemType: "artist" | "playlist";
spotify_id: string;
}
type WatchedArtist = ArtistType & { itemType: "artist" };
type WatchedPlaylist = PlaylistType & { itemType: "playlist" };
type WatchedItem = WatchedArtist | WatchedPlaylist;
export const Watchlist = () => {
const { settings, isLoading: settingsLoading } = useSettings();
const [items, setItems] = useState<WatchedItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [expectedCount, setExpectedCount] = useState<number | null>(null);
// Utility to batch fetch details
async function batchFetch<T>(
ids: string[],
fetchFn: (id: string) => Promise<T>,
batchSize: number,
onBatch: (results: T[]) => void
) {
for (let i = 0; i < ids.length; i += batchSize) {
const batchIds = ids.slice(i, i + batchSize);
const batchResults = await Promise.all(
batchIds.map((id) => fetchFn(id).catch(() => null))
);
onBatch(batchResults.filter(Boolean) as T[]);
}
}
const fetchWatchlist = useCallback(async () => {
setIsLoading(true);
setItems([]); // Clear previous items
setExpectedCount(null);
try {
const [artistsRes, playlistsRes] = await Promise.all([
apiClient.get<BaseWatched[]>("/artist/watch/list"),
apiClient.get<BaseWatched[]>("/playlist/watch/list"),
]);
// Prepare lists of IDs
const artistIds = artistsRes.data.map((artist) => artist.spotify_id);
const playlistIds = playlistsRes.data.map((playlist) => playlist.spotify_id);
setExpectedCount(artistIds.length + playlistIds.length);
// Allow UI to render grid and skeletons immediately
setIsLoading(false);
// Helper to update state incrementally
const appendItems = (newItems: WatchedItem[]) => {
setItems((prev) => [...prev, ...newItems]);
};
// Fetch artist details in batches
await batchFetch<ArtistType>(
artistIds,
(id) => apiClient.get<ArtistType>(`/artist/info?id=${id}`).then(res => res.data),
5, // batch size
(results) => {
const items: WatchedArtist[] = results.map((data) => ({
...data,
itemType: "artist",
}));
appendItems(items);
}
);
// Fetch playlist details in batches
await batchFetch<PlaylistType>(
playlistIds,
(id) => apiClient.get<PlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
5, // batch size
(results) => {
const items: WatchedPlaylist[] = results.map((data) => ({
...data,
itemType: "playlist",
spotify_id: data.id,
}));
appendItems(items);
}
);
} catch {
toast.error("Failed to load watchlist.");
}
}, []);
useEffect(() => {
if (!settingsLoading && settings?.watch?.enabled) {
fetchWatchlist();
} else if (!settingsLoading) {
setIsLoading(false);
}
}, [settings, settingsLoading, fetchWatchlist]);
const handleUnwatch = async (item: WatchedItem) => {
toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.id}`), {
loading: `Unwatching ${item.name}...`,
success: () => {
setItems((prev) => prev.filter((i) => i.id !== item.id));
return `${item.name} has been unwatched.`;
},
error: `Failed to unwatch ${item.name}.`,
});
};
const handleCheck = async (item: WatchedItem) => {
toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.id}`), {
loading: `Checking ${item.name} for updates...`,
success: (res: { data: { message?: string } }) => res.data.message || `Check triggered for ${item.name}.`,
error: `Failed to trigger check for ${item.name}.`,
});
};
const handleCheckAll = () => {
toast.promise(
Promise.all([apiClient.post("/artist/watch/trigger_check"), apiClient.post("/playlist/watch/trigger_check")]),
{
loading: "Triggering checks for all watched items...",
success: "Successfully triggered checks for all items.",
error: "Failed to trigger one or more checks.",
},
);
};
if (isLoading || settingsLoading) {
return <div className="text-center text-content-muted dark:text-content-muted-dark">Loading Watchlist...</div>;
}
if (!settings?.watch?.enabled) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist Disabled</h2>
<p className="text-content-secondary dark:text-content-secondary-dark">The watchlist feature is currently disabled. You can enable it in the settings.</p>
<Link to="/config" className="text-primary hover:underline mt-4 inline-block">
Go to Settings
</Link>
</div>
);
}
// Show "empty" only if not loading and nothing expected
if (!isLoading && items.length === 0 && (!expectedCount || expectedCount === 0)) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
<p className="text-content-secondary dark:text-content-secondary-dark">Start watching artists or playlists to see them here.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Watched Artists & Playlists</h1>
<button
onClick={handleCheckAll}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md flex items-center gap-2"
>
<FaSearch className="icon-inverse" /> Check All
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => (
<div key={item.id} className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col">
<a href={`/${item.itemType}/${item.id}`} className="flex-grow">
<img
src={item.images?.[0]?.url || "/images/placeholder.jpg"}
alt={item.name}
className="w-full h-auto object-cover rounded-md aspect-square"
/>
<h3 className="font-bold pt-2 truncate text-content-primary dark:text-content-primary-dark">{item.name}</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark capitalize">{item.itemType}</p>
</a>
<div className="flex gap-2 pt-2">
<button
onClick={() => handleUnwatch(item)}
className="w-full px-3 py-1.5 text-sm bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center justify-center gap-2"
>
<FaRegTrashAlt className="icon-inverse" /> Unwatch
</button>
<button
onClick={() => handleCheck(item)}
className="w-full px-3 py-1.5 text-sm bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md flex items-center justify-center gap-2"
>
<FaSearch className="icon-secondary hover:icon-primary" /> Check
</button>
</div>
</div>
))}
{/* Skeletons for loading items */}
{isLoading && expectedCount && items.length < expectedCount &&
Array.from({ length: expectedCount - items.length }).map((_, idx) => (
<div
key={`skeleton-${idx}`}
className="bg-surface dark:bg-surface-secondary-dark p-4 rounded-lg shadow space-y-2 flex flex-col animate-pulse"
>
<div className="flex-grow">
<div className="w-full aspect-square bg-gray-200 dark:bg-gray-700 rounded-md mb-2" />
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-1" />
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-1/2" />
</div>
<div className="flex gap-2 pt-2">
<div className="w-full h-8 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="w-full h-8 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
</div>
))
}
</div>
</div>
);
};