improved theming and improved PWA mobile support

This commit is contained in:
Xoconoch
2025-07-27 20:34:50 -06:00
parent 454b22c282
commit af413359d8
19 changed files with 376 additions and 177 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.ef94id05f1c" "revision": "0.udbs5r8ifc8"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -2,9 +2,19 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Theme-dependent favicons with rounded corners -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- Light theme favicons (black background, white logo) -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" media="(prefers-color-scheme: light)" />
<!-- Dark theme favicons (currently same as light - you could create light background versions) -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" media="(prefers-color-scheme: dark)" />
<!-- Fallback for browsers that don't support media queries -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotizerr</title> <title>Spotizerr</title>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.7 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: 7.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -4,6 +4,16 @@ import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import toIco from 'to-ico'; import toIco from 'to-ico';
// Helper function to create a rounded square mask
async function createRoundedSquareMask(size, radius) {
const svg = `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="white"/>
</svg>
`;
return Buffer.from(svg);
}
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -80,26 +90,36 @@ async function generateIcons() {
size: 16, size: 16,
name: 'favicon-16x16.png', name: 'favicon-16x16.png',
padding: 0.1, // 10% padding for small icons padding: 0.1, // 10% padding for small icons
rounded: true,
cornerRadius: 3, // 3px radius for small icons
}, },
{ {
size: 32, size: 32,
name: 'favicon-32x32.png', name: 'favicon-32x32.png',
padding: 0.1, padding: 0.1,
rounded: true,
cornerRadius: 6, // 6px radius
}, },
{ {
size: 180, size: 180,
name: 'apple-touch-icon-180x180.png', name: 'apple-touch-icon-180x180.png',
padding: 0.05, // 5% padding for Apple (they prefer less padding) padding: 0.05, // 5% padding for Apple (they prefer less padding)
rounded: true,
cornerRadius: 32, // ~18% radius for Apple icons
}, },
{ {
size: 192, size: 192,
name: 'pwa-192x192.png', name: 'pwa-192x192.png',
padding: 0.1, padding: 0.1,
rounded: true,
cornerRadius: 34, // ~18% radius
}, },
{ {
size: 512, size: 512,
name: 'pwa-512x512.png', name: 'pwa-512x512.png',
padding: 0.1, padding: 0.1,
rounded: true,
cornerRadius: 92, // ~18% radius
} }
]; ];
@@ -107,7 +127,9 @@ async function generateIcons() {
const sourceImage = sharp(processedImage); const sourceImage = sharp(processedImage);
for (const config of iconConfigs) { for (const config of iconConfigs) {
const { size, name, padding } = config; const { size, name, padding, rounded, cornerRadius } = config;
let finalIcon;
if (padding > 0) { if (padding > 0) {
// Create icon with padding by compositing on a background // Create icon with padding by compositing on a background
@@ -115,7 +137,7 @@ async function generateIcons() {
const offset = Math.round((size - paddedSize) / 2); const offset = Math.round((size - paddedSize) / 2);
// Create a pure black background and composite the resized logo on top // Create a pure black background and composite the resized logo on top
await sharp({ finalIcon = await sharp({
create: { create: {
width: size, width: size,
height: size, height: size,
@@ -129,25 +151,42 @@ async function generateIcons() {
left: offset left: offset
}]) }])
.png() .png()
.toFile(join(publicDir, name)); .toBuffer();
} else { } else {
// Direct resize without padding // Direct resize without padding
await sourceImage finalIcon = await sourceImage
.resize(size, size) .resize(size, size)
.png() .png()
.toFile(join(publicDir, name)); .toBuffer();
} }
console.log(`✅ Generated ${name} (${size}x${size}) - padding: ${padding * 100}%`); // Apply rounded corners if specified
if (rounded && cornerRadius > 0) {
const mask = await createRoundedSquareMask(size, cornerRadius);
finalIcon = await sharp(finalIcon)
.composite([{
input: mask,
blend: 'dest-in'
}])
.png()
.toBuffer();
}
// Write the final icon to file
await sharp(finalIcon).toFile(join(publicDir, name));
const roundedText = rounded ? ` - rounded (${cornerRadius}px)` : '';
console.log(`✅ Generated ${name} (${size}x${size}) - padding: ${padding * 100}%${roundedText}`);
} }
// Create maskable icon (less padding, solid background) // Create maskable icon (less padding, solid background, rounded)
const maskableSize = 512; const maskableSize = 512;
const maskablePadding = 0.05; // 5% padding for maskable icons const maskablePadding = 0.05; // 5% padding for maskable icons
const maskableRadius = 92; // ~18% radius for consistency
const maskablePaddedSize = Math.round(maskableSize * (1 - maskablePadding * 2)); const maskablePaddedSize = Math.round(maskableSize * (1 - maskablePadding * 2));
const maskableOffset = Math.round((maskableSize - maskablePaddedSize) / 2); const maskableOffset = Math.round((maskableSize - maskablePaddedSize) / 2);
await sharp({ let maskableIcon = await sharp({
create: { create: {
width: maskableSize, width: maskableSize,
height: maskableSize, height: maskableSize,
@@ -161,47 +200,33 @@ async function generateIcons() {
left: maskableOffset left: maskableOffset
}]) }])
.png() .png()
.toFile(join(publicDir, 'pwa-512x512-maskable.png')); .toBuffer();
console.log(`✅ Generated pwa-512x512-maskable.png (${maskableSize}x${maskableSize}) - maskable`); // Apply rounded corners to maskable icon
const maskableMask = await createRoundedSquareMask(maskableSize, maskableRadius);
maskableIcon = await sharp(maskableIcon)
.composite([{
input: maskableMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
// Generate additional favicon sizes for ICO compatibility await sharp(maskableIcon).toFile(join(publicDir, 'pwa-512x512-maskable.png'));
console.log(`✅ Generated pwa-512x512-maskable.png (${maskableSize}x${maskableSize}) - maskable, rounded (${maskableRadius}px)`);
// Generate additional favicon sizes for ICO compatibility (with rounded corners)
const additionalSizes = [48, 64, 96, 128, 256]; const additionalSizes = [48, 64, 96, 128, 256];
for (const size of additionalSizes) { for (const size of additionalSizes) {
const padding = size <= 48 ? 0.05 : 0.1; const padding = size <= 48 ? 0.05 : 0.1;
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);
// Calculate corner radius proportional to size (~18% for larger icons, smaller for tiny ones)
const cornerRadius = size <= 48 ? Math.round(size * 0.125) : Math.round(size * 0.18);
await sharp({ let additionalIcon = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toFile(join(publicDir, `favicon-${size}x${size}.png`));
console.log(`✅ Generated favicon-${size}x${size}.png (${size}x${size}) - padding: ${padding * 100}%`);
}
// Generate favicon.ico with multiple sizes
console.log('🎯 Generating favicon.ico...');
const icoSizes = [16, 32, 48];
const icoBuffers = [];
for (const size of icoSizes) {
const padding = 0.1; // 10% padding for ICO
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
const buffer = await sharp({
create: { create: {
width: size, width: size,
height: size, height: size,
@@ -217,29 +242,82 @@ async function generateIcons() {
.png() .png()
.toBuffer(); .toBuffer();
icoBuffers.push(buffer); // Apply rounded corners
const additionalMask = await createRoundedSquareMask(size, cornerRadius);
additionalIcon = await sharp(additionalIcon)
.composite([{
input: additionalMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
await sharp(additionalIcon).toFile(join(publicDir, `favicon-${size}x${size}.png`));
console.log(`✅ Generated favicon-${size}x${size}.png (${size}x${size}) - padding: ${padding * 100}%, rounded (${cornerRadius}px)`);
}
// Generate favicon.ico with multiple sizes (rounded)
console.log('🎯 Generating favicon.ico with rounded corners...');
const icoSizes = [16, 32, 48];
const icoBuffers = [];
for (const size of icoSizes) {
const padding = 0.1; // 10% padding for ICO
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
const cornerRadius = size <= 32 ? Math.round(size * 0.125) : Math.round(size * 0.18);
let icoIcon = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toBuffer();
// Apply rounded corners to ICO icon
const icoMask = await createRoundedSquareMask(size, cornerRadius);
const roundedIcoIcon = await sharp(icoIcon)
.composite([{
input: icoMask,
blend: 'dest-in'
}])
.png()
.toBuffer();
icoBuffers.push(roundedIcoIcon);
} }
// Create the ICO file // Create the ICO file
const icoBuffer = await toIco(icoBuffers); const icoBuffer = await toIco(icoBuffers);
writeFileSync(join(publicDir, 'favicon.ico'), icoBuffer); writeFileSync(join(publicDir, 'favicon.ico'), icoBuffer);
console.log(`✅ Generated favicon.ico (${icoSizes.join('x, ')}x sizes) - multi-size ICO`); console.log(`✅ Generated favicon.ico (${icoSizes.join('x, ')}x sizes) - multi-size ICO, rounded corners`);
console.log('🎉 All PWA icons generated successfully!'); console.log('🎉 All PWA icons generated successfully with rounded corners!');
console.log(''); console.log('');
console.log('📋 Generated files:'); console.log('📋 Generated files:');
iconConfigs.forEach(config => { iconConfigs.forEach(config => {
console.log(`${config.name} (${config.size}x${config.size})`); console.log(`${config.name} (${config.size}x${config.size}) - rounded`);
}); });
console.log(' • pwa-512x512-maskable.png (512x512)'); console.log(' • pwa-512x512-maskable.png (512x512) - rounded');
additionalSizes.forEach(size => { additionalSizes.forEach(size => {
console.log(` • favicon-${size}x${size}.png (${size}x${size})`); console.log(` • favicon-${size}x${size}.png (${size}x${size}) - rounded`);
}); });
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)'); console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48) - rounded');
console.log(''); console.log('');
console.log('💡 Icons generated from SVG source, scaled by 0.67, and centered on pure black backgrounds.'); 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('💡 All icons feature rounded corners for a modern, polished appearance.');
console.log('💡 Corner radius scales proportionally with icon size (~18% for larger icons, ~12.5% for smaller ones).');
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,103 +1,110 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
/* Background Colors */ /* Background Colors - Light theme: pure white to warm grays */
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-secondary: #f8fafc; --color-surface-secondary: #fafbfc;
--color-surface-muted: #f1f5f9; --color-surface-muted: #f4f6f8;
--color-surface-accent: #e2e8f0; --color-surface-accent: #e8ecf0;
--color-surface-overlay: rgba(255, 255, 255, 0.95); --color-surface-overlay: rgba(255, 255, 255, 0.96);
/* Dark mode backgrounds */ /* Dark mode backgrounds - Rich blacks with subtle warm undertones */
--color-surface-dark: #0f172a; --color-surface-dark: #0a0a0a;
--color-surface-secondary-dark: #1e293b; --color-surface-secondary-dark: #141414;
--color-surface-muted-dark: #334155; --color-surface-muted-dark: #1f1f1f;
--color-surface-accent-dark: #475569; --color-surface-accent-dark: #2d2d2d;
--color-surface-overlay-dark: rgba(15, 23, 42, 0.95); --color-surface-overlay-dark: rgba(10, 10, 10, 0.96);
/* Text Colors */ /* Text Colors - Light theme with enhanced contrast */
--color-content-primary: #0f172a; --color-content-primary: #0d1117;
--color-content-secondary: #475569; --color-content-secondary: #57606a;
--color-content-muted: #64748b; --color-content-muted: #768390;
--color-content-accent: #94a3b8; --color-content-accent: #8c959f;
--color-content-inverse: #ffffff; --color-content-inverse: #ffffff;
/* Dark mode text */ /* Dark mode text - Crisp whites and refined grays */
--color-content-primary-dark: #f8fafc; --color-content-primary-dark: #f0f6fc;
--color-content-secondary-dark: #e2e8f0; --color-content-secondary-dark: #c9d1d9;
--color-content-muted-dark: #cbd5e1; --color-content-muted-dark: #8b949e;
--color-content-accent-dark: #94a3b8; --color-content-accent-dark: #6e7681;
--color-content-inverse-dark: #0f172a; --color-content-inverse-dark: #0d1117;
/* Interactive Colors */ /* Interactive Colors - Enhanced Spotify green with sophistication */
--color-primary: #3b82f6; --color-primary: #1ed760;
--color-primary-hover: #2563eb; --color-primary-hover: #1fdf64;
--color-primary-active: #1d4ed8; --color-primary-active: #1db954;
--color-primary-muted: #dbeafe; --color-primary-muted: #d1f7e0;
--color-secondary: #64748b; /* Secondary colors with purple accent for contrast */
--color-secondary-hover: #475569; --color-secondary: #8b5fbf;
--color-secondary-active: #334155; --color-secondary-hover: #9d70d1;
--color-secondary-muted: #f1f5f9; --color-secondary-active: #7a4fa3;
--color-secondary-muted: #f0ebff;
/* Accent purple for complementary design */
--color-accent: #8b5fbf;
--color-accent-hover: #9d70d1;
--color-accent-active: #7a4fa3;
--color-accent-muted: #f0ebff;
/* Status Colors */ /* Status Colors - Refined and harmonious */
--color-success: #22c55e; --color-success: #28a745;
--color-success-hover: #16a34a; --color-success-hover: #1e7b34;
--color-success-muted: #dcfce7; --color-success-muted: #d4edda;
--color-success-text: #15803d; --color-success-text: #155724;
--color-error: #ef4444; --color-error: #dc3545;
--color-error-hover: #dc2626; --color-error-hover: #c82333;
--color-error-muted: #fef2f2; --color-error-muted: #f8d7da;
--color-error-text: #dc2626; --color-error-text: #721c24;
--color-warning: #f59e0b; --color-warning: #fd7e14;
--color-warning-hover: #d97706; --color-warning-hover: #e8590c;
--color-warning-muted: #fef3c7; --color-warning-muted: #fff3cd;
--color-warning-text: #d97706; --color-warning-text: #856404;
--color-info: #3b82f6; --color-info: #17a2b8;
--color-info-hover: #2563eb; --color-info-hover: #138496;
--color-info-muted: #dbeafe; --color-info-muted: #d1ecf1;
--color-info-text: #2563eb; --color-info-text: #0c5460;
--color-processing: #a855f7; --color-processing: #8b5fbf;
--color-processing-hover: #9333ea; --color-processing-hover: #7a4fa3;
--color-processing-muted: #f3e8ff; --color-processing-muted: #f0ebff;
--color-processing-text: #9333ea; --color-processing-text: #5d3e7a;
/* Border Colors */ /* Border Colors - Subtle and refined */
--color-border: #e2e8f0; --color-border: #e1e5e9;
--color-border-muted: #f1f5f9; --color-border-muted: #f4f6f8;
--color-border-accent: #cbd5e1; --color-border-accent: #d0d7de;
--color-border-focus: #3b82f6; --color-border-focus: #1ed760;
--color-border-dark: #475569; --color-border-dark: #30363d;
--color-border-muted-dark: #334155; --color-border-muted-dark: #21262d;
--color-border-accent-dark: #64748b; --color-border-accent-dark: #373e47;
/* Input Colors */ /* Input Colors */
--color-input-background: #f1f5f9; --color-input-background: #f4f6f8;
--color-input-border: #e2e8f0; --color-input-border: #e1e5e9;
--color-input-focus: #3b82f6; --color-input-focus: #1ed760;
--color-input-background-dark: #1e293b; --color-input-background-dark: #21262d;
--color-input-border-dark: #475569; --color-input-border-dark: #30363d;
/* Button Colors */ /* Button Colors */
--color-button-primary: #3b82f6; --color-button-primary: #1ed760;
--color-button-primary-hover: #2563eb; --color-button-primary-hover: #1fdf64;
--color-button-primary-text: #ffffff; --color-button-primary-text: #ffffff;
--color-button-secondary: #f1f5f9; --color-button-secondary: #f4f6f8;
--color-button-secondary-hover: #64748b; --color-button-secondary-hover: #8b5fbf;
--color-button-secondary-text: #64748b; --color-button-secondary-text: #57606a;
--color-button-secondary-text-hover: #ffffff; --color-button-secondary-text-hover: #ffffff;
--color-button-success: #22c55e; --color-button-success: #28a745;
--color-button-success-hover: #16a34a; --color-button-success-hover: #1e7b34;
--color-button-success-text: #ffffff; --color-button-success-text: #ffffff;
--color-icon-button-hover: #e5e7eb; --color-icon-button-hover: #e8ecf0;
--color-icon-button-hover-dark: #334155; --color-icon-button-hover-dark: #30363d;
/* Icon Colors */ /* Icon Colors */
--color-icon-primary: #000000; --color-icon-primary: #000000;
@@ -316,4 +323,20 @@
color: var(--color-icon-inverse-dark); color: var(--color-icon-inverse-dark);
filter: brightness(0); filter: brightness(0);
} }
/* Logo Utility Classes - Automatic dark mode detection like Tailwind v4 */
.logo {
filter: brightness(0); /* Black in light mode */
}
@media (prefers-color-scheme: dark) {
.logo {
filter: brightness(0) invert(1); /* White in dark mode */
}
}
/* Class-based override for manual toggles */
.dark .logo {
filter: brightness(0) invert(1); /* White when dark class is present */
}
} }

View File

@@ -130,7 +130,9 @@ export const Home = () => {
return ( return (
<div className="max-w-4xl mx-auto p-4"> <div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6 text-content-primary dark:text-content-primary-dark">Search Spotify</h1> <div className="text-center mb-8">
<h1 className="text-2xl font-bold text-content-primary dark:text-content-primary-dark">Spotizerr</h1>
</div>
<div className="flex flex-col sm:flex-row gap-3 mb-6"> <div className="flex flex-col sm:flex-row gap-3 mb-6">
<input <input
type="text" type="text"

View File

@@ -9,34 +9,65 @@ function AppLayout() {
const { toggleVisibility } = useContext(QueueContext) || {}; const { toggleVisibility } = useContext(QueueContext) || {};
return ( return (
<div className="min-h-screen bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark"> <div className="min-h-screen bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark flex flex-col">
<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"> {/* Desktop Header */}
<header className="hidden md:block 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"> <div className="container mx-auto h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="flex items-center">
<img src="/music.svg" alt="Logo" className="w-6 h-6 icon-primary" /> <img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
<h1 className="text-xl font-bold">Spotizerr</h1>
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link to="/watchlist" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark"> <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-primary" /> <img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 logo" />
</Link> </Link>
<Link to="/history" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark"> <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-primary" /> <img src="/history.svg" alt="History" className="w-6 h-6 logo" />
</Link> </Link>
<Link to="/config" className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark"> <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-primary" /> <img src="/settings.svg" alt="Settings" className="w-6 h-6 logo" />
</Link> </Link>
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark"> <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-primary" /> <img src="/queue.svg" alt="Queue" className="w-6 h-6 logo" />
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<main className="container mx-auto p-4"> {/* Mobile Header - Just logo/title */}
<header className="md:hidden 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-center">
<Link to="/" className="flex items-center">
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
</Link>
</div>
</header>
{/* Main content - flex-1 to push navigation to bottom on mobile */}
<main className="container mx-auto p-4 flex-1 pb-20 md:pb-4">
<Outlet /> <Outlet />
</main> </main>
{/* Mobile Bottom Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm">
<div className="container mx-auto h-16 flex items-center justify-around">
<Link to="/" className="p-3 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark">
<img src="/home.svg" alt="Home" className="w-6 h-6 logo" />
</Link>
<Link to="/watchlist" className="p-3 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 logo" />
</Link>
<Link to="/history" className="p-3 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 logo" />
</Link>
<Link to="/config" className="p-3 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 logo" />
</Link>
<button onClick={toggleVisibility} className="p-3 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 logo" />
</button>
</div>
</nav>
<Queue /> <Queue />
</div> </div>
); );

View File

@@ -63,7 +63,7 @@ export const Track = () => {
const imageUrl = track.album.images?.[0]?.url; const imageUrl = track.album.images?.[0]?.url;
return ( return (
<div className="max-w-4xl mx-auto p-4 md:p-8"> <div className="max-w-6xl mx-auto p-4 md:p-8">
<div className="mb-6"> <div className="mb-6">
<button <button
onClick={() => window.history.back()} onClick={() => window.history.back()}
@@ -73,66 +73,121 @@ export const Track = () => {
<span>Back to results</span> <span>Back to results</span>
</button> </button>
</div> </div>
<div className="bg-surface dark:bg-surface-secondary-dark shadow-lg rounded-lg overflow-hidden md:flex">
{imageUrl && ( {/* Hero Section with Cover */}
<div className="md:w-1/3"> <div className="bg-gradient-to-b from-surface-muted to-surface dark:from-surface-muted-dark dark:to-surface-dark rounded-lg overflow-hidden mb-8">
<img src={imageUrl} alt={track.album.name} className="w-full h-auto object-cover" /> <div className="flex flex-col md:flex-row items-center md:items-end gap-6 p-6 md:p-8">
{/* Album Cover */}
<div className="flex-shrink-0">
{imageUrl ? (
<img
src={imageUrl}
alt={track.album.name}
className="w-48 h-48 md:w-64 md:h-64 object-cover rounded-lg shadow-2xl"
/>
) : (
<div className="w-48 h-48 md:w-64 md:h-64 bg-surface-accent dark:bg-surface-accent-dark rounded-lg shadow-2xl flex items-center justify-center">
<img src="/placeholder.jpg" alt="No cover" className="w-16 h-16 opacity-50 logo" />
</div>
)}
</div> </div>
)}
<div className="p-6 md:w-2/3 flex flex-col justify-between"> {/* Track Info */}
<div> <div className="flex-1 text-center md:text-left md:pb-4">
<div className="flex items-baseline justify-between"> <div className="flex flex-col md:flex-row md:items-baseline gap-2 md:gap-4 mb-2">
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{track.name}</h1> <h1 className="text-3xl md:text-5xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">
{track.name}
</h1>
{track.explicit && ( {track.explicit && (
<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> <span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-inverse dark:text-content-inverse-dark px-3 py-1 rounded-full self-center md:self-auto">
EXPLICIT
</span>
)} )}
</div> </div>
<div className="text-lg text-content-secondary dark:text-content-secondary-dark mt-1">
<div className="text-lg md:text-xl text-content-secondary dark:text-content-secondary-dark mb-2">
{track.artists.map((artist, index) => ( {track.artists.map((artist, index) => (
<span key={artist.id}> <span key={artist.id}>
<Link to="/artist/$artistId" params={{ artistId: artist.id }}> <Link
to="/artist/$artistId"
params={{ artistId: artist.id }}
className="hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
>
{artist.name} {artist.name}
</Link> </Link>
{index < track.artists.length - 1 && ", "} {index < track.artists.length - 1 && ", "}
</span> </span>
))} ))}
</div> </div>
<p className="text-md text-content-muted dark:text-content-muted-dark mt-4">
From the album{" "} <p className="text-content-muted dark:text-content-muted-dark">
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="font-semibold"> From{" "}
<Link
to="/album/$albumId"
params={{ albumId: track.album.id }}
className="font-semibold hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
>
{track.album.name} {track.album.name}
</Link> </Link>
</p> </p>
<div className="mt-4 text-sm text-content-secondary dark:text-content-secondary-dark"> </div>
<p>Release Date: {track.album.release_date}</p> </div>
<p>Duration: {formatDuration(track.duration_ms)}</p> </div>
</div>
<div className="mt-4"> {/* Details Section */}
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">Popularity:</p> <div className="bg-surface dark:bg-surface-secondary-dark rounded-lg shadow-lg p-6 md:p-8">
<div className="w-full bg-surface-muted dark:bg-surface-muted-dark rounded-full h-2.5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-success h-2.5 rounded-full" style={{ width: `${track.popularity}%` }}></div> {/* Track Details */}
<div>
<h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">Track Details</h2>
<div className="space-y-2 text-sm text-content-secondary dark:text-content-secondary-dark">
<div className="flex gap-4">
<span className="w-24 flex-shrink-0">Release Date:</span>
<span>{track.album.release_date}</span>
</div>
<div className="flex gap-4">
<span className="w-24 flex-shrink-0">Duration:</span>
<span>{formatDuration(track.duration_ms)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 mt-6">
<button {/* Popularity */}
onClick={handleDownloadTrack} <div>
className="bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-2 px-4 rounded-full transition duration-300" <h2 className="text-lg font-semibold text-content-primary dark:text-content-primary-dark mb-4">Popularity</h2>
> <div className="flex items-center gap-3">
Download <div className="flex-1 bg-surface-muted dark:bg-surface-muted-dark rounded-full h-3">
</button> <div
<a className="bg-primary h-3 rounded-full transition-all duration-500"
href={track.external_urls.spotify} style={{ width: `${track.popularity}%` }}
target="_blank" ></div>
rel="noopener noreferrer" </div>
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" <span className="text-sm font-medium text-content-secondary dark:text-content-secondary-dark">
aria-label="Listen on Spotify" {track.popularity}%
> </span>
<FaSpotify size={24} className="icon-secondary hover:icon-primary" /> </div>
<span className="font-semibold">Listen on Spotify</span>
</a>
</div> </div>
</div> </div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-center gap-4">
<button
onClick={handleDownloadTrack}
className="w-full sm:w-auto bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-3 px-8 rounded-full transition duration-300 shadow-lg hover:shadow-xl"
>
Download
</button>
<a
href={track.external_urls.spotify}
target="_blank"
rel="noopener noreferrer"
className="w-full sm:w-auto flex items-center justify-center gap-3 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300 py-3 px-8 border border-border dark:border-border-dark rounded-full hover:border-border-accent dark:hover:border-border-accent-dark"
aria-label="Listen on Spotify"
>
<FaSpotify size={20} className="icon-secondary hover:icon-primary" />
<span className="font-semibold">Listen on Spotify</span>
</a>
</div>
</div> </div>
</div> </div>
); );