Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { memo } from "react";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
mbid?: string;
|
||||
name: string;
|
||||
coverArt?: string;
|
||||
albumCount?: number;
|
||||
}
|
||||
|
||||
interface ArtistsGridProps {
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
// Helper to get the correct image source
|
||||
const getArtistImageSrc = (coverArt: string | undefined) => {
|
||||
if (!coverArt) {
|
||||
return null;
|
||||
}
|
||||
return api.getCoverArtUrl(coverArt, 300);
|
||||
};
|
||||
|
||||
interface ArtistCardProps {
|
||||
artist: Artist;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const ArtistCard = memo(
|
||||
function ArtistCard({ artist, index }: ArtistCardProps) {
|
||||
const imageSrc = getArtistImageSrc(artist.coverArt);
|
||||
|
||||
return (
|
||||
<CarouselItem>
|
||||
<Link
|
||||
href={`/artist/${artist.mbid || artist.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors">
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 flex items-center justify-center overflow-hidden relative shadow-lg">
|
||||
{artist.coverArt && imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={artist.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="180px"
|
||||
priority={false}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{artist.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.artist.id === nextProps.artist.id && prevProps.index === nextProps.index;
|
||||
}
|
||||
);
|
||||
|
||||
const ArtistsGrid = memo(function ArtistsGrid({ artists }: ArtistsGridProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{artists.map((artist, index) => (
|
||||
<ArtistCard key={artist.id} artist={artist} index={index} />
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
});
|
||||
|
||||
export { ArtistsGrid, getArtistImageSrc };
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Audiobook } from "../types";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
import { memo } from "react";
|
||||
|
||||
interface AudiobooksGridProps {
|
||||
audiobooks: Audiobook[];
|
||||
}
|
||||
|
||||
interface AudiobookCardProps {
|
||||
audiobook: Audiobook;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const AudiobookCard = memo(function AudiobookCard({
|
||||
audiobook,
|
||||
index
|
||||
}: AudiobookCardProps) {
|
||||
return (
|
||||
<CarouselItem>
|
||||
<Link
|
||||
href={`/audiobooks/${audiobook.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors">
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 flex items-center justify-center overflow-hidden relative shadow-lg">
|
||||
{audiobook.coverUrl ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(audiobook.coverUrl, 300)}
|
||||
alt={audiobook.title}
|
||||
fill
|
||||
sizes="180px"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<BookOpen className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{audiobook.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{audiobook.author || "Audiobook"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
});
|
||||
|
||||
export function AudiobooksGrid({ audiobooks }: AudiobooksGridProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{audiobooks.map((audiobook, index) => (
|
||||
<AudiobookCard
|
||||
key={audiobook.id}
|
||||
audiobook={audiobook}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music, Disc, BookOpen } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ContinueListeningItem {
|
||||
id: string;
|
||||
mbid?: string;
|
||||
name: string;
|
||||
type: "artist" | "podcast" | "audiobook";
|
||||
coverArt?: string;
|
||||
progress?: number;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
interface ContinueListeningProps {
|
||||
items: ContinueListeningItem[];
|
||||
}
|
||||
|
||||
// Helper to get the correct image source
|
||||
const getArtistImageSrc = (coverArt: string | undefined) => {
|
||||
if (!coverArt) {
|
||||
return null;
|
||||
}
|
||||
return api.getCoverArtUrl(coverArt, 300);
|
||||
};
|
||||
|
||||
const getImageForItem = (item: ContinueListeningItem) => {
|
||||
if (item.type === "audiobook") {
|
||||
return api.getCoverArtUrl(`/audiobooks/${item.id}/cover`, 300);
|
||||
}
|
||||
|
||||
if (item.coverArt) {
|
||||
return getArtistImageSrc(item.coverArt);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getDescriptionLabel = (item: ContinueListeningItem) => {
|
||||
if (item.type === "podcast") {
|
||||
if (
|
||||
item.author &&
|
||||
item.author.trim().length > 0 &&
|
||||
item.author.trim().toLowerCase() !== item.name.trim().toLowerCase()
|
||||
) {
|
||||
return item.author;
|
||||
}
|
||||
return "Podcast";
|
||||
}
|
||||
|
||||
if (item.type === "audiobook") {
|
||||
return item.author && item.author.trim().length > 0
|
||||
? item.author
|
||||
: "Audiobook";
|
||||
}
|
||||
|
||||
return "Artist";
|
||||
};
|
||||
|
||||
interface ContinueListeningCardProps {
|
||||
item: ContinueListeningItem;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const ContinueListeningCard = memo(function ContinueListeningCard({
|
||||
item,
|
||||
index
|
||||
}: ContinueListeningCardProps) {
|
||||
const isPodcast = item.type === "podcast";
|
||||
const isAudiobook = item.type === "audiobook";
|
||||
const imageSrc = getImageForItem(item);
|
||||
const href = isPodcast
|
||||
? `/podcasts/${item.id}`
|
||||
: isAudiobook
|
||||
? `/audiobooks/${item.id}`
|
||||
: `/artist/${item.mbid || item.id}`;
|
||||
const hasProgress =
|
||||
(isPodcast || isAudiobook) &&
|
||||
item.progress &&
|
||||
item.progress > 0;
|
||||
|
||||
return (
|
||||
<CarouselItem>
|
||||
<Link
|
||||
href={href}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors h-full flex flex-col">
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 flex items-center justify-center overflow-hidden relative shadow-lg shrink-0">
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="180px"
|
||||
priority={false}
|
||||
unoptimized
|
||||
/>
|
||||
) : isPodcast ? (
|
||||
<Disc className="w-10 h-10 text-gray-600" />
|
||||
) : isAudiobook ? (
|
||||
<BookOpen className="w-10 h-10 text-gray-600" />
|
||||
) : (
|
||||
<Music className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
{hasProgress && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
|
||||
<div
|
||||
className="h-full bg-[#ecb200]"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{getDescriptionLabel(item)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
});
|
||||
|
||||
export function ContinueListening({ items }: ContinueListeningProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{items.map((item, index) => (
|
||||
<ContinueListeningCard
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { Music2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
import { memo } from "react";
|
||||
|
||||
interface PlaylistPreview {
|
||||
id: string;
|
||||
source: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
creator: string;
|
||||
imageUrl: string | null;
|
||||
trackCount: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface FeaturedPlaylistsGridProps {
|
||||
playlists: PlaylistPreview[];
|
||||
}
|
||||
|
||||
// Deezer icon
|
||||
const DeezerIcon = ({ className }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
|
||||
<path d="M18.81 4.16v3.03H24V4.16h-5.19zM6.27 8.38v3.027h5.189V8.38h-5.19zm12.54 0v3.027H24V8.38h-5.19zM6.27 12.595v3.027h5.189v-3.027h-5.19zm6.27 0v3.027h5.19v-3.027h-5.19zm6.27 0v3.027H24v-3.027h-5.19zM0 16.81v3.029h5.19v-3.03H0zm6.27 0v3.029h5.189v-3.03h-5.19zm6.27 0v3.029h5.19v-3.03h-5.19zm6.27 0v3.029H24v-3.03h-5.19z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: PlaylistPreview;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const PlaylistCard = memo(function PlaylistCard({
|
||||
playlist,
|
||||
onClick
|
||||
}: PlaylistCardProps) {
|
||||
return (
|
||||
<CarouselItem>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="relative aspect-square mb-3 rounded-md overflow-hidden bg-[#282828] shadow-lg">
|
||||
{playlist.imageUrl ? (
|
||||
<img
|
||||
src={playlist.imageUrl}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-[#AD47FF]/30 to-[#AD47FF]/10">
|
||||
<Music2 className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
{/* Play button on hover */}
|
||||
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 transition-all duration-200">
|
||||
<div className="w-10 h-10 rounded-full bg-[#ecb200] flex items-center justify-center shadow-xl hover:scale-105 transition-transform">
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 text-black ml-0.5" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/* Deezer badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<DeezerIcon className="w-4 h-4 text-[#AD47FF] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{playlist.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{playlist.trackCount} songs
|
||||
</p>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
});
|
||||
|
||||
export function FeaturedPlaylistsGrid({ playlists }: FeaturedPlaylistsGridProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handlePlaylistClick = (playlist: PlaylistPreview) => {
|
||||
router.push(`/browse/playlists/${playlist.id}`);
|
||||
};
|
||||
|
||||
if (!playlists || playlists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{playlists.slice(0, 20).map((playlist, index) => (
|
||||
<PlaylistCard
|
||||
key={`home-playlist-${playlist.id}-${index}`}
|
||||
playlist={playlist}
|
||||
onClick={() => handlePlaylistClick(playlist)}
|
||||
/>
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
};
|
||||
|
||||
export function HomeHero() {
|
||||
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>
|
||||
|
||||
{/* Hero Section - Compact */}
|
||||
<div className="relative">
|
||||
<div className="relative max-w-[1800px] mx-auto px-4 pt-6 pb-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||
{getGreeting()}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import {
|
||||
Radio,
|
||||
Play,
|
||||
Loader2,
|
||||
Shuffle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAudioControls } from "@/lib/audio-controls-context";
|
||||
import { Track } from "@/lib/audio-state-context";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
|
||||
|
||||
interface RadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
filter: {
|
||||
type:
|
||||
| "genre"
|
||||
| "decade"
|
||||
| "discovery"
|
||||
| "favorites"
|
||||
| "all"
|
||||
| "workout";
|
||||
value?: string;
|
||||
};
|
||||
minTracks?: number;
|
||||
}
|
||||
|
||||
interface GenreCount {
|
||||
genre: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Static radio stations (always shown if tracks exist)
|
||||
const STATIC_STATIONS: RadioStation[] = [
|
||||
{
|
||||
id: "all",
|
||||
name: "Shuffle All",
|
||||
description: "Your entire library",
|
||||
color: "from-[#ecb200]/60 to-amber-600/40",
|
||||
filter: { type: "all" },
|
||||
minTracks: 10,
|
||||
},
|
||||
{
|
||||
id: "workout",
|
||||
name: "Workout",
|
||||
description: "High energy tracks",
|
||||
color: "from-red-500/50 to-orange-600/40",
|
||||
filter: { type: "workout" },
|
||||
minTracks: 15,
|
||||
},
|
||||
{
|
||||
id: "discovery",
|
||||
name: "Discovery",
|
||||
description: "Lesser-played gems",
|
||||
color: "from-emerald-500/50 to-teal-600/40",
|
||||
filter: { type: "discovery" },
|
||||
minTracks: 20,
|
||||
},
|
||||
{
|
||||
id: "favorites",
|
||||
name: "Favorites",
|
||||
description: "Most played",
|
||||
color: "from-rose-500/50 to-pink-600/40",
|
||||
filter: { type: "favorites" },
|
||||
minTracks: 10,
|
||||
},
|
||||
];
|
||||
|
||||
interface DecadeCount {
|
||||
decade: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Decade color mapping
|
||||
const DECADE_COLORS: Record<number, string> = {
|
||||
1700: "from-amber-800/50 to-yellow-900/40",
|
||||
1800: "from-slate-500/50 to-gray-600/40",
|
||||
1900: "from-amber-400/50 to-yellow-500/40",
|
||||
1920: "from-yellow-500/50 to-amber-600/40",
|
||||
1940: "from-red-400/50 to-orange-500/40",
|
||||
1950: "from-pink-400/50 to-red-500/40",
|
||||
1960: "from-amber-500/50 to-orange-600/40",
|
||||
1970: "from-orange-500/50 to-red-600/40",
|
||||
1980: "from-fuchsia-500/50 to-purple-600/40",
|
||||
1990: "from-purple-500/50 to-violet-600/40",
|
||||
2000: "from-blue-500/50 to-cyan-600/40",
|
||||
2010: "from-teal-500/50 to-emerald-600/40",
|
||||
2020: "from-orange-500/50 to-amber-600/40",
|
||||
};
|
||||
|
||||
const getDecadeColor = (decade: number): string => {
|
||||
const knownDecades = Object.keys(DECADE_COLORS)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
for (const known of knownDecades) {
|
||||
if (decade >= known) {
|
||||
return DECADE_COLORS[known];
|
||||
}
|
||||
}
|
||||
return "from-gray-500/50 to-slate-600/40";
|
||||
};
|
||||
|
||||
const getDecadeName = (decade: number): string => {
|
||||
if (decade < 1900) return `${decade}s`;
|
||||
if (decade < 2000) return `${decade.toString().slice(2)}s`;
|
||||
return `${decade}s`;
|
||||
};
|
||||
|
||||
// Genre color mapping
|
||||
const GENRE_COLORS: Record<string, string> = {
|
||||
rock: "from-red-500/50 to-orange-600/40",
|
||||
pop: "from-pink-500/50 to-rose-600/40",
|
||||
"hip hop": "from-purple-500/50 to-indigo-600/40",
|
||||
"hip-hop": "from-purple-500/50 to-indigo-600/40",
|
||||
rap: "from-purple-500/50 to-indigo-600/40",
|
||||
electronic: "from-cyan-500/50 to-blue-600/40",
|
||||
jazz: "from-amber-500/50 to-yellow-600/40",
|
||||
classical: "from-slate-400/50 to-gray-500/40",
|
||||
metal: "from-zinc-600/50 to-neutral-700/40",
|
||||
country: "from-orange-400/50 to-amber-500/40",
|
||||
folk: "from-green-500/50 to-emerald-600/40",
|
||||
indie: "from-violet-500/50 to-purple-600/40",
|
||||
alternative: "from-indigo-500/50 to-blue-600/40",
|
||||
"r&b": "from-fuchsia-500/50 to-pink-600/40",
|
||||
soul: "from-amber-600/50 to-orange-700/40",
|
||||
blues: "from-blue-600/50 to-indigo-700/40",
|
||||
punk: "from-lime-500/50 to-green-600/40",
|
||||
reggae: "from-green-400/50 to-yellow-500/40",
|
||||
default: "from-gray-500/50 to-slate-600/40",
|
||||
};
|
||||
|
||||
const getGenreColor = (genre: string): string => {
|
||||
const lower = genre.toLowerCase();
|
||||
return GENRE_COLORS[lower] || GENRE_COLORS.default;
|
||||
};
|
||||
|
||||
export function LibraryRadioStations() {
|
||||
const { playTracks } = useAudioControls();
|
||||
const [loadingStation, setLoadingStation] = useState<string | null>(null);
|
||||
const [genres, setGenres] = useState<GenreCount[]>([]);
|
||||
const [decades, setDecades] = useState<DecadeCount[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [genresRes, decadesRes] = await Promise.all([
|
||||
api.get<{ genres: GenreCount[] }>("/library/genres"),
|
||||
api.get<{ decades: DecadeCount[] }>("/library/decades"),
|
||||
]);
|
||||
|
||||
const validGenres = (genresRes.genres || [])
|
||||
.filter((g) => g.count >= 15)
|
||||
.slice(0, 6);
|
||||
setGenres(validGenres);
|
||||
setDecades((decadesRes.decades || []).slice(0, 4));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch radio data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const startRadio = async (station: RadioStation) => {
|
||||
setLoadingStation(station.id);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", station.filter.type);
|
||||
if (station.filter.value) {
|
||||
params.set("value", station.filter.value);
|
||||
}
|
||||
params.set("limit", "100");
|
||||
|
||||
const response = await api.get<{ tracks: Track[] }>(
|
||||
`/library/radio?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.tracks || response.tracks.length === 0) {
|
||||
toast.error(`No tracks found for ${station.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.tracks.length < (station.minTracks || 10)) {
|
||||
toast.error(`Not enough tracks for ${station.name} radio`, {
|
||||
description: `Found ${
|
||||
response.tracks.length
|
||||
}, need at least ${station.minTracks || 10}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shuffled = [...response.tracks].sort(
|
||||
() => Math.random() - 0.5
|
||||
);
|
||||
playTracks(shuffled, 0);
|
||||
toast.success(`${station.name} Radio`, {
|
||||
description: `Shuffling ${shuffled.length} tracks`,
|
||||
icon: <Shuffle className="w-4 h-4" />,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start radio:", error);
|
||||
toast.error("Failed to start radio station");
|
||||
} finally {
|
||||
setLoadingStation(null);
|
||||
}
|
||||
};
|
||||
|
||||
const allStations = useMemo(() => {
|
||||
const genreStations: RadioStation[] = genres.map((g) => ({
|
||||
id: `genre-${g.genre}`,
|
||||
name: g.genre,
|
||||
description: `${g.count} tracks`,
|
||||
color: getGenreColor(g.genre),
|
||||
filter: { type: "genre" as const, value: g.genre },
|
||||
minTracks: 15,
|
||||
}));
|
||||
|
||||
const decadeStations: RadioStation[] = decades.map((d) => ({
|
||||
id: `decade-${d.decade}`,
|
||||
name: getDecadeName(d.decade),
|
||||
description: `${d.count} tracks`,
|
||||
color: getDecadeColor(d.decade),
|
||||
filter: { type: "decade" as const, value: d.decade.toString() },
|
||||
minTracks: 15,
|
||||
}));
|
||||
|
||||
return [...STATIC_STATIONS, ...genreStations, ...decadeStations];
|
||||
}, [genres, decades]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
const isTablet = useIsTablet();
|
||||
const isMobileOrTablet = isMobile || isTablet;
|
||||
|
||||
// Group stations into pages of 6 (2x3 grid) for mobile only
|
||||
const stationPages = useMemo(() => {
|
||||
const pages: RadioStation[][] = [];
|
||||
for (let i = 0; i < allStations.length; i += 6) {
|
||||
pages.push(allStations.slice(i, i + 6));
|
||||
}
|
||||
return pages;
|
||||
}, [allStations]);
|
||||
|
||||
const checkScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
|
||||
|
||||
// Update current page for mobile
|
||||
if (isMobileOrTablet) {
|
||||
const pageWidth = el.clientWidth;
|
||||
const newPage = Math.round(el.scrollLeft / pageWidth);
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.addEventListener("scroll", checkScroll);
|
||||
window.addEventListener("resize", checkScroll);
|
||||
}
|
||||
return () => {
|
||||
if (el) el.removeEventListener("scroll", checkScroll);
|
||||
window.removeEventListener("resize", checkScroll);
|
||||
};
|
||||
}, [stationPages, isMobileOrTablet]);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = isMobileOrTablet
|
||||
? el.clientWidth
|
||||
: el.clientWidth * 0.8;
|
||||
el.scrollBy({
|
||||
left: direction === "left" ? -scrollAmount : scrollAmount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
// Desktop: Compact horizontal card
|
||||
const renderDesktopCard = (station: RadioStation) => (
|
||||
<button
|
||||
key={station.id}
|
||||
onClick={() => startRadio(station)}
|
||||
disabled={loadingStation !== null}
|
||||
className={cn(
|
||||
"flex-shrink-0 snap-start",
|
||||
"w-[160px] h-[72px] rounded-lg overflow-hidden",
|
||||
`bg-gradient-to-br ${station.color}`,
|
||||
"border border-white/10 hover:border-white/20",
|
||||
"transition-all duration-200",
|
||||
"hover:scale-[1.02] active:scale-[0.98]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"group relative"
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 p-3 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="w-3 h-3 text-white/70" />
|
||||
<span className="text-[8px] text-white/70 font-semibold uppercase tracking-wider">
|
||||
Radio
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white truncate leading-tight">
|
||||
{station.name}
|
||||
</h3>
|
||||
<p className="text-[10px] text-white/60 truncate">
|
||||
{station.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingStation === station.id && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play button on hover */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-[#f5c518] flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition-transform">
|
||||
<Play
|
||||
className="w-4 h-4 text-black ml-0.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Mobile: Card for 2x3 grid
|
||||
const renderMobileCard = (station: RadioStation) => (
|
||||
<button
|
||||
key={station.id}
|
||||
onClick={() => startRadio(station)}
|
||||
disabled={loadingStation !== null}
|
||||
className={cn(
|
||||
"relative group w-full",
|
||||
"aspect-[5/3] rounded-lg overflow-hidden",
|
||||
`bg-gradient-to-br ${station.color}`,
|
||||
"border border-white/10",
|
||||
"transition-all duration-200",
|
||||
"active:scale-[0.98]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 p-2 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="w-2.5 h-2.5 text-white/70" />
|
||||
<span className="text-[7px] text-white/70 font-semibold uppercase tracking-wider">
|
||||
Radio
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[11px] font-bold text-white truncate leading-tight">
|
||||
{station.name}
|
||||
</h3>
|
||||
<p className="text-[9px] text-white/60 truncate">
|
||||
{station.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingStation === station.id && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Desktop layout: single row horizontal carousel
|
||||
if (!isMobileOrTablet) {
|
||||
return (
|
||||
<div className="relative group/carousel">
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll("left")}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg -translate-x-1/2"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory gap-3 px-1"
|
||||
>
|
||||
{allStations.map((station) => renderDesktopCard(station))}
|
||||
|
||||
{isLoading &&
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 w-[160px] h-[72px] rounded-lg bg-white/5 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll("right")}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg translate-x-1/2"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile/Tablet layout: 2x3 grid pages
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory gap-3"
|
||||
>
|
||||
{stationPages.map((page, pageIndex) => (
|
||||
<div
|
||||
key={pageIndex}
|
||||
className="flex-shrink-0 snap-start w-full grid grid-cols-3 grid-rows-2 gap-2"
|
||||
>
|
||||
{page.map((station) => renderMobileCard(station))}
|
||||
{page.length < 6 &&
|
||||
Array.from({ length: 6 - page.length }).map(
|
||||
(_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="aspect-[5/3]"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex-shrink-0 snap-start w-full grid grid-cols-3 grid-rows-2 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[5/3] rounded-lg bg-white/5 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stationPages.length > 1 && (
|
||||
<div className="flex justify-center gap-1.5 mt-3">
|
||||
{stationPages.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({
|
||||
left: index * el.clientWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||
index === currentPage
|
||||
? "bg-white"
|
||||
: "bg-white/30 hover:bg-white/50"
|
||||
)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { MixCard } from "@/components/MixCard";
|
||||
import { Mix } from "../types";
|
||||
import { memo } from "react";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
|
||||
interface MixesGridProps {
|
||||
mixes: Mix[];
|
||||
}
|
||||
|
||||
const MixesGrid = memo(function MixesGrid({ mixes }: MixesGridProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{mixes.map((mix, index) => (
|
||||
<CarouselItem key={mix.id}>
|
||||
<MixCard mix={mix} index={index} />
|
||||
</CarouselItem>
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
});
|
||||
|
||||
export { MixesGrid };
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Disc } from "lucide-react";
|
||||
import { Podcast } from "../types";
|
||||
import { memo } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
|
||||
interface PodcastsGridProps {
|
||||
podcasts: Podcast[];
|
||||
}
|
||||
|
||||
interface PodcastCardProps {
|
||||
podcast: Podcast;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Always proxy images through the backend for caching and mobile compatibility
|
||||
const getProxiedImageUrl = (podcast: Podcast): string | null => {
|
||||
const imageUrl = podcast.coverUrl || podcast.coverArt || podcast.imageUrl;
|
||||
if (!imageUrl) return null;
|
||||
return api.getCoverArtUrl(imageUrl, 300);
|
||||
};
|
||||
|
||||
const PodcastCard = memo(
|
||||
function PodcastCard({ podcast, index }: PodcastCardProps) {
|
||||
const imageUrl = getProxiedImageUrl(podcast);
|
||||
|
||||
return (
|
||||
<CarouselItem>
|
||||
<Link
|
||||
href={`/podcasts/${podcast.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors">
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 flex items-center justify-center overflow-hidden relative shadow-lg">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={podcast.title}
|
||||
fill
|
||||
sizes="180px"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Disc className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{podcast.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{podcast.author || "Podcast"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.podcast.id === nextProps.podcast.id && prevProps.index === nextProps.index;
|
||||
}
|
||||
);
|
||||
|
||||
const PodcastsGrid = memo(function PodcastsGrid({
|
||||
podcasts,
|
||||
}: PodcastsGridProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{podcasts.map((podcast, index) => (
|
||||
<PodcastCard key={podcast.id} podcast={podcast} index={index} />
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
});
|
||||
|
||||
export { PodcastsGrid };
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { HorizontalCarousel, CarouselItem } from "@/components/ui/HorizontalCarousel";
|
||||
import { memo } from "react";
|
||||
|
||||
interface PopularArtist {
|
||||
id?: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
listeners?: number;
|
||||
}
|
||||
|
||||
interface PopularArtistsGridProps {
|
||||
artists: PopularArtist[];
|
||||
}
|
||||
|
||||
// Always proxy images through the backend for caching and mobile compatibility
|
||||
const getProxiedImageUrl = (imageUrl: string | undefined): string | null => {
|
||||
if (!imageUrl) return null;
|
||||
return api.getCoverArtUrl(imageUrl, 300);
|
||||
};
|
||||
|
||||
interface PopularArtistCardProps {
|
||||
artist: PopularArtist;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const PopularArtistCard = memo(function PopularArtistCard({
|
||||
artist,
|
||||
index
|
||||
}: PopularArtistCardProps) {
|
||||
const imageUrl = getProxiedImageUrl(artist.image);
|
||||
|
||||
return (
|
||||
<CarouselItem>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(artist.name)}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-3 rounded-md group cursor-pointer hover:bg-white/5 transition-colors">
|
||||
<div className="aspect-square bg-[#282828] rounded-full mb-3 flex items-center justify-center overflow-hidden relative shadow-lg">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={artist.name}
|
||||
fill
|
||||
sizes="180px"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white truncate">
|
||||
{artist.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{artist.listeners?.toLocaleString()} listeners
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
);
|
||||
});
|
||||
|
||||
export function PopularArtistsGrid({ artists }: PopularArtistsGridProps) {
|
||||
return (
|
||||
<HorizontalCarousel>
|
||||
{artists.map((artist, index) => (
|
||||
<PopularArtistCard
|
||||
key={artist.id || artist.name}
|
||||
artist={artist}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</HorizontalCarousel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { memo } from "react";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
showAllHref?: string;
|
||||
rightAction?: React.ReactNode;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const SectionHeader = memo(function SectionHeader({
|
||||
title,
|
||||
showAllHref,
|
||||
rightAction,
|
||||
badge,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||
{badge && <Badge variant="ai">{badge}</Badge>}
|
||||
</div>
|
||||
{rightAction ? (
|
||||
rightAction
|
||||
) : showAllHref ? (
|
||||
<Link
|
||||
href={showAllHref}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-white transition-colors font-semibold group"
|
||||
>
|
||||
Show all
|
||||
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { SectionHeader };
|
||||
Reference in New Issue
Block a user