Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import React, { memo, useCallback } from "react";
import { Album } from "../types";
import { PlayableCard } from "@/components/ui/PlayableCard";
import { EmptyState } from "@/components/ui/EmptyState";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
import { Disc3, Trash2 } from "lucide-react";
import { api } from "@/lib/api";
interface AlbumsGridProps {
albums: Album[];
onPlay: (albumId: string) => Promise<void>;
onDelete: (albumId: string, albumTitle: string) => void;
isLoading?: boolean;
}
interface AlbumCardItemProps {
album: Album;
index: number;
onPlay: (albumId: string) => Promise<void>;
onDelete: (albumId: string, albumTitle: string) => void;
}
const AlbumCardItem = memo(
function AlbumCardItem({
album,
index,
onPlay,
onDelete,
}: AlbumCardItemProps) {
const handlePlay = useCallback(
() => onPlay(album.id),
[album.id, onPlay]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDelete(album.id, album.title);
},
[album.id, album.title, onDelete]
);
return (
<div className="relative group">
<PlayableCard
href={`/album/${album.id}`}
coverArt={
album.coverArt
? api.getCoverArtUrl(album.coverArt, 300)
: null
}
title={album.title}
subtitle={album.artist?.name}
placeholderIcon={
<Disc3 className="w-10 h-10 text-gray-600" />
}
circular={false}
onPlay={handlePlay}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
/>
{/* Delete button - only visible on hover */}
<button
onClick={handleDelete}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
title="Delete album"
>
<Trash2 className="w-3.5 h-3.5 text-white" />
</button>
</div>
);
},
(prevProps, nextProps) => {
return prevProps.album.id === nextProps.album.id;
}
);
const AlbumsGrid = memo(function AlbumsGrid({
albums,
onPlay,
onDelete,
isLoading = false,
}: AlbumsGridProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<GradientSpinner size="md" />
</div>
);
}
if (albums.length === 0) {
return (
<EmptyState
icon={<Disc3 className="w-12 h-12" />}
title="No albums yet"
description="Your library is empty. Sync your music to get started."
/>
);
}
return (
<div
data-tv-section="library-albums"
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-4"
>
{albums.map((album, index) => (
<AlbumCardItem
key={album.id}
album={album}
index={index}
onPlay={onPlay}
onDelete={onDelete}
/>
))}
</div>
);
});
export { AlbumsGrid };

View File

@@ -0,0 +1,122 @@
import React, { memo, useCallback } from "react";
import { Music, Trash2 } from "lucide-react";
import { Artist } from "../types";
import { PlayableCard } from "@/components/ui/PlayableCard";
import { EmptyState } from "@/components/ui/EmptyState";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
import { api } from "@/lib/api";
interface ArtistsGridProps {
artists: Artist[];
onPlay: (artistId: string) => Promise<void>;
onDelete: (artistId: string, artistName: string) => void;
isLoading?: boolean;
}
const getArtistImageSrc = (coverArt?: string): string | null => {
if (!coverArt) return null;
return api.getCoverArtUrl(coverArt, 300);
};
interface ArtistCardItemProps {
artist: Artist;
index: number;
onPlay: (artistId: string) => Promise<void>;
onDelete: (artistId: string, artistName: string) => void;
}
const ArtistCardItem = memo(
function ArtistCardItem({
artist,
index,
onPlay,
onDelete,
}: ArtistCardItemProps) {
const handlePlay = useCallback(
() => onPlay(artist.id),
[artist.id, onPlay]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDelete(artist.id, artist.name);
},
[artist.id, artist.name, onDelete]
);
return (
<div className="relative group">
<PlayableCard
href={`/artist/${artist.mbid || artist.id}`}
coverArt={getArtistImageSrc(artist.coverArt)}
title={artist.name}
subtitle={`${artist.albumCount || 0} albums`}
placeholderIcon={
<Music className="w-10 h-10 text-gray-600" />
}
circular={true}
onPlay={handlePlay}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
/>
{/* Delete button - only visible on hover */}
<button
onClick={handleDelete}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
title="Delete artist"
>
<Trash2 className="w-3.5 h-3.5 text-white" />
</button>
</div>
);
},
(prevProps, nextProps) => {
return prevProps.artist.id === nextProps.artist.id;
}
);
const ArtistsGrid = memo(function ArtistsGrid({
artists,
onPlay,
onDelete,
isLoading = false,
}: ArtistsGridProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<GradientSpinner size="md" />
</div>
);
}
if (artists.length === 0) {
return (
<EmptyState
icon={<Music className="w-12 h-12" />}
title="No artists yet"
description="Your library is empty. Sync your music to get started."
/>
);
}
return (
<div
data-tv-section="library-artists"
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-4"
>
{artists.map((artist, index) => (
<ArtistCardItem
key={artist.id}
artist={artist}
index={index}
onPlay={onPlay}
onDelete={onDelete}
/>
))}
</div>
);
});
export { ArtistsGrid };

View File

@@ -0,0 +1,24 @@
export function LibraryHeader() {
return (
<div className="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>
{/* Compact header */}
<div className="relative px-4 md:px-8 pt-6 pb-2">
<h1 className="text-2xl font-bold text-white">
Your Library
</h1>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Tab } from "../types";
import { cn } from "@/utils/cn";
interface LibraryTabsProps {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
}
export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
return (
<div data-tv-section="library-tabs" className="flex gap-2 mb-4">
<button
data-tv-card
data-tv-card-index={0}
tabIndex={0}
onClick={() => onTabChange("artists")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
activeTab === "artists"
? "bg-white text-black"
: "bg-white/10 text-white hover:bg-white/15"
)}
>
Artists
</button>
<button
data-tv-card
data-tv-card-index={1}
tabIndex={0}
onClick={() => onTabChange("albums")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
activeTab === "albums"
? "bg-white text-black"
: "bg-white/10 text-white hover:bg-white/15"
)}
>
Albums
</button>
<button
data-tv-card
data-tv-card-index={2}
tabIndex={0}
onClick={() => onTabChange("tracks")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
activeTab === "tracks"
? "bg-white text-black"
: "bg-white/10 text-white hover:bg-white/15"
)}
>
Songs
</button>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState, memo, useCallback } from "react";
import Image from "next/image";
import { Track } from "../types";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { PlaylistSelector } from "@/components/ui/PlaylistSelector";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
import { AudioLines, ListPlus, Plus, Trash2, Play } from "lucide-react";
import { cn } from "@/utils/cn";
import { api } from "@/lib/api";
interface TracksListProps {
tracks: Track[];
onPlay: (tracks: Track[], startIndex?: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (playlistId: string, trackId: string) => void;
onDelete: (trackId: string, trackTitle: string) => void;
currentTrackId?: string;
isLoading?: boolean;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
interface TrackRowProps {
track: Track;
index: number;
isCurrentlyPlaying: boolean;
onPlayTrack: () => void;
onAddToQueue: (track: Track) => void;
onShowAddToPlaylist: (trackId: string) => void;
onDelete: (trackId: string, trackTitle: string) => void;
}
const TrackRow = memo(function TrackRow({
track,
index,
isCurrentlyPlaying,
onPlayTrack,
onAddToQueue,
onShowAddToPlaylist,
onDelete,
}: TrackRowProps) {
return (
<div
key={track.id}
onClick={onPlayTrack}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className={cn(
"grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_1fr_1fr_auto] items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 transition-colors group cursor-pointer",
isCurrentlyPlaying && "bg-white/5"
)}
>
{/* Track number / Play icon */}
<div className="w-8 flex items-center justify-center">
<span className={cn(
"text-sm group-hover:hidden",
isCurrentlyPlaying ? "text-[#ecb200]" : "text-gray-500"
)}>
{isCurrentlyPlaying ? (
<AudioLines className="w-4 h-4 text-[#ecb200]" />
) : (
index + 1
)}
</span>
<Play className="w-4 h-4 text-white hidden group-hover:block fill-current" />
</div>
{/* Cover + Title/Artist */}
<div className="flex items-center gap-3 min-w-0">
<div className="relative w-10 h-10 bg-[#282828] rounded flex items-center justify-center overflow-hidden shrink-0">
{track.album?.coverArt ? (
<Image
src={api.getCoverArtUrl(track.album.coverArt, 80)}
alt={track.title}
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : (
<AudioLines className="w-4 h-4 text-gray-600" />
)}
</div>
<div className="min-w-0">
<h3 className={cn(
"text-sm font-medium truncate",
isCurrentlyPlaying ? "text-[#ecb200]" : "text-white"
)}>
{track.title}
</h3>
<p className="text-xs text-gray-400 truncate">
{track.album?.artist?.name}
</p>
</div>
</div>
{/* Album - hidden on mobile */}
<div className="hidden md:block min-w-0">
<p className="text-sm text-gray-400 truncate">
{track.album?.title}
</p>
</div>
{/* Actions + Duration */}
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onAddToQueue(track);
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
title="Add to Queue"
>
<ListPlus className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onShowAddToPlaylist(track.id);
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
title="Add to Playlist"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(track.id, track.title);
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
title="Delete Track"
>
<Trash2 className="w-4 h-4" />
</button>
<span className="text-xs text-gray-500 w-10 text-right">
{formatDuration(track.duration)}
</span>
</div>
</div>
);
}, (prevProps, nextProps) => {
return (
prevProps.track.id === nextProps.track.id &&
prevProps.isCurrentlyPlaying === nextProps.isCurrentlyPlaying &&
prevProps.index === nextProps.index
);
});
export function TracksList({
tracks,
onPlay,
onAddToQueue,
onAddToPlaylist,
onDelete,
currentTrackId,
isLoading = false,
}: TracksListProps) {
const [showPlaylistSelector, setShowPlaylistSelector] = useState(false);
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
const handleShowAddToPlaylist = useCallback((trackId: string) => {
setSelectedTrackId(trackId);
setShowPlaylistSelector(true);
}, []);
const handleAddToPlaylist = useCallback(async (playlistId: string) => {
if (!selectedTrackId) return;
onAddToPlaylist(playlistId, selectedTrackId);
setShowPlaylistSelector(false);
setSelectedTrackId(null);
}, [selectedTrackId, onAddToPlaylist]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<GradientSpinner size="md" />
</div>
);
}
if (tracks.length === 0) {
return (
<EmptyState
icon={<AudioLines className="w-12 h-12" />}
title="No songs yet"
description="Your library is empty. Sync your music to get started."
/>
);
}
return (
<>
{/* Header row */}
<div className="grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_1fr_1fr_auto] items-center gap-3 px-3 py-2 border-b border-white/10 text-xs text-gray-500 uppercase tracking-wider">
<div className="w-8 text-center">#</div>
<div>Title</div>
<div className="hidden md:block">Album</div>
<div className="w-[140px] text-right pr-2">Duration</div>
</div>
<div data-tv-section="library-tracks" className="space-y-0.5">
{tracks.map((track, index) => {
const isCurrentlyPlaying = currentTrackId === track.id;
return (
<TrackRow
key={track.id}
track={track}
index={index}
isCurrentlyPlaying={isCurrentlyPlaying}
onPlayTrack={() => onPlay(tracks, index)}
onAddToQueue={onAddToQueue}
onShowAddToPlaylist={handleShowAddToPlaylist}
onDelete={onDelete}
/>
);
})}
</div>
<PlaylistSelector
isOpen={showPlaylistSelector}
onClose={() => {
setShowPlaylistSelector(false);
setSelectedTrackId(null);
}}
onSelectPlaylist={handleAddToPlaylist}
/>
</>
);
}