455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo, useContext, useCallback, useRef } from "react";
|
|
import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
|
|
import { useDebounce } from "use-debounce";
|
|
import { toast } from "sonner";
|
|
import type { TrackType, AlbumType, SearchResult } from "@/types/spotify";
|
|
import { parseSpotifyUrl } from "@/lib/spotify-utils";
|
|
import { QueueContext } from "@/contexts/queue-context";
|
|
import { SearchResultCard } from "@/components/SearchResultCard";
|
|
import { indexRoute } from "@/router";
|
|
import { Music, Disc, User, ListMusic } from "lucide-react";
|
|
import { authApiClient } from "@/lib/api-client";
|
|
import { useSettings } from "@/contexts/settings-context";
|
|
import { FaEye, FaDownload } from "react-icons/fa";
|
|
|
|
// Utility function to safely get properties from search results
|
|
const safelyGetProperty = <T,>(obj: any, path: string[], fallback: T): T => {
|
|
try {
|
|
let current = obj;
|
|
for (const key of path) {
|
|
if (current == null || typeof current !== "object") {
|
|
return fallback;
|
|
}
|
|
current = current[key];
|
|
}
|
|
return (current ?? fallback) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
export const Home = () => {
|
|
const navigate = useNavigate({ from: "/" });
|
|
const { q, type } = useSearch({ from: "/" });
|
|
const { items: allResults } = indexRoute.useLoaderData();
|
|
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
|
|
const { settings } = useSettings();
|
|
|
|
const [query, setQuery] = useState(q || "");
|
|
const [searchType, setSearchType] = useState<
|
|
"track" | "album" | "artist" | "playlist"
|
|
>(type || "track");
|
|
const [debouncedQuery] = useDebounce(query, 500);
|
|
const [activeTab, setActiveTab] = useState<"search" | "bulkAdd">("search");
|
|
const [linksInput, setLinksInput] = useState("");
|
|
const [isBulkAdding, setIsBulkAdding] = useState(false);
|
|
const [isBulkWatching, setIsBulkWatching] = useState(false);
|
|
|
|
const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const context = useContext(QueueContext);
|
|
const loaderRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) });
|
|
}, [debouncedQuery, searchType, navigate]);
|
|
|
|
useEffect(() => {
|
|
setDisplayedResults(allResults.slice(0, PAGE_SIZE));
|
|
}, [allResults]);
|
|
|
|
if (!context) {
|
|
throw new Error("useQueue must be used within a QueueProvider");
|
|
}
|
|
const { addItem } = context;
|
|
|
|
const handleAddBulkLinks = useCallback(async () => {
|
|
const allLinks = linksInput
|
|
.split("\n")
|
|
.map((link) => link.trim())
|
|
.filter(Boolean);
|
|
if (allLinks.length === 0) {
|
|
toast.info("No links provided to add.");
|
|
return;
|
|
}
|
|
|
|
const supportedLinks: string[] = [];
|
|
const unsupportedLinks: string[] = [];
|
|
|
|
allLinks.forEach((link) => {
|
|
const parsed = parseSpotifyUrl(link);
|
|
if (parsed.type !== "unknown") {
|
|
supportedLinks.push(link);
|
|
} else {
|
|
unsupportedLinks.push(link);
|
|
}
|
|
});
|
|
|
|
if (unsupportedLinks.length > 0) {
|
|
toast.warning("Some links are not supported and will be skipped.", {
|
|
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
|
|
});
|
|
}
|
|
|
|
if (supportedLinks.length === 0) {
|
|
toast.info("No supported links to add.");
|
|
return;
|
|
}
|
|
|
|
setIsBulkAdding(true);
|
|
try {
|
|
const response = await authApiClient.client.post("/bulk/bulk-add-spotify-links", {
|
|
links: supportedLinks,
|
|
});
|
|
const { count, failed_links } = response.data;
|
|
|
|
if (failed_links && failed_links.length > 0) {
|
|
toast.warning("Bulk Add Completed with Warnings", {
|
|
description: `${count} links added. Failed to add ${failed_links.length} links: ${failed_links.join(
|
|
", "
|
|
)}`,
|
|
});
|
|
} else {
|
|
toast.success("Bulk Add Successful", {
|
|
description: `${count} links added to queue.`,
|
|
});
|
|
}
|
|
setLinksInput(""); // Clear input after successful add
|
|
} catch (error: any) {
|
|
const errorMessage = error.response?.data?.detail?.message || error.message;
|
|
const failedLinks = error.response?.data?.detail?.failed_links || [];
|
|
|
|
let description = errorMessage;
|
|
if (failedLinks.length > 0) {
|
|
description += ` Failed links: ${failedLinks.join(", ")}`;
|
|
}
|
|
|
|
toast.error("Bulk Add Failed", {
|
|
description: description,
|
|
});
|
|
if (failedLinks.length > 0) {
|
|
console.error("Failed links:", failedLinks);
|
|
}
|
|
} finally {
|
|
setIsBulkAdding(false);
|
|
}
|
|
}, [linksInput]);
|
|
|
|
const handleWatchBulkLinks = useCallback(async () => {
|
|
const links = linksInput
|
|
.split("\n")
|
|
.map((link) => link.trim())
|
|
.filter(Boolean);
|
|
if (links.length === 0) {
|
|
toast.info("No links provided to watch.");
|
|
return;
|
|
}
|
|
|
|
const supportedLinks: { type: "artist" | "playlist"; id: string }[] = [];
|
|
const unsupportedLinks: string[] = [];
|
|
|
|
links.forEach((link) => {
|
|
const parsed = parseSpotifyUrl(link);
|
|
if (parsed.type === "artist" || parsed.type === "playlist") {
|
|
supportedLinks.push({ type: parsed.type, id: parsed.id });
|
|
} else {
|
|
unsupportedLinks.push(link);
|
|
}
|
|
});
|
|
|
|
if (unsupportedLinks.length > 0) {
|
|
toast.warning("Some links are not supported for watching.", {
|
|
description: `Unsupported: ${unsupportedLinks.join(", ")}`,
|
|
});
|
|
}
|
|
|
|
if (supportedLinks.length === 0) {
|
|
toast.info("No supported links to watch.");
|
|
return;
|
|
}
|
|
|
|
setIsBulkWatching(true);
|
|
try {
|
|
const watchPromises = supportedLinks.map((item) =>
|
|
authApiClient.client.put(`/${item.type}/watch/${item.id}`)
|
|
);
|
|
await Promise.all(watchPromises);
|
|
toast.success("Bulk Watch Successful", {
|
|
description: `${supportedLinks.length} supported links added to watchlist.`,
|
|
});
|
|
setLinksInput(""); // Clear input after successful add
|
|
} catch (error: any) {
|
|
const errorMessage = error.response?.data?.detail?.message || error.message;
|
|
toast.error("Bulk Watch Failed", {
|
|
description: errorMessage,
|
|
});
|
|
} finally {
|
|
setIsBulkWatching(false);
|
|
}
|
|
}, [linksInput]);
|
|
|
|
const loadMore = useCallback(() => {
|
|
setIsLoadingMore(true);
|
|
setTimeout(() => {
|
|
const currentLength = displayedResults.length;
|
|
const nextBatch = allResults.slice(currentLength, currentLength + PAGE_SIZE);
|
|
setDisplayedResults((prev) => [...prev, ...nextBatch]);
|
|
setIsLoadingMore(false);
|
|
}, 500); // Simulate network delay
|
|
}, [allResults, displayedResults]);
|
|
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const firstEntry = entries[0];
|
|
if (firstEntry.isIntersecting && allResults.length > displayedResults.length) {
|
|
loadMore();
|
|
}
|
|
},
|
|
{ threshold: 1.0 }
|
|
);
|
|
|
|
const currentLoader = loaderRef.current;
|
|
if (currentLoader) {
|
|
observer.observe(currentLoader);
|
|
}
|
|
|
|
return () => {
|
|
if (currentLoader) {
|
|
observer.unobserve(currentLoader);
|
|
}
|
|
};
|
|
}, [allResults, displayedResults, loadMore]);
|
|
|
|
const handleDownloadTrack = useCallback(
|
|
(track: TrackType) => {
|
|
const artistName = track.artists?.map((a) => a.name).join(", ");
|
|
addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName });
|
|
toast.info(`Adding ${track.name} to queue...`);
|
|
},
|
|
[addItem]
|
|
);
|
|
|
|
const handleDownloadAlbum = useCallback(
|
|
(album: AlbumType) => {
|
|
const artistName = album.artists?.map((a) => a.name).join(", ");
|
|
addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName });
|
|
toast.info(`Adding ${album.name} to queue...`);
|
|
},
|
|
[addItem]
|
|
);
|
|
|
|
const resultComponent = useMemo(() => {
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{displayedResults
|
|
.map((item) => {
|
|
// Add safety checks for essential properties
|
|
if (!item || !item.id || !item.name || !item.model) {
|
|
return null;
|
|
}
|
|
|
|
let imageUrl;
|
|
let onDownload: (() => void) | undefined;
|
|
let subtitle: string | undefined;
|
|
|
|
if (item.model === "track") {
|
|
imageUrl = safelyGetProperty(item, ["album", "images", "0", "url"], undefined);
|
|
onDownload = () => handleDownloadTrack(item as TrackType);
|
|
const artists = safelyGetProperty(item, ["artists"], []);
|
|
subtitle = Array.isArray(artists)
|
|
? artists
|
|
.map((a: any) => safelyGetProperty(a, ["name"], "Unknown"))
|
|
.join(", ")
|
|
: "Unknown Artist";
|
|
} else if (item.model === "album") {
|
|
imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
|
|
onDownload = () => handleDownloadAlbum(item as AlbumType);
|
|
const artists = safelyGetProperty(item, ["artists"], []);
|
|
subtitle = Array.isArray(artists)
|
|
? artists
|
|
.map((a: any) => safelyGetProperty(a, ["name"], "Unknown"))
|
|
.join(", ")
|
|
: "Unknown Artist";
|
|
} else if (item.model === "artist") {
|
|
imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
|
|
subtitle = "Artist";
|
|
} else if (item.model === "playlist") {
|
|
imageUrl = safelyGetProperty(item, ["images", "0", "url"], undefined);
|
|
const ownerName = safelyGetProperty(item, ["owner", "display_name"], "Unknown");
|
|
subtitle = `By ${ownerName}`;
|
|
}
|
|
|
|
return (
|
|
<SearchResultCard
|
|
key={item.id}
|
|
id={item.id}
|
|
name={item.name}
|
|
type={item.model}
|
|
imageUrl={imageUrl}
|
|
subtitle={subtitle}
|
|
onDownload={onDownload}
|
|
/>
|
|
);
|
|
})
|
|
.filter(Boolean)}
|
|
</div>
|
|
);
|
|
}, [displayedResults, handleDownloadTrack, handleDownloadAlbum]);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto h-full flex flex-col md:p-4">
|
|
<div className="text-center mb-4 md:mb-8 px-4 md:px-0">
|
|
<h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex justify-center mb-4 md:mb-6 px-4 md:px-0 border-b border-gray-300 dark:border-gray-700">
|
|
<button
|
|
className={`flex-1 py-2 text-center transition-colors duration-200 ${
|
|
activeTab === "search"
|
|
? "border-b-2 border-green-500 text-green-500"
|
|
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
|
|
}`}
|
|
onClick={() => setActiveTab("search")}
|
|
>
|
|
Search
|
|
</button>
|
|
<button
|
|
className={`flex-1 py-2 text-center transition-colors duration-200 ${
|
|
activeTab === "bulkAdd"
|
|
? "border-b-2 border-green-500 text-green-500"
|
|
: "border-b-2 border-transparent text-gray-800 dark:text-gray-200 hover:text-green-500"
|
|
}`}
|
|
onClick={() => setActiveTab("bulkAdd")}
|
|
>
|
|
Bulk Add
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === "search" && (
|
|
<>
|
|
<div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search for a track, album, or artist"
|
|
className="flex-1 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
/>
|
|
|
|
{/* Icon buttons for search type (larger screens) */}
|
|
<div className="hidden sm:flex gap-2 items-center">
|
|
{(["track", "album", "artist", "playlist"] as const).map((typeOption) => (
|
|
<button
|
|
key={typeOption}
|
|
onClick={() => setSearchType(typeOption)}
|
|
aria-label={`Search ${typeOption}`}
|
|
className={`flex items-center gap-1 p-2 rounded-md text-sm font-medium transition-colors border ${
|
|
searchType === typeOption
|
|
? "bg-green-600 text-white border-green-600"
|
|
: "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
}`}
|
|
>
|
|
{
|
|
{
|
|
track: <Music size={16} />,
|
|
album: <Disc size={16} />,
|
|
artist: <User size={16} />,
|
|
playlist: <ListMusic size={16} />,
|
|
}[typeOption]
|
|
}
|
|
<span className="hidden md:inline">
|
|
{typeOption.charAt(0).toUpperCase() + typeOption.slice(1)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Select for smaller screens */}
|
|
<select
|
|
value={searchType}
|
|
onChange={(e) =>
|
|
setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")
|
|
}
|
|
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus sm:hidden"
|
|
>
|
|
<option value="track">Track</option>
|
|
<option value="album">Album</option>
|
|
<option value="artist">Artist</option>
|
|
<option value="playlist">Playlist</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex-1 px-4 md:px-0 pb-4 ${
|
|
// Only restrict overflow on mobile when there are results, otherwise allow normal behavior
|
|
displayedResults.length > 0 ? "overflow-y-auto md:overflow-visible" : ""
|
|
}`}
|
|
>
|
|
{isLoading ? (
|
|
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
|
|
) : (
|
|
<>
|
|
{resultComponent}
|
|
<div ref={loaderRef} />
|
|
{isLoadingMore && (
|
|
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "bulkAdd" && (
|
|
<div className="flex flex-col gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
|
|
<textarea
|
|
className="w-full h-60 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
placeholder="Paste Spotify links here, one per line..."
|
|
value={linksInput}
|
|
onChange={(e) => setLinksInput(e.target.value)}
|
|
></textarea>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setLinksInput("")} // Clear input
|
|
className="px-4 py-2 bg-gray-300 dark:bg-gray-700 text-content-primary dark:text-content-primary-dark rounded-md hover:bg-gray-400 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
onClick={handleAddBulkLinks}
|
|
disabled={isBulkAdding}
|
|
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{isBulkAdding ? "Adding..." : (
|
|
<>
|
|
<FaDownload className="icon-inverse" /> Download
|
|
</>
|
|
)}
|
|
</button>
|
|
{settings?.watch?.enabled && (
|
|
<button
|
|
onClick={handleWatchBulkLinks}
|
|
disabled={isBulkWatching}
|
|
className="px-4 py-2 bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Only Spotify Artist and Playlist links are supported for watching."
|
|
>
|
|
{isBulkWatching ? "Watching..." : (
|
|
<>
|
|
<FaEye className="icon-inverse" /> Watch
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|