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

453 lines
17 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { usePlaylistsQuery } from "@/hooks/useQueries";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/hooks/useQueries";
import { useAuth } from "@/lib/auth-context";
import { useAudio } from "@/lib/audio-context";
import { Play, Music, Eye, EyeOff } from "lucide-react";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
import { api } from "@/lib/api";
import { cn, isLocalUrl } from "@/utils/cn";
// Lidify brand yellow
const LIDIFY_YELLOW = "#ecb200";
interface PlaylistItem {
id: string;
track: {
album?: {
coverArt?: string;
};
};
}
interface Playlist {
id: string;
name: string;
trackCount?: number;
items?: PlaylistItem[];
isOwner?: boolean;
isHidden?: boolean;
user?: {
username: string;
};
}
// Generate mosaic cover from playlist tracks
function PlaylistMosaic({
items,
size = 4,
greyed = false,
}: {
items?: PlaylistItem[];
size?: number;
greyed?: boolean;
}) {
const coverUrls = useMemo(() => {
if (!items || items.length === 0) return [];
const tracksWithCovers = items.filter(
(item) => item.track?.album?.coverArt
);
if (tracksWithCovers.length === 0) return [];
// Get unique cover arts (up to 4)
const uniqueCovers = Array.from(
new Set(tracksWithCovers.map((item) => item.track.album!.coverArt))
).slice(0, size);
return uniqueCovers.map((cover) => api.getCoverArtUrl(cover!, 200));
}, [items, size]);
if (coverUrls.length === 0) {
return (
<div
className={cn(
"w-full h-full flex items-center justify-center bg-gradient-to-br from-[#282828] to-[#181818]",
greyed && "opacity-50"
)}
>
<Music className="w-10 h-10 text-gray-600" />
</div>
);
}
if (coverUrls.length === 1) {
return (
<Image
src={coverUrls[0]}
alt=""
fill
className={cn("object-cover", greyed && "opacity-50 grayscale")}
sizes="200px"
unoptimized
/>
);
}
return (
<div
className={cn(
"grid grid-cols-2 w-full h-full",
greyed && "opacity-50 grayscale"
)}
>
{coverUrls.slice(0, 4).map((url, index) => (
<div key={index} className="relative">
<Image
src={url}
alt=""
fill
className="object-cover"
sizes="100px"
unoptimized
/>
</div>
))}
{Array.from({ length: Math.max(0, 4 - coverUrls.length) }).map(
(_, index) => (
<div
key={`empty-${index}`}
className="relative bg-[#282828] flex items-center justify-center"
>
<Music className="w-5 h-5 text-gray-600" />
</div>
)
)}
</div>
);
}
function PlaylistCard({
playlist,
index,
onPlay,
onToggleHide,
isHiddenView = false,
}: {
playlist: Playlist;
index: number;
onPlay: (playlistId: string) => void;
onToggleHide: (playlistId: string, hide: boolean) => void;
isHiddenView?: boolean;
}) {
const isShared = playlist.isOwner === false;
const [isHiding, setIsHiding] = useState(false);
const handleToggleHide = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsHiding(true);
try {
await onToggleHide(playlist.id, !playlist.isHidden);
} finally {
setIsHiding(false);
}
};
return (
<Link href={`/playlist/${playlist.id}`}>
<div
className={cn(
"group cursor-pointer p-3 rounded-md transition-colors hover:bg-white/5",
isHiddenView && "opacity-60 hover:opacity-100"
)}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
>
{/* Cover Image */}
<div className="relative aspect-square mb-3 rounded-md overflow-hidden bg-[#282828] shadow-lg">
<PlaylistMosaic
items={playlist.items}
greyed={isHiddenView}
/>
{/* Hide/Unhide button for shared playlists */}
{isShared && (
<button
onClick={handleToggleHide}
disabled={isHiding}
className={cn(
"absolute top-2 right-2 w-7 h-7 rounded-full flex items-center justify-center",
"bg-black/60 transition-all duration-200",
"opacity-0 group-hover:opacity-100",
playlist.isHidden
? "text-green-400"
: "text-gray-400",
isHiding && "opacity-50 cursor-not-allowed"
)}
title={
playlist.isHidden
? "Show playlist"
: "Hide playlist"
}
>
{playlist.isHidden ? (
<Eye className="w-3.5 h-3.5" />
) : (
<EyeOff className="w-3.5 h-3.5" />
)}
</button>
)}
{/* Play button overlay */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onPlay(playlist.id);
}}
style={{ backgroundColor: LIDIFY_YELLOW }}
className={cn(
"absolute bottom-2 right-2 w-10 h-10 rounded-full flex items-center justify-center",
"shadow-lg shadow-black/40 transition-all duration-200",
"hover:scale-105 hover:brightness-110",
"opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0"
)}
title="Play playlist"
>
<Play className="w-4 h-4 fill-current ml-0.5 text-black" />
</button>
</div>
{/* Title and info */}
<h3
className={cn(
"text-sm font-semibold truncate",
isHiddenView ? "text-gray-400" : "text-white"
)}
>
{playlist.name}
</h3>
<p className="text-xs text-gray-400 mt-0.5 truncate">
{isShared && playlist.user?.username ? (
<span className="text-gray-500">
By {playlist.user.username} ·{" "}
</span>
) : null}
{playlist.trackCount || 0}{" "}
{playlist.trackCount === 1 ? "song" : "songs"}
</p>
</div>
</Link>
);
}
export default function PlaylistsPage() {
const router = useRouter();
const { isAuthenticated } = useAuth();
const { playTracks } = useAudio();
const queryClient = useQueryClient();
const [showHiddenTab, setShowHiddenTab] = useState(false);
// Use React Query hook for playlists
const { data: playlists = [], isLoading } = usePlaylistsQuery();
// Separate visible and hidden playlists
const { visiblePlaylists, hiddenPlaylists } = useMemo(() => {
const visible: Playlist[] = [];
const hidden: Playlist[] = [];
playlists.forEach((p: Playlist) => {
if (p.isHidden) {
hidden.push(p);
} else {
visible.push(p);
}
});
return { visiblePlaylists: visible, hiddenPlaylists: hidden };
}, [playlists]);
// Listen for playlist events and invalidate cache
useEffect(() => {
const handlePlaylistEvent = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.playlists() });
};
window.addEventListener("playlist-created", handlePlaylistEvent);
window.addEventListener("playlist-updated", handlePlaylistEvent);
window.addEventListener("playlist-deleted", handlePlaylistEvent);
return () => {
window.removeEventListener("playlist-created", handlePlaylistEvent);
window.removeEventListener("playlist-updated", handlePlaylistEvent);
window.removeEventListener("playlist-deleted", handlePlaylistEvent);
};
}, [queryClient]);
const handlePlayPlaylist = async (playlistId: string) => {
try {
const playlist = await api.getPlaylist(playlistId);
if (playlist?.items && playlist.items.length > 0) {
const tracks = playlist.items.map((item: any) => ({
id: item.track.id,
title: item.track.title,
artist: {
name: item.track.album?.artist?.name || "Unknown",
id: item.track.album?.artist?.id,
},
album: {
title: item.track.album?.title || "Unknown",
coverArt: item.track.album?.coverArt,
id: item.track.album?.id,
},
duration: item.track.duration,
}));
playTracks(tracks, 0);
}
} catch (error) {
console.error("Failed to play playlist:", error);
}
};
const handleToggleHide = async (playlistId: string, hide: boolean) => {
try {
if (hide) {
await api.hidePlaylist(playlistId);
} else {
await api.unhidePlaylist(playlistId);
}
// Invalidate and refetch playlists
queryClient.invalidateQueries({ queryKey: queryKeys.playlists() });
} catch (error) {
console.error("Failed to toggle playlist visibility:", error);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<GradientSpinner size="md" />
</div>
);
}
const displayedPlaylists = showHiddenTab
? hiddenPlaylists
: visiblePlaylists;
return (
<div className="min-h-screen relative">
{/* Quick gradient fade - yellow to purple */}
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute inset-0 bg-gradient-to-b from-[#ecb200]/15 via-purple-900/10 to-transparent"
style={{ height: "35vh" }}
/>
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-[#ecb200]/8 via-transparent to-transparent"
style={{ height: "25vh" }}
/>
</div>
{/* Header */}
<div className="relative px-6 pt-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
Playlists
</h1>
<p className="text-sm text-gray-400 mt-0.5">
{visiblePlaylists.length}{" "}
{visiblePlaylists.length === 1
? "playlist"
: "playlists"}
</p>
</div>
<div className="flex items-center gap-2">
{/* Browse Public Playlists */}
<Link
href="/browse/playlists"
className="px-4 py-2 rounded-full text-sm font-medium bg-[#ecb200] text-black hover:brightness-110 transition-all"
>
Browse Playlists
</Link>
{/* Hidden Playlists Toggle */}
{hiddenPlaylists.length > 0 && (
<button
onClick={() => setShowHiddenTab(!showHiddenTab)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-all",
showHiddenTab
? "bg-white/10 text-white"
: "bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
)}
>
{showHiddenTab
? "Show All"
: `Hidden (${hiddenPlaylists.length})`}
</button>
)}
</div>
</div>
</div>
{/* Content */}
<div className="relative px-4 pb-24">
{/* Hidden playlists notice */}
{showHiddenTab && (
<div className="mx-2 mb-4 px-4 py-3 bg-white/5 rounded-lg">
<p className="text-sm text-gray-400">
Hidden playlists won't appear in your library. Hover
and click the eye icon to restore.
</p>
</div>
)}
{displayedPlaylists.length > 0 ? (
<div
data-tv-section="playlists"
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-2"
>
{displayedPlaylists.map(
(playlist: Playlist, index: number) => (
<PlaylistCard
key={playlist.id}
playlist={playlist}
index={index}
onPlay={handlePlayPlaylist}
onToggleHide={handleToggleHide}
isHiddenView={showHiddenTab}
/>
)
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
<Music className="w-8 h-8 text-gray-500" />
</div>
<h2 className="text-lg font-semibold text-white mb-1">
{showHiddenTab
? "No hidden playlists"
: "No playlists yet"}
</h2>
<p className="text-sm text-gray-400 max-w-sm">
{showHiddenTab
? "You haven't hidden any playlists"
: "Create your first playlist by adding songs from albums or artists"}
</p>
{!showHiddenTab && (
<Link
href="/browse/playlists"
className="mt-6 px-5 py-2.5 rounded-full text-sm font-medium bg-[#ecb200] text-black hover:brightness-110 transition-all"
>
Browse Playlists
</Link>
)}
</div>
)}
</div>
</div>
);
}