Implemented PWA
1
spotizerr-ui/dev-dist/registerSW.js
Normal 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
@@ -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} didn’t 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');
|
||||
|
||||
}));
|
||||
4618
spotizerr-ui/dev-dist/workbox-f70c5944.js
Normal file
@@ -3,8 +3,27 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
@@ -42,9 +43,12 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"sharp": "^0.34.3",
|
||||
"to-ico": "^1.1.5",
|
||||
"typescript": "~5.8.3",
|
||||
"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"
|
||||
}
|
||||
|
||||
3711
spotizerr-ui/pnpm-lock.yaml
generated
2
spotizerr-ui/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
BIN
spotizerr-ui/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
spotizerr-ui/public/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
spotizerr-ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 387 B |
BIN
spotizerr-ui/public/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
spotizerr-ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 758 B |
BIN
spotizerr-ui/public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
spotizerr-ui/public/favicon-64x64.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
spotizerr-ui/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
spotizerr-ui/public/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 14 KiB |
BIN
spotizerr-ui/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
spotizerr-ui/public/pwa-512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
spotizerr-ui/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
spotizerr-ui/public/spotizerr.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
7
spotizerr-ui/public/spotizerr.svg
Normal 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 |
199
spotizerr-ui/scripts/generate-icons.js
Normal 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();
|
||||
@@ -100,57 +100,57 @@
|
||||
--color-icon-button-hover-dark: #334155;
|
||||
|
||||
/* Icon Colors */
|
||||
--color-icon-primary: #475569;
|
||||
--color-icon-primary-hover: #334155;
|
||||
--color-icon-primary-active: #1e293b;
|
||||
--color-icon-primary-dark: #cbd5e1;
|
||||
--color-icon-primary-hover-dark: #e2e8f0;
|
||||
--color-icon-primary-active-dark: #f8fafc;
|
||||
--color-icon-primary: #000000;
|
||||
--color-icon-primary-hover: #000000;
|
||||
--color-icon-primary-active: #000000;
|
||||
--color-icon-primary-dark: #ffffff;
|
||||
--color-icon-primary-hover-dark: #ffffff;
|
||||
--color-icon-primary-active-dark: #ffffff;
|
||||
|
||||
--color-icon-secondary: #64748b;
|
||||
--color-icon-secondary-hover: #475569;
|
||||
--color-icon-secondary-active: #334155;
|
||||
--color-icon-secondary-dark: #94a3b8;
|
||||
--color-icon-secondary-hover-dark: #cbd5e1;
|
||||
--color-icon-secondary-active-dark: #e2e8f0;
|
||||
--color-icon-secondary: #000000;
|
||||
--color-icon-secondary-hover: #000000;
|
||||
--color-icon-secondary-active: #000000;
|
||||
--color-icon-secondary-dark: #ffffff;
|
||||
--color-icon-secondary-hover-dark: #ffffff;
|
||||
--color-icon-secondary-active-dark: #ffffff;
|
||||
|
||||
--color-icon-muted: #94a3b8;
|
||||
--color-icon-muted-hover: #64748b;
|
||||
--color-icon-muted-active: #475569;
|
||||
--color-icon-muted-dark: #64748b;
|
||||
--color-icon-muted-hover-dark: #94a3b8;
|
||||
--color-icon-muted-active-dark: #cbd5e1;
|
||||
--color-icon-muted: #000000;
|
||||
--color-icon-muted-hover: #000000;
|
||||
--color-icon-muted-active: #000000;
|
||||
--color-icon-muted-dark: #ffffff;
|
||||
--color-icon-muted-hover-dark: #ffffff;
|
||||
--color-icon-muted-active-dark: #ffffff;
|
||||
|
||||
--color-icon-accent: #3b82f6;
|
||||
--color-icon-accent-hover: #2563eb;
|
||||
--color-icon-accent-active: #1d4ed8;
|
||||
--color-icon-accent-dark: #60a5fa;
|
||||
--color-icon-accent-hover-dark: #93c5fd;
|
||||
--color-icon-accent-active-dark: #bfdbfe;
|
||||
--color-icon-accent: #000000;
|
||||
--color-icon-accent-hover: #000000;
|
||||
--color-icon-accent-active: #000000;
|
||||
--color-icon-accent-dark: #ffffff;
|
||||
--color-icon-accent-hover-dark: #ffffff;
|
||||
--color-icon-accent-active-dark: #ffffff;
|
||||
|
||||
--color-icon-success: #22c55e;
|
||||
--color-icon-success-hover: #16a34a;
|
||||
--color-icon-success-active: #15803d;
|
||||
--color-icon-success-dark: #4ade80;
|
||||
--color-icon-success-hover-dark: #86efac;
|
||||
--color-icon-success-active-dark: #bbf7d0;
|
||||
--color-icon-success: #000000;
|
||||
--color-icon-success-hover: #000000;
|
||||
--color-icon-success-active: #000000;
|
||||
--color-icon-success-dark: #ffffff;
|
||||
--color-icon-success-hover-dark: #ffffff;
|
||||
--color-icon-success-active-dark: #ffffff;
|
||||
|
||||
--color-icon-error: #ef4444;
|
||||
--color-icon-error-hover: #dc2626;
|
||||
--color-icon-error-active: #b91c1c;
|
||||
--color-icon-error-dark: #f87171;
|
||||
--color-icon-error-hover-dark: #fca5a5;
|
||||
--color-icon-error-active-dark: #fecaca;
|
||||
--color-icon-error: #000000;
|
||||
--color-icon-error-hover: #000000;
|
||||
--color-icon-error-active: #000000;
|
||||
--color-icon-error-dark: #ffffff;
|
||||
--color-icon-error-hover-dark: #ffffff;
|
||||
--color-icon-error-active-dark: #ffffff;
|
||||
|
||||
--color-icon-warning: #f59e0b;
|
||||
--color-icon-warning-hover: #d97706;
|
||||
--color-icon-warning-active: #b45309;
|
||||
--color-icon-warning-dark: #fbbf24;
|
||||
--color-icon-warning-hover-dark: #fcd34d;
|
||||
--color-icon-warning-active-dark: #fde68a;
|
||||
--color-icon-warning: #000000;
|
||||
--color-icon-warning-hover: #000000;
|
||||
--color-icon-warning-active: #000000;
|
||||
--color-icon-warning-dark: #ffffff;
|
||||
--color-icon-warning-hover-dark: #ffffff;
|
||||
--color-icon-warning-active-dark: #ffffff;
|
||||
|
||||
--color-icon-inverse: #ffffff;
|
||||
--color-icon-inverse-dark: #0f172a;
|
||||
--color-icon-inverse-dark: #000000;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -162,158 +162,158 @@
|
||||
.icon-primary {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: 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 {
|
||||
fill: var(--color-icon-inverse);
|
||||
color: var(--color-icon-inverse);
|
||||
filter: brightness(0) saturate(100%) invert(100%);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.dark .icon-inverse {
|
||||
fill: 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { router } from "./router";
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router";
|
||||
import { Root } from "./routes/root";
|
||||
import Root from "./routes/root";
|
||||
import { Album } from "./routes/album";
|
||||
import { Artist } from "./routes/artist";
|
||||
import { Track } from "./routes/track";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useContext, useRef, useCallback } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
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 { FaArrowLeft } from "react-icons/fa";
|
||||
import { FaDownload } from "react-icons/fa6";
|
||||
|
||||
@@ -1,61 +1,53 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { QueueProvider } from "../contexts/QueueProvider";
|
||||
import { useQueue } from "../contexts/queue-context";
|
||||
import { Queue } from "../components/Queue";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { SettingsProvider } from "../contexts/SettingsProvider";
|
||||
import { Toaster } from "sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient();
|
||||
import { Outlet, Link } from "@tanstack/react-router";
|
||||
import { QueueProvider } from "@/contexts/QueueProvider";
|
||||
import { SettingsProvider } from "@/contexts/SettingsProvider";
|
||||
import { QueueContext } from "@/contexts/queue-context";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { useContext } from "react";
|
||||
|
||||
function AppLayout() {
|
||||
const { toggleVisibility } = useQueue();
|
||||
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="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-inverse" />
|
||||
<h1 className="text-xl font-bold">Spotizerr</h1>
|
||||
<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="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>
|
||||
<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>
|
||||
<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-inverse hover:icon-accent" />
|
||||
</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-inverse hover:icon-accent" />
|
||||
</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-inverse hover:icon-accent" />
|
||||
</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>
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto p-4">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Queue />
|
||||
<Toaster richColors duration={1500} position="bottom-left" />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Root = () => {
|
||||
export default function Root() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
<QueueProvider>
|
||||
<AppLayout />
|
||||
</QueueProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
<SettingsProvider>
|
||||
<QueueProvider>
|
||||
<AppLayout />
|
||||
</QueueProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,13 +3,97 @@ import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// https://vite.dev/config/
|
||||
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: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
|
||||