Files
lidify/frontend/app/library/page.tsx
2025-12-25 18:58:06 -06:00

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>
);
}