fix(ui): improve watchlist loading with batching and skeletons

This commit is contained in:
che-pj
2025-08-27 16:30:00 +02:00
parent 443edd9c3d
commit 1e9271eac4

View File

@@ -20,39 +20,77 @@ export const Watchlist = () => {
const { settings, isLoading: settingsLoading } = useSettings(); const { settings, isLoading: settingsLoading } = useSettings();
const [items, setItems] = useState<WatchedItem[]>([]); const [items, setItems] = useState<WatchedItem[]>([]);
const [isLoading, setIsLoading] = useState(true); 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 () => { const fetchWatchlist = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setItems([]); // Clear previous items
setExpectedCount(null);
try { try {
const [artistsRes, playlistsRes] = await Promise.all([ const [artistsRes, playlistsRes] = await Promise.all([
apiClient.get<BaseWatched[]>("/artist/watch/list"), apiClient.get<BaseWatched[]>("/artist/watch/list"),
apiClient.get<BaseWatched[]>("/playlist/watch/list"), apiClient.get<BaseWatched[]>("/playlist/watch/list"),
]); ]);
const artistDetailsPromises = artistsRes.data.map((artist) => // Prepare lists of IDs
apiClient.get<ArtistType>(`/artist/info?id=${artist.spotify_id}`), const artistIds = artistsRes.data.map((artist) => artist.spotify_id);
); const playlistIds = playlistsRes.data.map((playlist) => playlist.spotify_id);
const playlistDetailsPromises = playlistsRes.data.map((playlist) => setExpectedCount(artistIds.length + playlistIds.length);
apiClient.get<PlaylistType>(`/playlist/info?id=${playlist.spotify_id}`),
// 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);
}
); );
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([ // Fetch playlist details in batches
Promise.all(artistDetailsPromises), await batchFetch<PlaylistType>(
Promise.all(playlistDetailsPromises), playlistIds,
]); (id) => apiClient.get<PlaylistType>(`/playlist/info?id=${id}`).then(res => res.data),
5, // batch size
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" })); (results) => {
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({ const items: WatchedPlaylist[] = results.map((data) => ({
...res.data, ...data,
itemType: "playlist", itemType: "playlist",
spotify_id: res.data.id, spotify_id: data.id,
})); }));
appendItems(items);
setItems([...artists, ...playlists]); }
);
} catch { } catch {
toast.error("Failed to load watchlist."); toast.error("Failed to load watchlist.");
} finally {
setIsLoading(false);
} }
}, []); }, []);
@@ -110,7 +148,8 @@ export const Watchlist = () => {
); );
} }
if (items.length === 0) { // Show "empty" only if not loading and nothing expected
if (!isLoading && items.length === 0 && (!expectedCount || expectedCount === 0)) {
return ( return (
<div className="text-center p-8"> <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> <h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
@@ -158,6 +197,25 @@ export const Watchlist = () => {
</div> </div>
</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>
</div> </div>
); );