Merge branch 'dev' into buttons-no-dropdown
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spotizerr-ui",
|
||||
"private": true,
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -49,7 +49,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={!!status && status !== "error"}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-100 sm:opacity-0 sm:group-hover:opacity-100 duration-300 z-10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
status
|
||||
? status === "queued"
|
||||
|
||||
@@ -53,6 +53,19 @@ function extractApiErrorMessage(error: unknown): string {
|
||||
if (typeof data?.detail === "string") return data.detail;
|
||||
if (typeof data?.message === "string") return data.message;
|
||||
if (typeof data?.error === "string") return data.error;
|
||||
// If data.error is an object, try to extract a message from it
|
||||
if (typeof data?.error === "object" && data.error !== null && typeof data.error.message === "string") {
|
||||
return data.error.message;
|
||||
}
|
||||
// If data is an object but none of the above matched, try JSON stringifying it
|
||||
if (typeof data === "object" && data !== null) {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch (e) {
|
||||
// Fallback if stringify fails
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof anyErr?.message === "string") return anyErr.message;
|
||||
return fallback;
|
||||
@@ -66,7 +79,6 @@ export function AccountsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeService, setActiveService] = useState<Service>("spotify");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const { data: credentials, isLoading } = useQuery({
|
||||
queryKey: ["credentials", activeService],
|
||||
@@ -85,15 +97,12 @@ export function AccountsTab() {
|
||||
onSuccess: () => {
|
||||
toast.success("Account added successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify account in UI
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify/Deezer account in UI
|
||||
setIsAdding(false);
|
||||
setSubmitError(null);
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
const msg = extractApiErrorMessage(error);
|
||||
setSubmitError(msg);
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,7 +119,6 @@ export function AccountsTab() {
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<AccountFormData> = (data) => {
|
||||
setSubmitError(null);
|
||||
addMutation.mutate({ service: activeService, data });
|
||||
};
|
||||
|
||||
@@ -118,11 +126,6 @@ export function AccountsTab() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-line dark:border-border-dark rounded-lg mt-4 space-y-4">
|
||||
<h4 className="font-semibold text-content-primary dark:text-content-primary-dark">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-error-text bg-error-muted border border-error rounded p-2 text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="accountName" className="text-content-primary dark:text-content-primary-dark">Account Name</label>
|
||||
|
||||
@@ -89,6 +89,7 @@ export function WatchTab() {
|
||||
onSuccess: () => {
|
||||
toast.success("Watch settings saved successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate main config to refresh watch.enabled in SettingsProvider
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
LoginResponse,
|
||||
AuthStatusResponse,
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
LoginResponse,
|
||||
AuthStatusResponse,
|
||||
User,
|
||||
CreateUserRequest,
|
||||
SSOStatusResponse
|
||||
SSOStatusResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
class AuthApiClient {
|
||||
@@ -38,7 +38,7 @@ class AuthApiClient {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
@@ -62,11 +62,11 @@ class AuthApiClient {
|
||||
// Only clear token for auth-related endpoints
|
||||
const requestUrl = error.config?.url || "";
|
||||
const isAuthEndpoint = requestUrl.includes("/auth/") || requestUrl.endsWith("/auth");
|
||||
|
||||
|
||||
if (isAuthEndpoint) {
|
||||
// Clear invalid token only for auth endpoints
|
||||
this.clearToken();
|
||||
|
||||
|
||||
// Only show auth error if not during initial token check
|
||||
if (!this.isCheckingToken) {
|
||||
toast.error("Session Expired", {
|
||||
@@ -96,11 +96,12 @@ class AuthApiClient {
|
||||
description: "The server did not respond in time. Please try again later.",
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error.response?.data?.detail ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"An unknown error occurred.";
|
||||
|
||||
const errorMessage =
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"An unknown error occurred.";
|
||||
|
||||
// Don't show toast errors during token validation
|
||||
if (!this.isCheckingToken) {
|
||||
toast.error("API Error", {
|
||||
@@ -109,14 +110,14 @@ class AuthApiClient {
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced token management with storage options
|
||||
setToken(token: string | null, rememberMe: boolean = true) {
|
||||
this.token = token;
|
||||
|
||||
|
||||
if (token) {
|
||||
if (rememberMe) {
|
||||
// Store in localStorage for persistence across browser sessions
|
||||
@@ -149,16 +150,16 @@ class AuthApiClient {
|
||||
// Try localStorage first (persistent)
|
||||
let token = localStorage.getItem("auth_token");
|
||||
let isRemembered = localStorage.getItem("auth_remember") === "true";
|
||||
|
||||
|
||||
// If not found in localStorage, try sessionStorage
|
||||
if (!token) {
|
||||
token = sessionStorage.getItem("auth_token");
|
||||
isRemembered = false;
|
||||
}
|
||||
|
||||
|
||||
if (token) {
|
||||
this.token = token;
|
||||
console.log(`Loaded ${isRemembered ? 'persistent' : 'session'} token from storage`);
|
||||
console.log(`Loaded ${isRemembered ? "persistent" : "session"} token from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ class AuthApiClient {
|
||||
// Preserve the remember me preference when clearing invalid tokens
|
||||
const wasRemembered = this.isRemembered();
|
||||
this.token = null;
|
||||
|
||||
|
||||
if (wasRemembered) {
|
||||
// Keep the remember preference but remove the invalid token
|
||||
localStorage.removeItem("auth_token");
|
||||
@@ -196,7 +197,7 @@ class AuthApiClient {
|
||||
try {
|
||||
this.isCheckingToken = true;
|
||||
const response = await this.apiClient.get<AuthStatusResponse>("/auth/status");
|
||||
|
||||
|
||||
// If the token is valid and user is authenticated
|
||||
if (response.data.auth_enabled && response.data.authenticated && response.data.user) {
|
||||
console.log("Stored token is valid, user authenticated");
|
||||
@@ -224,24 +225,24 @@ class AuthApiClient {
|
||||
async login(credentials: LoginRequest, rememberMe: boolean = true): Promise<LoginResponse> {
|
||||
const response = await this.apiClient.post<LoginResponse>("/auth/login", credentials);
|
||||
const loginData = response.data;
|
||||
|
||||
|
||||
// Store the token with remember preference
|
||||
this.setToken(loginData.access_token, rememberMe);
|
||||
|
||||
|
||||
toast.success("Login Successful", {
|
||||
description: `Test , ${loginData.user.username}!`,
|
||||
description: `Welcome, ${loginData.user.username}!`,
|
||||
});
|
||||
|
||||
|
||||
return loginData;
|
||||
}
|
||||
|
||||
async register(userData: RegisterRequest): Promise<{ message: string }> {
|
||||
const response = await this.apiClient.post("/auth/register", userData);
|
||||
|
||||
|
||||
toast.success("Registration Successful", {
|
||||
description: "Account created successfully! You can now log in.",
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -252,9 +253,9 @@ class AuthApiClient {
|
||||
// Ignore logout errors - clear token anyway
|
||||
console.warn("Logout request failed:", error);
|
||||
}
|
||||
|
||||
|
||||
this.clearAllAuthData(); // Changed from this.clearToken()
|
||||
|
||||
|
||||
toast.success("Logged Out", {
|
||||
description: "You have been logged out successfully.",
|
||||
});
|
||||
@@ -270,11 +271,11 @@ class AuthApiClient {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
|
||||
|
||||
toast.success("Password Changed", {
|
||||
description: "Your password has been updated successfully.",
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -286,31 +287,31 @@ class AuthApiClient {
|
||||
|
||||
async deleteUser(username: string): Promise<{ message: string }> {
|
||||
const response = await this.apiClient.delete(`/auth/users/${username}`);
|
||||
|
||||
|
||||
toast.success("User Deleted", {
|
||||
description: `User ${username} has been deleted.`,
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateUserRole(username: string, role: "user" | "admin"): Promise<{ message: string }> {
|
||||
const response = await this.apiClient.put(`/auth/users/${username}/role`, { role });
|
||||
|
||||
|
||||
toast.success("Role Updated", {
|
||||
description: `User ${username} role updated to ${role}.`,
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createUser(userData: CreateUserRequest): Promise<{ message: string }> {
|
||||
const response = await this.apiClient.post("/auth/users/create", userData);
|
||||
|
||||
|
||||
toast.success("User Created", {
|
||||
description: `User ${userData.username} created successfully.`,
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -318,11 +319,11 @@ class AuthApiClient {
|
||||
const response = await this.apiClient.put(`/auth/users/${username}/password`, {
|
||||
new_password: newPassword,
|
||||
});
|
||||
|
||||
|
||||
toast.success("Password Reset", {
|
||||
description: `Password for ${username} has been reset successfully.`,
|
||||
});
|
||||
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -336,7 +337,7 @@ class AuthApiClient {
|
||||
async handleSSOToken(token: string, rememberMe: boolean = true): Promise<User> {
|
||||
// Set the token and get user info
|
||||
this.setToken(token, rememberMe);
|
||||
|
||||
|
||||
// Validate the token and get user data
|
||||
const tokenValidation = await this.validateStoredToken();
|
||||
if (tokenValidation.isValid && tokenValidation.userData?.user) {
|
||||
@@ -374,4 +375,4 @@ class AuthApiClient {
|
||||
export const authApiClient = new AuthApiClient();
|
||||
|
||||
// Export the client as default for backward compatibility
|
||||
export default authApiClient.client;
|
||||
export default authApiClient.client;
|
||||
|
||||
15
spotizerr-ui/src/lib/spotify-utils.ts
Normal file
15
spotizerr-ui/src/lib/spotify-utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface ParsedSpotifyUrl {
|
||||
type: "track" | "album" | "playlist" | "artist" | "unknown";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const parseSpotifyUrl = (url: string): ParsedSpotifyUrl => {
|
||||
const match = url.match(/https:\/\/open\.spotify\.com(?:\/intl-[a-z]{2})?\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)(?:\?.*)?/);
|
||||
if (match) {
|
||||
return {
|
||||
type: match[1] as ParsedSpotifyUrl["type"],
|
||||
id: match[2],
|
||||
};
|
||||
}
|
||||
return { type: "unknown", id: "" };
|
||||
};
|
||||
@@ -166,18 +166,32 @@ export const History = () => {
|
||||
cell: (info) => {
|
||||
const entry = info.row.original;
|
||||
const isChild = "album_title" in entry;
|
||||
return isChild ? (
|
||||
const historyEntry = entry as HistoryEntry;
|
||||
const spotifyId = historyEntry.external_ids?.spotify;
|
||||
const downloadType = historyEntry.download_type;
|
||||
|
||||
const titleContent = isChild ? (
|
||||
<span className="pl-4 text-muted-foreground">└─ {entry.title}</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{entry.title}</span>
|
||||
{(entry as HistoryEntry).children_table && (
|
||||
{historyEntry.children_table && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{(entry as HistoryEntry).total_tracks || "?"} tracks
|
||||
{historyEntry.total_tracks || "?"} tracks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isChild && spotifyId && downloadType) {
|
||||
return (
|
||||
<a href={`/${downloadType}/${spotifyId}`} className="hover:underline">
|
||||
{titleContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return titleContent;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("artists", {
|
||||
|
||||
@@ -3,22 +3,26 @@ 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') {
|
||||
if (current == null || typeof current !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current ?? fallback;
|
||||
return (current ?? fallback) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -31,18 +35,23 @@ export const Home = () => {
|
||||
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 [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);
|
||||
|
||||
// Removed scroll locking on mobile empty state to avoid blocking scroll globally
|
||||
|
||||
useEffect(() => {
|
||||
navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) });
|
||||
}, [debouncedQuery, searchType, navigate]);
|
||||
@@ -56,6 +65,131 @@ export const Home = () => {
|
||||
}
|
||||
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(() => {
|
||||
@@ -74,7 +208,7 @@ export const Home = () => {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
const currentLoader = loaderRef.current;
|
||||
@@ -95,7 +229,7 @@ export const Home = () => {
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName });
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
},
|
||||
[addItem],
|
||||
[addItem]
|
||||
);
|
||||
|
||||
const handleDownloadAlbum = useCallback(
|
||||
@@ -104,53 +238,63 @@ export const Home = () => {
|
||||
addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName });
|
||||
toast.info(`Adding ${album.name} to queue...`);
|
||||
},
|
||||
[addItem],
|
||||
[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;
|
||||
}
|
||||
{displayedResults
|
||||
.map((item) => {
|
||||
// Add safety checks for essential properties
|
||||
if (!item || !item.id || !item.name || !item.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let imageUrl;
|
||||
let onDownload;
|
||||
let subtitle;
|
||||
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}`;
|
||||
}
|
||||
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)} {/* Filter out null components */}
|
||||
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]);
|
||||
@@ -160,53 +304,151 @@ export const Home = () => {
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 md:mb-6 px-4 md:px-0 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for a track, album, artist, or playlist"
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{["track", "album", "artist", "playlist"].map((typeOption) => (
|
||||
<button
|
||||
key={typeOption}
|
||||
onClick={() => setSearchType(typeOption as "track" | "album" | "artist" | "playlist")}
|
||||
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]
|
||||
}
|
||||
{typeOption.charAt(0).toUpperCase() + typeOption.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
) : (
|
||||
|
||||
{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>}
|
||||
{isLoadingMore && (
|
||||
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user