Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
hasSearched: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function EmptyState({ hasSearched, isLoading }: EmptyStateProps) {
|
||||
// Don't show empty state while loading or if search has been performed
|
||||
if (isLoading || hasSearched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<SearchIcon className="w-16 h-16 text-gray-700 mb-4" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">Search your library</h3>
|
||||
<p className="text-gray-400">Use the search bar above to find music, artists, and albums</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { Disc3 } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Album } from "../types";
|
||||
|
||||
interface LibraryAlbumsGridProps {
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export function LibraryAlbumsGrid({ albums }: LibraryAlbumsGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-albums">
|
||||
{albums.slice(0, 6).map((album, index) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
href={`/album/${album.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
|
||||
<div className="aspect-square bg-[#181818] rounded-md mb-4 flex items-center justify-center overflow-hidden">
|
||||
{album.coverUrl || album.albumId ? (
|
||||
<img
|
||||
src={api.getCoverArtUrl(
|
||||
album.coverUrl || album.albumId,
|
||||
300
|
||||
)}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-all"
|
||||
/>
|
||||
) : (
|
||||
<Disc3 className="w-12 h-12 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
|
||||
{album.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[#b3b3b3] line-clamp-1">
|
||||
{album.artist?.name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { Podcast } from "../types";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface LibraryPodcastsGridProps {
|
||||
podcasts: Podcast[];
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
export function LibraryPodcastsGrid({ podcasts }: LibraryPodcastsGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-podcasts">
|
||||
{podcasts.slice(0, 6).map((podcast, index) => {
|
||||
const imageUrl = getProxiedImageUrl(podcast.imageUrl);
|
||||
return (
|
||||
<Link
|
||||
key={podcast.id}
|
||||
href={`/podcasts/${podcast.id}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
|
||||
<div className="aspect-square bg-[#181818] rounded-md mb-4 flex items-center justify-center overflow-hidden relative">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={podcast.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
className="object-cover group-hover:scale-110 transition-all"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-12 h-12 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
|
||||
{podcast.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[#b3b3b3] line-clamp-1">
|
||||
{podcast.author || "Podcast"}
|
||||
</p>
|
||||
{podcast.episodeCount && podcast.episodeCount > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{podcast.episodeCount} episodes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { formatTime } from "@/utils/formatTime";
|
||||
import type { LibraryTrack } from "../types";
|
||||
|
||||
interface LibraryTracksListProps {
|
||||
tracks: LibraryTrack[];
|
||||
}
|
||||
|
||||
export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
|
||||
const { currentTrack, isPlaying, playTracks, pause, resume } = useAudio();
|
||||
|
||||
if (!tracks || tracks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlePlayTrack = (track: LibraryTrack, index: number) => {
|
||||
// Format tracks for playback
|
||||
const formattedTracks = tracks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
duration: t.duration,
|
||||
artist: {
|
||||
id: t.album.artist.id,
|
||||
name: t.album.artist.name,
|
||||
},
|
||||
album: {
|
||||
id: t.album.id,
|
||||
title: t.album.title,
|
||||
coverArt: t.album.coverUrl,
|
||||
},
|
||||
}));
|
||||
|
||||
if (currentTrack?.id === track.id) {
|
||||
// Toggle play/pause if clicking the same track
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
} else {
|
||||
// Play from this track
|
||||
playTracks(formattedTracks, index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{tracks.slice(0, 10).map((track, index) => {
|
||||
const isCurrentTrack = currentTrack?.id === track.id;
|
||||
const isPlayingThis = isCurrentTrack && isPlaying;
|
||||
const coverUrl = track.album.coverUrl
|
||||
? api.getCoverArtUrl(track.album.coverUrl, 48)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-md group transition-colors",
|
||||
isCurrentTrack
|
||||
? "bg-white/10"
|
||||
: "hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{/* Play Button / Track Number */}
|
||||
<button
|
||||
onClick={() => handlePlayTrack(track, index)}
|
||||
className="w-8 h-8 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{isPlayingThis ? (
|
||||
<Pause className="w-4 h-4 text-[#ecb200]" />
|
||||
) : isCurrentTrack ? (
|
||||
<Play className="w-4 h-4 text-[#ecb200] ml-0.5" />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 group-hover:hidden">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Play className="w-4 h-4 text-white hidden group-hover:block ml-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="w-10 h-10 bg-[#282828] rounded overflow-hidden flex-shrink-0">
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={track.album.title}
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-cover w-full h-full"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-gray-500 text-xs">♪</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isCurrentTrack ? "text-[#ecb200]" : "text-white"
|
||||
)}
|
||||
>
|
||||
{track.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
<Link
|
||||
href={`/artist/${track.album.artist.mbid || track.album.artist.id}`}
|
||||
className="hover:underline hover:text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{track.album.artist.name}
|
||||
</Link>
|
||||
<span className="mx-1">•</span>
|
||||
<Link
|
||||
href={`/album/${track.album.id}`}
|
||||
className="hover:underline hover:text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{track.album.title}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="text-sm text-gray-400 flex-shrink-0">
|
||||
{formatTime(track.duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { FilterTab } from "../types";
|
||||
|
||||
interface SearchFiltersProps {
|
||||
filterTab: FilterTab;
|
||||
onFilterChange: (tab: FilterTab) => void;
|
||||
soulseekEnabled: boolean;
|
||||
hasSearched: boolean;
|
||||
}
|
||||
|
||||
export function SearchFilters({
|
||||
filterTab,
|
||||
onFilterChange,
|
||||
soulseekEnabled,
|
||||
hasSearched,
|
||||
}: SearchFiltersProps) {
|
||||
if (!hasSearched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 mb-8" data-tv-section="search-filters">
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={0}
|
||||
tabIndex={0}
|
||||
onClick={() => onFilterChange("all")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-bold rounded-full transition-all",
|
||||
filterTab === "all" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={1}
|
||||
tabIndex={0}
|
||||
onClick={() => onFilterChange("library")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-bold rounded-full transition-all",
|
||||
filterTab === "library" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
|
||||
)}
|
||||
>
|
||||
My Library
|
||||
</button>
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={2}
|
||||
tabIndex={0}
|
||||
onClick={() => onFilterChange("discover")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-bold rounded-full transition-all",
|
||||
filterTab === "discover" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
|
||||
)}
|
||||
>
|
||||
Discover
|
||||
</button>
|
||||
{soulseekEnabled && (
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={3}
|
||||
tabIndex={0}
|
||||
onClick={() => onFilterChange("soulseek")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-bold rounded-full transition-all flex items-center gap-2",
|
||||
filterTab === "soulseek" ? "bg-[#ecb200] text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
|
||||
)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Soulseek
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { DiscoverResult } from "../types";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface SimilarArtistsGridProps {
|
||||
discoverResults: DiscoverResult[];
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
export function SimilarArtistsGrid({
|
||||
discoverResults,
|
||||
}: SimilarArtistsGridProps) {
|
||||
const artistResults = discoverResults.filter((r) => r.type === "music");
|
||||
|
||||
if (artistResults.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
Similar Artists
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-artists">
|
||||
{artistResults.slice(1, 7).map((result, index) => {
|
||||
const artistId =
|
||||
result.mbid || encodeURIComponent(result.name);
|
||||
const imageUrl = getProxiedImageUrl(result.image);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`artist-${artistId}-${index}`}
|
||||
href={`/artist/${artistId}`}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
|
||||
<div className="aspect-square bg-[#181818] rounded-full mb-4 flex items-center justify-center overflow-hidden relative">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={result.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
|
||||
className="object-cover group-hover:scale-110 transition-all"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-12 h-12 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
|
||||
{result.name}
|
||||
</h3>
|
||||
<p className="text-sm text-[#b3b3b3]">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Music, Download, CheckCircle } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { SoulseekResult } from "../types";
|
||||
|
||||
interface SoulseekSongsListProps {
|
||||
soulseekResults: SoulseekResult[];
|
||||
downloadingFiles: Set<string>;
|
||||
onDownload: (result: SoulseekResult) => void;
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// Helper function to get quality badge
|
||||
export const getQualityBadge = (result: SoulseekResult) => {
|
||||
if (result.format === "flac") {
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold bg-purple-600/20 text-purple-400 rounded">
|
||||
FLAC
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (result.bitrate >= 320) {
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold bg-green-600/20 text-green-400 rounded">
|
||||
320 kbps
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (result.bitrate >= 256) {
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold bg-blue-600/20 text-blue-400 rounded">
|
||||
256 kbps
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold bg-gray-600/20 text-gray-400 rounded">
|
||||
{result.bitrate} kbps
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to parse filename
|
||||
export const parseFilename = (
|
||||
filename: string
|
||||
): { artist: string; title: string } => {
|
||||
const match = filename.match(/([^/\\]+)\.(?:mp3|flac|m4a|wav)$/i);
|
||||
if (match) {
|
||||
const nameWithoutExt = match[1];
|
||||
const parts = nameWithoutExt.split(" - ");
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
artist: parts[0].trim(),
|
||||
title: parts.slice(1).join(" - ").trim(),
|
||||
};
|
||||
}
|
||||
return { artist: "Unknown", title: nameWithoutExt };
|
||||
}
|
||||
return { artist: "Unknown", title: filename };
|
||||
};
|
||||
|
||||
export function SoulseekSongsList({
|
||||
soulseekResults,
|
||||
downloadingFiles,
|
||||
onDownload,
|
||||
}: SoulseekSongsListProps) {
|
||||
if (soulseekResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Songs</h2>
|
||||
<div className="space-y-2" data-tv-section="search-results-songs">
|
||||
{soulseekResults.slice(0, 5).map((result, index) => {
|
||||
const parsed = result.parsedArtist
|
||||
? {
|
||||
artist: result.parsedArtist,
|
||||
title:
|
||||
result.filename
|
||||
.split("\\")
|
||||
.pop()
|
||||
?.split(" - ")
|
||||
.slice(1)
|
||||
.join(" - ") || result.filename,
|
||||
}
|
||||
: parseFilename(result.filename);
|
||||
|
||||
const isDownloading = downloadingFiles.has(result.filename);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${result.username}-${result.filename}-${index}`}
|
||||
className="flex items-center gap-4 p-3 rounded hover:bg-[#1a1a1a] group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-[#181818] rounded flex items-center justify-center flex-shrink-0">
|
||||
<Music className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-bold text-white truncate">
|
||||
{parsed.title}
|
||||
</h4>
|
||||
<p className="text-sm text-[#b3b3b3] truncate">
|
||||
{parsed.artist}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{getQualityBadge(result)}
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
onClick={() => onDownload(result)}
|
||||
disabled={isDownloading}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-full font-bold transition-all flex items-center gap-2 text-sm",
|
||||
isDownloading
|
||||
? "bg-green-600/20 text-green-400 cursor-not-allowed"
|
||||
: "bg-[#ecb200] text-black hover:bg-[#d4a000] hover:scale-105"
|
||||
)}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Downloading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useIsTV } from "@/lib/tv-utils";
|
||||
|
||||
interface TVSearchInputProps {
|
||||
initialQuery?: string;
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
export function TVSearchInput({ initialQuery = "", onSearch }: TVSearchInputProps) {
|
||||
const isTV = useIsTV();
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Update query when initialQuery changes
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery);
|
||||
}, [initialQuery]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim());
|
||||
// Blur the input after search to return to D-pad navigation
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// On Enter, submit the search
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit(e);
|
||||
}
|
||||
// On Escape, blur the input
|
||||
if (e.key === "Escape") {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// Only render this component in TV mode
|
||||
if (!isTV) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8" data-tv-section="tv-search">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative max-w-2xl">
|
||||
<SearchIcon className="absolute left-5 top-1/2 -translate-y-1/2 w-6 h-6 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Press Enter to search..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
data-tv-card
|
||||
data-tv-card-index={0}
|
||||
tabIndex={0}
|
||||
className={`
|
||||
w-full h-16 pl-14 pr-6
|
||||
bg-[#1a1a1a]
|
||||
rounded-lg
|
||||
text-xl text-white
|
||||
placeholder-gray-500
|
||||
transition-all
|
||||
outline-none
|
||||
border-2
|
||||
${isFocused
|
||||
? "border-[#ecb200] bg-[#242424]"
|
||||
: "border-transparent hover:bg-[#242424]"
|
||||
}
|
||||
`}
|
||||
/>
|
||||
{query && (
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
||||
Press Enter to search
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Music } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Artist, DiscoverResult } from "../types";
|
||||
|
||||
interface TopResultProps {
|
||||
libraryArtist?: Artist;
|
||||
discoveryArtist?: DiscoverResult;
|
||||
}
|
||||
|
||||
export function TopResult({ libraryArtist, discoveryArtist }: TopResultProps) {
|
||||
// Prefer library artist over discovery
|
||||
if (!libraryArtist && !discoveryArtist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLibrary = !!libraryArtist;
|
||||
|
||||
// Get the display name
|
||||
const name = libraryArtist?.name || discoveryArtist?.name || "";
|
||||
|
||||
// Get the artist ID for linking - prefer MBID for consistent URLs
|
||||
const artistId = isLibrary
|
||||
? libraryArtist!.mbid || libraryArtist!.id
|
||||
: discoveryArtist?.mbid || encodeURIComponent(name);
|
||||
|
||||
// Get the image URL
|
||||
const imageUrl = isLibrary
|
||||
? libraryArtist?.heroUrl
|
||||
: discoveryArtist?.image;
|
||||
|
||||
return (
|
||||
<section data-tv-section="search-top-result">
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Top result</h2>
|
||||
<Link
|
||||
href={`/artist/${artistId}`}
|
||||
className="bg-[#121212] hover:bg-[#181818] p-6 rounded-lg transition-all flex items-center gap-6 w-full sm:w-96"
|
||||
data-tv-card
|
||||
data-tv-card-index={0}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="relative w-24 h-24 bg-[#181818] rounded-full flex items-center justify-center overflow-hidden shrink-0">
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(imageUrl, 300)}
|
||||
alt={name}
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-12 h-12 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-3xl font-bold text-white mb-2">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-sm text-white font-bold">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user