Implemented PWA

This commit is contained in:
Xoconoch
2025-07-27 12:36:08 -06:00
parent a459e0eee6
commit 60c922e200
28 changed files with 8904 additions and 136 deletions

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

107
spotizerr-ui/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,107 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.qqj3c2rpjk8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/_/, /\/[^/?]+\.[^/]+$/]
}));
workbox.registerRoute(/^https:\/\/api\./i, new workbox.NetworkFirst({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/, new workbox.CacheFirst({
"cacheName": "images-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 2592000
})]
}), 'GET');
}));

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,27 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<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>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#1e293b" />
<meta name="description" content="Music downloader and manager for Spotify content" />
<!-- iOS specific -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Spotizerr" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<!-- Windows specific -->
<meta name="msapplication-TileColor" content="#0f172a" />
<meta name="msapplication-TileImage" content="/pwa-512x512.png" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -8,7 +8,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"preview": "vite preview" "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
@@ -42,9 +43,12 @@
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sharp": "^0.34.3",
"to-ico": "^1.1.5",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
}, },
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
spotizerr-ui/public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="282px" height="145px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.952" fill="#fefffe" d="M -0.5,-0.5 C 75.5,-0.5 151.5,-0.5 227.5,-0.5C 257.391,6.72372 275.391,25.3904 281.5,55.5C 281.5,66.5 281.5,77.5 281.5,88.5C 275.391,118.61 257.391,137.276 227.5,144.5C 165.833,144.5 104.167,144.5 42.5,144.5C 24.4585,136.734 18.6252,123.401 25,104.5C 27.0322,100.592 29.8655,97.425 33.5,95C 22.8385,86.0099 19.6719,74.8432 24,61.5C 25.8865,57.5598 28.3865,54.0598 31.5,51C 14.0385,43.1972 3.37183,30.0305 -0.5,11.5C -0.5,7.5 -0.5,3.5 -0.5,-0.5 Z M 20.5,9.5 C 50.3305,8.33612 80.3305,8.16945 110.5,9C 104.07,13.4286 99.0699,19.0952 95.5,26C 77.4613,26.9158 59.4613,26.5825 41.5,25C 32.5985,22.246 25.5985,17.0793 20.5,9.5 Z M 137.5,8.5 C 151.833,8.5 166.167,8.5 180.5,8.5C 180.667,24.8367 180.5,41.1701 180,57.5C 172.704,80.7861 156.87,91.2861 132.5,89C 105.769,80.0414 95.936,61.8748 103,34.5C 109.798,19.5282 121.298,10.8615 137.5,8.5 Z M 190.5,8.5 C 200.172,8.33353 209.839,8.5002 219.5,9C 242.508,11.4265 258.675,23.2598 268,44.5C 270.902,53.43 272.069,62.5966 271.5,72C 273.122,100.781 260.788,120.781 234.5,132C 230.584,133.451 226.584,134.118 222.5,134C 233.428,117.354 234.261,100.187 225,82.5C 217.133,70.3038 205.966,63.4704 191.5,62C 190.572,61.6121 189.905,60.9454 189.5,60C 190.479,42.8682 190.813,25.7016 190.5,8.5 Z M 44.5,54.5 C 59.8333,54.5 75.1667,54.5 90.5,54.5C 91.0085,60.3713 92.6752,65.8713 95.5,71C 81.5,71.6667 67.5,71.6667 53.5,71C 46.222,68.7388 42.3887,63.9054 42,56.5C 42.9947,55.9341 43.828,55.2674 44.5,54.5 Z M 46.5,99.5 C 76.1667,99.5 105.833,99.5 135.5,99.5C 135.193,105.093 135.527,110.593 136.5,116C 108.5,116.667 80.5,116.667 52.5,116C 45.6274,113.34 42.294,108.507 42.5,101.5C 44.0255,101.006 45.3588,100.339 46.5,99.5 Z M 154.5,99.5 C 166.171,99.3335 177.838,99.5001 189.5,100C 205.152,104.125 212.318,114.291 211,130.5C 207.498,123 201.664,118.5 193.5,117C 185.167,116.667 176.833,116.333 168.5,116C 160.775,113.108 156.109,107.608 154.5,99.5 Z"/></g>
<g><path style="opacity:0.958" fill="#fefffe" d="M 136.5,21.5 C 155.343,21.178 166.01,30.3446 168.5,49C 165.558,69.2725 153.891,78.2725 133.5,76C 113.904,67.8912 108.404,54.0579 117,34.5C 121.815,27.5177 128.315,23.1843 136.5,21.5 Z"/></g>
<g><path style="opacity:0.923" fill="#fefffe" d="M 216.5,21.5 C 228.111,21.1253 234.778,26.6253 236.5,38C 235.667,48.1667 230.167,53.6667 220,54.5C 209.826,53.6598 204.326,48.1598 203.5,38C 204.011,29.3381 208.344,23.8381 216.5,21.5 Z"/></g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,199 @@
import sharp from 'sharp';
import { existsSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import toIco from 'to-ico';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const publicDir = join(__dirname, '../public');
const pngPath = join(publicDir, 'spotizerr.png');
async function generateIcons() {
try {
// Check if the PNG file exists
if (!existsSync(pngPath)) {
throw new Error(`PNG file not found at ${pngPath}. Please ensure spotizerr.png exists in the public directory.`);
}
console.log('🎨 Generating PWA icons from PNG...');
// Since the source is already 1667x1667 (square), we don't need to worry about aspect ratio
const sourceSize = 1667;
// Define icon configurations
const iconConfigs = [
{
size: 16,
name: 'favicon-16x16.png',
padding: 0.1, // 10% padding for small icons
},
{
size: 32,
name: 'favicon-32x32.png',
padding: 0.1,
},
{
size: 180,
name: 'apple-touch-icon-180x180.png',
padding: 0.05, // 5% padding for Apple (they prefer less padding)
},
{
size: 192,
name: 'pwa-192x192.png',
padding: 0.1,
},
{
size: 512,
name: 'pwa-512x512.png',
padding: 0.1,
}
];
// Load the source PNG
const sourceImage = sharp(pngPath);
for (const config of iconConfigs) {
const { size, name, padding } = config;
if (padding > 0) {
// Create icon with padding by compositing on a background
const paddedSize = Math.round(size * (1 - padding * 2));
const offset = Math.round((size - paddedSize) / 2);
// Create a black background and composite the resized logo on top
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } // #0f172a in RGB
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toFile(join(publicDir, name));
} else {
// Direct resize without padding
await sourceImage
.resize(size, size)
.png()
.toFile(join(publicDir, name));
}
console.log(`✅ Generated ${name} (${size}x${size}) - padding: ${padding * 100}%`);
}
// Create maskable icon (less padding, solid background)
const maskableSize = 512;
const maskablePadding = 0.05; // 5% padding for maskable icons
const maskablePaddedSize = Math.round(maskableSize * (1 - maskablePadding * 2));
const maskableOffset = Math.round((maskableSize - maskablePaddedSize) / 2);
await sharp({
create: {
width: maskableSize,
height: maskableSize,
channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 } // Solid background for maskable
}
})
.composite([{
input: await sourceImage.resize(maskablePaddedSize, maskablePaddedSize).png().toBuffer(),
top: maskableOffset,
left: maskableOffset
}])
.png()
.toFile(join(publicDir, 'pwa-512x512-maskable.png'));
console.log(`✅ Generated pwa-512x512-maskable.png (${maskableSize}x${maskableSize}) - maskable`);
// Generate additional favicon sizes for ICO compatibility
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);
await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 }
}
})
.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: {
width: size,
height: size,
channels: 4,
background: { r: 15, g: 23, b: 42, alpha: 1 }
}
})
.composite([{
input: await sourceImage.resize(paddedSize, paddedSize).png().toBuffer(),
top: offset,
left: offset
}])
.png()
.toBuffer();
icoBuffers.push(buffer);
}
// 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('🎉 All PWA icons generated successfully!');
console.log('');
console.log('📋 Generated files:');
iconConfigs.forEach(config => {
console.log(`${config.name} (${config.size}x${config.size})`);
});
console.log(' • pwa-512x512-maskable.png (512x512)');
additionalSizes.forEach(size => {
console.log(` • favicon-${size}x${size}.png (${size}x${size})`);
});
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)');
console.log('');
console.log('💡 The icons are generated with appropriate padding and the dark theme background.');
console.log('💡 The source PNG already has the perfect background, so no additional styling needed.');
console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.');
} catch (error) {
console.error('❌ Error generating PWA icons:', error);
process.exit(1);
}
}
generateIcons();

View File

@@ -100,57 +100,57 @@
--color-icon-button-hover-dark: #334155; --color-icon-button-hover-dark: #334155;
/* Icon Colors */ /* Icon Colors */
--color-icon-primary: #475569; --color-icon-primary: #000000;
--color-icon-primary-hover: #334155; --color-icon-primary-hover: #000000;
--color-icon-primary-active: #1e293b; --color-icon-primary-active: #000000;
--color-icon-primary-dark: #cbd5e1; --color-icon-primary-dark: #ffffff;
--color-icon-primary-hover-dark: #e2e8f0; --color-icon-primary-hover-dark: #ffffff;
--color-icon-primary-active-dark: #f8fafc; --color-icon-primary-active-dark: #ffffff;
--color-icon-secondary: #64748b; --color-icon-secondary: #000000;
--color-icon-secondary-hover: #475569; --color-icon-secondary-hover: #000000;
--color-icon-secondary-active: #334155; --color-icon-secondary-active: #000000;
--color-icon-secondary-dark: #94a3b8; --color-icon-secondary-dark: #ffffff;
--color-icon-secondary-hover-dark: #cbd5e1; --color-icon-secondary-hover-dark: #ffffff;
--color-icon-secondary-active-dark: #e2e8f0; --color-icon-secondary-active-dark: #ffffff;
--color-icon-muted: #94a3b8; --color-icon-muted: #000000;
--color-icon-muted-hover: #64748b; --color-icon-muted-hover: #000000;
--color-icon-muted-active: #475569; --color-icon-muted-active: #000000;
--color-icon-muted-dark: #64748b; --color-icon-muted-dark: #ffffff;
--color-icon-muted-hover-dark: #94a3b8; --color-icon-muted-hover-dark: #ffffff;
--color-icon-muted-active-dark: #cbd5e1; --color-icon-muted-active-dark: #ffffff;
--color-icon-accent: #3b82f6; --color-icon-accent: #000000;
--color-icon-accent-hover: #2563eb; --color-icon-accent-hover: #000000;
--color-icon-accent-active: #1d4ed8; --color-icon-accent-active: #000000;
--color-icon-accent-dark: #60a5fa; --color-icon-accent-dark: #ffffff;
--color-icon-accent-hover-dark: #93c5fd; --color-icon-accent-hover-dark: #ffffff;
--color-icon-accent-active-dark: #bfdbfe; --color-icon-accent-active-dark: #ffffff;
--color-icon-success: #22c55e; --color-icon-success: #000000;
--color-icon-success-hover: #16a34a; --color-icon-success-hover: #000000;
--color-icon-success-active: #15803d; --color-icon-success-active: #000000;
--color-icon-success-dark: #4ade80; --color-icon-success-dark: #ffffff;
--color-icon-success-hover-dark: #86efac; --color-icon-success-hover-dark: #ffffff;
--color-icon-success-active-dark: #bbf7d0; --color-icon-success-active-dark: #ffffff;
--color-icon-error: #ef4444; --color-icon-error: #000000;
--color-icon-error-hover: #dc2626; --color-icon-error-hover: #000000;
--color-icon-error-active: #b91c1c; --color-icon-error-active: #000000;
--color-icon-error-dark: #f87171; --color-icon-error-dark: #ffffff;
--color-icon-error-hover-dark: #fca5a5; --color-icon-error-hover-dark: #ffffff;
--color-icon-error-active-dark: #fecaca; --color-icon-error-active-dark: #ffffff;
--color-icon-warning: #f59e0b; --color-icon-warning: #000000;
--color-icon-warning-hover: #d97706; --color-icon-warning-hover: #000000;
--color-icon-warning-active: #b45309; --color-icon-warning-active: #000000;
--color-icon-warning-dark: #fbbf24; --color-icon-warning-dark: #ffffff;
--color-icon-warning-hover-dark: #fcd34d; --color-icon-warning-hover-dark: #ffffff;
--color-icon-warning-active-dark: #fde68a; --color-icon-warning-active-dark: #ffffff;
--color-icon-inverse: #ffffff; --color-icon-inverse: #ffffff;
--color-icon-inverse-dark: #0f172a; --color-icon-inverse-dark: #000000;
} }
@layer base { @layer base {
@@ -162,158 +162,158 @@
.icon-primary { .icon-primary {
fill: var(--color-icon-primary); fill: var(--color-icon-primary);
color: var(--color-icon-primary); color: var(--color-icon-primary);
filter: brightness(0) saturate(100%) invert(27%) sepia(8%) saturate(1363%) hue-rotate(183deg) brightness(96%) contrast(89%); filter: brightness(0);
} }
.dark .icon-primary { .dark .icon-primary {
fill: var(--color-icon-primary-dark); fill: var(--color-icon-primary-dark);
color: var(--color-icon-primary-dark); color: var(--color-icon-primary-dark);
filter: brightness(0) saturate(100%) invert(83%) sepia(8%) saturate(1018%) hue-rotate(183deg) brightness(96%) contrast(91%); filter: brightness(0) invert(100%);
} }
.icon-primary:hover { .icon-primary:hover {
fill: var(--color-icon-primary-hover); fill: var(--color-icon-primary-hover);
color: var(--color-icon-primary-hover); color: var(--color-icon-primary-hover);
filter: brightness(0) saturate(100%) invert(21%) sepia(12%) saturate(1190%) hue-rotate(183deg) brightness(96%) contrast(92%); filter: brightness(0);
} }
.dark .icon-primary:hover { .dark .icon-primary:hover {
fill: var(--color-icon-primary-hover-dark); fill: var(--color-icon-primary-hover-dark);
color: var(--color-icon-primary-hover-dark); color: var(--color-icon-primary-hover-dark);
filter: brightness(0) saturate(100%) invert(90%) sepia(6%) saturate(668%) hue-rotate(183deg) brightness(97%) contrast(91%); filter: brightness(0) invert(100%);
} }
.icon-secondary { .icon-secondary {
fill: var(--color-icon-secondary); fill: var(--color-icon-secondary);
color: var(--color-icon-secondary); color: var(--color-icon-secondary);
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%); filter: brightness(0);
} }
.dark .icon-secondary { .dark .icon-secondary {
fill: var(--color-icon-secondary-dark); fill: var(--color-icon-secondary-dark);
color: var(--color-icon-secondary-dark); color: var(--color-icon-secondary-dark);
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%); filter: brightness(0) invert(100%);
} }
.icon-secondary:hover { .icon-secondary:hover {
fill: var(--color-icon-secondary-hover); fill: var(--color-icon-secondary-hover);
color: var(--color-icon-secondary-hover); color: var(--color-icon-secondary-hover);
filter: brightness(0) saturate(100%) invert(27%) sepia(8%) saturate(1363%) hue-rotate(183deg) brightness(96%) contrast(89%); filter: brightness(0);
} }
.dark .icon-secondary:hover { .dark .icon-secondary:hover {
fill: var(--color-icon-secondary-hover-dark); fill: var(--color-icon-secondary-hover-dark);
color: var(--color-icon-secondary-hover-dark); color: var(--color-icon-secondary-hover-dark);
filter: brightness(0) saturate(100%) invert(83%) sepia(8%) saturate(1018%) hue-rotate(183deg) brightness(96%) contrast(91%); filter: brightness(0) invert(100%);
} }
.icon-muted { .icon-muted {
fill: var(--color-icon-muted); fill: var(--color-icon-muted);
color: var(--color-icon-muted); color: var(--color-icon-muted);
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%); filter: brightness(0);
} }
.dark .icon-muted { .dark .icon-muted {
fill: var(--color-icon-muted-dark); fill: var(--color-icon-muted-dark);
color: var(--color-icon-muted-dark); color: var(--color-icon-muted-dark);
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%); filter: brightness(0) invert(100%);
} }
.icon-muted:hover { .icon-muted:hover {
fill: var(--color-icon-muted-hover); fill: var(--color-icon-muted-hover);
color: var(--color-icon-muted-hover); color: var(--color-icon-muted-hover);
filter: brightness(0) saturate(100%) invert(48%) sepia(14%) saturate(725%) hue-rotate(183deg) brightness(94%) contrast(88%); filter: brightness(0);
} }
.dark .icon-muted:hover { .dark .icon-muted:hover {
fill: var(--color-icon-muted-hover-dark); fill: var(--color-icon-muted-hover-dark);
color: var(--color-icon-muted-hover-dark); color: var(--color-icon-muted-hover-dark);
filter: brightness(0) saturate(100%) invert(69%) sepia(11%) saturate(763%) hue-rotate(183deg) brightness(93%) contrast(89%); filter: brightness(0) invert(100%);
} }
.icon-accent { .icon-accent {
fill: var(--color-icon-accent); fill: var(--color-icon-accent);
color: var(--color-icon-accent); color: var(--color-icon-accent);
filter: brightness(0) saturate(100%) invert(41%) sepia(96%) saturate(1347%) hue-rotate(215deg) brightness(99%) contrast(86%); filter: brightness(0);
} }
.dark .icon-accent { .dark .icon-accent {
fill: var(--color-icon-accent-dark); fill: var(--color-icon-accent-dark);
color: var(--color-icon-accent-dark); color: var(--color-icon-accent-dark);
filter: brightness(0) saturate(100%) invert(67%) sepia(25%) saturate(2334%) hue-rotate(215deg) brightness(102%) contrast(96%); filter: brightness(0) invert(100%);
} }
.icon-accent:hover { .icon-accent:hover {
fill: var(--color-icon-accent-hover); fill: var(--color-icon-accent-hover);
color: var(--color-icon-accent-hover); color: var(--color-icon-accent-hover);
filter: brightness(0) saturate(100%) invert(29%) sepia(81%) saturate(2476%) hue-rotate(215deg) brightness(98%) contrast(86%); filter: brightness(0);
} }
.dark .icon-accent:hover { .dark .icon-accent:hover {
fill: var(--color-icon-accent-hover-dark); fill: var(--color-icon-accent-hover-dark);
color: var(--color-icon-accent-hover-dark); color: var(--color-icon-accent-hover-dark);
filter: brightness(0) saturate(100%) invert(75%) sepia(28%) saturate(1388%) hue-rotate(215deg) brightness(104%) contrast(96%); filter: brightness(0) invert(100%);
} }
.icon-success { .icon-success {
fill: var(--color-icon-success); fill: var(--color-icon-success);
color: var(--color-icon-success); color: var(--color-icon-success);
filter: brightness(0) saturate(100%) invert(47%) sepia(95%) saturate(450%) hue-rotate(92deg) brightness(98%) contrast(91%); filter: brightness(0);
} }
.dark .icon-success { .dark .icon-success {
fill: var(--color-icon-success-dark); fill: var(--color-icon-success-dark);
color: var(--color-icon-success-dark); color: var(--color-icon-success-dark);
filter: brightness(0) saturate(100%) invert(64%) sepia(78%) saturate(394%) hue-rotate(92deg) brightness(101%) contrast(89%); filter: brightness(0) invert(100%);
} }
.icon-success:hover { .icon-success:hover {
fill: var(--color-icon-success-hover); fill: var(--color-icon-success-hover);
color: var(--color-icon-success-hover); color: var(--color-icon-success-hover);
filter: brightness(0) saturate(100%) invert(42%) sepia(78%) saturate(440%) hue-rotate(92deg) brightness(96%) contrast(89%); filter: brightness(0);
} }
.dark .icon-success:hover { .dark .icon-success:hover {
fill: var(--color-icon-success-hover-dark); fill: var(--color-icon-success-hover-dark);
color: var(--color-icon-success-hover-dark); color: var(--color-icon-success-hover-dark);
filter: brightness(0) saturate(100%) invert(75%) sepia(64%) saturate(295%) hue-rotate(92deg) brightness(103%) contrast(87%); filter: brightness(0) invert(100%);
} }
.icon-error { .icon-error {
fill: var(--color-icon-error); fill: var(--color-icon-error);
color: var(--color-icon-error); color: var(--color-icon-error);
filter: brightness(0) saturate(100%) invert(30%) sepia(93%) saturate(1742%) hue-rotate(339deg) brightness(98%) contrast(94%); filter: brightness(0);
} }
.dark .icon-error { .dark .icon-error {
fill: var(--color-icon-error-dark); fill: var(--color-icon-error-dark);
color: var(--color-icon-error-dark); color: var(--color-icon-error-dark);
filter: brightness(0) saturate(100%) invert(62%) sepia(93%) saturate(1360%) hue-rotate(339deg) brightness(99%) contrast(96%); filter: brightness(0) invert(100%);
} }
.icon-error:hover { .icon-error:hover {
fill: var(--color-icon-error-hover); fill: var(--color-icon-error-hover);
color: var(--color-icon-error-hover); color: var(--color-icon-error-hover);
filter: brightness(0) saturate(100%) invert(17%) sepia(95%) saturate(2341%) hue-rotate(339deg) brightness(96%) contrast(94%); filter: brightness(0);
} }
.dark .icon-error:hover { .dark .icon-error:hover {
fill: var(--color-icon-error-hover-dark); fill: var(--color-icon-error-hover-dark);
color: var(--color-icon-error-hover-dark); color: var(--color-icon-error-hover-dark);
filter: brightness(0) saturate(100%) invert(74%) sepia(29%) saturate(1214%) hue-rotate(339deg) brightness(99%) contrast(94%); filter: brightness(0) invert(100%);
} }
.icon-warning { .icon-warning {
fill: var(--color-icon-warning); fill: var(--color-icon-warning);
color: var(--color-icon-warning); color: var(--color-icon-warning);
filter: brightness(0) saturate(100%) invert(67%) sepia(98%) saturate(1284%) hue-rotate(12deg) brightness(95%) contrast(92%); filter: brightness(0);
} }
.dark .icon-warning { .dark .icon-warning {
fill: var(--color-icon-warning-dark); fill: var(--color-icon-warning-dark);
color: var(--color-icon-warning-dark); color: var(--color-icon-warning-dark);
filter: brightness(0) saturate(100%) invert(84%) sepia(36%) saturate(1043%) hue-rotate(12deg) brightness(97%) contrast(91%); filter: brightness(0) invert(100%);
} }
.icon-warning:hover { .icon-warning:hover {
fill: var(--color-icon-warning-hover); fill: var(--color-icon-warning-hover);
color: var(--color-icon-warning-hover); color: var(--color-icon-warning-hover);
filter: brightness(0) saturate(100%) invert(58%) sepia(89%) saturate(1619%) hue-rotate(12deg) brightness(94%) contrast(96%); filter: brightness(0);
} }
.dark .icon-warning:hover { .dark .icon-warning:hover {
fill: var(--color-icon-warning-hover-dark); fill: var(--color-icon-warning-hover-dark);
color: var(--color-icon-warning-hover-dark); color: var(--color-icon-warning-hover-dark);
filter: brightness(0) saturate(100%) invert(89%) sepia(21%) saturate(789%) hue-rotate(12deg) brightness(98%) contrast(88%); filter: brightness(0) invert(100%);
} }
.icon-inverse { .icon-inverse {
fill: var(--color-icon-inverse); fill: var(--color-icon-inverse);
color: var(--color-icon-inverse); color: var(--color-icon-inverse);
filter: brightness(0) saturate(100%) invert(100%); filter: brightness(0) invert(100%);
} }
.dark .icon-inverse { .dark .icon-inverse {
fill: var(--color-icon-inverse-dark); fill: var(--color-icon-inverse-dark);
color: var(--color-icon-inverse-dark); color: var(--color-icon-inverse-dark);
filter: brightness(0) saturate(100%) invert(5%) sepia(8%) saturate(7470%) hue-rotate(183deg) brightness(97%) contrast(108%); filter: brightness(0);
} }
} }

View File

@@ -1,11 +1,51 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./router"; import { router } from "./router";
import "./index.css"; import "./index.css";
// Dark mode detection and setup
function setupDarkMode() {
// Check for saved theme preference or default to system preference
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
if (e.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
});
}
// Initialize dark mode
setupDarkMode();
// Create a QueryClient instance
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,5 +1,5 @@
import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router"; import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router";
import { Root } from "./routes/root"; import Root from "./routes/root";
import { Album } from "./routes/album"; import { Album } from "./routes/album";
import { Artist } from "./routes/artist"; import { Artist } from "./routes/artist";
import { Track } from "./routes/track"; import { Track } from "./routes/track";

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
import apiClient from "../lib/api-client"; import apiClient from "../lib/api-client";
import { useSettings } from "../contexts/settings-context"; import { useSettings } from "../contexts/settings-context";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PlaylistType, TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify"; import type { TrackType, PlaylistMetadataType, PlaylistTracksResponseType, PlaylistItemType } from "../types/spotify";
import { QueueContext } from "../contexts/queue-context"; import { QueueContext } from "../contexts/queue-context";
import { FaArrowLeft } from "react-icons/fa"; import { FaArrowLeft } from "react-icons/fa";
import { FaDownload } from "react-icons/fa6"; import { FaDownload } from "react-icons/fa6";

View File

@@ -1,61 +1,53 @@
import { Outlet } from "@tanstack/react-router"; import { Outlet, Link } from "@tanstack/react-router";
import { QueueProvider } from "../contexts/QueueProvider"; import { QueueProvider } from "@/contexts/QueueProvider";
import { useQueue } from "../contexts/queue-context"; import { SettingsProvider } from "@/contexts/SettingsProvider";
import { Queue } from "../components/Queue"; import { QueueContext } from "@/contexts/queue-context";
import { Link } from "@tanstack/react-router"; import { Queue } from "@/components/Queue";
import { SettingsProvider } from "../contexts/SettingsProvider"; import { useContext } from "react";
import { Toaster } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Create a client
const queryClient = new QueryClient();
function AppLayout() { function AppLayout() {
const { toggleVisibility } = useQueue(); 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"> <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">
<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="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 gap-2"> <img src="/music.svg" alt="Logo" className="w-6 h-6 icon-primary" />
<img src="/music.svg" alt="Logo" className="w-6 h-6 icon-inverse" /> <h1 className="text-xl font-bold">Spotizerr</h1>
<h1 className="text-xl font-bold">Spotizerr</h1> </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" />
</Link> </Link>
<div className="flex items-center gap-2"> <Link to="/history" 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="/history.svg" alt="History" className="w-6 h-6 icon-primary" />
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6 icon-inverse hover:icon-accent" /> </Link>
</Link> <Link to="/config" 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="/settings.svg" alt="Settings" className="w-6 h-6 icon-primary" />
<img src="/history.svg" alt="History" className="w-6 h-6 icon-inverse hover:icon-accent" /> </Link>
</Link> <button onClick={toggleVisibility} 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="/queue.svg" alt="Queue" className="w-6 h-6 icon-primary" />
<img src="/settings.svg" alt="Settings" className="w-6 h-6 icon-inverse hover:icon-accent" /> </button>
</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-inverse hover:icon-accent" />
</button>
</div>
</div> </div>
</header> </div>
<main className="container mx-auto px-4 py-8"> </header>
<Outlet />
</main> <main className="container mx-auto p-4">
</div> <Outlet />
</main>
<Queue /> <Queue />
<Toaster richColors duration={1500} position="bottom-left" /> </div>
</>
); );
} }
export const Root = () => { export default function Root() {
return ( return (
<QueryClientProvider client={queryClient}> <SettingsProvider>
<SettingsProvider> <QueueProvider>
<QueueProvider> <AppLayout />
<AppLayout /> </QueueProvider>
</QueueProvider> </SettingsProvider>
</SettingsProvider>
</QueryClientProvider>
); );
}; }

View File

@@ -3,13 +3,97 @@ import react from "@vitejs/plugin-react";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, resolve } from "path"; import { dirname, resolve } from "path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'spotizerr.svg', '*.svg'],
injectRegister: 'auto',
manifest: {
name: 'Spotizerr',
short_name: 'Spotizerr',
description: 'Music downloader and manager for Spotify content',
theme_color: '#1e293b',
background_color: '#0f172a',
display: 'standalone',
scope: '/',
start_url: '/',
lang: 'en',
orientation: 'portrait-primary',
categories: ['music', 'entertainment', 'utilities'],
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
},
{
src: 'apple-touch-icon-180x180.png',
sizes: '180x180',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
})
],
resolve: { resolve: {
alias: { alias: {
"@": resolve(__dirname, "./src"), "@": resolve(__dirname, "./src"),