fix(ui): improve watchlist loading with batching and skeletons
This commit is contained in:
@@ -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}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([
|
// Allow UI to render grid and skeletons immediately
|
||||||
Promise.all(artistDetailsPromises),
|
setIsLoading(false);
|
||||||
Promise.all(playlistDetailsPromises),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" }));
|
// Helper to update state incrementally
|
||||||
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({
|
const appendItems = (newItems: WatchedItem[]) => {
|
||||||
...res.data,
|
setItems((prev) => [...prev, ...newItems]);
|
||||||
itemType: "playlist",
|
};
|
||||||
spotify_id: res.data.id,
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
setItems([...artists, ...playlists]);
|
// 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 {
|
} 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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user