Added custom theming to UI
This commit is contained in:
@@ -11,7 +11,7 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
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="group flex flex-col rounded-lg overflow-hidden bg-surface dark:bg-surface-secondary-dark hover:bg-surface-secondary dark:hover:bg-surface-muted-dark 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" />
|
||||
@@ -21,10 +21,10 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
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"
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
title="Download album"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
@@ -33,11 +33,11 @@ export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
<Link
|
||||
to="/album/$albumId"
|
||||
params={{ albumId: album.id }}
|
||||
className="font-semibold text-gray-900 dark:text-white truncate block"
|
||||
className="font-semibold text-content-primary dark:text-content-primary-dark truncate block"
|
||||
>
|
||||
{album.name}
|
||||
</Link>
|
||||
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}
|
||||
{subtitle && <p className="text-sm text-content-secondary dark:text-content-secondary-dark mt-1 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,72 +15,83 @@ const isTerminalStatus = (status: QueueStatus) =>
|
||||
|
||||
const statusStyles: Record<
|
||||
QueueStatus,
|
||||
{ icon: React.ReactNode; color: string; bgColor: string; name: string }
|
||||
{ icon: React.ReactNode; color: string; bgColor: string; borderColor: string; name: string }
|
||||
> = {
|
||||
queued: {
|
||||
icon: <FaHourglassHalf />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
icon: <FaHourglassHalf className="icon-muted" />,
|
||||
color: "text-content-muted dark:text-content-muted-dark",
|
||||
bgColor: "bg-gradient-to-r from-surface-muted to-surface-accent dark:from-surface-muted-dark dark:to-surface-accent-dark",
|
||||
borderColor: "border-border dark:border-border-dark",
|
||||
name: "Queued",
|
||||
},
|
||||
initializing: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-100",
|
||||
icon: <FaSync className="animate-spin icon-accent" />,
|
||||
color: "text-info",
|
||||
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
|
||||
borderColor: "border-info/30 dark:border-info/40",
|
||||
name: "Initializing",
|
||||
},
|
||||
downloading: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-100",
|
||||
icon: <FaSync className="animate-spin icon-accent" />,
|
||||
color: "text-info",
|
||||
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
|
||||
borderColor: "border-info/30 dark:border-info/40",
|
||||
name: "Downloading",
|
||||
},
|
||||
processing: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-100",
|
||||
icon: <FaSync className="animate-spin icon-warning" />,
|
||||
color: "text-processing",
|
||||
bgColor: "bg-gradient-to-r from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/30",
|
||||
borderColor: "border-processing/30 dark:border-processing/40",
|
||||
name: "Processing",
|
||||
},
|
||||
retrying: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-100",
|
||||
icon: <FaSync className="animate-spin icon-warning" />,
|
||||
color: "text-warning",
|
||||
bgColor: "bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/30",
|
||||
borderColor: "border-warning/30 dark:border-warning/40",
|
||||
name: "Retrying",
|
||||
},
|
||||
completed: {
|
||||
icon: <FaCheckCircle />,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-100",
|
||||
icon: <FaCheckCircle className="icon-success" />,
|
||||
color: "text-success",
|
||||
bgColor: "bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/30",
|
||||
borderColor: "border-success/30 dark:border-success/40",
|
||||
name: "Completed",
|
||||
},
|
||||
done: {
|
||||
icon: <FaCheckCircle />,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-100",
|
||||
icon: <FaCheckCircle className="icon-success" />,
|
||||
color: "text-success",
|
||||
bgColor: "bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/30",
|
||||
borderColor: "border-success/30 dark:border-success/40",
|
||||
name: "Done",
|
||||
},
|
||||
error: {
|
||||
icon: <FaExclamationCircle />,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-100",
|
||||
icon: <FaExclamationCircle className="icon-error" />,
|
||||
color: "text-error",
|
||||
bgColor: "bg-gradient-to-r from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/30",
|
||||
borderColor: "border-error/30 dark:border-error/40",
|
||||
name: "Error",
|
||||
},
|
||||
cancelled: {
|
||||
icon: <FaTimes />,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-100",
|
||||
icon: <FaTimes className="icon-warning" />,
|
||||
color: "text-warning",
|
||||
bgColor: "bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/30",
|
||||
borderColor: "border-warning/30 dark:border-warning/40",
|
||||
name: "Cancelled",
|
||||
},
|
||||
skipped: {
|
||||
icon: <FaTimes />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
icon: <FaTimes className="icon-muted" />,
|
||||
color: "text-content-muted dark:text-content-muted-dark",
|
||||
bgColor: "bg-gradient-to-r from-surface-muted to-surface-accent dark:from-surface-muted-dark dark:to-surface-accent-dark",
|
||||
borderColor: "border-border dark:border-border-dark",
|
||||
name: "Skipped",
|
||||
},
|
||||
pending: {
|
||||
icon: <FaHourglassHalf />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
icon: <FaHourglassHalf className="icon-muted" />,
|
||||
color: "text-content-muted dark:text-content-muted-dark",
|
||||
bgColor: "bg-gradient-to-r from-surface-muted to-surface-accent dark:from-surface-muted-dark dark:to-surface-accent-dark",
|
||||
borderColor: "border-border dark:border-border-dark",
|
||||
name: "Pending",
|
||||
},
|
||||
};
|
||||
@@ -116,24 +127,26 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
const progressText = getProgressText();
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg shadow-md mb-3 transition-all duration-300 ${statusInfo.bgColor}`}>
|
||||
<div className={`p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] ${statusInfo.bgColor} ${statusInfo.borderColor}`}>
|
||||
<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 items-center gap-4 min-w-0 flex-1">
|
||||
<div className={`text-2xl ${statusInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm`}>
|
||||
{statusInfo.icon}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
{item.type === "track" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
<FaMusic className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
{item.albumName && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.albumName}>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.albumName}>
|
||||
{item.albumName}
|
||||
</p>
|
||||
)}
|
||||
@@ -142,16 +155,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
{item.type === "album" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCompactDisc className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
<FaCompactDisc className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
{item.currentTrackTitle && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -160,16 +173,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
{item.type === "playlist" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
<FaMusic className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.playlistOwner}>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.playlistOwner}>
|
||||
{item.playlistOwner}
|
||||
</p>
|
||||
{item.currentTrackTitle && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -177,60 +190,82 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 ml-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 className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${statusInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
|
||||
{statusInfo.name}
|
||||
</div>
|
||||
{progressText && <p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{isTerminal ? (
|
||||
<button
|
||||
onClick={() => removeItem?.(item.id)}
|
||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<FaTimes className="text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => cancelItem?.(item.id)}
|
||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-warning hover:bg-warning/10 transition-all duration-200 shadow-sm"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<FaTimes className="text-sm" />
|
||||
</button>
|
||||
)}
|
||||
{item.canRetry && (
|
||||
<button
|
||||
onClick={() => retryItem?.(item.id)}
|
||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-info hover:bg-info/10 transition-all duration-200 shadow-sm"
|
||||
aria-label="Retry"
|
||||
>
|
||||
<FaSync className="text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isTerminal ? (
|
||||
<button
|
||||
onClick={() => removeItem?.(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => cancelItem?.(item.id)}
|
||||
className="text-gray-400 hover:text-orange-500 transition-colors"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<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.status === "error" || item.status === "retrying") && item.error && (
|
||||
<p className="text-xs text-red-600 mt-2">Error: {item.error}</p>
|
||||
<div className="mt-3 p-2 bg-error/10 border border-error/20 rounded-lg">
|
||||
<p className="text-xs text-error font-medium">Error: {item.error}</p>
|
||||
</div>
|
||||
)}
|
||||
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
||||
<div className="mt-2 text-xs">
|
||||
{item.summary.total_failed > 0 && (
|
||||
<p className="text-red-600">{item.summary.total_failed} track(s) failed.</p>
|
||||
)}
|
||||
{item.summary.total_skipped > 0 && (
|
||||
<p className="text-yellow-600">{item.summary.total_skipped} track(s) skipped.</p>
|
||||
)}
|
||||
<div className="mt-3 p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
|
||||
<div className="flex gap-4 text-xs">
|
||||
{item.summary.total_failed > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-error rounded-full"></div>
|
||||
<span className="text-error font-medium">{item.summary.total_failed} failed</span>
|
||||
</span>
|
||||
)}
|
||||
{item.summary.total_skipped > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-warning rounded-full"></div>
|
||||
<span className="text-warning font-medium">{item.summary.total_skipped} skipped</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(item.status === "downloading" || item.status === "processing") &&
|
||||
item.type === "track" &&
|
||||
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 className="mt-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-content-muted dark:text-content-muted-dark">Progress</span>
|
||||
<span className="text-xs font-semibold text-content-primary dark:text-content-primary-dark">{item.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||
item.status === "downloading" ? "bg-info" : "bg-processing"
|
||||
}`}
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -249,13 +284,15 @@ export const Queue = () => {
|
||||
const hasFinished = items.some((item) => isTerminalStatus(item.status));
|
||||
|
||||
return (
|
||||
<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="fixed bottom-4 right-4 w-full max-w-md bg-surface dark:bg-surface-dark rounded-xl shadow-2xl border border-border dark:border-border-dark z-50 backdrop-blur-sm">
|
||||
<header className="flex items-center justify-between p-4 border-b border-border dark:border-border-dark bg-gradient-to-r from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark rounded-t-xl">
|
||||
<h2 className="text-lg font-bold text-content-primary dark:text-content-primary-dark">
|
||||
Download Queue ({items.length})
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelAll}
|
||||
className="text-sm text-gray-500 hover:text-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="text-sm text-content-muted dark:text-content-muted-dark hover:text-warning transition-colors px-2 py-1 rounded-md hover:bg-warning/10"
|
||||
disabled={!hasActive}
|
||||
aria-label="Cancel all active downloads"
|
||||
>
|
||||
@@ -263,20 +300,30 @@ export const Queue = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCompleted}
|
||||
className="text-sm text-gray-500 hover:text-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="text-sm text-content-muted dark:text-content-muted-dark hover:text-success transition-colors px-2 py-1 rounded-md hover:bg-success/10"
|
||||
disabled={!hasFinished}
|
||||
aria-label="Clear all finished downloads"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
<button onClick={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
|
||||
<FaTimes />
|
||||
<button
|
||||
onClick={toggleVisibility}
|
||||
className="text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark p-1 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
|
||||
aria-label="Close queue"
|
||||
>
|
||||
<FaTimes className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 overflow-y-auto max-h-96">
|
||||
<div className="p-4 overflow-y-auto max-h-96 bg-gradient-to-b from-surface-secondary/30 to-surface/30 dark:from-surface-secondary-dark/30 dark:to-surface-dark/30">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-4">The queue is empty.</p>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-muted dark:bg-surface-muted-dark flex items-center justify-center">
|
||||
<FaMusic className="text-2xl icon-muted" />
|
||||
</div>
|
||||
<p className="text-content-muted dark:text-content-muted-dark">The queue is empty.</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">Downloads will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
||||
)}
|
||||
|
||||
@@ -24,24 +24,24 @@ 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 transition-shadow duration-300 ease-in-out">
|
||||
<div className="group flex flex-col rounded-lg overflow-hidden bg-surface dark:bg-surface-secondary-dark hover:bg-surface-secondary dark:hover:bg-surface-muted-dark 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-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
title={`Download ${type}`}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex-grow flex flex-col">
|
||||
<Link to={getLinkPath()} className="font-semibold text-gray-900 dark:text-white truncate block">
|
||||
<Link to={getLinkPath()} className="font-semibold text-content-primary dark:text-content-primary-dark truncate block">
|
||||
{name}
|
||||
</Link>
|
||||
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}
|
||||
{subtitle && <p className="text-sm text-content-secondary dark:text-content-secondary-dark mt-1 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,61 +87,61 @@ export function AccountsTab() {
|
||||
};
|
||||
|
||||
const renderAddForm = () => (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border rounded-lg mt-4 space-y-4">
|
||||
<h4 className="font-semibold">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-line dark:border-border-dark rounded-lg mt-4 space-y-4">
|
||||
<h4 className="font-semibold text-content-primary dark:text-content-primary-dark">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="accountName">Account Name</label>
|
||||
<label htmlFor="accountName" className="text-content-primary dark:text-content-primary-dark">Account Name</label>
|
||||
<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 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
|
||||
{errors.accountName && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.accountName.message}</p>}
|
||||
</div>
|
||||
{activeService === "spotify" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="authBlob">Auth Blob (JSON)</label>
|
||||
<label htmlFor="authBlob" className="text-content-primary dark:text-content-primary-dark">Auth Blob (JSON)</label>
|
||||
<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 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
rows={4}
|
||||
></textarea>
|
||||
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
|
||||
{errors.authBlob && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.authBlob.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{activeService === "deezer" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="arl">ARL Token</label>
|
||||
<label htmlFor="arl" className="text-content-primary dark:text-content-primary-dark">ARL Token</label>
|
||||
<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 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
|
||||
{errors.arl && <p className="text-error-text bg-error-muted px-2 py-1 rounded text-sm">{errors.arl.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="accountRegion">Region (Optional)</label>
|
||||
<label htmlFor="accountRegion" className="text-content-primary dark:text-content-primary-dark">Region (Optional)</label>
|
||||
<input
|
||||
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 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{addMutation.isPending ? "Saving..." : "Save Account"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAdding(false)}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -151,35 +151,35 @@ export function AccountsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b">
|
||||
<div className="flex gap-2 border-b border-line dark:border-border-dark">
|
||||
<button
|
||||
onClick={() => setActiveService("spotify")}
|
||||
className={`p-2 ${activeService === "spotify" ? "border-b-2 border-blue-500 font-semibold" : ""}`}
|
||||
className={`p-2 text-content-primary dark:text-content-primary-dark ${activeService === "spotify" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
Spotify
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveService("deezer")}
|
||||
className={`p-2 ${activeService === "deezer" ? "border-b-2 border-blue-500 font-semibold" : ""}`}
|
||||
className={`p-2 text-content-primary dark:text-content-primary-dark ${activeService === "deezer" ? "border-b-2 border-primary font-semibold" : ""}`}
|
||||
>
|
||||
Deezer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p>Loading accounts...</p>
|
||||
<p className="text-content-muted dark:text-content-muted-dark">Loading accounts...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{credentials?.map((cred) => (
|
||||
<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"
|
||||
className="flex justify-between items-center p-3 bg-surface-muted dark:bg-surface-muted-dark text-content-primary dark:text-content-primary-dark rounded-md"
|
||||
>
|
||||
<span>{cred.name}</span>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })}
|
||||
disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
className="text-error hover:text-error-hover icon-error"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -191,7 +191,7 @@ export function AccountsTab() {
|
||||
{!isAdding && (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
|
||||
@@ -89,36 +89,36 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Download Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Download Behavior</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Download Behavior</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label>
|
||||
<label htmlFor="maxConcurrentDownloads" className="text-content-primary dark:text-content-primary-dark">Max Concurrent Downloads</label>
|
||||
<input
|
||||
id="maxConcurrentDownloads"
|
||||
type="number"
|
||||
min="1"
|
||||
{...register("maxConcurrentDownloads")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="realTimeToggle">Real-time downloading</label>
|
||||
<label htmlFor="realTimeToggle" className="text-content-primary dark:text-content-primary-dark">Real-time downloading</label>
|
||||
<input id="realTimeToggle" type="checkbox" {...register("realTime")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="fallbackToggle">Download Fallback</label>
|
||||
<label htmlFor="fallbackToggle" className="text-content-primary dark:text-content-primary-dark">Download Fallback</label>
|
||||
<input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Quality Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Source Quality</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Source Quality</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
||||
<label htmlFor="spotifyQuality" className="text-content-primary dark:text-content-primary-dark">Spotify Quality</label>
|
||||
<select
|
||||
id="spotifyQuality"
|
||||
{...register("spotifyQuality")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="NORMAL">OGG 96kbps</option>
|
||||
<option value="HIGH">OGG 160kbps</option>
|
||||
@@ -126,31 +126,31 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="deezerQuality">Deezer Quality</label>
|
||||
<label htmlFor="deezerQuality" className="text-content-primary dark:text-content-primary-dark">Deezer Quality</label>
|
||||
<select
|
||||
id="deezerQuality"
|
||||
{...register("deezerQuality")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="MP3_128">MP3 128kbps</option>
|
||||
<option value="MP3_320">MP3 320kbps</option>
|
||||
<option value="FLAC">FLAC (HiFi)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
This sets the quality of the original download. Conversion settings below are applied after download.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conversion Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Conversion</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Conversion</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="convertToSelect">Convert To Format</label>
|
||||
<label htmlFor="convertToSelect" className="text-content-primary dark:text-content-primary-dark">Convert To Format</label>
|
||||
<select
|
||||
id="convertToSelect"
|
||||
{...register("convertTo")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="">No Conversion</option>
|
||||
{Object.keys(CONVERSION_FORMATS).map((format) => (
|
||||
@@ -161,11 +161,11 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="bitrateSelect">Bitrate</label>
|
||||
<label htmlFor="bitrateSelect" className="text-content-primary dark:text-content-primary-dark">Bitrate</label>
|
||||
<select
|
||||
id="bitrateSelect"
|
||||
{...register("bitrate")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0}
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
@@ -180,35 +180,35 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
|
||||
{/* Retry Options */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Retries</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Retries</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="maxRetries">Max Retry Attempts</label>
|
||||
<label htmlFor="maxRetries" className="text-content-primary dark:text-content-primary-dark">Max Retry Attempts</label>
|
||||
<input
|
||||
id="maxRetries"
|
||||
type="number"
|
||||
min="0"
|
||||
{...register("maxRetries")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label>
|
||||
<label htmlFor="retryDelaySeconds" className="text-content-primary dark:text-content-primary-dark">Initial Retry Delay (s)</label>
|
||||
<input
|
||||
id="retryDelaySeconds"
|
||||
type="number"
|
||||
min="1"
|
||||
{...register("retryDelaySeconds")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label>
|
||||
<label htmlFor="retryDelayIncrease" className="text-content-primary dark:text-content-primary-dark">Retry Delay Increase (s)</label>
|
||||
<input
|
||||
id="retryDelayIncrease"
|
||||
type="number"
|
||||
min="0"
|
||||
{...register("retryDelayIncrease")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +216,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Download Settings"}
|
||||
</button>
|
||||
|
||||
@@ -50,7 +50,7 @@ const placeholders = {
|
||||
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||
<select
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
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 text-sm mt-1"
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus text-sm mt-1"
|
||||
>
|
||||
<option value="">-- Insert Placeholder --</option>
|
||||
{Object.entries(placeholders).map(([group, options]) => (
|
||||
@@ -104,15 +104,15 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading formatting settings...</div>;
|
||||
return <div className="text-content-muted dark:text-content-muted-dark">Loading formatting settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">File Naming</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">File Naming</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="customDirFormat">Custom Directory Format</label>
|
||||
<label htmlFor="customDirFormat" className="text-content-primary dark:text-content-primary-dark">Custom Directory Format</label>
|
||||
<input
|
||||
id="customDirFormat"
|
||||
type="text"
|
||||
@@ -121,12 +121,12 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
dirFormatRef(e);
|
||||
dirInputRef.current = e;
|
||||
}}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="customTrackFormat">Custom Track Format</label>
|
||||
<label htmlFor="customTrackFormat" className="text-content-primary dark:text-content-primary-dark">Custom Track Format</label>
|
||||
<input
|
||||
id="customTrackFormat"
|
||||
type="text"
|
||||
@@ -135,12 +135,12 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
trackFormatRef(e);
|
||||
trackInputRef.current = e;
|
||||
}}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
|
||||
<label htmlFor="tracknumPaddingToggle" className="text-content-primary dark:text-content-primary-dark">Track Number Padding</label>
|
||||
<input
|
||||
id="tracknumPaddingToggle"
|
||||
type="checkbox"
|
||||
@@ -149,7 +149,7 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="saveCoverToggle">Save Album Cover</label>
|
||||
<label htmlFor="saveCoverToggle" className="text-content-primary dark:text-content-primary-dark">Save Album Cover</label>
|
||||
<input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Formatting Settings"}
|
||||
</button>
|
||||
|
||||
@@ -70,18 +70,18 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
};
|
||||
|
||||
const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading;
|
||||
if (isLoading) return <p>Loading general settings...</p>;
|
||||
if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading general settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Service Defaults</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Service Defaults</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="service">Default Service</label>
|
||||
<label htmlFor="service" className="text-content-primary dark:text-content-primary-dark">Default Service</label>
|
||||
<select
|
||||
id="service"
|
||||
{...register("service")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="deezer">Deezer</option>
|
||||
@@ -90,13 +90,13 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Spotify Settings</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="spotifyAccount">Active Spotify Account</label>
|
||||
<label htmlFor="spotifyAccount" className="text-content-primary dark:text-content-primary-dark">Active Spotify Account</label>
|
||||
<select
|
||||
id="spotifyAccount"
|
||||
{...register("spotify")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
{spotifyAccounts?.map((acc) => (
|
||||
<option key={acc.name} value={acc.name}>
|
||||
@@ -108,13 +108,13 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Deezer Settings</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Deezer Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="deezerAccount">Active Deezer Account</label>
|
||||
<label htmlFor="deezerAccount" className="text-content-primary dark:text-content-primary-dark">Active Deezer Account</label>
|
||||
<select
|
||||
id="deezerAccount"
|
||||
{...register("deezer")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
{deezerAccounts?.map((acc) => (
|
||||
<option key={acc.name} value={acc.name}>
|
||||
@@ -126,17 +126,17 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Content Filters</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Content Filters</h3>
|
||||
<div className="form-item--row">
|
||||
<label>Filter Explicit Content</label>
|
||||
<label className="text-content-primary dark:text-content-primary-dark">Filter Explicit Content</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-green-400" : "text-red-400"}`}>
|
||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
|
||||
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
|
||||
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-inverse dark:text-content-inverse-dark px-2 py-1 rounded-full">ENV</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
The explicit content filter is controlled by an environment variable and cannot be changed here.
|
||||
</p>
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save General Settings"}
|
||||
</button>
|
||||
|
||||
@@ -62,34 +62,34 @@ function SpotifyApiForm() {
|
||||
|
||||
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
|
||||
|
||||
if (isLoading) return <p>Loading Spotify API settings...</p>;
|
||||
if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading Spotify API settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="client_id">Client ID</label>
|
||||
<label htmlFor="client_id" className="text-content-primary dark:text-content-primary-dark">Client ID</label>
|
||||
<input
|
||||
id="client_id"
|
||||
type="password"
|
||||
{...register("client_id")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="client_secret">Client Secret</label>
|
||||
<label htmlFor="client_secret" className="text-content-primary dark:text-content-primary-dark">Client Secret</label>
|
||||
<input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
{...register("client_secret")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Spotify API"}
|
||||
</button>
|
||||
@@ -126,22 +126,22 @@ function WebhookForm() {
|
||||
|
||||
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
|
||||
|
||||
if (isLoading) return <p>Loading Webhook settings...</p>;
|
||||
if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading Webhook settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="webhookUrl">Webhook URL</label>
|
||||
<label htmlFor="webhookUrl" className="text-content-primary dark:text-content-primary-dark">Webhook URL</label>
|
||||
<input
|
||||
id="webhookUrl"
|
||||
type="url"
|
||||
{...register("url")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label>Webhook Events</label>
|
||||
<label className="text-content-primary dark:text-content-primary-dark">Webhook Events</label>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{data?.available_events.map((event) => (
|
||||
<Controller
|
||||
@@ -149,7 +149,7 @@ function WebhookForm() {
|
||||
name="events"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded"
|
||||
@@ -171,7 +171,7 @@ function WebhookForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Webhook"}
|
||||
</button>
|
||||
@@ -179,7 +179,7 @@ function WebhookForm() {
|
||||
type="button"
|
||||
onClick={() => testMutation.mutate(currentUrl)}
|
||||
disabled={!currentUrl || testMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md disabled:opacity-50"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
@@ -192,14 +192,14 @@ export function ServerTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Spotify API</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify API</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
|
||||
<SpotifyApiForm />
|
||||
</div>
|
||||
<hr className="border-gray-600" />
|
||||
<hr className="border-border dark:border-border-dark" />
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Webhooks</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Webhooks</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
Get notifications for events like download completion. (Currently disabled)
|
||||
</p>
|
||||
<WebhookForm />
|
||||
|
||||
@@ -62,33 +62,33 @@ export function WatchTab() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading watch settings...</div>;
|
||||
return <div className="text-content-muted dark:text-content-muted-dark">Loading watch settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Watchlist Behavior</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
|
||||
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
|
||||
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label>
|
||||
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label>
|
||||
<input
|
||||
id="watchPollIntervalSeconds"
|
||||
type="number"
|
||||
min="60"
|
||||
{...register("watchPollIntervalSeconds")}
|
||||
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 bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">How often to check watched items for updates.</p>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">How often to check watched items for updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Artist Album Groups</h3>
|
||||
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p>
|
||||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Artist Album Groups</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">Select which album groups to monitor for watched artists.</p>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{ALBUM_GROUPS.map((group) => (
|
||||
<Controller
|
||||
@@ -96,7 +96,7 @@ export function WatchTab() {
|
||||
name="watchedArtistAlbumGroup"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded"
|
||||
@@ -118,7 +118,7 @@ export function WatchTab() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Watch Settings"}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,319 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Background Colors */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-secondary: #f8fafc;
|
||||
--color-surface-muted: #f1f5f9;
|
||||
--color-surface-accent: #e2e8f0;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.95);
|
||||
|
||||
/* Dark mode backgrounds */
|
||||
--color-surface-dark: #0f172a;
|
||||
--color-surface-secondary-dark: #1e293b;
|
||||
--color-surface-muted-dark: #334155;
|
||||
--color-surface-accent-dark: #475569;
|
||||
--color-surface-overlay-dark: rgba(15, 23, 42, 0.95);
|
||||
|
||||
/* Text Colors */
|
||||
--color-content-primary: #0f172a;
|
||||
--color-content-secondary: #475569;
|
||||
--color-content-muted: #64748b;
|
||||
--color-content-accent: #94a3b8;
|
||||
--color-content-inverse: #ffffff;
|
||||
|
||||
/* Dark mode text */
|
||||
--color-content-primary-dark: #f8fafc;
|
||||
--color-content-secondary-dark: #e2e8f0;
|
||||
--color-content-muted-dark: #cbd5e1;
|
||||
--color-content-accent-dark: #94a3b8;
|
||||
--color-content-inverse-dark: #0f172a;
|
||||
|
||||
/* Interactive Colors */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
--color-primary-active: #1d4ed8;
|
||||
--color-primary-muted: #dbeafe;
|
||||
|
||||
--color-secondary: #64748b;
|
||||
--color-secondary-hover: #475569;
|
||||
--color-secondary-active: #334155;
|
||||
--color-secondary-muted: #f1f5f9;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #22c55e;
|
||||
--color-success-hover: #16a34a;
|
||||
--color-success-muted: #dcfce7;
|
||||
--color-success-text: #15803d;
|
||||
|
||||
--color-error: #ef4444;
|
||||
--color-error-hover: #dc2626;
|
||||
--color-error-muted: #fef2f2;
|
||||
--color-error-text: #dc2626;
|
||||
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-hover: #d97706;
|
||||
--color-warning-muted: #fef3c7;
|
||||
--color-warning-text: #d97706;
|
||||
|
||||
--color-info: #3b82f6;
|
||||
--color-info-hover: #2563eb;
|
||||
--color-info-muted: #dbeafe;
|
||||
--color-info-text: #2563eb;
|
||||
|
||||
--color-processing: #a855f7;
|
||||
--color-processing-hover: #9333ea;
|
||||
--color-processing-muted: #f3e8ff;
|
||||
--color-processing-text: #9333ea;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-muted: #f1f5f9;
|
||||
--color-border-accent: #cbd5e1;
|
||||
--color-border-focus: #3b82f6;
|
||||
--color-border-dark: #475569;
|
||||
--color-border-muted-dark: #334155;
|
||||
--color-border-accent-dark: #64748b;
|
||||
|
||||
/* Input Colors */
|
||||
--color-input-background: #f1f5f9;
|
||||
--color-input-border: #e2e8f0;
|
||||
--color-input-focus: #3b82f6;
|
||||
--color-input-background-dark: #1e293b;
|
||||
--color-input-border-dark: #475569;
|
||||
|
||||
/* Button Colors */
|
||||
--color-button-primary: #3b82f6;
|
||||
--color-button-primary-hover: #2563eb;
|
||||
--color-button-primary-text: #ffffff;
|
||||
|
||||
--color-button-secondary: #f1f5f9;
|
||||
--color-button-secondary-hover: #64748b;
|
||||
--color-button-secondary-text: #64748b;
|
||||
--color-button-secondary-text-hover: #ffffff;
|
||||
|
||||
--color-button-success: #22c55e;
|
||||
--color-button-success-hover: #16a34a;
|
||||
--color-button-success-text: #ffffff;
|
||||
|
||||
--color-icon-button-hover: #e5e7eb;
|
||||
--color-icon-button-hover-dark: #334155;
|
||||
|
||||
/* Icon Colors */
|
||||
--color-icon-primary: #475569;
|
||||
--color-icon-primary-hover: #334155;
|
||||
--color-icon-primary-active: #1e293b;
|
||||
--color-icon-primary-dark: #cbd5e1;
|
||||
--color-icon-primary-hover-dark: #e2e8f0;
|
||||
--color-icon-primary-active-dark: #f8fafc;
|
||||
|
||||
--color-icon-secondary: #64748b;
|
||||
--color-icon-secondary-hover: #475569;
|
||||
--color-icon-secondary-active: #334155;
|
||||
--color-icon-secondary-dark: #94a3b8;
|
||||
--color-icon-secondary-hover-dark: #cbd5e1;
|
||||
--color-icon-secondary-active-dark: #e2e8f0;
|
||||
|
||||
--color-icon-muted: #94a3b8;
|
||||
--color-icon-muted-hover: #64748b;
|
||||
--color-icon-muted-active: #475569;
|
||||
--color-icon-muted-dark: #64748b;
|
||||
--color-icon-muted-hover-dark: #94a3b8;
|
||||
--color-icon-muted-active-dark: #cbd5e1;
|
||||
|
||||
--color-icon-accent: #3b82f6;
|
||||
--color-icon-accent-hover: #2563eb;
|
||||
--color-icon-accent-active: #1d4ed8;
|
||||
--color-icon-accent-dark: #60a5fa;
|
||||
--color-icon-accent-hover-dark: #93c5fd;
|
||||
--color-icon-accent-active-dark: #bfdbfe;
|
||||
|
||||
--color-icon-success: #22c55e;
|
||||
--color-icon-success-hover: #16a34a;
|
||||
--color-icon-success-active: #15803d;
|
||||
--color-icon-success-dark: #4ade80;
|
||||
--color-icon-success-hover-dark: #86efac;
|
||||
--color-icon-success-active-dark: #bbf7d0;
|
||||
|
||||
--color-icon-error: #ef4444;
|
||||
--color-icon-error-hover: #dc2626;
|
||||
--color-icon-error-active: #b91c1c;
|
||||
--color-icon-error-dark: #f87171;
|
||||
--color-icon-error-hover-dark: #fca5a5;
|
||||
--color-icon-error-active-dark: #fecaca;
|
||||
|
||||
--color-icon-warning: #f59e0b;
|
||||
--color-icon-warning-hover: #d97706;
|
||||
--color-icon-warning-active: #b45309;
|
||||
--color-icon-warning-dark: #fbbf24;
|
||||
--color-icon-warning-hover-dark: #fcd34d;
|
||||
--color-icon-warning-active-dark: #fde68a;
|
||||
|
||||
--color-icon-inverse: #ffffff;
|
||||
--color-icon-inverse-dark: #0f172a;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
@apply no-underline hover:underline cursor-pointer;
|
||||
}
|
||||
|
||||
/* SVG Icon Utility Classes - Using filters for img elements */
|
||||
.icon-primary {
|
||||
fill: var(--color-icon-primary);
|
||||
color: var(--color-icon-primary);
|
||||
filter: brightness(0) saturate(100%) invert(27%) sepia(8%) saturate(1363%) hue-rotate(183deg) brightness(96%) contrast(89%);
|
||||
}
|
||||
.dark .icon-primary {
|
||||
fill: var(--color-icon-primary-dark);
|
||||
color: var(--color-icon-primary-dark);
|
||||
filter: brightness(0) saturate(100%) invert(83%) sepia(8%) saturate(1018%) hue-rotate(183deg) brightness(96%) contrast(91%);
|
||||
}
|
||||
.icon-primary:hover {
|
||||
fill: var(--color-icon-primary-hover);
|
||||
color: var(--color-icon-primary-hover);
|
||||
filter: brightness(0) saturate(100%) invert(21%) sepia(12%) saturate(1190%) hue-rotate(183deg) brightness(96%) contrast(92%);
|
||||
}
|
||||
.dark .icon-primary:hover {
|
||||
fill: var(--color-icon-primary-hover-dark);
|
||||
color: var(--color-icon-primary-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(90%) sepia(6%) saturate(668%) hue-rotate(183deg) brightness(97%) contrast(91%);
|
||||
}
|
||||
|
||||
.icon-secondary {
|
||||
fill: var(--color-icon-secondary);
|
||||
color: var(--color-icon-secondary);
|
||||
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%);
|
||||
}
|
||||
.dark .icon-secondary {
|
||||
fill: var(--color-icon-secondary-dark);
|
||||
color: var(--color-icon-secondary-dark);
|
||||
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%);
|
||||
}
|
||||
.icon-secondary:hover {
|
||||
fill: var(--color-icon-secondary-hover);
|
||||
color: var(--color-icon-secondary-hover);
|
||||
filter: brightness(0) saturate(100%) invert(27%) sepia(8%) saturate(1363%) hue-rotate(183deg) brightness(96%) contrast(89%);
|
||||
}
|
||||
.dark .icon-secondary:hover {
|
||||
fill: var(--color-icon-secondary-hover-dark);
|
||||
color: var(--color-icon-secondary-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(83%) sepia(8%) saturate(1018%) hue-rotate(183deg) brightness(96%) contrast(91%);
|
||||
}
|
||||
|
||||
.icon-muted {
|
||||
fill: var(--color-icon-muted);
|
||||
color: var(--color-icon-muted);
|
||||
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%);
|
||||
}
|
||||
.dark .icon-muted {
|
||||
fill: var(--color-icon-muted-dark);
|
||||
color: var(--color-icon-muted-dark);
|
||||
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%);
|
||||
}
|
||||
.icon-muted:hover {
|
||||
fill: var(--color-icon-muted-hover);
|
||||
color: var(--color-icon-muted-hover);
|
||||
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%);
|
||||
}
|
||||
.dark .icon-muted:hover {
|
||||
fill: var(--color-icon-muted-hover-dark);
|
||||
color: var(--color-icon-muted-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%);
|
||||
}
|
||||
|
||||
.icon-accent {
|
||||
fill: var(--color-icon-accent);
|
||||
color: var(--color-icon-accent);
|
||||
filter: brightness(0) saturate(100%) invert(41%) sepia(96%) saturate(1347%) hue-rotate(215deg) brightness(99%) contrast(86%);
|
||||
}
|
||||
.dark .icon-accent {
|
||||
fill: var(--color-icon-accent-dark);
|
||||
color: var(--color-icon-accent-dark);
|
||||
filter: brightness(0) saturate(100%) invert(67%) sepia(25%) saturate(2334%) hue-rotate(215deg) brightness(102%) contrast(96%);
|
||||
}
|
||||
.icon-accent:hover {
|
||||
fill: var(--color-icon-accent-hover);
|
||||
color: var(--color-icon-accent-hover);
|
||||
filter: brightness(0) saturate(100%) invert(29%) sepia(81%) saturate(2476%) hue-rotate(215deg) brightness(98%) contrast(86%);
|
||||
}
|
||||
.dark .icon-accent:hover {
|
||||
fill: var(--color-icon-accent-hover-dark);
|
||||
color: var(--color-icon-accent-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(75%) sepia(28%) saturate(1388%) hue-rotate(215deg) brightness(104%) contrast(96%);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
fill: var(--color-icon-success);
|
||||
color: var(--color-icon-success);
|
||||
filter: brightness(0) saturate(100%) invert(47%) sepia(95%) saturate(450%) hue-rotate(92deg) brightness(98%) contrast(91%);
|
||||
}
|
||||
.dark .icon-success {
|
||||
fill: var(--color-icon-success-dark);
|
||||
color: var(--color-icon-success-dark);
|
||||
filter: brightness(0) saturate(100%) invert(64%) sepia(78%) saturate(394%) hue-rotate(92deg) brightness(101%) contrast(89%);
|
||||
}
|
||||
.icon-success:hover {
|
||||
fill: var(--color-icon-success-hover);
|
||||
color: var(--color-icon-success-hover);
|
||||
filter: brightness(0) saturate(100%) invert(42%) sepia(78%) saturate(440%) hue-rotate(92deg) brightness(96%) contrast(89%);
|
||||
}
|
||||
.dark .icon-success:hover {
|
||||
fill: var(--color-icon-success-hover-dark);
|
||||
color: var(--color-icon-success-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(75%) sepia(64%) saturate(295%) hue-rotate(92deg) brightness(103%) contrast(87%);
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
fill: var(--color-icon-error);
|
||||
color: var(--color-icon-error);
|
||||
filter: brightness(0) saturate(100%) invert(30%) sepia(93%) saturate(1742%) hue-rotate(339deg) brightness(98%) contrast(94%);
|
||||
}
|
||||
.dark .icon-error {
|
||||
fill: var(--color-icon-error-dark);
|
||||
color: var(--color-icon-error-dark);
|
||||
filter: brightness(0) saturate(100%) invert(62%) sepia(93%) saturate(1360%) hue-rotate(339deg) brightness(99%) contrast(96%);
|
||||
}
|
||||
.icon-error:hover {
|
||||
fill: var(--color-icon-error-hover);
|
||||
color: var(--color-icon-error-hover);
|
||||
filter: brightness(0) saturate(100%) invert(17%) sepia(95%) saturate(2341%) hue-rotate(339deg) brightness(96%) contrast(94%);
|
||||
}
|
||||
.dark .icon-error:hover {
|
||||
fill: var(--color-icon-error-hover-dark);
|
||||
color: var(--color-icon-error-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(74%) sepia(29%) saturate(1214%) hue-rotate(339deg) brightness(99%) contrast(94%);
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
fill: var(--color-icon-warning);
|
||||
color: var(--color-icon-warning);
|
||||
filter: brightness(0) saturate(100%) invert(67%) sepia(98%) saturate(1284%) hue-rotate(12deg) brightness(95%) contrast(92%);
|
||||
}
|
||||
.dark .icon-warning {
|
||||
fill: var(--color-icon-warning-dark);
|
||||
color: var(--color-icon-warning-dark);
|
||||
filter: brightness(0) saturate(100%) invert(84%) sepia(36%) saturate(1043%) hue-rotate(12deg) brightness(97%) contrast(91%);
|
||||
}
|
||||
.icon-warning:hover {
|
||||
fill: var(--color-icon-warning-hover);
|
||||
color: var(--color-icon-warning-hover);
|
||||
filter: brightness(0) saturate(100%) invert(58%) sepia(89%) saturate(1619%) hue-rotate(12deg) brightness(94%) contrast(96%);
|
||||
}
|
||||
.dark .icon-warning:hover {
|
||||
fill: var(--color-icon-warning-hover-dark);
|
||||
color: var(--color-icon-warning-hover-dark);
|
||||
filter: brightness(0) saturate(100%) invert(89%) sepia(21%) saturate(789%) hue-rotate(12deg) brightness(98%) contrast(88%);
|
||||
}
|
||||
|
||||
.icon-inverse {
|
||||
fill: var(--color-icon-inverse);
|
||||
color: var(--color-icon-inverse);
|
||||
filter: brightness(0) saturate(100%) invert(100%);
|
||||
}
|
||||
.dark .icon-inverse {
|
||||
fill: var(--color-icon-inverse-dark);
|
||||
color: var(--color-icon-inverse-dark);
|
||||
filter: brightness(0) saturate(100%) invert(5%) sepia(8%) saturate(7470%) hue-rotate(183deg) brightness(97%) contrast(108%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ export const Album = () => {
|
||||
<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"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -87,8 +87,8 @@ export const Album = () => {
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold">{album.name}</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{album.name}</h1>
|
||||
<p className="text-lg text-content-secondary dark:text-content-secondary-dark">
|
||||
By{" "}
|
||||
{album.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
@@ -99,16 +99,16 @@ export const Album = () => {
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-600">{album.label}</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownloadAlbum}
|
||||
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
className="w-full px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
|
||||
}
|
||||
@@ -119,33 +119,33 @@ export const Album = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
|
||||
<div className="space-y-2">
|
||||
{album.tracks.items.map((track, index) => {
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50"
|
||||
className="flex items-center justify-between p-3 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||
<p className="font-medium text-gray-500">Explicit track filtered</p>
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-8 text-right">{index + 1}</span>
|
||||
<p className="font-medium text-content-muted dark:text-content-muted-dark">Explicit track filtered</p>
|
||||
</div>
|
||||
<span className="text-gray-500">--:--</span>
|
||||
<span className="text-content-muted dark:text-content-muted-dark">--:--</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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"
|
||||
className="flex items-center justify-between p-3 hover:bg-surface-muted dark:hover:bg-surface-muted-dark 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>
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-8 text-right">{index + 1}</span>
|
||||
<div>
|
||||
<p className="font-medium">{track.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="font-medium text-content-primary dark:text-content-primary-dark">{track.name}</p>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link
|
||||
@@ -164,16 +164,16 @@ export const Album = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-content-muted dark:text-content-muted-dark">
|
||||
{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"
|
||||
className="p-2 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-secondary hover:icon-success" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,9 +133,9 @@ export const Artist = () => {
|
||||
<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"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -147,31 +147,31 @@ export const Artist = () => {
|
||||
className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-5xl font-bold">{artist.name}</h1>
|
||||
<h1 className="text-5xl font-bold text-content-primary dark:text-content-primary-dark">{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"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-md transition-colors"
|
||||
>
|
||||
<FaDownload />
|
||||
<FaDownload className="icon-inverse" />
|
||||
<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"
|
||||
? "bg-button-primary text-button-primary-text border-primary"
|
||||
: "bg-surface dark:bg-surface-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark border-border dark:border-border-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
{isWatched ? (
|
||||
<>
|
||||
<FaBookmark />
|
||||
<FaBookmark className="icon-inverse" />
|
||||
<span>Watching</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegBookmark />
|
||||
<FaRegBookmark className="icon-primary" />
|
||||
<span>Watch</span>
|
||||
</>
|
||||
)}
|
||||
@@ -181,17 +181,20 @@ export const Artist = () => {
|
||||
|
||||
{topTracks.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Top Tracks</h2>
|
||||
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">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"
|
||||
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
|
||||
>
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold">
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold text-content-primary dark:text-content-primary-dark">
|
||||
{track.name}
|
||||
</Link>
|
||||
<button onClick={() => handleDownloadTrack(track)} className="download-btn">
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="px-3 py-1 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
@@ -202,7 +205,7 @@ export const Artist = () => {
|
||||
|
||||
{artistAlbums.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Albums</h2>
|
||||
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">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)} />
|
||||
@@ -213,7 +216,7 @@ export const Artist = () => {
|
||||
|
||||
{artistSingles.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Singles</h2>
|
||||
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">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)} />
|
||||
@@ -224,7 +227,7 @@ export const Artist = () => {
|
||||
|
||||
{artistCompilations.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Compilations</h2>
|
||||
<h2 className="text-3xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">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)} />
|
||||
|
||||
@@ -14,8 +14,8 @@ const ConfigComponent = () => {
|
||||
const { settings: config, isLoading } = useSettings();
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (isLoading) return <p className="text-center">Loading configuration...</p>;
|
||||
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
|
||||
if (isLoading) return <p className="text-center text-content-muted dark:text-content-muted-dark">Loading configuration...</p>;
|
||||
if (!config) return <p className="text-center text-error-text bg-error-muted p-2 rounded">Error loading configuration.</p>;
|
||||
|
||||
switch (activeTab) {
|
||||
case "general":
|
||||
@@ -38,8 +38,8 @@ const ConfigComponent = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Configuration</h1>
|
||||
<p className="text-gray-500">Manage application settings and services.</p>
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
|
||||
<p className="text-content-muted dark:text-content-muted-dark">Manage application settings and services.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
@@ -47,37 +47,37 @@ const ConfigComponent = () => {
|
||||
<nav className="flex flex-col space-y-1">
|
||||
<button
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "general" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "general" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("downloads")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "downloads" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "downloads" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
Downloads
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("formatting")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "formatting" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "formatting" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
Formatting
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("accounts")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "accounts" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "accounts" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
Accounts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("watch")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "watch" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "watch" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
Watch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("server")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "server" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "server" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
>
|
||||
Server
|
||||
</button>
|
||||
|
||||
@@ -32,10 +32,10 @@ type HistoryEntry = {
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
COMPLETED: "text-green-500",
|
||||
ERROR: "text-red-500",
|
||||
CANCELLED: "text-gray-500",
|
||||
SKIPPED: "text-yellow-500",
|
||||
COMPLETED: "text-success",
|
||||
ERROR: "text-error",
|
||||
CANCELLED: "text-content-muted dark:text-content-muted-dark",
|
||||
SKIPPED: "text-warning",
|
||||
};
|
||||
|
||||
const QUALITY_MAP: Record<string, Record<string, string>> = {
|
||||
@@ -140,12 +140,12 @@ export const History = () => {
|
||||
const statusKey = (status || "").toUpperCase();
|
||||
const statusClass =
|
||||
{
|
||||
COMPLETED: "text-green-500",
|
||||
SUCCESSFUL: "text-green-500",
|
||||
ERROR: "text-red-500",
|
||||
FAILED: "text-red-500",
|
||||
CANCELLED: "text-gray-500",
|
||||
SKIPPED: "text-yellow-500",
|
||||
COMPLETED: "text-success",
|
||||
SUCCESSFUL: "text-success",
|
||||
ERROR: "text-error",
|
||||
FAILED: "text-error",
|
||||
CANCELLED: "text-content-muted dark:text-content-muted-dark",
|
||||
SKIPPED: "text-warning",
|
||||
}[statusKey] || "text-gray-500";
|
||||
|
||||
return <span className={`font-semibold ${statusClass}`}>{status}</span>;
|
||||
@@ -304,43 +304,43 @@ export const History = () => {
|
||||
<div className="space-y-4">
|
||||
{parentTaskId && parentTask ? (
|
||||
<div className="space-y-4">
|
||||
<button onClick={viewParentTask} className="flex items-center gap-2 text-sm hover:underline">
|
||||
<button onClick={viewParentTask} className="flex items-center gap-2 text-sm hover:underline text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark">
|
||||
← Back to All History
|
||||
</button>
|
||||
<div className="rounded-lg border bg-gradient-to-br from-card to-muted/30 p-6 shadow-lg">
|
||||
<div className="rounded-lg border border-border dark:border-border-dark bg-gradient-to-br from-surface to-surface-muted dark:from-surface-dark dark:to-surface-muted-dark p-6 shadow-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{parentTask.item_name}</h2>
|
||||
<p className="text-xl text-muted-foreground">{parentTask.item_artist}</p>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-content-primary dark:text-content-primary-dark">{parentTask.item_name}</h2>
|
||||
<p className="text-xl text-content-secondary dark:text-content-secondary-dark">{parentTask.item_artist}</p>
|
||||
<div className="pt-2">
|
||||
<span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-secondary text-secondary-foreground">
|
||||
<span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark">
|
||||
{parentTask.download_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm md:text-right">
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${
|
||||
className={`inline-flex items-center rounded-full border border-border dark:border-border-dark px-3 py-1 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||
STATUS_CLASS[parentTask.status_final]
|
||||
}`}
|
||||
>
|
||||
{parentTask.status_final}
|
||||
</div>
|
||||
<p className="text-muted-foreground pt-2">
|
||||
<span className="font-semibold text-foreground">Quality: </span>
|
||||
<p className="text-content-muted dark:text-content-muted-dark pt-2">
|
||||
<span className="font-semibold text-content-primary dark:text-content-primary-dark">Quality: </span>
|
||||
{formatQuality(parentTask)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Completed: </span>
|
||||
<p className="text-content-muted dark:text-content-muted-dark">
|
||||
<span className="font-semibold text-content-primary dark:text-content-primary-dark">Completed: </span>
|
||||
{new Date(parentTask.timestamp_completed * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold tracking-tight pt-4">Tracks</h3>
|
||||
<h3 className="text-2xl font-bold tracking-tight pt-4 text-content-primary dark:text-content-primary-dark">Tracks</h3>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="text-3xl font-bold">Download History</h1>
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Download History</h1>
|
||||
)}
|
||||
|
||||
{/* Filter Controls */}
|
||||
@@ -349,7 +349,7 @@ export const History = () => {
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
@@ -360,7 +360,7 @@ export const History = () => {
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="track">Track</option>
|
||||
@@ -371,14 +371,14 @@ export const History = () => {
|
||||
<select
|
||||
value={trackStatusFilter}
|
||||
onChange={(e) => setTrackStatusFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="">All Track Statuses</option>
|
||||
<option value="SUCCESSFUL">Successful</option>
|
||||
<option value="SKIPPED">Skipped</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showChildTracks}
|
||||
@@ -397,7 +397,7 @@ export const History = () => {
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="p-2 text-left">
|
||||
<th key={header.id} className="p-2 text-left text-content-primary dark:text-content-primary-dark">
|
||||
{header.isPlaceholder ? null : (
|
||||
<div
|
||||
{...{
|
||||
@@ -417,13 +417,13 @@ export const History = () => {
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center p-4">
|
||||
<td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center p-4">
|
||||
<td colSpan={columns.length} className="text-center p-4 text-content-muted dark:text-content-muted-dark">
|
||||
No history entries found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -433,17 +433,17 @@ export const History = () => {
|
||||
!row.original.parent_task_id &&
|
||||
(row.original.download_type === "album" || row.original.download_type === "playlist");
|
||||
const isChild = !!row.original.parent_task_id;
|
||||
let rowClass = "hover:bg-muted/50";
|
||||
let rowClass = "hover:bg-surface-muted dark:hover:bg-surface-muted-dark";
|
||||
if (isParent) {
|
||||
rowClass += " bg-muted/50 font-semibold hover:bg-muted";
|
||||
rowClass += " bg-surface-accent dark:bg-surface-accent-dark font-semibold hover:bg-surface-muted dark:hover:bg-surface-muted-dark";
|
||||
} else if (isChild) {
|
||||
rowClass += " border-t border-dashed border-muted-foreground/20";
|
||||
rowClass += " border-t border-dashed border-content-muted dark:border-content-muted-dark border-opacity-20";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={row.id} className={`border-b border-border ${rowClass}`}>
|
||||
<tr key={row.id} className={`border-b border-border dark:border-border-dark ${rowClass}`}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="p-3">
|
||||
<td key={cell.id} className="p-3 text-content-primary dark:text-content-primary-dark">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
@@ -460,11 +460,11 @@ export const History = () => {
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="p-2 border rounded-md disabled:opacity-50"
|
||||
className="p-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
<span className="text-content-primary dark:text-content-primary-dark">
|
||||
Page{" "}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
@@ -473,14 +473,14 @@ export const History = () => {
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="p-2 border rounded-md disabled:opacity-50"
|
||||
className="p-2 border bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover border-border dark:border-border-dark rounded-md disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
|
||||
@@ -130,19 +130,19 @@ export const Home = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Search Spotify</h1>
|
||||
<h1 className="text-2xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Search Spotify</h1>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for a track, album, or artist"
|
||||
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex-1 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
/>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")}
|
||||
className="p-2 border rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="track">Track</option>
|
||||
<option value="album">Album</option>
|
||||
@@ -150,13 +150,13 @@ export const Home = () => {
|
||||
<option value="playlist">Playlist</option>
|
||||
</select>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-center my-4">Loading results...</p>
|
||||
) : (
|
||||
<>
|
||||
{resultComponent}
|
||||
<div ref={loaderRef} />
|
||||
{isLoadingMore && <p className="text-center my-4">Loading more results...</p>}
|
||||
{isLoading ? (
|
||||
<p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading results...</p>
|
||||
) : (
|
||||
<>
|
||||
{resultComponent}
|
||||
<div ref={loaderRef} />
|
||||
{isLoadingMore && <p className="text-center my-4 text-content-muted dark:text-content-muted-dark">Loading more results...</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -179,9 +179,9 @@ export const Playlist = () => {
|
||||
<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"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,11 +194,11 @@ export const Playlist = () => {
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold">{playlistMetadata.name}</h1>
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{playlistMetadata.name}</h1>
|
||||
{playlistMetadata.description && (
|
||||
<p className="text-gray-500 dark:text-gray-400">{playlistMetadata.description}</p>
|
||||
<p className="text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-400 dark:text-gray-500">
|
||||
<div className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
<p>
|
||||
By {playlistMetadata.owner.display_name} • {playlistMetadata.followers.total.toLocaleString()} followers •{" "}
|
||||
{totalTracks} songs
|
||||
@@ -207,7 +207,7 @@ export const Playlist = () => {
|
||||
<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"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-colors"
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
@@ -215,15 +215,14 @@ export const Playlist = () => {
|
||||
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"
|
||||
? "bg-error hover:bg-error-hover text-button-primary-text"
|
||||
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className="w-5 h-5"
|
||||
style={{ filter: !isWatched ? "invert(1)" : undefined }}
|
||||
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "icon-primary"}`}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
@@ -234,9 +233,9 @@ export const Playlist = () => {
|
||||
{/* Tracks Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
|
||||
{tracks.length > 0 && (
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
Showing {tracks.length} of {totalTracks} tracks
|
||||
</span>
|
||||
)}
|
||||
@@ -248,20 +247,20 @@ export const Playlist = () => {
|
||||
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"
|
||||
className="flex items-center justify-between p-3 hover:bg-surface-muted dark:hover:bg-surface-muted-dark 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>
|
||||
<span className="text-content-muted dark:text-content-muted-dark 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">
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium hover:underline text-content-primary dark:text-content-primary-dark">
|
||||
{track.name}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||
@@ -274,16 +273,16 @@ export const Playlist = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-content-muted dark:text-content-muted-dark">
|
||||
{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"
|
||||
className="p-2 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<FaDownload />
|
||||
<FaDownload className="icon-secondary hover:icon-success" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +292,7 @@ export const Playlist = () => {
|
||||
{/* Loading indicator */}
|
||||
{loadingTracks && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -304,7 +303,7 @@ export const Playlist = () => {
|
||||
|
||||
{/* End of tracks indicator */}
|
||||
{!hasMoreTracks && tracks.length > 0 && (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
|
||||
All tracks loaded
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,25 +15,25 @@ function AppLayout() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="min-h-screen bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark">
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm">
|
||||
<div className="container mx-auto h-14 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
|
||||
<img src="/music.svg" alt="Logo" className="w-6 h-6 icon-inverse" />
|
||||
<h1 className="text-xl font-bold">Spotizerr</h1>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/watchlist" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" />
|
||||
<Link to="/watchlist" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
|
||||
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 icon-inverse hover:icon-accent" />
|
||||
</Link>
|
||||
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/history.svg" alt="History" className="w-6 h-6" />
|
||||
<Link to="/history" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
|
||||
<img src="/history.svg" alt="History" className="w-6 h-6 icon-inverse hover:icon-accent" />
|
||||
</Link>
|
||||
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/settings.svg" alt="Settings" className="w-6 h-6" />
|
||||
<Link to="/config" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
|
||||
<img src="/settings.svg" alt="Settings" className="w-6 h-6 icon-inverse hover:icon-accent" />
|
||||
</Link>
|
||||
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/queue.svg" alt="Queue" className="w-6 h-6" />
|
||||
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
|
||||
<img src="/queue.svg" alt="Queue" className="w-6 h-6 icon-inverse hover:icon-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ function AppLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
export const Root = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
@@ -58,4 +58,4 @@ export function Root() {
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Track = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-red-500 text-lg">{error}</p>
|
||||
<p className="text-error text-lg">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export const Track = () => {
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-lg">Loading...</p>
|
||||
<p className="text-lg text-content-muted dark:text-content-muted-dark">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,13 +67,13 @@ export const Track = () => {
|
||||
<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"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white shadow-lg rounded-lg overflow-hidden md:flex">
|
||||
<div className="bg-surface dark:bg-surface-secondary-dark 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" />
|
||||
@@ -82,12 +82,12 @@ export const Track = () => {
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{track.name}</h1>
|
||||
{track.explicit && (
|
||||
<span className="text-xs bg-gray-700 text-white px-2 py-1 rounded-full">EXPLICIT</span>
|
||||
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-inverse dark:text-content-inverse-dark px-2 py-1 rounded-full">EXPLICIT</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-lg text-gray-600 mt-1">
|
||||
<div className="text-lg text-content-secondary dark:text-content-secondary-dark mt-1">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }}>
|
||||
@@ -97,27 +97,27 @@ export const Track = () => {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-md text-gray-500 mt-4">
|
||||
<p className="text-md text-content-muted dark:text-content-muted-dark 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">
|
||||
<div className="mt-4 text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
<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>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">Popularity:</p>
|
||||
<div className="w-full bg-surface-muted dark:bg-surface-muted-dark rounded-full h-2.5">
|
||||
<div className="bg-success 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"
|
||||
className="bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-2 px-4 rounded-full transition duration-300"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
@@ -125,10 +125,10 @@ export const Track = () => {
|
||||
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"
|
||||
className="flex items-center gap-2 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300"
|
||||
aria-label="Listen on Spotify"
|
||||
>
|
||||
<FaSpotify size={24} />
|
||||
<FaSpotify size={24} className="icon-secondary hover:icon-primary" />
|
||||
<span className="font-semibold">Listen on Spotify</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -95,15 +95,15 @@ export const Watchlist = () => {
|
||||
};
|
||||
|
||||
if (isLoading || settingsLoading) {
|
||||
return <div className="text-center">Loading Watchlist...</div>;
|
||||
return <div className="text-center text-content-muted dark:text-content-muted-dark">Loading Watchlist...</div>;
|
||||
}
|
||||
|
||||
if (!settings?.watch?.enabled) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2>
|
||||
<p>The watchlist feature is currently disabled. You can enable it in the settings.</p>
|
||||
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block">
|
||||
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist Disabled</h2>
|
||||
<p className="text-content-secondary dark:text-content-secondary-dark">The watchlist feature is currently disabled. You can enable it in the settings.</p>
|
||||
<Link to="/config" className="text-primary hover:underline mt-4 inline-block">
|
||||
Go to Settings
|
||||
</Link>
|
||||
</div>
|
||||
@@ -113,8 +113,8 @@ export const Watchlist = () => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<h2 className="text-2xl font-bold mb-2">Watchlist is Empty</h2>
|
||||
<p>Start watching artists or playlists to see them here.</p>
|
||||
<h2 className="text-2xl font-bold mb-2 text-content-primary dark:text-content-primary-dark">Watchlist is Empty</h2>
|
||||
<p className="text-content-secondary dark:text-content-secondary-dark">Start watching artists or playlists to see them here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,38 +122,38 @@ export const Watchlist = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Watched Artists & Playlists</h1>
|
||||
<button
|
||||
onClick={handleCheckAll}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2"
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md flex items-center gap-2"
|
||||
>
|
||||
<FaSearch /> Check All
|
||||
<FaSearch className="icon-inverse" /> 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.id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
|
||||
<div key={item.id} className="bg-surface dark:bg-surface-secondary-dark 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}
|
||||
className="w-full h-auto object-cover rounded-md aspect-square"
|
||||
/>
|
||||
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
|
||||
<h3 className="font-bold pt-2 truncate text-content-primary dark:text-content-primary-dark">{item.name}</h3>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark capitalize">{item.itemType}</p>
|
||||
</a>
|
||||
<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 flex items-center justify-center gap-2"
|
||||
className="w-full px-3 py-1.5 text-sm bg-error hover:bg-error-hover text-button-primary-text rounded-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaRegTrashAlt /> Unwatch
|
||||
<FaRegTrashAlt className="icon-inverse" /> 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 flex items-center justify-center gap-2"
|
||||
className="w-full px-3 py-1.5 text-sm bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaSearch /> Check
|
||||
<FaSearch className="icon-secondary hover:icon-primary" /> Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user