improved mobile responsiveness + logo

This commit is contained in:
Xoconoch
2025-07-27 13:06:48 -06:00
parent 60c922e200
commit 454b22c282
16 changed files with 215 additions and 101 deletions

View File

@@ -82,7 +82,7 @@ define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.qqj3c2rpjk8" "revision": "0.ef94id05f1c"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 B

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -8,19 +8,71 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const publicDir = join(__dirname, '../public'); const publicDir = join(__dirname, '../public');
const pngPath = join(publicDir, 'spotizerr.png'); const svgPath = join(publicDir, 'spotizerr.svg');
async function generateIcons() { async function generateIcons() {
try { try {
// Check if the PNG file exists // Check if the SVG file exists
if (!existsSync(pngPath)) { if (!existsSync(svgPath)) {
throw new Error(`PNG file not found at ${pngPath}. Please ensure spotizerr.png exists in the public directory.`); throw new Error(`SVG file not found at ${svgPath}. Please ensure spotizerr.svg exists in the public directory.`);
} }
console.log('🎨 Generating PWA icons from PNG...'); console.log('🎨 Generating PWA icons from SVG...');
// Since the source is already 1667x1667 (square), we don't need to worry about aspect ratio // First, convert SVG to PNG and process it
const sourceSize = 1667; console.log('📐 Processing SVG: converting to PNG, scaling by 0.67, and centering in black box...');
// Create a base canvas size for processing
const baseCanvasSize = 1000;
const scaleFactor = 3;
// Convert SVG to PNG and get its dimensions
const svgToPng = await sharp(svgPath)
.png()
.toBuffer();
const svgMetadata = await sharp(svgToPng).metadata();
const svgWidth = svgMetadata.width;
const svgHeight = svgMetadata.height;
// Calculate scaled dimensions
const scaledWidth = Math.round(svgWidth * scaleFactor);
const scaledHeight = Math.round(svgHeight * scaleFactor);
// Calculate centering offsets
const offsetX = Math.round((baseCanvasSize - scaledWidth) / 2);
const offsetY = Math.round((baseCanvasSize - scaledHeight) / 2);
// Create the processed base image: scale SVG and center in black box
const processedImage = await sharp({
create: {
width: baseCanvasSize,
height: baseCanvasSize,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sharp(svgToPng)
.resize(scaledWidth, scaledHeight)
.png()
.toBuffer(),
top: offsetY,
left: offsetX
}])
.png()
.toBuffer();
console.log(`✅ Processed SVG: ${svgWidth}x${svgHeight} → scaled to ${scaledWidth}x${scaledHeight} → centered in ${baseCanvasSize}x${baseCanvasSize} black box`);
// Save the processed base image for reference
await sharp(processedImage)
.png()
.toFile(join(publicDir, 'spotizerr.png'));
console.log(`✅ Saved processed base image as spotizerr.png (${baseCanvasSize}x${baseCanvasSize})`);
const sourceSize = baseCanvasSize;
// Define icon configurations // Define icon configurations
const iconConfigs = [ const iconConfigs = [
@@ -51,8 +103,8 @@ async function generateIcons() {
} }
]; ];
// Load the source PNG // Use the processed image as source
const sourceImage = sharp(pngPath); const sourceImage = sharp(processedImage);
for (const config of iconConfigs) { for (const config of iconConfigs) {
const { size, name, padding } = config; const { size, name, padding } = config;
@@ -62,13 +114,13 @@ async function generateIcons() {
const paddedSize = Math.round(size * (1 - padding * 2)); const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2); const offset = Math.round((size - paddedSize) / 2);
// Create a black background and composite the resized logo on top // Create a pure black background and composite the resized logo on top
await sharp({ await sharp({
create: { create: {
width: size, width: size,
height: size, height: size,
channels: 4, channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } // #0f172a in RGB background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
} }
}) })
.composite([{ .composite([{
@@ -100,7 +152,7 @@ async function generateIcons() {
width: maskableSize, width: maskableSize,
height: maskableSize, height: maskableSize,
channels: 4, channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } // Solid background for maskable background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background for maskable
} }
}) })
.composite([{ .composite([{
@@ -125,7 +177,7 @@ async function generateIcons() {
width: size, width: size,
height: size, height: size,
channels: 4, channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
} }
}) })
.composite([{ .composite([{
@@ -154,7 +206,7 @@ async function generateIcons() {
width: size, width: size,
height: size, height: size,
channels: 4, channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
} }
}) })
.composite([{ .composite([{
@@ -186,8 +238,8 @@ async function generateIcons() {
}); });
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)'); console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)');
console.log(''); console.log('');
console.log('💡 The icons are generated with appropriate padding and the dark theme background.'); console.log('💡 Icons generated from SVG source, scaled by 0.67, and centered on pure black backgrounds.');
console.log('💡 The source PNG already has the perfect background, so no additional styling needed.'); console.log('💡 The SVG logo is automatically processed and optimized for all icon formats.');
console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.'); console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.');
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
import { useContext } from "react"; import { useContext, useState, useRef, useEffect } from "react";
import { import {
FaTimes, FaTimes,
FaSync, FaSync,
@@ -127,26 +127,27 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
const progressText = getProgressText(); const progressText = getProgressText();
return ( return (
<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={`p-4 md:p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] ${statusInfo.bgColor} ${statusInfo.borderColor}`}>
<div className="flex items-center justify-between"> {/* Mobile-first layout: stack status and actions on mobile, inline on desktop */}
<div className="flex items-center gap-4 min-w-0 flex-1"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
<div className={`text-2xl ${statusInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm`}> <div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<div className={`text-2xl md:text-2xl ${statusInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 md:p-2 rounded-full shadow-sm flex-shrink-0`}>
{statusInfo.icon} {statusInfo.icon}
</div> </div>
<div className="flex-grow min-w-0"> <div className="flex-grow min-w-0">
{item.type === "track" && ( {item.type === "track" && (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaMusic className="icon-muted text-sm" /> <FaMusic className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}> <p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name} {item.name}
</p> </p>
</div> </div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}> <p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist} {item.artist}
</p> </p>
{item.albumName && ( {item.albumName && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.albumName}> <p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.albumName}>
{item.albumName} {item.albumName}
</p> </p>
)} )}
@@ -155,16 +156,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
{item.type === "album" && ( {item.type === "album" && (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaCompactDisc className="icon-muted text-sm" /> <FaCompactDisc className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}> <p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name} {item.name}
</p> </p>
</div> </div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}> <p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist} {item.artist}
</p> </p>
{item.currentTrackTitle && ( {item.currentTrackTitle && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}> <p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle} {item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
</p> </p>
)} )}
@@ -173,16 +174,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
{item.type === "playlist" && ( {item.type === "playlist" && (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FaMusic className="icon-muted text-sm" /> <FaMusic className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}> <p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name} {item.name}
</p> </p>
</div> </div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.playlistOwner}> <p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.playlistOwner}>
{item.playlistOwner} {item.playlistOwner}
</p> </p>
{item.currentTrackTitle && ( {item.currentTrackTitle && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}> <p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle} {item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
</p> </p>
)} )}
@@ -190,60 +191,62 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-3 ml-4">
<div className="text-right"> {/* Status and actions - stacked on mobile, inline on desktop */}
<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`}> <div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
<div className="flex-1 md:flex-none md:text-right">
<div className={`inline-flex items-center px-3 py-1 md:px-3 md:py-1 rounded-full text-sm md:text-xs font-semibold ${statusInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
{statusInfo.name} {statusInfo.name}
</div> </div>
{progressText && <p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>} {progressText && <p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-2 md:gap-1 flex-shrink-0">
{isTerminal ? ( {isTerminal ? (
<button <button
onClick={() => removeItem?.(item.id)} 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" className="p-3 md: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 min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Remove" aria-label="Remove"
> >
<FaTimes className="text-sm" /> <FaTimes className="text-base md:text-sm" />
</button> </button>
) : ( ) : (
<button <button
onClick={() => cancelItem?.(item.id)} 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" className="p-3 md: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 min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Cancel" aria-label="Cancel"
> >
<FaTimes className="text-sm" /> <FaTimes className="text-base md:text-sm" />
</button> </button>
)} )}
{item.canRetry && ( {item.canRetry && (
<button <button
onClick={() => retryItem?.(item.id)} 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" className="p-3 md: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 min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Retry" aria-label="Retry"
> >
<FaSync className="text-sm" /> <FaSync className="text-base md:text-sm" />
</button> </button>
)} )}
</div> </div>
</div> </div>
</div> </div>
{(item.status === "error" || item.status === "retrying") && item.error && ( {(item.status === "error" || item.status === "retrying") && item.error && (
<div className="mt-3 p-2 bg-error/10 border border-error/20 rounded-lg"> <div className="mt-3 p-3 md:p-2 bg-error/10 border border-error/20 rounded-lg">
<p className="text-xs text-error font-medium">Error: {item.error}</p> <p className="text-sm md:text-xs text-error font-medium break-words">Error: {item.error}</p>
</div> </div>
)} )}
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && ( {isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
<div className="mt-3 p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg"> <div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
<div className="flex gap-4 text-xs"> <div className="flex flex-wrap gap-3 md:gap-4 text-sm md:text-xs">
{item.summary.total_failed > 0 && ( {item.summary.total_failed > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-2 md:gap-1">
<div className="w-2 h-2 bg-error rounded-full"></div> <div className="w-3 h-3 md:w-2 md:h-2 bg-error rounded-full flex-shrink-0"></div>
<span className="text-error font-medium">{item.summary.total_failed} failed</span> <span className="text-error font-medium">{item.summary.total_failed} failed</span>
</span> </span>
)} )}
{item.summary.total_skipped > 0 && ( {item.summary.total_skipped > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-2 md:gap-1">
<div className="w-2 h-2 bg-warning rounded-full"></div> <div className="w-3 h-3 md:w-2 md:h-2 bg-warning rounded-full flex-shrink-0"></div>
<span className="text-warning font-medium">{item.summary.total_skipped} skipped</span> <span className="text-warning font-medium">{item.summary.total_skipped} skipped</span>
</span> </span>
)} )}
@@ -253,12 +256,12 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
{(item.status === "downloading" || item.status === "processing") && {(item.status === "downloading" || item.status === "processing") &&
item.type === "track" && item.type === "track" &&
item.progress !== undefined && ( item.progress !== undefined && (
<div className="mt-3"> <div className="mt-4 md:mt-3">
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-2 md:mb-1">
<span className="text-xs text-content-muted dark:text-content-muted-dark">Progress</span> <span className="text-sm md: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> <span className="text-sm md:text-xs font-semibold text-content-primary dark:text-content-primary-dark">{item.progress.toFixed(0)}%</span>
</div> </div>
<div className="h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden"> <div className="h-3 md:h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-300 ease-out ${ className={`h-full rounded-full transition-all duration-300 ease-out ${
item.status === "downloading" ? "bg-info" : "bg-processing" item.status === "downloading" ? "bg-info" : "bg-processing"
@@ -274,6 +277,9 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
export const Queue = () => { export const Queue = () => {
const context = useContext(QueueContext); const context = useContext(QueueContext);
const [startY, setStartY] = useState<number | null>(null);
const [currentY, setCurrentY] = useState<number | null>(null);
const queueRef = useRef<HTMLDivElement>(null);
if (!context) return null; if (!context) return null;
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context; const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
@@ -283,51 +289,107 @@ export const Queue = () => {
const hasActive = items.some((item) => !isTerminalStatus(item.status)); const hasActive = items.some((item) => !isTerminalStatus(item.status));
const hasFinished = items.some((item) => isTerminalStatus(item.status)); const hasFinished = items.some((item) => isTerminalStatus(item.status));
// Handle mobile swipe-to-dismiss
const handleTouchStart = (e: React.TouchEvent) => {
setStartY(e.touches[0].clientY);
setCurrentY(e.touches[0].clientY);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (startY === null) return;
setCurrentY(e.touches[0].clientY);
const deltaY = e.touches[0].clientY - startY;
// Only allow downward swipes to dismiss
if (deltaY > 0) {
if (queueRef.current) {
queueRef.current.style.transform = `translateY(${Math.min(deltaY, 100)}px)`;
queueRef.current.style.opacity = `${Math.max(0.3, 1 - deltaY / 200)}`;
}
}
};
const handleTouchEnd = () => {
if (startY === null || currentY === null) return;
const deltaY = currentY - startY;
if (queueRef.current) {
queueRef.current.style.transform = '';
queueRef.current.style.opacity = '';
}
// Dismiss if swiped down more than 50px
if (deltaY > 50) {
toggleVisibility();
}
setStartY(null);
setCurrentY(null);
};
return ( return (
<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"> {/* Mobile backdrop overlay */}
<h2 className="text-lg font-bold text-content-primary dark:text-content-primary-dark"> <div
Download Queue ({items.length}) className="fixed inset-0 bg-black/50 z-40 md:hidden"
</h2> onClick={toggleVisibility}
<div className="flex gap-2"> />
<button
onClick={cancelAll} <div
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" ref={queueRef}
disabled={!hasActive} className="fixed inset-x-0 bottom-0 md:bottom-4 md:right-4 md:inset-x-auto w-full md:max-w-md bg-surface dark:bg-surface-dark md:rounded-xl shadow-2xl border-t md:border border-border dark:border-border-dark z-50 backdrop-blur-sm md:rounded-b-xl transition-transform transition-opacity"
aria-label="Cancel all active downloads" onTouchStart={handleTouchStart}
> onTouchMove={handleTouchMove}
Cancel All onTouchEnd={handleTouchEnd}
</button> >
<button <header className="flex items-center justify-between p-4 md: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 md:rounded-t-xl">
onClick={clearCompleted} {/* Add drag indicator for mobile */}
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" <div className="md:hidden absolute top-2 left-1/2 transform -translate-x-1/2 w-12 h-1 bg-content-muted dark:bg-content-muted-dark rounded-full opacity-50"></div>
disabled={!hasFinished} <h2 className="text-lg md:text-lg font-bold text-content-primary dark:text-content-primary-dark">
aria-label="Clear all finished downloads" Download Queue ({items.length})
> </h2>
Clear Finished <div className="flex gap-1 md:gap-2">
</button> <button
<button onClick={cancelAll}
onClick={toggleVisibility} className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-warning transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-warning/10 min-h-[44px] md:min-h-auto"
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" disabled={!hasActive}
aria-label="Close queue" aria-label="Cancel all active downloads"
> >
<FaTimes className="text-sm" /> Cancel All
</button> </button>
</div> <button
</header> onClick={clearCompleted}
<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"> className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-success transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-success/10 min-h-[44px] md:min-h-auto"
{items.length === 0 ? ( disabled={!hasFinished}
<div className="text-center py-8"> aria-label="Clear all finished downloads"
<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" /> Clear Finished
</div> </button>
<p className="text-content-muted dark:text-content-muted-dark">The queue is empty.</p> <button
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">Downloads will appear here</p> onClick={toggleVisibility}
className="text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark p-3 md:p-1 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Close queue"
>
<FaTimes className="text-base md:text-sm" />
</button>
</div> </div>
) : ( </header>
items.map((item) => <QueueItemCard key={item.id} item={item} />) <div className="p-4 overflow-y-auto max-h-[60vh] md: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 ? (
<div className="text-center py-8 md:py-8">
<div className="w-20 h-20 md:w-16 md:h-16 mx-auto mb-4 rounded-full bg-surface-muted dark:bg-surface-muted-dark flex items-center justify-center">
<FaMusic className="text-3xl md:text-2xl icon-muted" />
</div>
<p className="text-base md:text-sm text-content-muted dark:text-content-muted-dark">The queue is empty.</p>
<p className="text-sm md: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} />)
)}
</div>
</div> </div>
</div> </>
); );
}; };