fix search items
This commit is contained in:
@@ -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",
|
||||
|
||||
13
spotizerr-ui/pnpm-lock.yaml
generated
13
spotizerr-ui/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
44
spotizerr-ui/src/components/AlbumCard.tsx
Normal file
44
spotizerr-ui/src/components/AlbumCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
<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={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={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-3 flex-grow overflow-y-auto space-y-4">
|
||||
<div className="p-4 overflow-y-auto max-h-96">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-gray-400 text-center py-10">
|
||||
<p>The queue is empty.</p>
|
||||
</div>
|
||||
<p className="text-center text-gray-500 py-4">The queue is empty.</p>
|
||||
) : (
|
||||
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>
|
||||
<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>
|
||||
))
|
||||
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
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>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
@apply no-underline hover:underline cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
setError("Could not determine artist from album data.");
|
||||
return;
|
||||
}
|
||||
setAlbums(albumData.items);
|
||||
} else {
|
||||
throw new Error("Failed to load artist details");
|
||||
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");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!artist.name) {
|
||||
return <div>Artist data could not be fully loaded. Please try again later.</div>;
|
||||
}
|
||||
|
||||
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>
|
||||
</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="watch-btn">
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
<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.map((track) => (
|
||||
<div key={track.id} className="track-item">
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }}>
|
||||
{track.name}
|
||||
</Link>
|
||||
<button onClick={() => handleDownloadTrack(track)}>Download</button>
|
||||
{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 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)} className="download-btn">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,34 +97,108 @@ 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 }) => {
|
||||
if (!track) return null;
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }}>
|
||||
{track.name}
|
||||
</Link>
|
||||
<button onClick={() => handleDownloadTrack(track)}>Download</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user