Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user