improved theming and improved PWA mobile support
@@ -82,7 +82,7 @@ define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.ef94id05f1c"
|
||||
"revision": "0.udbs5r8ifc8"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Theme-dependent favicons with rounded corners -->
|
||||
<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="16x16" href="/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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 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 __dirname = dirname(__filename);
|
||||
|
||||
@@ -80,26 +90,36 @@ async function generateIcons() {
|
||||
size: 16,
|
||||
name: 'favicon-16x16.png',
|
||||
padding: 0.1, // 10% padding for small icons
|
||||
rounded: true,
|
||||
cornerRadius: 3, // 3px radius for small icons
|
||||
},
|
||||
{
|
||||
size: 32,
|
||||
name: 'favicon-32x32.png',
|
||||
padding: 0.1,
|
||||
rounded: true,
|
||||
cornerRadius: 6, // 6px radius
|
||||
},
|
||||
{
|
||||
size: 180,
|
||||
name: 'apple-touch-icon-180x180.png',
|
||||
padding: 0.05, // 5% padding for Apple (they prefer less padding)
|
||||
rounded: true,
|
||||
cornerRadius: 32, // ~18% radius for Apple icons
|
||||
},
|
||||
{
|
||||
size: 192,
|
||||
name: 'pwa-192x192.png',
|
||||
padding: 0.1,
|
||||
rounded: true,
|
||||
cornerRadius: 34, // ~18% radius
|
||||
},
|
||||
{
|
||||
size: 512,
|
||||
name: 'pwa-512x512.png',
|
||||
padding: 0.1,
|
||||
rounded: true,
|
||||
cornerRadius: 92, // ~18% radius
|
||||
}
|
||||
];
|
||||
|
||||
@@ -107,7 +127,9 @@ async function generateIcons() {
|
||||
const sourceImage = sharp(processedImage);
|
||||
|
||||
for (const config of iconConfigs) {
|
||||
const { size, name, padding } = config;
|
||||
const { size, name, padding, rounded, cornerRadius } = config;
|
||||
|
||||
let finalIcon;
|
||||
|
||||
if (padding > 0) {
|
||||
// Create icon with padding by compositing on a background
|
||||
@@ -115,7 +137,7 @@ async function generateIcons() {
|
||||
const offset = Math.round((size - paddedSize) / 2);
|
||||
|
||||
// Create a pure black background and composite the resized logo on top
|
||||
await sharp({
|
||||
finalIcon = await sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -129,25 +151,42 @@ async function generateIcons() {
|
||||
left: offset
|
||||
}])
|
||||
.png()
|
||||
.toFile(join(publicDir, name));
|
||||
.toBuffer();
|
||||
} else {
|
||||
// Direct resize without padding
|
||||
await sourceImage
|
||||
finalIcon = await sourceImage
|
||||
.resize(size, size)
|
||||
.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 maskablePadding = 0.05; // 5% padding for maskable icons
|
||||
const maskableRadius = 92; // ~18% radius for consistency
|
||||
const maskablePaddedSize = Math.round(maskableSize * (1 - maskablePadding * 2));
|
||||
const maskableOffset = Math.round((maskableSize - maskablePaddedSize) / 2);
|
||||
|
||||
await sharp({
|
||||
let maskableIcon = await sharp({
|
||||
create: {
|
||||
width: maskableSize,
|
||||
height: maskableSize,
|
||||
@@ -161,47 +200,33 @@ async function generateIcons() {
|
||||
left: maskableOffset
|
||||
}])
|
||||
.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];
|
||||
for (const size of additionalSizes) {
|
||||
const padding = size <= 48 ? 0.05 : 0.1;
|
||||
const paddedSize = Math.round(size * (1 - padding * 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({
|
||||
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({
|
||||
let additionalIcon = await sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -217,29 +242,82 @@ async function generateIcons() {
|
||||
.png()
|
||||
.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
|
||||
const icoBuffer = await toIco(icoBuffers);
|
||||
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('📋 Generated files:');
|
||||
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 => {
|
||||
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('💡 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.');
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,103 +1,110 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Background Colors */
|
||||
/* Background Colors - Light theme: pure white to warm grays */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-secondary: #f8fafc;
|
||||
--color-surface-muted: #f1f5f9;
|
||||
--color-surface-accent: #e2e8f0;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.95);
|
||||
--color-surface-secondary: #fafbfc;
|
||||
--color-surface-muted: #f4f6f8;
|
||||
--color-surface-accent: #e8ecf0;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.96);
|
||||
|
||||
/* Dark mode backgrounds */
|
||||
--color-surface-dark: #0f172a;
|
||||
--color-surface-secondary-dark: #1e293b;
|
||||
--color-surface-muted-dark: #334155;
|
||||
--color-surface-accent-dark: #475569;
|
||||
--color-surface-overlay-dark: rgba(15, 23, 42, 0.95);
|
||||
/* Dark mode backgrounds - Rich blacks with subtle warm undertones */
|
||||
--color-surface-dark: #0a0a0a;
|
||||
--color-surface-secondary-dark: #141414;
|
||||
--color-surface-muted-dark: #1f1f1f;
|
||||
--color-surface-accent-dark: #2d2d2d;
|
||||
--color-surface-overlay-dark: rgba(10, 10, 10, 0.96);
|
||||
|
||||
/* Text Colors */
|
||||
--color-content-primary: #0f172a;
|
||||
--color-content-secondary: #475569;
|
||||
--color-content-muted: #64748b;
|
||||
--color-content-accent: #94a3b8;
|
||||
/* Text Colors - Light theme with enhanced contrast */
|
||||
--color-content-primary: #0d1117;
|
||||
--color-content-secondary: #57606a;
|
||||
--color-content-muted: #768390;
|
||||
--color-content-accent: #8c959f;
|
||||
--color-content-inverse: #ffffff;
|
||||
|
||||
/* Dark mode text */
|
||||
--color-content-primary-dark: #f8fafc;
|
||||
--color-content-secondary-dark: #e2e8f0;
|
||||
--color-content-muted-dark: #cbd5e1;
|
||||
--color-content-accent-dark: #94a3b8;
|
||||
--color-content-inverse-dark: #0f172a;
|
||||
/* Dark mode text - Crisp whites and refined grays */
|
||||
--color-content-primary-dark: #f0f6fc;
|
||||
--color-content-secondary-dark: #c9d1d9;
|
||||
--color-content-muted-dark: #8b949e;
|
||||
--color-content-accent-dark: #6e7681;
|
||||
--color-content-inverse-dark: #0d1117;
|
||||
|
||||
/* Interactive Colors */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
--color-primary-active: #1d4ed8;
|
||||
--color-primary-muted: #dbeafe;
|
||||
/* Interactive Colors - Enhanced Spotify green with sophistication */
|
||||
--color-primary: #1ed760;
|
||||
--color-primary-hover: #1fdf64;
|
||||
--color-primary-active: #1db954;
|
||||
--color-primary-muted: #d1f7e0;
|
||||
|
||||
--color-secondary: #64748b;
|
||||
--color-secondary-hover: #475569;
|
||||
--color-secondary-active: #334155;
|
||||
--color-secondary-muted: #f1f5f9;
|
||||
/* Secondary colors with purple accent for contrast */
|
||||
--color-secondary: #8b5fbf;
|
||||
--color-secondary-hover: #9d70d1;
|
||||
--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 */
|
||||
--color-success: #22c55e;
|
||||
--color-success-hover: #16a34a;
|
||||
--color-success-muted: #dcfce7;
|
||||
--color-success-text: #15803d;
|
||||
/* Status Colors - Refined and harmonious */
|
||||
--color-success: #28a745;
|
||||
--color-success-hover: #1e7b34;
|
||||
--color-success-muted: #d4edda;
|
||||
--color-success-text: #155724;
|
||||
|
||||
--color-error: #ef4444;
|
||||
--color-error-hover: #dc2626;
|
||||
--color-error-muted: #fef2f2;
|
||||
--color-error-text: #dc2626;
|
||||
--color-error: #dc3545;
|
||||
--color-error-hover: #c82333;
|
||||
--color-error-muted: #f8d7da;
|
||||
--color-error-text: #721c24;
|
||||
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-hover: #d97706;
|
||||
--color-warning-muted: #fef3c7;
|
||||
--color-warning-text: #d97706;
|
||||
--color-warning: #fd7e14;
|
||||
--color-warning-hover: #e8590c;
|
||||
--color-warning-muted: #fff3cd;
|
||||
--color-warning-text: #856404;
|
||||
|
||||
--color-info: #3b82f6;
|
||||
--color-info-hover: #2563eb;
|
||||
--color-info-muted: #dbeafe;
|
||||
--color-info-text: #2563eb;
|
||||
--color-info: #17a2b8;
|
||||
--color-info-hover: #138496;
|
||||
--color-info-muted: #d1ecf1;
|
||||
--color-info-text: #0c5460;
|
||||
|
||||
--color-processing: #a855f7;
|
||||
--color-processing-hover: #9333ea;
|
||||
--color-processing-muted: #f3e8ff;
|
||||
--color-processing-text: #9333ea;
|
||||
--color-processing: #8b5fbf;
|
||||
--color-processing-hover: #7a4fa3;
|
||||
--color-processing-muted: #f0ebff;
|
||||
--color-processing-text: #5d3e7a;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-muted: #f1f5f9;
|
||||
--color-border-accent: #cbd5e1;
|
||||
--color-border-focus: #3b82f6;
|
||||
--color-border-dark: #475569;
|
||||
--color-border-muted-dark: #334155;
|
||||
--color-border-accent-dark: #64748b;
|
||||
/* Border Colors - Subtle and refined */
|
||||
--color-border: #e1e5e9;
|
||||
--color-border-muted: #f4f6f8;
|
||||
--color-border-accent: #d0d7de;
|
||||
--color-border-focus: #1ed760;
|
||||
--color-border-dark: #30363d;
|
||||
--color-border-muted-dark: #21262d;
|
||||
--color-border-accent-dark: #373e47;
|
||||
|
||||
/* Input Colors */
|
||||
--color-input-background: #f1f5f9;
|
||||
--color-input-border: #e2e8f0;
|
||||
--color-input-focus: #3b82f6;
|
||||
--color-input-background-dark: #1e293b;
|
||||
--color-input-border-dark: #475569;
|
||||
--color-input-background: #f4f6f8;
|
||||
--color-input-border: #e1e5e9;
|
||||
--color-input-focus: #1ed760;
|
||||
--color-input-background-dark: #21262d;
|
||||
--color-input-border-dark: #30363d;
|
||||
|
||||
/* Button Colors */
|
||||
--color-button-primary: #3b82f6;
|
||||
--color-button-primary-hover: #2563eb;
|
||||
--color-button-primary: #1ed760;
|
||||
--color-button-primary-hover: #1fdf64;
|
||||
--color-button-primary-text: #ffffff;
|
||||
|
||||
--color-button-secondary: #f1f5f9;
|
||||
--color-button-secondary-hover: #64748b;
|
||||
--color-button-secondary-text: #64748b;
|
||||
--color-button-secondary: #f4f6f8;
|
||||
--color-button-secondary-hover: #8b5fbf;
|
||||
--color-button-secondary-text: #57606a;
|
||||
--color-button-secondary-text-hover: #ffffff;
|
||||
|
||||
--color-button-success: #22c55e;
|
||||
--color-button-success-hover: #16a34a;
|
||||
--color-button-success: #28a745;
|
||||
--color-button-success-hover: #1e7b34;
|
||||
--color-button-success-text: #ffffff;
|
||||
|
||||
--color-icon-button-hover: #e5e7eb;
|
||||
--color-icon-button-hover-dark: #334155;
|
||||
--color-icon-button-hover: #e8ecf0;
|
||||
--color-icon-button-hover-dark: #30363d;
|
||||
|
||||
/* Icon Colors */
|
||||
--color-icon-primary: #000000;
|
||||
@@ -316,4 +323,20 @@
|
||||
color: var(--color-icon-inverse-dark);
|
||||
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 (
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -9,34 +9,65 @@ function AppLayout() {
|
||||
const { toggleVisibility } = useContext(QueueContext) || {};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark">
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm">
|
||||
<div className="min-h-screen bg-surface dark:bg-surface-dark text-content-primary dark:text-content-primary-dark flex flex-col">
|
||||
{/* 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">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/music.svg" alt="Logo" className="w-6 h-6 icon-primary" />
|
||||
<h1 className="text-xl font-bold">Spotizerr</h1>
|
||||
<Link to="/" className="flex items-center">
|
||||
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
|
||||
</Link>
|
||||
<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">
|
||||
<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 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 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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 />
|
||||
</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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Track = () => {
|
||||
const imageUrl = track.album.images?.[0]?.url;
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
@@ -73,66 +73,121 @@ export const Track = () => {
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-surface dark:bg-surface-secondary-dark shadow-lg rounded-lg overflow-hidden md:flex">
|
||||
{imageUrl && (
|
||||
<div className="md:w-1/3">
|
||||
<img src={imageUrl} alt={track.album.name} className="w-full h-auto object-cover" />
|
||||
|
||||
{/* Hero Section with Cover */}
|
||||
<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">
|
||||
<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 className="p-6 md:w-2/3 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{track.name}</h1>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 text-center md:text-left md:pb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-baseline gap-2 md:gap-4 mb-2">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">
|
||||
{track.name}
|
||||
</h1>
|
||||
{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 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) => (
|
||||
<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}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-md text-content-muted dark:text-content-muted-dark mt-4">
|
||||
From the album{" "}
|
||||
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="font-semibold">
|
||||
|
||||
<p className="text-content-muted dark:text-content-muted-dark">
|
||||
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}
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
<p>Release Date: {track.album.release_date}</p>
|
||||
<p>Duration: {formatDuration(track.duration_ms)}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">Popularity:</p>
|
||||
<div className="w-full bg-surface-muted dark:bg-surface-muted-dark rounded-full h-2.5">
|
||||
<div className="bg-success h-2.5 rounded-full" style={{ width: `${track.popularity}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="bg-surface dark:bg-surface-secondary-dark rounded-lg shadow-lg p-6 md:p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
{/* 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 className="flex items-center gap-4 mt-6">
|
||||
<button
|
||||
onClick={handleDownloadTrack}
|
||||
className="bg-button-primary hover:bg-button-primary-hover text-button-primary-text font-bold py-2 px-4 rounded-full transition duration-300"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<a
|
||||
href={track.external_urls.spotify}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition duration-300"
|
||||
aria-label="Listen on Spotify"
|
||||
>
|
||||
<FaSpotify size={24} className="icon-secondary hover:icon-primary" />
|
||||
<span className="font-semibold">Listen on Spotify</span>
|
||||
</a>
|
||||
|
||||
{/* Popularity */}
|
||||
<div>
|
||||
<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">
|
||||
<div className="flex-1 bg-surface-muted dark:bg-surface-muted-dark rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${track.popularity}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-content-secondary dark:text-content-secondary-dark">
|
||||
{track.popularity}%
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
|
||||