improved theming and improved PWA mobile support
@@ -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"), {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
@@ -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) {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||