496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, 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,
|
|
} 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";
|
|
|
|
type SortOption = "name" | "name-desc" | "recent" | "tracks";
|
|
|
|
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
|
|
const { artists, albums, tracks, isLoading, reloadData } = useLibraryData({
|
|
activeTab,
|
|
filter,
|
|
});
|
|
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]);
|
|
|
|
// Sort and paginate data
|
|
const sortedArtists = useMemo(() => {
|
|
const sorted = [...artists];
|
|
switch (sortBy) {
|
|
case "name":
|
|
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
break;
|
|
case "name-desc":
|
|
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
|
break;
|
|
case "tracks":
|
|
sorted.sort(
|
|
(a, b) => (b.trackCount || 0) - (a.trackCount || 0)
|
|
);
|
|
break;
|
|
default:
|
|
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
return sorted;
|
|
}, [artists, sortBy]);
|
|
|
|
const sortedAlbums = useMemo(() => {
|
|
const sorted = [...albums];
|
|
switch (sortBy) {
|
|
case "name":
|
|
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
|
break;
|
|
case "name-desc":
|
|
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
|
break;
|
|
case "recent":
|
|
sorted.sort((a, b) => (b.year || 0) - (a.year || 0));
|
|
break;
|
|
default:
|
|
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
return sorted;
|
|
}, [albums, sortBy]);
|
|
|
|
const sortedTracks = useMemo(() => {
|
|
const sorted = [...tracks];
|
|
switch (sortBy) {
|
|
case "name":
|
|
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
|
break;
|
|
case "name-desc":
|
|
sorted.sort((a, b) => b.title.localeCompare(a.title));
|
|
break;
|
|
default:
|
|
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
return sorted;
|
|
}, [tracks, sortBy]);
|
|
|
|
// Paginate
|
|
const paginatedArtists = useMemo(() => {
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
return sortedArtists.slice(start, start + itemsPerPage);
|
|
}, [sortedArtists, currentPage, itemsPerPage]);
|
|
|
|
const paginatedAlbums = useMemo(() => {
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
return sortedAlbums.slice(start, start + itemsPerPage);
|
|
}, [sortedAlbums, currentPage, itemsPerPage]);
|
|
|
|
const paginatedTracks = useMemo(() => {
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
return sortedTracks.slice(start, start + itemsPerPage);
|
|
}, [sortedTracks, currentPage, itemsPerPage]);
|
|
|
|
// Total counts and pages
|
|
const totalItems =
|
|
activeTab === "artists"
|
|
? artists.length
|
|
: activeTab === "albums"
|
|
? albums.length
|
|
: tracks.length;
|
|
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
|
|
|
// 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
|
|
const handleShuffleLibrary = async () => {
|
|
try {
|
|
// Get all tracks if not already loaded
|
|
let allTracks = tracks;
|
|
if (activeTab !== "tracks" || tracks.length === 0) {
|
|
const { tracks: fetchedTracks } = await api.getTracks({
|
|
limit: 1000,
|
|
});
|
|
allTracks = fetchedTracks;
|
|
}
|
|
|
|
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}{" "}
|
|
{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");
|
|
setCurrentPage(1);
|
|
}}
|
|
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");
|
|
setCurrentPage(1);
|
|
}}
|
|
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");
|
|
setCurrentPage(1);
|
|
}}
|
|
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);
|
|
setCurrentPage(1);
|
|
}}
|
|
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));
|
|
setCurrentPage(1);
|
|
}}
|
|
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={paginatedArtists}
|
|
isLoading={isLoading}
|
|
onPlay={playArtist}
|
|
onDelete={(id, name) =>
|
|
setDeleteConfirm({
|
|
isOpen: true,
|
|
type: "artist",
|
|
id,
|
|
title: name,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === "albums" && (
|
|
<AlbumsGrid
|
|
albums={paginatedAlbums}
|
|
isLoading={isLoading}
|
|
onPlay={playAlbum}
|
|
onDelete={(id, title) =>
|
|
setDeleteConfirm({
|
|
isOpen: true,
|
|
type: "album",
|
|
id,
|
|
title,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === "tracks" && (
|
|
<TracksList
|
|
tracks={paginatedTracks}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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>
|
|
);
|
|
}
|