667 lines
23 KiB
TypeScript
667 lines
23 KiB
TypeScript
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<void>;
|
|
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<backend.SearchResponse | null>(null);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
|
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
|
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
|
|
tracks: false,
|
|
albums: false,
|
|
artists: false,
|
|
playlists: false,
|
|
});
|
|
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
|
|
const [invalidUrl, setInvalidUrl] = useState("");
|
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<ResultTab, string> = {
|
|
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<HTMLInputElement>) => {
|
|
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 (<div className="space-y-4">
|
|
<div className="flex gap-2">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
|
{searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<div className="relative flex-1">
|
|
{!searchMode ? (<>
|
|
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/>
|
|
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
|
|
<XCircle className="h-4 w-4"/>
|
|
</button>)}
|
|
</>) : (<>
|
|
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
|
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
|
|
setSearchQuery("");
|
|
setSearchResults(null);
|
|
setLastSearchedQuery("");
|
|
}}>
|
|
<XCircle className="h-4 w-4"/>
|
|
</button>)}
|
|
</>)}
|
|
</div>
|
|
|
|
{!searchMode && (<>
|
|
<Select value={region} onValueChange={onRegionChange}>
|
|
<SelectTrigger className="w-[70px] shrink-0">
|
|
<SelectValue placeholder="Region"/>
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-[300px]">
|
|
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
|
{r}{" "}
|
|
<span className="text-muted-foreground">
|
|
({getRegionName(r)})
|
|
</span>
|
|
</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button onClick={handleFetchWithValidation} disabled={loading}>
|
|
{loading ? (<>
|
|
<Spinner />
|
|
Fetching...
|
|
</>) : (<>
|
|
<CloudDownload className="h-4 w-4"/>
|
|
Fetch
|
|
</>)}
|
|
</Button>
|
|
</>)}
|
|
</div>
|
|
|
|
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
|
|
|
{searchMode && (<div className="space-y-4">
|
|
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
|
<span>{query}</span>
|
|
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeRecentSearch(query);
|
|
}}>
|
|
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
|
</button>
|
|
</div>))}
|
|
</div>
|
|
</div>)}
|
|
|
|
{isSearching && (<div className="flex items-center justify-center py-8">
|
|
<Spinner />
|
|
<span className="ml-2 text-muted-foreground">Searching...</span>
|
|
</div>)}
|
|
|
|
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
|
No results found for "{searchQuery}"
|
|
</div>)}
|
|
|
|
{!isSearching && hasAnyResults && (<>
|
|
<div className="flex gap-1 border-b">
|
|
{tabs.map((tab) => {
|
|
const count = getTabCount(tab.key);
|
|
if (count === 0)
|
|
return null;
|
|
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
|
? "border-primary text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
|
{tab.label} ({count})
|
|
</button>);
|
|
})}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
{activeTab === "tracks" &&
|
|
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
|
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<p className="font-medium truncate">{track.name}</p>
|
|
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
|
E
|
|
</span>)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground truncate">
|
|
{track.artists}
|
|
</p>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground shrink-0">
|
|
{formatDuration(track.duration_ms || 0)}
|
|
</span>
|
|
</button>))}
|
|
|
|
{activeTab === "albums" &&
|
|
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
|
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{album.name}</p>
|
|
<p className="text-sm text-muted-foreground truncate">
|
|
{album.artists}
|
|
</p>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground shrink-0">
|
|
{album.release_date || ""}
|
|
</span>
|
|
</button>))}
|
|
|
|
{activeTab === "artists" &&
|
|
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
|
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{artist.name}</p>
|
|
<p className="text-sm text-muted-foreground">Artist</p>
|
|
</div>
|
|
</button>))}
|
|
|
|
{activeTab === "playlists" &&
|
|
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
|
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{playlist.name}</p>
|
|
<p className="text-sm text-muted-foreground truncate">
|
|
{playlist.owner || ""}
|
|
</p>
|
|
</div>
|
|
</button>))}
|
|
</div>
|
|
|
|
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
|
{isLoadingMore ? (<>
|
|
<Spinner />
|
|
Loading...
|
|
</>) : (<>
|
|
<ChevronDown className="h-4 w-4"/>
|
|
Load More
|
|
</>)}
|
|
</Button>
|
|
</div>)}
|
|
</>)}
|
|
</div>)}
|
|
|
|
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Invalid URL</DialogTitle>
|
|
<DialogDescription>
|
|
Only Spotify links are allowed in Fetch mode.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
|
|
{invalidUrl}
|
|
</div>)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => {
|
|
setShowInvalidUrlDialog(false);
|
|
setInvalidUrl("");
|
|
}}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => {
|
|
onSearchModeChange(true);
|
|
setShowInvalidUrlDialog(false);
|
|
setInvalidUrl("");
|
|
}}>
|
|
Switch to Search
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>);
|
|
}
|