Files
lidify/frontend/app/library/page.tsx
Daniel Thiberge 567f38e1ea fix: remove 500/1000 item cap on library pagination (Issue #12)
- Remove Math.min(..., 1000) hard cap on /library/artists endpoint
- Remove Math.min(..., 1000) hard cap on /library/albums endpoint
- Add offset parameter and total count to /library/tracks endpoint
- Rewrite useLibraryData hook for true server-side pagination
- Update library page to fetch pages on demand instead of client-side slicing
- Disable pagination buttons during loading to prevent race conditions

This allows libraries of any size to be fully browsable. Previously,
users with 10,000+ songs were capped at seeing only 500 items.

Fixes #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 02:44:12 -05:00

409 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAudio } from "@/lib/audio-context";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { Tab, DeleteDialogState } from "@/features/library/types";
import {
useLibraryData,
LibraryFilter,
SortOption,
} from "@/features/library/hooks/useLibraryData";
import { api } from "@/lib/api";
import { useLibraryActions } from "@/features/library/hooks/useLibraryActions";
import { LibraryHeader } from "@/features/library/components/LibraryHeader";
import { LibraryTabs } from "@/features/library/components/LibraryTabs";
import { ArtistsGrid } from "@/features/library/components/ArtistsGrid";
import { AlbumsGrid } from "@/features/library/components/AlbumsGrid";
import { TracksList } from "@/features/library/components/TracksList";
import { Shuffle, ListFilter } from "lucide-react";
export default function LibraryPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { currentTrack, playTracks } = useAudio();
// Get active tab from URL params, default to "artists"
const activeTab = (searchParams.get("tab") as Tab) || "artists";
// Filter state (owned = your library, discovery = discovery weekly artists)
const [filter, setFilter] = useState<LibraryFilter>("owned");
// Sort and pagination state
const [sortBy, setSortBy] = useState<SortOption>("name");
const [itemsPerPage, setItemsPerPage] = useState<number>(50);
const [currentPage, setCurrentPage] = useState(1);
const [showFilters, setShowFilters] = useState(false);
// Use custom hooks with server-side pagination
const { artists, albums, tracks, isLoading, reloadData, pagination } = useLibraryData({
activeTab,
filter,
sortBy,
itemsPerPage,
currentPage,
});
const {
playArtist,
playAlbum,
addTrackToQueue,
addTrackToPlaylist,
deleteArtist,
deleteAlbum,
deleteTrack,
} = useLibraryActions();
// Reset page and filter when tab changes
useEffect(() => {
setCurrentPage(1);
// Reset filter to 'owned' when switching to tracks tab (which doesn't support filter)
if (activeTab === "tracks") {
setFilter("owned");
}
}, [activeTab]);
// Reset page when filter or sort changes
useEffect(() => {
setCurrentPage(1);
}, [filter, sortBy, itemsPerPage]);
// Get total items and pages from pagination
const totalItems = pagination.total;
const totalPages = pagination.totalPages;
// Delete confirmation dialog state
const [deleteConfirm, setDeleteConfirm] = useState<DeleteDialogState>({
isOpen: false,
type: "track",
id: "",
title: "",
});
// Change tab function
const changeTab = (tab: Tab) => {
router.push(`/library?tab=${tab}`, { scroll: false });
};
// Helper to convert library Track to audio context Track format
const formatTracksForAudio = (libraryTracks: typeof tracks) => {
return libraryTracks.map((track) => ({
id: track.id,
title: track.title,
duration: track.duration,
artist: {
id: track.album?.artist?.id,
name: track.album?.artist?.name || "Unknown Artist",
},
album: {
id: track.album?.id,
title: track.album?.title || "Unknown Album",
coverArt: track.album?.coverArt,
},
}));
};
// Wrapper for playTracks that converts track format
const handlePlayTracks = (
libraryTracks: typeof tracks,
startIndex?: number
) => {
const formattedTracks = formatTracksForAudio(libraryTracks);
playTracks(formattedTracks, startIndex);
};
// Shuffle entire library - fetches all tracks for true shuffle
const handleShuffleLibrary = async () => {
try {
// Fetch a large batch of tracks for shuffling
const { tracks: allTracks } = await api.getTracks({
limit: 10000,
});
if (allTracks.length === 0) {
return;
}
// Shuffle the tracks
const shuffled = [...allTracks].sort(() => Math.random() - 0.5);
const formattedTracks = formatTracksForAudio(shuffled);
playTracks(formattedTracks, 0);
} catch (error) {
console.error("Failed to shuffle library:", error);
}
};
// Handle delete confirmation
const handleDelete = async () => {
try {
switch (deleteConfirm.type) {
case "artist":
await deleteArtist(deleteConfirm.id);
break;
case "album":
await deleteAlbum(deleteConfirm.id);
break;
case "track":
await deleteTrack(deleteConfirm.id);
break;
}
// Reload data and close dialog - the item disappearing is feedback enough
await reloadData();
setDeleteConfirm({
isOpen: false,
type: "track",
id: "",
title: "",
});
} catch (error) {
console.error(`Failed to delete ${deleteConfirm.type}:`, error);
// Keep dialog open on error so user can retry
}
};
return (
<div className="min-h-screen relative">
<LibraryHeader />
<div className="relative px-4 md:px-8 pb-24">
{/* Tabs and Controls Row */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<LibraryTabs
activeTab={activeTab}
onTabChange={changeTab}
/>
<div className="flex items-center gap-2">
{/* Shuffle Button */}
<button
onClick={handleShuffleLibrary}
className="flex items-center justify-center w-8 h-8 rounded-full bg-[#ecb200] hover:bg-[#d4a000] text-black transition-all hover:scale-105"
title="Shuffle Library"
>
<Shuffle className="w-4 h-4" />
</button>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center justify-center w-8 h-8 rounded-full transition-all ${
showFilters
? "bg-white/20 text-white"
: "bg-white/5 text-gray-400 hover:text-white hover:bg-white/10"
}`}
title="Show Filters"
>
<ListFilter className="w-4 h-4" />
</button>
{/* Item Count */}
<span className="text-sm text-gray-400 ml-2">
{totalItems.toLocaleString()}{" "}
{activeTab === "artists"
? "artists"
: activeTab === "albums"
? "albums"
: "songs"}
</span>
</div>
</div>
{/* Expandable Filters Row */}
{showFilters && (
<div className="flex flex-wrap items-center gap-2 mb-6 pb-4 border-b border-white/5">
{/* Filter Toggle (Owned / Discovery / All) - Only show for artists and albums */}
{(activeTab === "artists" ||
activeTab === "albums") && (
<div className="flex items-center gap-1">
<button
onClick={() => setFilter("owned")}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
filter === "owned"
? "bg-[#ecb200] text-black"
: "bg-white/5 text-gray-400 hover:text-white hover:bg-white/10"
}`}
>
Owned
</button>
<button
onClick={() => setFilter("discovery")}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
filter === "discovery"
? "bg-purple-500 text-white"
: "bg-white/5 text-gray-400 hover:text-white hover:bg-white/10"
}`}
>
Discovery
</button>
<button
onClick={() => setFilter("all")}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
filter === "all"
? "bg-white/20 text-white"
: "bg-white/5 text-gray-400 hover:text-white hover:bg-white/10"
}`}
>
All
</button>
</div>
)}
{/* Sort Dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value="name">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
{activeTab === "albums" && (
<option value="recent">Year (Newest)</option>
)}
{activeTab === "artists" && (
<option value="tracks">Most Tracks</option>
)}
</select>
{/* Items per page */}
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-full text-white text-xs focus:outline-none focus:border-white/20 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
<option value={250}>250 per page</option>
</select>
</div>
)}
{activeTab === "artists" && (
<ArtistsGrid
artists={artists}
isLoading={isLoading}
onPlay={playArtist}
onDelete={(id, name) =>
setDeleteConfirm({
isOpen: true,
type: "artist",
id,
title: name,
})
}
/>
)}
{activeTab === "albums" && (
<AlbumsGrid
albums={albums}
isLoading={isLoading}
onPlay={playAlbum}
onDelete={(id, title) =>
setDeleteConfirm({
isOpen: true,
type: "album",
id,
title,
})
}
/>
)}
{activeTab === "tracks" && (
<TracksList
tracks={tracks}
isLoading={isLoading}
currentTrackId={currentTrack?.id}
onPlay={handlePlayTracks}
onAddToQueue={addTrackToQueue}
onAddToPlaylist={addTrackToPlaylist}
onDelete={(id: string, title: string) =>
setDeleteConfirm({
isOpen: true,
type: "track",
id,
title,
})
}
/>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8 pt-4 border-t border-white/5">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1 || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1 || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="px-4 py-1.5 text-xs text-white">
{currentPage} / {totalPages}
</span>
<button
onClick={() =>
setCurrentPage((p) =>
Math.min(totalPages, p + 1)
)
}
disabled={currentPage === totalPages || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages || isLoading}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
)}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() =>
setDeleteConfirm({
isOpen: false,
type: "track",
id: "",
title: "",
})
}
onConfirm={handleDelete}
title={`Delete ${
deleteConfirm.type === "artist"
? "Artist"
: deleteConfirm.type === "album"
? "Album"
: "Track"
}?`}
message={
deleteConfirm.type === "track"
? `Are you sure you want to delete "${deleteConfirm.title}"? This will permanently delete the file from your system.`
: deleteConfirm.type === "album"
? `Are you sure you want to delete "${deleteConfirm.title}"? This will permanently delete all tracks and files from your system.`
: `Are you sure you want to delete "${deleteConfirm.title}"? This will permanently delete all albums, tracks, and files from your system.`
}
confirmText="Delete"
cancelText="Cancel"
variant="danger"
/>
</div>
</div>
);
}