improved mobile responsiveness + logo
@@ -82,7 +82,7 @@ define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.qqj3c2rpjk8"
|
||||
"revision": "0.ef94id05f1c"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 758 B After Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 94 KiB |
@@ -8,19 +8,71 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const publicDir = join(__dirname, '../public');
|
||||
const pngPath = join(publicDir, 'spotizerr.png');
|
||||
const svgPath = join(publicDir, 'spotizerr.svg');
|
||||
|
||||
async function generateIcons() {
|
||||
try {
|
||||
// Check if the PNG file exists
|
||||
if (!existsSync(pngPath)) {
|
||||
throw new Error(`PNG file not found at ${pngPath}. Please ensure spotizerr.png exists in the public directory.`);
|
||||
// Check if the SVG file exists
|
||||
if (!existsSync(svgPath)) {
|
||||
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
|
||||
const sourceSize = 1667;
|
||||
// First, convert SVG to PNG and process it
|
||||
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
|
||||
const iconConfigs = [
|
||||
@@ -51,8 +103,8 @@ async function generateIcons() {
|
||||
}
|
||||
];
|
||||
|
||||
// Load the source PNG
|
||||
const sourceImage = sharp(pngPath);
|
||||
// Use the processed image as source
|
||||
const sourceImage = sharp(processedImage);
|
||||
|
||||
for (const config of iconConfigs) {
|
||||
const { size, name, padding } = config;
|
||||
@@ -62,13 +114,13 @@ async function generateIcons() {
|
||||
const paddedSize = Math.round(size * (1 - padding * 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({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
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([{
|
||||
@@ -100,7 +152,7 @@ async function generateIcons() {
|
||||
width: maskableSize,
|
||||
height: maskableSize,
|
||||
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([{
|
||||
@@ -125,7 +177,7 @@ async function generateIcons() {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: { r: 15, g: 23, b: 42, alpha: 1 }
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||
}
|
||||
})
|
||||
.composite([{
|
||||
@@ -154,7 +206,7 @@ async function generateIcons() {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: { r: 15, g: 23, b: 42, alpha: 1 }
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||
}
|
||||
})
|
||||
.composite([{
|
||||
@@ -186,8 +238,8 @@ async function generateIcons() {
|
||||
});
|
||||
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)');
|
||||
console.log('');
|
||||
console.log('💡 The icons are generated with appropriate padding and the dark theme background.');
|
||||
console.log('💡 The source PNG already has the perfect background, so no additional styling needed.');
|
||||
console.log('💡 Icons generated from SVG source, scaled by 0.67, and centered on pure black backgrounds.');
|
||||
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.');
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
FaTimes,
|
||||
FaSync,
|
||||
@@ -127,26 +127,27 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
const progressText = getProgressText();
|
||||
|
||||
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="flex items-center justify-between">
|
||||
<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`}>
|
||||
<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}`}>
|
||||
{/* Mobile-first layout: stack status and actions on mobile, inline on desktop */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
|
||||
<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}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
{item.type === "track" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
<FaMusic className="icon-muted text-sm flex-shrink-0" />
|
||||
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</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}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -155,16 +156,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
{item.type === "album" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCompactDisc className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
<FaCompactDisc className="icon-muted text-sm flex-shrink-0" />
|
||||
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</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}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -173,16 +174,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
{item.type === "playlist" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="icon-muted text-sm" />
|
||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
<FaMusic className="icon-muted text-sm flex-shrink-0" />
|
||||
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</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}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -190,60 +191,62 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<div className="text-right">
|
||||
<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`}>
|
||||
|
||||
{/* Status and actions - stacked on mobile, inline on desktop */}
|
||||
<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}
|
||||
</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 className="flex gap-1">
|
||||
<div className="flex gap-2 md:gap-1 flex-shrink-0">
|
||||
{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"
|
||||
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"
|
||||
>
|
||||
<FaTimes className="text-sm" />
|
||||
<FaTimes className="text-base md: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"
|
||||
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"
|
||||
>
|
||||
<FaTimes className="text-sm" />
|
||||
<FaTimes className="text-base md: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"
|
||||
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"
|
||||
>
|
||||
<FaSync className="text-sm" />
|
||||
<FaSync className="text-base md:text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.status === "error" || item.status === "retrying") && item.error && (
|
||||
<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 className="mt-3 p-3 md:p-2 bg-error/10 border border-error/20 rounded-lg">
|
||||
<p className="text-sm md:text-xs text-error font-medium break-words">Error: {item.error}</p>
|
||||
</div>
|
||||
)}
|
||||
{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="flex gap-4 text-xs">
|
||||
<div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
|
||||
<div className="flex flex-wrap gap-3 md:gap-4 text-sm md: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="flex items-center gap-2 md:gap-1">
|
||||
<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>
|
||||
)}
|
||||
{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="flex items-center gap-2 md:gap-1">
|
||||
<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>
|
||||
)}
|
||||
@@ -253,12 +256,12 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
{(item.status === "downloading" || item.status === "processing") &&
|
||||
item.type === "track" &&
|
||||
item.progress !== undefined && (
|
||||
<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 className="mt-4 md:mt-3">
|
||||
<div className="flex justify-between items-center mb-2 md:mb-1">
|
||||
<span className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark">Progress</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 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
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||
item.status === "downloading" ? "bg-info" : "bg-processing"
|
||||
@@ -274,6 +277,9 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
|
||||
export const Queue = () => {
|
||||
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;
|
||||
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
|
||||
@@ -283,51 +289,107 @@ export const Queue = () => {
|
||||
const hasActive = 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 (
|
||||
<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-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"
|
||||
>
|
||||
Cancel All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCompleted}
|
||||
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-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 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">
|
||||
<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>
|
||||
<>
|
||||
{/* Mobile backdrop overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={toggleVisibility}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={queueRef}
|
||||
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"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<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">
|
||||
{/* Add drag indicator for mobile */}
|
||||
<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>
|
||||
<h2 className="text-lg md:text-lg font-bold text-content-primary dark:text-content-primary-dark">
|
||||
Download Queue ({items.length})
|
||||
</h2>
|
||||
<div className="flex gap-1 md:gap-2">
|
||||
<button
|
||||
onClick={cancelAll}
|
||||
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"
|
||||
disabled={!hasActive}
|
||||
aria-label="Cancel all active downloads"
|
||||
>
|
||||
Cancel All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCompleted}
|
||||
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"
|
||||
disabled={!hasFinished}
|
||||
aria-label="Clear all finished downloads"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
) : (
|
||||
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
||||
)}
|
||||
</header>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||