fix search items

This commit is contained in:
Mustafa Soylu
2025-06-11 16:44:49 +02:00
parent ab6b0a6cef
commit 7ef0d3f34a
18 changed files with 736 additions and 359 deletions

View File

@@ -22,6 +22,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.8",
"use-debounce": "^10.0.5",

View File

@@ -40,6 +40,9 @@ importers:
react-hook-form:
specifier: ^7.57.0
version: 7.57.0(react@19.1.0)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.1.0)
sonner:
specifier: ^2.0.5
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1719,6 +1722,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-icons@5.5.0:
resolution:
{ integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== }
peerDependencies:
react: "*"
react-refresh@0.17.0:
resolution:
{ integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== }
@@ -3196,6 +3205,10 @@ snapshots:
dependencies:
react: 19.1.0
react-icons@5.5.0(react@19.1.0):
dependencies:
react: 19.1.0
react-refresh@0.17.0: {}
react@19.1.0: {}

View File

@@ -0,0 +1,44 @@
import { Link } from "@tanstack/react-router";
import type { AlbumType } from "../types/spotify";
interface AlbumCardProps {
album: AlbumType;
onDownload?: () => void;
}
export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg";
const subtitle = album.artists.map((artist) => artist.name).join(", ");
return (
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-all duration-300 ease-in-out hover:-translate-y-1 hover:scale-105">
<div className="relative">
<Link to="/album/$albumId" params={{ albumId: album.id }}>
<img src={imageUrl} alt={album.name} className="w-full aspect-square object-cover" />
{onDownload && (
<button
onClick={(e) => {
e.preventDefault();
onDownload();
}}
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
title="Download album"
>
<img src="/download.svg" alt="Download" className="w-5 h-5" />
</button>
)}
</Link>
</div>
<div className="p-4 flex-grow flex flex-col">
<Link
to="/album/$albumId"
params={{ albumId: album.id }}
className="font-semibold text-gray-900 dark:text-white truncate block"
>
{album.name}
</Link>
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}
</div>
</div>
);
};

View File

@@ -1,162 +1,183 @@
import { useQueue, type QueueItem } from "../contexts/queue-context";
import { useContext } from "react";
import {
FaTimes,
FaSync,
FaCheckCircle,
FaExclamationCircle,
FaHourglassHalf,
FaMusic,
FaCompactDisc,
} from "react-icons/fa";
import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue-context";
export function Queue() {
const { items, isVisible, removeItem, retryItem, clearQueue, toggleVisibility, clearCompleted } = useQueue();
const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string; bgColor: string; name: string }> = {
queued: {
icon: <FaHourglassHalf />,
color: "text-gray-500",
bgColor: "bg-gray-100",
name: "Queued",
},
initializing: {
icon: <FaSync className="animate-spin" />,
color: "text-blue-500",
bgColor: "bg-blue-100",
name: "Initializing",
},
downloading: {
icon: <FaSync className="animate-spin" />,
color: "text-blue-500",
bgColor: "bg-blue-100",
name: "Downloading",
},
processing: {
icon: <FaSync className="animate-spin" />,
color: "text-purple-500",
bgColor: "bg-purple-100",
name: "Processing",
},
completed: {
icon: <FaCheckCircle />,
color: "text-green-500",
bgColor: "bg-green-100",
name: "Completed",
},
done: {
icon: <FaCheckCircle />,
color: "text-green-500",
bgColor: "bg-green-100",
name: "Done",
},
error: {
icon: <FaExclamationCircle />,
color: "text-red-500",
bgColor: "bg-red-100",
name: "Error",
},
cancelled: {
icon: <FaTimes />,
color: "text-yellow-500",
bgColor: "bg-yellow-100",
name: "Cancelled",
},
skipped: {
icon: <FaTimes />,
color: "text-gray-500",
bgColor: "bg-gray-100",
name: "Skipped",
},
pending: {
icon: <FaHourglassHalf />,
color: "text-gray-500",
bgColor: "bg-gray-100",
name: "Pending",
},
};
const QueueItemCard = ({ item }: { item: QueueItem }) => {
const { removeItem, retryItem } = useContext(QueueContext) || {};
const statusInfo = statusStyles[item.status] || statusStyles.queued;
const isTerminal = item.status === "completed" || item.status === "done";
const currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber;
const progressText =
item.type === "album" || item.type === "playlist"
? `${currentCount || 0}/${item.totalTracks || "?"}`
: item.progress
? `${item.progress.toFixed(0)}%`
: "";
return (
<div className={`p-4 rounded-lg shadow-md mb-3 transition-all duration-300 ${statusInfo.bgColor}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 min-w-0">
<div className={`text-2xl ${statusInfo.color}`}>{statusInfo.icon}</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
{item.type === "track" ? (
<FaMusic className="text-gray-500" />
) : (
<FaCompactDisc className="text-gray-500" />
)}
<p className="font-bold text-gray-800 truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-sm text-gray-500 truncate" title={item.artist}>
{item.artist}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className={`text-sm font-semibold ${statusInfo.color}`}>{statusInfo.name}</p>
{progressText && <p className="text-xs text-gray-500">{progressText}</p>}
</div>
<button
onClick={() => removeItem?.(item.id)}
className="text-gray-400 hover:text-red-500 transition-colors"
aria-label="Remove"
>
<FaTimes />
</button>
{item.canRetry && (
<button
onClick={() => retryItem?.(item.id)}
className="text-gray-400 hover:text-blue-500 transition-colors"
aria-label="Retry"
>
<FaSync />
</button>
)}
</div>
</div>
{item.error && <p className="text-xs text-red-600 mt-2">Error: {item.error}</p>}
{(item.status === "downloading" || item.status === "processing") && item.progress !== undefined && (
<div className="mt-2 h-1.5 w-full bg-gray-200 rounded-full">
<div
className={`h-1.5 rounded-full ${statusInfo.color.replace("text", "bg")}`}
style={{ width: `${item.progress}%` }}
/>
</div>
)}
</div>
);
};
export const Queue = () => {
const context = useContext(QueueContext);
if (!context) return null;
const { items, isVisible, toggleVisibility, clearQueue } = context;
if (!isVisible) return null;
const handleClearQueue = () => {
if (confirm("Are you sure you want to cancel all downloads and clear the queue?")) {
clearQueue();
}
};
const renderProgress = (item: QueueItem) => {
if (item.status === "downloading" || item.status === "processing") {
const isMultiTrack = item.totalTracks && item.totalTracks > 1;
const overallProgress =
isMultiTrack && item.totalTracks
? ((item.currentTrackNumber || 0) / item.totalTracks) * 100
: item.progress || 0;
return (
<div className="w-full bg-gray-700 rounded-full h-2.5 mt-1">
<div className="bg-green-600 h-2.5 rounded-full" style={{ width: `${overallProgress}%` }}></div>
{isMultiTrack && (
<div className="w-full bg-gray-600 rounded-full h-1.5 mt-1">
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${item.progress || 0}%` }}></div>
</div>
)}
</div>
);
}
return null;
};
const renderStatusDetails = (item: QueueItem) => {
const statusClass = {
initializing: "text-gray-400",
pending: "text-gray-400",
downloading: "text-blue-400",
processing: "text-purple-400",
completed: "text-green-500 font-semibold",
error: "text-red-500 font-semibold",
skipped: "text-yellow-500",
cancelled: "text-gray-500",
queued: "text-gray-400",
}[item.status];
const isMultiTrack = item.totalTracks && item.totalTracks > 1;
return (
<div className="text-xs text-gray-400 flex justify-between w-full mt-1">
<span className={statusClass}>{item.status.toUpperCase()}</span>
{item.status === "downloading" && (
<>
<span>{item.progress?.toFixed(0)}%</span>
<span>{item.speed}</span>
<span>{item.eta}</span>
</>
)}
{isMultiTrack && (
<span>
{item.currentTrackNumber}/{item.totalTracks}
</span>
)}
</div>
);
};
const renderSummary = (item: QueueItem) => {
if (item.status !== "completed" || !item.summary) return null;
return (
<div className="text-xs text-gray-300 mt-1">
<span>
Success: <span className="text-green-500">{item.summary.successful}</span>
</span>{" "}
|{" "}
<span>
Skipped: <span className="text-yellow-500">{item.summary.skipped}</span>
</span>{" "}
|{" "}
<span>
Failed: <span className="text-red-500">{item.summary.failed}</span>
</span>
</div>
);
};
return (
<aside className="fixed top-0 right-0 h-full w-96 bg-gray-900 border-l border-gray-700 z-50 flex flex-col shadow-2xl">
<header className="flex justify-between items-center p-4 border-b border-gray-700 flex-shrink-0">
<h3 className="font-semibold text-lg">Download Queue ({items.length})</h3>
<button onClick={() => toggleVisibility()} className="text-gray-400 hover:text-white" title="Close">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<main className="p-3 flex-grow overflow-y-auto space-y-4">
{items.length === 0 ? (
<div className="text-gray-400 text-center py-10">
<p>The queue is empty.</p>
</div>
) : (
items.map((item) => (
<div key={item.id} className="text-sm bg-gray-800 p-3 rounded-md border border-gray-700">
<div className="flex justify-between items-start">
<span className="font-medium truncate pr-2 flex-grow">{item.name}</span>
<div className="fixed bottom-4 right-4 w-full max-w-md bg-white rounded-lg shadow-xl border border-gray-200 z-50">
<header className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-bold">Download Queue ({items.length})</h2>
<div className="flex gap-2">
<button
onClick={() => removeItem(item.id)}
className="text-gray-500 hover:text-red-500 flex-shrink-0"
title="Cancel Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-2 space-y-1">
{renderProgress(item)}
{renderStatusDetails(item)}
{renderSummary(item)}
{item.status === "error" && (
<div className="flex items-center justify-between mt-2">
<p className="text-red-500 text-xs truncate" title={item.error}>
{item.error || "An unknown error occurred."}
</p>
{item.canRetry && (
<button
onClick={() => retryItem(item.id)}
className="text-xs bg-blue-600 hover:bg-blue-700 text-white py-1 px-2 rounded"
>
Retry
</button>
)}
</div>
)}
</div>
</div>
))
)}
</main>
<footer className="p-3 border-t border-gray-700 flex-shrink-0 flex gap-2">
<button
onClick={handleClearQueue}
className="text-sm bg-red-800 hover:bg-red-700 text-white py-2 px-4 rounded w-full"
onClick={clearQueue}
className="text-sm text-gray-500 hover:text-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={items.length === 0}
aria-label="Clear all items in queue"
>
Clear All
</button>
<button
onClick={clearCompleted}
className="text-sm bg-gray-700 hover:bg-gray-600 text-white py-2 px-4 rounded w-full"
>
Clear Completed
<button onClick={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
<FaTimes />
</button>
</footer>
</aside>
</div>
</header>
<div className="p-4 overflow-y-auto max-h-96">
{items.length === 0 ? (
<p className="text-center text-gray-500 py-4">The queue is empty.</p>
) : (
items.map((item) => <QueueItemCard key={item.id} item={item} />)
)}
</div>
</div>
);
}
};

View File

@@ -24,13 +24,13 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
};
return (
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-300 ease-in-out">
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-shadow duration-300 ease-in-out">
<div className="relative">
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover" />
{onDownload && (
<button
onClick={onDownload}
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors shadow-lg opacity-0 group-hover:opacity-100"
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
title={`Download ${type}`}
>
<img src="/download.svg" alt="Download" className="w-5 h-5" />
@@ -38,7 +38,7 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
)}
</div>
<div className="p-4 flex-grow flex flex-col">
<Link to={getLinkPath()} className="font-semibold text-gray-900 dark:text-white truncate block hover:underline">
<Link to={getLinkPath()} className="font-semibold text-gray-900 dark:text-white truncate block">
{name}
</Link>
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}

View File

@@ -94,7 +94,7 @@ export function AccountsTab() {
<input
id="accountName"
{...register("accountName", { required: "This field is required" })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
</div>
@@ -104,7 +104,7 @@ export function AccountsTab() {
<textarea
id="authBlob"
{...register("authBlob", { required: activeService === "spotify" ? "Auth Blob is required" : false })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
></textarea>
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
@@ -116,7 +116,7 @@ export function AccountsTab() {
<input
id="arl"
{...register("arl", { required: activeService === "deezer" ? "ARL is required" : false })}
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
</div>
@@ -127,7 +127,7 @@ export function AccountsTab() {
id="accountRegion"
{...register("accountRegion")}
placeholder="e.g. US, GB"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex gap-2">
@@ -171,7 +171,10 @@ export function AccountsTab() {
) : (
<div className="space-y-2">
{credentials?.map((cred) => (
<div key={cred.name} className="flex justify-between items-center p-3 bg-gray-800 rounded-md">
<div
key={cred.name}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white rounded-md"
>
<span>{cred.name}</span>
<button
onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })}

View File

@@ -21,8 +21,8 @@ interface TaskStatusDTO {
current_track?: number;
total_tracks?: number;
summary?: {
successful_tracks: number;
skipped_tracks: number;
successful_tracks: string[];
skipped_tracks: string[];
failed_tracks: number;
failed_track_details: { name: string; reason: string }[];
};
@@ -51,14 +51,15 @@ interface TaskDTO {
[key: string]: unknown;
};
summary?: {
successful_tracks: number;
skipped_tracks: number;
successful_tracks: string[];
skipped_tracks: string[];
failed_tracks: number;
failed_track_details?: { name: string; reason: string }[];
};
}
const isTerminalStatus = (status: QueueStatus) => ["completed", "error", "cancelled", "skipped"].includes(status);
const isTerminalStatus = (status: QueueStatus) =>
["completed", "error", "cancelled", "skipped", "done"].includes(status);
export function QueueProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueueItem[]>(() => {
@@ -180,7 +181,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
// --- Core Action: Add Item ---
const addItem = useCallback(
async (item: { name: string; type: DownloadType; spotifyId: string }) => {
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
const internalId = uuidv4();
const newItem: QueueItem = {
...item,
@@ -191,11 +192,13 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (!isVisible) setIsVisible(true);
try {
// Use the specific type endpoints instead of a generic /download endpoint
let endpoint = "";
if (item.type === "track") {
endpoint = `/track/download/${item.spotifyId}`;
// WORKAROUND: Use the playlist endpoint for single tracks to avoid
// connection issues with the direct track downloader.
const trackUrl = `https://open.spotify.com/track/${item.spotifyId}`;
endpoint = `/playlist/download?url=${encodeURIComponent(trackUrl)}&name=${encodeURIComponent(item.name)}`;
} else if (item.type === "album") {
endpoint = `/album/download/${item.spotifyId}`;
} else if (item.type === "playlist") {

View File

@@ -93,10 +93,27 @@ const defaultSettings: FlatAppSettings = {
},
};
interface FetchedCamelCaseSettings {
watchEnabled?: boolean;
watch?: { enabled: boolean };
[key: string]: unknown;
}
const fetchSettings = async (): Promise<FlatAppSettings> => {
const { data } = await apiClient.get("/config");
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
apiClient.get("/config"),
apiClient.get("/config/watch"),
]);
const combinedConfig = {
...generalConfig,
watch: watchConfig,
};
// Transform the keys before returning the data
return convertKeysToCamelCase(data) as FlatAppSettings;
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
return camelData as unknown as FlatAppSettings;
};
export function SettingsProvider({ children }: { children: ReactNode }) {

View File

@@ -10,11 +10,13 @@ export type QueueStatus =
| "error"
| "skipped"
| "cancelled"
| "done"
| "queued";
export interface QueueItem {
id: string; // Unique ID for the queue item (can be task_id from backend)
name: string;
artist?: string;
type: DownloadType;
spotifyId: string; // Original Spotify ID
@@ -34,17 +36,17 @@ export interface QueueItem {
currentTrackNumber?: number;
totalTracks?: number;
summary?: {
successful: number;
skipped: number;
successful: string[];
skipped: string[];
failed: number;
failedTracks?: { name: string; reason: string }[];
failedTracks: { name: string; reason: string }[];
};
}
export interface QueueContextType {
items: QueueItem[];
isVisible: boolean;
addItem: (item: { name: string; type: DownloadType; spotifyId: string }) => void;
addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void;
removeItem: (id: string) => void;
retryItem: (id: string) => void;
clearQueue: () => void;

View File

@@ -1 +1,7 @@
@import "tailwindcss";
@layer base {
a {
@apply no-underline hover:underline cursor-pointer;
}
}

View File

@@ -8,15 +8,47 @@ import { Config } from "./routes/config";
import { Playlist } from "./routes/playlist";
import { History } from "./routes/history";
import { Watchlist } from "./routes/watchlist";
import apiClient from "./lib/api-client";
import type { SearchResult } from "./types/spotify";
const rootRoute = createRootRoute({
component: Root,
});
const indexRoute = createRoute({
export const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: Home,
validateSearch: (
search: Record<string, unknown>,
): { q?: string; type?: "track" | "album" | "artist" | "playlist" } => {
return {
q: search.q as string | undefined,
type: search.type as "track" | "album" | "artist" | "playlist" | undefined,
};
},
loaderDeps: ({ search: { q, type } }) => ({ q, type: type || "track" }),
loader: async ({ deps: { q, type } }) => {
if (!q || q.length < 3) return { items: [] };
const spotifyUrlRegex = /https:\/\/open\.spotify\.com\/(playlist|album|artist|track)\/([a-zA-Z0-9]+)/;
const match = q.match(spotifyUrlRegex);
if (match) {
const [, urlType, id] = match;
const response = await apiClient.get<SearchResult>(`/${urlType}/info?id=${id}`);
return { items: [{ ...response.data, model: urlType as "track" | "album" | "artist" | "playlist" }] };
}
const response = await apiClient.get<{ items: SearchResult[] }>(`/search?q=${q}&search_type=${type}&limit=50`);
const augmentedResults = response.data.items.map((item) => ({
...item,
model: type,
}));
return { items: augmentedResults };
},
gcTime: 5 * 60 * 1000, // 5 minutes
staleTime: 5 * 60 * 1000, // 5 minutes
});
const albumRoute = createRoute({

View File

@@ -5,6 +5,7 @@ import { QueueContext } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context";
import type { AlbumType, TrackType } from "../types/spotify";
import { toast } from "sonner";
import { FaArrowLeft } from "react-icons/fa";
export const Album = () => {
const { albumId } = useParams({ from: "/album/$albumId" });
@@ -70,6 +71,15 @@ export const Album = () => {
return (
<div className="space-y-6">
<div className="mb-6">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
>
<FaArrowLeft />
<span>Back to results</span>
</button>
</div>
<div className="flex flex-col md:flex-row items-start gap-6">
<img
src={album.images[0]?.url || "/placeholder.jpg"}

View File

@@ -5,6 +5,8 @@ import apiClient from "../lib/api-client";
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context";
import { useSettings } from "../contexts/settings-context";
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
import { AlbumCard } from "../components/AlbumCard";
export const Artist = () => {
const { artistId } = useParams({ from: "/artist/$artistId" });
@@ -25,36 +27,27 @@ export const Artist = () => {
const fetchArtistData = async () => {
if (!artistId) return;
try {
// Since the backend doesn't provide a single endpoint, we make multiple calls
const artistPromise = apiClient.get<ArtistType>(`/artist/info?id=${artistId}`);
const topTracksPromise = apiClient.get<{ tracks: TrackType[] }>(`/artist/${artistId}/top-tracks`);
const albumsPromise = apiClient.get<{ items: AlbumType[] }>(`/artist/${artistId}/albums`);
const watchStatusPromise = apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
const response = await apiClient.get<{ items: AlbumType[] }>(`/artist/info?id=${artistId}`);
const albumData = response.data;
const [artistRes, topTracksRes, albumsRes, watchStatusRes] = await Promise.allSettled([
artistPromise,
topTracksPromise,
albumsPromise,
watchStatusPromise,
]);
if (artistRes.status === "fulfilled") {
setArtist(artistRes.value.data);
if (albumData?.items && albumData.items.length > 0) {
const firstAlbum = albumData.items[0];
if (firstAlbum.artists && firstAlbum.artists.length > 0) {
setArtist(firstAlbum.artists[0]);
} else {
throw new Error("Failed to load artist details");
setError("Could not determine artist from album data.");
return;
}
setAlbums(albumData.items);
} else {
setError("No albums found for this artist.");
return;
}
if (topTracksRes.status === "fulfilled") {
setTopTracks(topTracksRes.value.data.tracks);
}
setTopTracks([]);
if (albumsRes.status === "fulfilled") {
setAlbums(albumsRes.value.data.items);
}
if (watchStatusRes.status === "fulfilled") {
setIsWatched(watchStatusRes.value.data.is_watched);
}
const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
setIsWatched(watchStatusResponse.data.is_watched);
} catch (err) {
setError("Failed to load artist page");
console.error(err);
@@ -70,6 +63,11 @@ export const Artist = () => {
addItem({ spotifyId: track.id, type: "track", name: track.name });
};
const handleDownloadAlbum = (album: AlbumType) => {
toast.info(`Adding ${album.name} to queue...`);
addItem({ spotifyId: album.id, type: "album", name: album.name });
};
const handleDownloadArtist = () => {
if (!artistId || !artist) return;
toast.info(`Adding ${artist.name} to queue...`);
@@ -105,51 +103,122 @@ export const Artist = () => {
return <div>Loading...</div>;
}
const filteredAlbums = albums.filter((album) => {
if (settings?.explicitFilter) {
return !album.name.toLowerCase().includes("remix");
if (!artist.name) {
return <div>Artist data could not be fully loaded. Please try again later.</div>;
}
return true;
});
const applyFilters = (items: AlbumType[]) => {
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
};
const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album"));
const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single"));
const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation"));
return (
<div className="artist-page">
<div className="artist-header">
<img src={artist.images[0]?.url} alt={artist.name} className="artist-image" />
<h1>{artist.name}</h1>
<div className="flex gap-2">
<button onClick={handleDownloadArtist} className="download-all-btn">
Download All
<div className="mb-6">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
>
<FaArrowLeft />
<span>Back to results</span>
</button>
<button onClick={handleToggleWatch} className="watch-btn">
{isWatched ? "Unwatch" : "Watch"}
</div>
<div className="artist-header mb-8 text-center">
{artist.images && artist.images.length > 0 && (
<img
src={artist.images[0]?.url}
alt={artist.name}
className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg"
/>
)}
<h1 className="text-5xl font-bold">{artist.name}</h1>
<div className="flex gap-4 justify-center mt-4">
<button
onClick={handleDownloadArtist}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
<FaDownload />
<span>Download All</span>
</button>
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${
isWatched
? "bg-blue-500 text-white border-blue-500"
: "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
{isWatched ? (
<>
<FaBookmark />
<span>Watching</span>
</>
) : (
<>
<FaRegBookmark />
<span>Watch</span>
</>
)}
</button>
</div>
</div>
<h2>Top Tracks</h2>
<div className="track-list">
{topTracks.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Top Tracks</h2>
<div className="track-list space-y-2">
{topTracks.map((track) => (
<div key={track.id} className="track-item">
<Link to="/track/$trackId" params={{ trackId: track.id }}>
<div
key={track.id}
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold">
{track.name}
</Link>
<button onClick={() => handleDownloadTrack(track)}>Download</button>
<button onClick={() => handleDownloadTrack(track)} className="download-btn">
Download
</button>
</div>
))}
</div>
</div>
)}
<h2>Albums</h2>
<div className="album-grid">
{filteredAlbums.map((album) => (
<div key={album.id} className="album-card">
<Link to="/album/$albumId" params={{ albumId: album.id }}>
<img src={album.images[0]?.url} alt={album.name} />
<p>{album.name}</p>
</Link>
</div>
{artistAlbums.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Albums</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistAlbums.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
))}
</div>
</div>
)}
{artistSingles.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Singles</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistSingles.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
))}
</div>
</div>
)}
{artistCompilations.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold mb-6">Compilations</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{artistCompilations.map((album) => (
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,63 +1,42 @@
import { useState, useEffect, useMemo, useContext, useCallback, useRef } from "react";
import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
import { useDebounce } from "use-debounce";
import apiClient from "@/lib/api-client";
import { toast } from "sonner";
import type { TrackType, AlbumType, ArtistType, PlaylistType } from "@/types/spotify";
import type { TrackType, AlbumType, ArtistType, PlaylistType, SearchResult } from "@/types/spotify";
import { QueueContext } from "@/contexts/queue-context";
import { SearchResultCard } from "@/components/SearchResultCard";
import { indexRoute } from "@/router";
const PAGE_SIZE = 12;
type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & {
model: "track" | "album" | "artist" | "playlist";
};
export const Home = () => {
const [query, setQuery] = useState("");
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">("track");
const [allResults, setAllResults] = useState<SearchResult[]>([]);
const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const navigate = useNavigate({ from: "/" });
const { q, type } = useSearch({ from: "/" });
const { items: allResults } = indexRoute.useLoaderData();
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
const [query, setQuery] = useState(q || "");
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track");
const [debouncedQuery] = useDebounce(query, 500);
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;
useEffect(() => {
if (debouncedQuery.length < 3) {
setAllResults([]);
setDisplayedResults([]);
return;
}
const performSearch = async () => {
setIsLoading(true);
try {
const response = await apiClient.get<{
items: SearchResult[];
}>(`/search?q=${debouncedQuery}&search_type=${searchType}&limit=50`);
const augmentedResults = response.data.items.map((item) => ({
...item,
model: searchType,
}));
setAllResults(augmentedResults);
setDisplayedResults(augmentedResults.slice(0, PAGE_SIZE));
} catch {
toast.error("Search failed. Please try again.");
} finally {
setIsLoading(false);
}
};
performSearch();
}, [debouncedQuery, searchType]);
const loadMore = useCallback(() => {
setIsLoadingMore(true);
setTimeout(() => {
@@ -93,7 +72,8 @@ export const Home = () => {
const handleDownloadTrack = useCallback(
(track: TrackType) => {
addItem({ spotifyId: track.id, type: "track", name: track.name });
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],
@@ -101,7 +81,8 @@ export const Home = () => {
const handleDownloadAlbum = useCallback(
(album: AlbumType) => {
addItem({ spotifyId: album.id, type: "album", name: album.name });
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],

View File

@@ -3,22 +3,10 @@ import { useEffect, useState, useContext } from "react";
import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner";
import type { ImageType, TrackType } from "../types/spotify";
import type { PlaylistType, TrackType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context";
interface PlaylistItemType {
track: TrackType | null;
}
interface PlaylistType {
id: string;
name: string;
description: string | null;
images: ImageType[];
tracks: {
items: PlaylistItemType[];
};
}
import { FaArrowLeft } from "react-icons/fa";
import { FaDownload } from "react-icons/fa6";
export const Playlist = () => {
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
@@ -95,11 +83,11 @@ export const Playlist = () => {
};
if (error) {
return <div className="text-red-500">{error}</div>;
return <div className="text-red-500 p-8 text-center">{error}</div>;
}
if (!playlist) {
return <div>Loading...</div>;
return <div className="p-8 text-center">Loading...</div>;
}
const filteredTracks = playlist.tracks.items.filter(({ track }) => {
@@ -109,35 +97,109 @@ export const Playlist = () => {
});
return (
<div className="playlist-page">
<div className="playlist-header">
<img src={playlist.images[0]?.url} alt={playlist.name} className="playlist-image" />
<div>
<h1>{playlist.name}</h1>
<p>{playlist.description}</p>
<div className="flex gap-2">
<button onClick={handleDownloadPlaylist} className="download-playlist-btn">
<div className="space-y-6">
<div className="mb-6">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
>
<FaArrowLeft />
<span>Back to results</span>
</button>
</div>
<div className="flex flex-col md:flex-row items-start gap-6">
<img
src={playlist.images[0]?.url || "/placeholder.jpg"}
alt={playlist.name}
className="w-48 h-48 object-cover rounded-lg shadow-lg"
/>
<div className="flex-grow space-y-2">
<h1 className="text-3xl font-bold">{playlist.name}</h1>
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>}
<div className="text-sm text-gray-400 dark:text-gray-500">
<p>
By {playlist.owner.display_name} {playlist.followers.total.toLocaleString()} followers {" "}
{playlist.tracks.total} songs
</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleDownloadPlaylist}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Download All
</button>
<button onClick={handleToggleWatch} className="watch-btn">
<button
onClick={handleToggleWatch}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
isWatched
? "bg-red-600 text-white hover:bg-red-700"
: "bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
>
<img
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
alt="Watch status"
className="w-5 h-5"
style={{ filter: !isWatched ? "invert(1)" : undefined }}
/>
{isWatched ? "Unwatch" : "Watch"}
</button>
</div>
</div>
</div>
<div className="track-list">
{filteredTracks.map(({ track }) => {
<div className="space-y-4">
<h2 className="text-xl font-semibold">Tracks</h2>
<div className="space-y-2">
{filteredTracks.map(({ track }, index) => {
if (!track) return null;
return (
<div key={track.id} className="track-item">
<Link to="/track/$trackId" params={{ trackId: track.id }}>
<div
key={track.id}
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
<img
src={track.album.images.at(-1)?.url}
alt={track.album.name}
className="w-10 h-10 object-cover rounded"
/>
<div>
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium hover:underline">
{track.name}
</Link>
<button onClick={() => handleDownloadTrack(track)}>Download</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 dark:text-gray-400">
{Math.floor(track.duration_ms / 60000)}:
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
</span>
<button
onClick={() => handleDownloadTrack(track)}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
title="Download"
>
<FaDownload />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -1,9 +1,17 @@
import { useParams } from "@tanstack/react-router";
import { Link, useParams } from "@tanstack/react-router";
import { useEffect, useState, useContext } from "react";
import apiClient from "../lib/api-client";
import type { TrackType } from "../types/spotify";
import { toast } from "sonner";
import { QueueContext } from "../contexts/queue-context";
import { FaSpotify, FaArrowLeft } from "react-icons/fa";
// Helper to format milliseconds to mm:ss
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, "0")}`;
};
export const Track = () => {
const { trackId } = useParams({ from: "/track/$trackId" });
@@ -37,18 +45,95 @@ export const Track = () => {
};
if (error) {
return <div className="text-red-500">{error}</div>;
return (
<div className="flex justify-center items-center h-full">
<p className="text-red-500 text-lg">{error}</p>
</div>
);
}
if (!track) {
return <div>Loading...</div>;
return (
<div className="flex justify-center items-center h-full">
<p className="text-lg">Loading...</p>
</div>
);
}
const imageUrl = track.album.images?.[0]?.url;
return (
<div className="track-page">
<h1>{track.name}</h1>
<p>by {track.artists.map((artist) => artist.name).join(", ")}</p>
<button onClick={handleDownloadTrack}>Download</button>
<div className="max-w-4xl mx-auto p-4 md:p-8">
<div className="mb-6">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
>
<FaArrowLeft />
<span>Back to results</span>
</button>
</div>
<div className="bg-white shadow-lg rounded-lg overflow-hidden md:flex">
{imageUrl && (
<div className="md:w-1/3">
<img src={imageUrl} alt={track.album.name} className="w-full h-auto object-cover" />
</div>
)}
<div className="p-6 md:w-2/3 flex flex-col justify-between">
<div>
<div className="flex items-baseline justify-between">
<h1 className="text-3xl font-bold text-gray-900">{track.name}</h1>
{track.explicit && (
<span className="text-xs bg-gray-700 text-white px-2 py-1 rounded-full">EXPLICIT</span>
)}
</div>
<div className="text-lg text-gray-600 mt-1">
{track.artists.map((artist, index) => (
<span key={artist.id}>
<Link to="/artist/$artistId" params={{ artistId: artist.id }}>
{artist.name}
</Link>
{index < track.artists.length - 1 && ", "}
</span>
))}
</div>
<p className="text-md text-gray-500 mt-4">
From the album{" "}
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="font-semibold">
{track.album.name}
</Link>
</p>
<div className="mt-4 text-sm text-gray-600">
<p>Release Date: {track.album.release_date}</p>
<p>Duration: {formatDuration(track.duration_ms)}</p>
</div>
<div className="mt-4">
<p className="text-sm text-gray-600">Popularity:</p>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div className="bg-green-500 h-2.5 rounded-full" style={{ width: `${track.popularity}%` }}></div>
</div>
</div>
</div>
<div className="flex items-center gap-4 mt-6">
<button
onClick={handleDownloadTrack}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-full transition duration-300"
>
Download
</button>
<a
href={track.external_urls.spotify}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-gray-700 hover:text-black transition duration-300"
aria-label="Listen on Spotify"
>
<FaSpotify size={24} />
<span className="font-semibold">Listen on Spotify</span>
</a>
</div>
</div>
</div>
</div>
);
};

View File

@@ -3,28 +3,16 @@ import apiClient from "../lib/api-client";
import { toast } from "sonner";
import { useSettings } from "../contexts/settings-context";
import { Link } from "@tanstack/react-router";
import type { ArtistType, PlaylistType } from "../types/spotify";
import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
// --- Type Definitions ---
interface Image {
url: string;
}
interface WatchedArtist {
itemType: "artist";
interface BaseWatched {
itemType: "artist" | "playlist";
spotify_id: string;
name: string;
images?: Image[];
total_albums?: number;
}
interface WatchedPlaylist {
itemType: "playlist";
spotify_id: string;
name: string;
images?: Image[];
owner?: { display_name?: string };
total_tracks?: number;
}
type WatchedArtist = ArtistType & { itemType: "artist" };
type WatchedPlaylist = PlaylistType & { itemType: "playlist" };
type WatchedItem = WatchedArtist | WatchedPlaylist;
@@ -37,12 +25,28 @@ export const Watchlist = () => {
setIsLoading(true);
try {
const [artistsRes, playlistsRes] = await Promise.all([
apiClient.get<Omit<WatchedArtist, "itemType">[]>("/artist/watch/list"),
apiClient.get<Omit<WatchedPlaylist, "itemType">[]>("/playlist/watch/list"),
apiClient.get<BaseWatched[]>("/artist/watch/list"),
apiClient.get<BaseWatched[]>("/playlist/watch/list"),
]);
const artists: WatchedItem[] = artistsRes.data.map((a) => ({ ...a, itemType: "artist" }));
const playlists: WatchedItem[] = playlistsRes.data.map((p) => ({ ...p, itemType: "playlist" }));
const artistDetailsPromises = artistsRes.data.map((artist) =>
apiClient.get<ArtistType>(`/artist/info?id=${artist.spotify_id}`),
);
const playlistDetailsPromises = playlistsRes.data.map((playlist) =>
apiClient.get<PlaylistType>(`/playlist/info?id=${playlist.spotify_id}`),
);
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([
Promise.all(artistDetailsPromises),
Promise.all(playlistDetailsPromises),
]);
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" }));
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({
...res.data,
itemType: "playlist",
spotify_id: res.data.id,
}));
setItems([...artists, ...playlists]);
} catch {
@@ -61,10 +65,10 @@ export const Watchlist = () => {
}, [settings, settingsLoading, fetchWatchlist]);
const handleUnwatch = async (item: WatchedItem) => {
toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), {
toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.id}`), {
loading: `Unwatching ${item.name}...`,
success: () => {
setItems((prev) => prev.filter((i) => i.spotify_id !== item.spotify_id));
setItems((prev) => prev.filter((i) => i.id !== item.id));
return `${item.name} has been unwatched.`;
},
error: `Failed to unwatch ${item.name}.`,
@@ -72,7 +76,7 @@ export const Watchlist = () => {
};
const handleCheck = async (item: WatchedItem) => {
toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), {
toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.id}`), {
loading: `Checking ${item.name} for updates...`,
success: (res: { data: { message?: string } }) => res.data.message || `Check triggered for ${item.name}.`,
error: `Failed to trigger check for ${item.name}.`,
@@ -119,14 +123,17 @@ export const Watchlist = () => {
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
<button onClick={handleCheckAll} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Check All
<button
onClick={handleCheckAll}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2"
>
<FaSearch /> Check All
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => (
<div key={item.spotify_id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
<a href={`/${item.itemType}/${item.spotify_id}`} className="flex-grow">
<div key={item.id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
<a href={`/${item.itemType}/${item.id}`} className="flex-grow">
<img
src={item.images?.[0]?.url || "/images/placeholder.jpg"}
alt={item.name}
@@ -138,15 +145,15 @@ export const Watchlist = () => {
<div className="flex gap-2 pt-2">
<button
onClick={() => handleUnwatch(item)}
className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 flex items-center justify-center gap-2"
>
Unwatch
<FaRegTrashAlt /> Unwatch
</button>
<button
onClick={() => handleCheck(item)}
className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700"
className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center justify-center gap-2"
>
Check
<FaSearch /> Check
</button>
</div>
</div>

View File

@@ -7,11 +7,14 @@ export interface ImageType {
export interface ArtistType {
id: string;
name: string;
images: ImageType[];
images?: ImageType[];
}
export interface TrackAlbumInfo {
id: string;
name: string;
images: ImageType[];
release_date: string;
}
export interface TrackType {
@@ -21,11 +24,16 @@ export interface TrackType {
duration_ms: number;
explicit: boolean;
album: TrackAlbumInfo;
popularity: number;
external_urls: {
spotify: string;
};
}
export interface AlbumType {
id: string;
name: string;
album_type: "album" | "single" | "compilation";
artists: ArtistType[];
images: ImageType[];
release_date: string;
@@ -39,9 +47,16 @@ export interface AlbumType {
}
export interface PlaylistItemType {
added_at: string;
is_local: boolean;
track: TrackType | null;
}
export interface PlaylistOwnerType {
id: string;
display_name: string;
}
export interface PlaylistType {
id: string;
name: string;
@@ -49,8 +64,14 @@ export interface PlaylistType {
images: ImageType[];
tracks: {
items: PlaylistItemType[];
total: number;
};
owner: {
display_name: string;
owner: PlaylistOwnerType;
followers: {
total: number;
};
}
export type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & {
model: "track" | "album" | "artist" | "playlist";
};