Restart repository history from target event
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { Search, Loader2, Music, Clock, Copy, Filter, ExternalLink } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
artists: { name: string }[];
|
||||
duration: number;
|
||||
url: string;
|
||||
imageCover?: { url: string; width: number; height: number }[];
|
||||
mediaTags?: string[];
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
export function TidalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<"relevance" | "duration" | "year">("relevance");
|
||||
const [filterQuality, setFilterQuality] = useState<"all" | "hires" | "lossless">("all");
|
||||
const [countryCode, setCountryCode] = useState("US");
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tidal/search?query=${encodeURIComponent(query)}&countryCode=${countryCode}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data && data.albums) {
|
||||
setResults(data.albums);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setResults(data);
|
||||
} else {
|
||||
console.warn("Unexpected API response structure:", data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
toast.error("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = results.filter(album => {
|
||||
if (filterQuality === "all") return true;
|
||||
if (!album.mediaTags) return false;
|
||||
if (filterQuality === "hires") {
|
||||
return album.mediaTags.includes("HIRES_LOSSLESS");
|
||||
}
|
||||
if (filterQuality === "lossless") {
|
||||
// Exclusive filter: Must be Lossless AND NOT Hi-Res/MQA
|
||||
const isHires = album.mediaTags.includes("HIRES_LOSSLESS");
|
||||
return album.mediaTags.includes("LOSSLESS") && !isHires;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedResults = [...filteredResults].sort((a, b) => {
|
||||
if (sortBy === "duration") {
|
||||
return (b.duration || 0) - (a.duration || 0);
|
||||
}
|
||||
if (sortBy === "year") {
|
||||
const dateA = new Date(a.releaseDate || 0).getFullYear();
|
||||
const dateB = new Date(b.releaseDate || 0).getFullYear();
|
||||
return dateB - dateA; // Newest first
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
// Clear existing timeout to prevent premature hiding
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
setCopiedId(id);
|
||||
toast.success("Copied to clipboard!");
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
toast.error("Failed to copy link");
|
||||
}
|
||||
};
|
||||
|
||||
function getYear(dateString?: string) {
|
||||
if (!dateString) return "";
|
||||
return new Date(dateString).getFullYear().toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-6 mx-auto w-full">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center justify-between bg-neutral-800/90 p-2 rounded-xl border shadow-sm backdrop-blur-sm">
|
||||
<div className="flex flex-1 gap-2 items-center bg-background/50 p-1.5 rounded-lg border border-border/40 focus-within:border-primary/50 transition-colors">
|
||||
<Search className="w-4 h-4 ml-2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search for music..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="flex-1 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 font-medium"
|
||||
/>
|
||||
<div className="h-5 w-[1px] bg-border/60 mx-1" />
|
||||
<Input
|
||||
placeholder="US"
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||
className="w-14 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 text-center font-mono text-xs uppercase text-muted-foreground focus:text-foreground transition-colors"
|
||||
maxLength={2}
|
||||
title="Region (ISO 3166-1 alpha-2)"
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading} size="sm" className="h-8 px-4 font-semibold shadow-none">
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center px-1">
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as "relevance" | "duration" | "year")}>
|
||||
<SelectTrigger className="w-[140px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="relevance">Relevance</SelectItem>
|
||||
<SelectItem value="duration">Duration</SelectItem>
|
||||
<SelectItem value="year">Release Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterQuality} onValueChange={(v) => setFilterQuality(v as "all" | "hires" | "lossless")}>
|
||||
<SelectTrigger className="w-[130px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||
<SelectValue placeholder="Quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Qualities</SelectItem>
|
||||
<SelectItem value="hires">Hi-Res Only</SelectItem>
|
||||
<SelectItem value="lossless">Lossless Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-4 px-4">
|
||||
{sortedResults.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-muted-foreground gap-4">
|
||||
<div className="p-6 rounded-full bg-muted/30">
|
||||
<Music className="w-12 h-12 opacity-30" />
|
||||
</div>
|
||||
<p className="font-medium text-lg text-muted-foreground/60">{query ? "No results found" : "Start your search above"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 pb-6">
|
||||
{sortedResults.map((album) => {
|
||||
const artworkUrl = album.imageCover && album.imageCover.length > 0
|
||||
? album.imageCover.find(img => img.width === 640)?.url || album.imageCover[0].url
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group flex flex-col gap-3 cursor-pointer"
|
||||
onClick={() => copyToClipboard(album.url, album.id)}
|
||||
>
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-muted/50 relative shadow-md group-hover:shadow-xl transition-all duration-300 ">
|
||||
{artworkUrl ? (
|
||||
<img
|
||||
src={artworkUrl}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground bg-secondary/30">
|
||||
<Music className="w-16 h-16 opacity-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 bg-black/60 backdrop-blur-[1px] flex items-center justify-center opacity-0 transition-opacity duration-200",
|
||||
copiedId === album.id ? "opacity-100 bg-emerald-500/90 backdrop-blur-sm" : "group-hover:opacity-100"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 transform transition-transform duration-200",
|
||||
copiedId === album.id ? "translate-y-0" : "translate-y-4 group-hover:translate-y-0"
|
||||
)}>
|
||||
{copiedId === album.id ? (
|
||||
<div className="flex flex-col items-center text-white animate-in zoom-in duration-200">
|
||||
<div className="h-12 w-12 rounded-full bg-white text-emerald-600 flex items-center justify-center shadow-lg mb-2">
|
||||
<Copy className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-bold tracking-widest text-sm">COPIED</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(album.url, album.id);
|
||||
}}
|
||||
title="Copy Link"
|
||||
>
|
||||
<Copy className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(album.url, '_blank');
|
||||
}}
|
||||
title="Open in TIDAL"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Badges - Refined Layout */}
|
||||
{album.mediaTags && (
|
||||
<div className="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1.5 pointer-events-none">
|
||||
{album.mediaTags.includes("HIRES_LOSSLESS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-[#FFD700] text-black text-[0.6rem] font-bold tracking-wider shadow-sm border border-yellow-600/20">HI-RES</span>
|
||||
)}
|
||||
{album.mediaTags.includes("MQA") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-black text-[#FFD700] border border-[#FFD700] text-[0.6rem] font-bold tracking-wider shadow-sm">MQA</span>
|
||||
)}
|
||||
{!album.mediaTags.includes("HIRES_LOSSLESS") && !album.mediaTags.includes("MQA") && album.mediaTags.includes("LOSSLESS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-cyan-500 text-black text-[0.6rem] font-bold tracking-wider shadow-sm">LOSSLESS</span>
|
||||
)}
|
||||
{album.mediaTags.includes("DOLBY_ATMOS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-indigo-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">ATMOS</span>
|
||||
)}
|
||||
{album.mediaTags.includes("SONY_360RA") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-blue-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">360RA</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="font-semibold text-sm leading-tight line-clamp-1 group-hover:text-primary transition-colors" title={album.title}>{album.title}</h3>
|
||||
<div className="h-6 w-full relative overflow-hidden">
|
||||
<ScrollableContent>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{album.artists && album.artists.length > 0 ? (
|
||||
album.artists.map((artist, index) => (
|
||||
<div key={index} className="flex items-center shrink-0">
|
||||
<span
|
||||
className="hover:text-primary cursor-copy transition-colors hover:underline decoration-primary/50 underline-offset-2"
|
||||
title={`Copy "${artist.name}"`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(artist.name);
|
||||
toast.success(`Copied "${artist.name}"`);
|
||||
}}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < album.artists.length - 1 && (
|
||||
<span className="text-muted-foreground/60 mr-1">,</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span>Unknown Artist</span>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableContent>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[0.65rem] font-medium text-muted-foreground/70 uppercase tracking-widest h-4">
|
||||
{album.releaseDate && (
|
||||
<span>{getYear(album.releaseDate)}</span>
|
||||
)}
|
||||
{formatDuration(album.duration) && (
|
||||
<>
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-border" />
|
||||
<span>{formatDuration(album.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollableContent({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollDistance, setScrollDistance] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && contentRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const contentWidth = contentRef.current.offsetWidth;
|
||||
|
||||
if (contentWidth > containerWidth) {
|
||||
const distance = contentWidth - containerWidth;
|
||||
setScrollDistance(distance);
|
||||
setDuration(distance * 0.01); // Adjust speed
|
||||
} else {
|
||||
setScrollDistance(0);
|
||||
}
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("overflow-hidden relative flex items-center w-full", className)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
className="whitespace-nowrap flex items-center"
|
||||
animate={{ x: isHovered && scrollDistance > 0 ? -scrollDistance : 0 }}
|
||||
transition={{
|
||||
duration: duration,
|
||||
ease: "linear",
|
||||
repeat: isHovered ? Infinity : 0,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 1
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user