import { useState, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { FetchHistory } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory"; import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; import { cn } from "@/lib/utils"; import { useTypingEffect } from "@/hooks/useTypingEffect"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; const FETCH_PLACEHOLDERS = [ "https://open.spotify.com/track/...", "https://open.spotify.com/album/...", "https://open.spotify.com/playlist/...", "https://open.spotify.com/artist/...", ]; const SEARCH_PLACEHOLDERS = [ "Golden", "Taylor Swift", "The Weeknd", "Starboy", "Joji", "Die For You", ]; const REGIONS = [ "AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW", ]; const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); const getRegionName = (code: string) => { try { if (code === "XK") return "Kosovo"; return regionNames.of(code) || code; } catch (e) { return code; } }; type ResultTab = "tracks" | "albums" | "artists" | "playlists"; const RECENT_SEARCHES_KEY = "spotiflac_recent_searches"; const MAX_RECENT_SEARCHES = 8; const SEARCH_LIMIT = 50; interface SearchBarProps { url: string; loading: boolean; onUrlChange: (url: string) => void; onFetch: () => void; onFetchUrl: (url: string) => Promise; history: HistoryItem[]; onHistorySelect: (item: HistoryItem) => void; onHistoryRemove: (id: string) => void; hasResult: boolean; searchMode: boolean; onSearchModeChange: (isSearch: boolean) => void; region: string; onRegionChange: (region: string) => void; } export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [lastSearchedQuery, setLastSearchedQuery] = useState(""); const [activeTab, setActiveTab] = useState("tracks"); const [recentSearches, setRecentSearches] = useState([]); const [hasMore, setHasMore] = useState>({ tracks: false, albums: false, artists: false, playlists: false, }); const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false); const [invalidUrl, setInvalidUrl] = useState(""); const searchTimeoutRef = useRef | null>(null); const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS; const placeholderText = useTypingEffect(placeholders); useEffect(() => { try { const saved = localStorage.getItem(RECENT_SEARCHES_KEY); if (saved) { setRecentSearches(JSON.parse(saved)); } } catch (error) { console.error("Failed to load recent searches:", error); } }, []); const saveRecentSearch = (query: string) => { const trimmed = query.trim(); if (!trimmed) return; setRecentSearches((prev) => { const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase()); const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES); try { localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); } catch (error) { console.error("Failed to save recent searches:", error); } return updated; }); }; const removeRecentSearch = (query: string) => { setRecentSearches((prev) => { const updated = prev.filter((s) => s !== query); try { localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); } catch (error) { console.error("Failed to save recent searches:", error); } return updated; }); }; useEffect(() => { if (!searchMode || !searchQuery.trim()) { return; } if (searchQuery.trim() === lastSearchedQuery) { return; } if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } searchTimeoutRef.current = setTimeout(async () => { setIsSearching(true); try { const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT, }); setSearchResults(results); setLastSearchedQuery(searchQuery.trim()); saveRecentSearch(searchQuery.trim()); setHasMore({ tracks: results.tracks.length === SEARCH_LIMIT, albums: results.albums.length === SEARCH_LIMIT, artists: results.artists.length === SEARCH_LIMIT, playlists: results.playlists.length === SEARCH_LIMIT, }); if (results.tracks.length > 0) setActiveTab("tracks"); else if (results.albums.length > 0) setActiveTab("albums"); else if (results.artists.length > 0) setActiveTab("artists"); else if (results.playlists.length > 0) setActiveTab("playlists"); } catch (error) { console.error("Search failed:", error); setSearchResults(null); } finally { setIsSearching(false); } }, 400); return () => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } }; }, [searchQuery, searchMode, lastSearchedQuery]); const handleLoadMore = async () => { if (!searchResults || !lastSearchedQuery || isLoadingMore) return; const typeMap: Record = { tracks: "track", albums: "album", artists: "artist", playlists: "playlist", }; const currentCount = getTabCount(activeTab); setIsLoadingMore(true); try { const moreResults = await SearchSpotifyByType({ query: lastSearchedQuery, search_type: typeMap[activeTab], limit: SEARCH_LIMIT, offset: currentCount, }); if (moreResults.length > 0) { setSearchResults((prev) => { if (!prev) return prev; const updated = new backend.SearchResponse({ tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks, albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums, artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists, playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists, }); return updated; }); } setHasMore((prev) => ({ ...prev, [activeTab]: moreResults.length === SEARCH_LIMIT, })); } catch (error) { console.error("Load more failed:", error); } finally { setIsLoadingMore(false); } }; const isSpotifyUrl = (text: string) => { const trimmed = text.trim(); if (!trimmed) return true; const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed); if (!isUrl) return true; return (trimmed.includes("spotify.com") || trimmed.includes("spotify.link") || trimmed.startsWith("spotify:")); }; const handlePaste = (e: React.ClipboardEvent) => { if (searchMode) return; const pastedText = e.clipboardData.getData("text"); if (pastedText && !isSpotifyUrl(pastedText)) { e.preventDefault(); setInvalidUrl(pastedText); setShowInvalidUrlDialog(true); } }; const handleFetchWithValidation = () => { if (!isSpotifyUrl(url)) { setInvalidUrl(url); setShowInvalidUrlDialog(true); return; } onFetch(); }; const handleResultClick = (externalUrl: string) => { onSearchModeChange(false); onFetchUrl(externalUrl); }; const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; const hasAnyResults = searchResults && (searchResults.tracks.length > 0 || searchResults.albums.length > 0 || searchResults.artists.length > 0 || searchResults.playlists.length > 0); const getTabCount = (tab: ResultTab): number => { if (!searchResults) return 0; switch (tab) { case "tracks": return searchResults.tracks.length; case "albums": return searchResults.albums.length; case "artists": return searchResults.artists.length; case "playlists": return searchResults.playlists.length; } }; const tabs: { key: ResultTab; label: string; }[] = [ { key: "tracks", label: "Tracks" }, { key: "albums", label: "Albums" }, { key: "artists", label: "Artists" }, { key: "playlists", label: "Playlists" }, ]; return (

{searchMode ? "Fetch Mode" : "Search Mode"}

{!searchMode ? (<> onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/> {url && ()} ) : (<> setSearchQuery(e.target.value)} className="pr-8"/> {searchQuery && ()} )}
{!searchMode && (<> )}
{!searchMode && !hasResult && ()} {searchMode && (
{!searchQuery && !searchResults && recentSearches.length > 0 && (

Recent Searches

{recentSearches.map((query) => (
setSearchQuery(query)}> {query}
))}
)} {isSearching && (
Searching...
)} {!isSearching && searchQuery && !hasAnyResults && (
No results found for "{searchQuery}"
)} {!isSearching && hasAnyResults && (<>
{tabs.map((tab) => { const count = getTabCount(tab.key); if (count === 0) return null; return (); })}
{activeTab === "tracks" && searchResults?.tracks.map((track) => ())} {activeTab === "albums" && searchResults?.albums.map((album) => ())} {activeTab === "artists" && searchResults?.artists.map((artist) => ())} {activeTab === "playlists" && searchResults?.playlists.map((playlist) => ())}
{hasMore[activeTab] && (
)} )}
)} Invalid URL Only Spotify links are allowed in Fetch mode. {invalidUrl && (
{invalidUrl}
)}
); }