General UI improvements
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.v6eu1k3j5b"
|
||||
"revision": "0.im8ejsgjrtc"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
4
spotizerr-ui/public/dark.svg
Normal file
4
spotizerr-ui/public/dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0557 3.59974C12.2752 3.2813 12.2913 2.86484 12.0972 2.53033C11.9031 2.19582 11.5335 2.00324 11.1481 2.03579C6.02351 2.46868 2 6.76392 2 12C2 17.5228 6.47715 22 12 22C17.236 22 21.5313 17.9764 21.9642 12.8518C21.9967 12.4664 21.8041 12.0968 21.4696 11.9027C21.1351 11.7086 20.7187 11.7248 20.4002 11.9443C19.4341 12.6102 18.2641 13 17 13C13.6863 13 11 10.3137 11 6.99996C11 5.73589 11.3898 4.56587 12.0557 3.59974Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 675 B |
29
spotizerr-ui/public/light.svg
Normal file
29
spotizerr-ui/public/light.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve">
|
||||
<g>
|
||||
<circle fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" cx="32.003" cy="32.005" r="16.001"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.001,31.997c0-2.211-1.789-4-4-4H4c-2.211,0-4,1.789-4,4
|
||||
s1.789,4,4,4h4C10.212,35.997,12.001,34.208,12.001,31.997z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.204,46.139l-2.832,2.833c-1.563,1.562-1.563,4.094,0,5.656
|
||||
c1.562,1.562,4.094,1.562,5.657,0l2.833-2.832c1.562-1.562,1.562-4.095,0-5.657C16.298,44.576,13.767,44.576,12.204,46.139z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M32.003,51.999c-2.211,0-4,1.789-4,4V60c0,2.211,1.789,4,4,4
|
||||
s4-1.789,4-4l-0.004-4.001C36.003,53.788,34.21,51.999,32.003,51.999z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M51.798,46.143c-1.559-1.566-4.091-1.566-5.653-0.004
|
||||
s-1.562,4.095,0,5.657l2.829,2.828c1.562,1.57,4.094,1.562,5.656,0s1.566-4.09,0-5.656L51.798,46.143z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M60.006,27.997l-4.009,0.008
|
||||
c-2.203-0.008-3.992,1.781-3.992,3.992c-0.008,2.211,1.789,4,3.992,4h4.001c2.219,0.008,4-1.789,4-4
|
||||
C64.002,29.79,62.217,27.997,60.006,27.997z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M51.798,17.859l2.828-2.829c1.574-1.566,1.562-4.094,0-5.657
|
||||
c-1.559-1.567-4.09-1.567-5.652-0.004l-2.829,2.836c-1.562,1.555-1.562,4.086,0,5.649C47.699,19.426,50.239,19.418,51.798,17.859z"
|
||||
/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M32.003,11.995c2.207,0.016,4-1.789,4-3.992v-4
|
||||
c0-2.219-1.789-4-4-4c-2.211-0.008-4,1.781-4,3.993l0.008,4.008C28.003,10.206,29.792,11.995,32.003,11.995z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" fill="#231F20" d="M12.212,17.855c1.555,1.562,4.079,1.562,5.646-0.004
|
||||
c1.574-1.551,1.566-4.09,0.008-5.649l-2.829-2.828c-1.57-1.571-4.094-1.559-5.657,0c-1.575,1.559-1.575,4.09-0.012,5.653
|
||||
L12.212,17.855z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
4
spotizerr-ui/public/system.svg
Normal file
4
spotizerr-ui/public/system.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 6C2 4.34315 3.34315 3 5 3H19C20.6569 3 22 4.34315 22 6V15C22 16.6569 20.6569 18 19 18H13V19H15C15.5523 19 16 19.4477 16 20C16 20.5523 15.5523 21 15 21H9C8.44772 21 8 20.5523 8 20C8 19.4477 8.44772 19 9 19H11V18H5C3.34315 18 2 16.6569 2 15V6ZM5 5C4.44772 5 4 5.44772 4 6V15C4 15.5523 4.44772 16 5 16H19C19.5523 16 20 15.5523 20 15V6C20 5.44772 19.5523 5 19 5H5Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 661 B |
@@ -26,11 +26,13 @@ export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownloa
|
||||
return (
|
||||
<div className="group flex flex-col rounded-lg overflow-hidden bg-surface dark:bg-surface-secondary-dark hover:bg-surface-secondary dark:hover:bg-surface-muted-dark shadow-xl hover:shadow-2xl transition-shadow duration-300 ease-in-out">
|
||||
<div className="relative">
|
||||
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover" />
|
||||
<Link to={getLinkPath()} className="block">
|
||||
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover hover:scale-105 transition-transform duration-300" />
|
||||
</Link>
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
className="absolute bottom-2 right-2 p-2 bg-button-success hover:bg-button-success-hover text-button-success-text rounded-full transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300 z-10"
|
||||
title={`Download ${type}`}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-inverse" />
|
||||
|
||||
@@ -84,7 +84,7 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||||
>
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="deezer">Deezer</option>
|
||||
<option value="deezer" disabled>Deezer (not yet...)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-success" : "text-error"}`}>
|
||||
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<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">ENV</span>
|
||||
<span className="text-xs bg-surface-accent dark:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark px-2 py-1 rounded-full">ENV</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Background Colors - Light theme: pure white to warm grays */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-secondary: #fafbfc;
|
||||
--color-surface-muted: #f4f6f8;
|
||||
--color-surface-accent: #e8ecf0;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.96);
|
||||
|
||||
/* 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);
|
||||
/* Override dark mode to use class-based instead of prefers-color-scheme */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Text Colors - Light theme with enhanced contrast */
|
||||
@theme {
|
||||
/* Background Colors with automatic dark mode variants and gradients */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-dark: #0a0a0a;
|
||||
--color-surface-secondary: #fafbfc;
|
||||
--color-surface-secondary-dark: #141418;
|
||||
--color-surface-muted: #f4f6f8;
|
||||
--color-surface-muted-dark: #1f1f23;
|
||||
--color-surface-accent: #e8ecf0;
|
||||
--color-surface-accent-dark: #2d2d33;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.85);
|
||||
--color-surface-overlay-dark: rgba(10, 10, 12, 0.85);
|
||||
|
||||
/* Gradient backgrounds for depth */
|
||||
--gradient-surface: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
--gradient-surface-dark: linear-gradient(135deg, #0a0a0a 0%, #141418 100%);
|
||||
--gradient-muted: linear-gradient(135deg, #f4f6f8 0%, #e8ecf0 100%);
|
||||
--gradient-muted-dark: linear-gradient(135deg, #1f1f23 0%, #2d2d33 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #e8ecf0 0%, #d6dce4 100%);
|
||||
--gradient-accent-dark: linear-gradient(135deg, #2d2d33 0%, #3a3a42 100%);
|
||||
|
||||
/* Text Colors with automatic dark mode variants and subtle tints */
|
||||
--color-content-primary: #0d1117;
|
||||
--color-content-secondary: #57606a;
|
||||
--color-content-muted: #768390;
|
||||
--color-content-accent: #8c959f;
|
||||
--color-content-inverse: #ffffff;
|
||||
|
||||
/* 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-secondary: #4a5568;
|
||||
--color-content-secondary-dark: #a0aec0;
|
||||
--color-content-muted: #718096;
|
||||
--color-content-muted-dark: #9ca3af;
|
||||
--color-content-accent: #6b7280;
|
||||
--color-content-accent-dark: #6b7280;
|
||||
--color-content-inverse: #ffffff;
|
||||
--color-content-inverse-dark: #0d1117;
|
||||
|
||||
/* Interactive Colors - Enhanced Spotify green with sophistication */
|
||||
@@ -73,21 +80,27 @@
|
||||
--color-processing-muted: #f0ebff;
|
||||
--color-processing-text: #5d3e7a;
|
||||
|
||||
/* Border Colors - Subtle and refined */
|
||||
--color-border: #e1e5e9;
|
||||
--color-border-muted: #f4f6f8;
|
||||
--color-border-accent: #d0d7de;
|
||||
/* Border Colors with automatic dark mode variants and gradients */
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-dark: #374151;
|
||||
--color-border-muted: #f1f5f9;
|
||||
--color-border-muted-dark: #1f2937;
|
||||
--color-border-accent: #cbd5e1;
|
||||
--color-border-accent-dark: #4b5563;
|
||||
--color-border-focus: #1ed760;
|
||||
--color-border-dark: #30363d;
|
||||
--color-border-muted-dark: #21262d;
|
||||
--color-border-accent-dark: #373e47;
|
||||
|
||||
/* Input Colors */
|
||||
--color-input-background: #f4f6f8;
|
||||
--color-input-border: #e1e5e9;
|
||||
/* Gradient borders for enhanced depth */
|
||||
--gradient-border: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%);
|
||||
--gradient-border-dark: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||
|
||||
/* Input Colors with automatic dark mode variants and gradients */
|
||||
--color-input-background: #f8fafc;
|
||||
--color-input-background-dark: #1f2937;
|
||||
--color-input-border: #e2e8f0;
|
||||
--color-input-border-dark: #374151;
|
||||
--color-input-focus: #1ed760;
|
||||
--color-input-background-dark: #21262d;
|
||||
--color-input-border-dark: #30363d;
|
||||
--gradient-input: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
--gradient-input-dark: linear-gradient(135deg, #1f2937 0%, #111827 100%);
|
||||
|
||||
/* Button Colors */
|
||||
--color-button-primary: #1ed760;
|
||||
@@ -103,8 +116,8 @@
|
||||
--color-button-success-hover: #1e7b34;
|
||||
--color-button-success-text: #ffffff;
|
||||
|
||||
--color-icon-button-hover: #e8ecf0;
|
||||
--color-icon-button-hover-dark: #30363d;
|
||||
--color-icon-button-hover: #f1f5f9;
|
||||
--color-icon-button-hover-dark: #374151;
|
||||
|
||||
/* Icon Colors */
|
||||
--color-icon-primary: #000000;
|
||||
@@ -161,182 +174,64 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* PWA Safe Area Support */
|
||||
:root {
|
||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
/* PWA specific body styling */
|
||||
body {
|
||||
background: var(--color-surface);
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.dark body {
|
||||
background: var(--color-surface-dark);
|
||||
}
|
||||
|
||||
/* PWA viewport fixes */
|
||||
html {
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Dynamic viewport height for mobile */
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply no-underline hover:underline cursor-pointer;
|
||||
}
|
||||
|
||||
/* SVG Icon Utility Classes - Using filters for img elements */
|
||||
.icon-primary {
|
||||
fill: var(--color-icon-primary);
|
||||
color: var(--color-icon-primary);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-primary {
|
||||
fill: var(--color-icon-primary-dark);
|
||||
color: var(--color-icon-primary-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-primary:hover {
|
||||
fill: var(--color-icon-primary-hover);
|
||||
color: var(--color-icon-primary-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-primary:hover {
|
||||
fill: var(--color-icon-primary-hover-dark);
|
||||
color: var(--color-icon-primary-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-secondary {
|
||||
fill: var(--color-icon-secondary);
|
||||
color: var(--color-icon-secondary);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-secondary {
|
||||
fill: var(--color-icon-secondary-dark);
|
||||
color: var(--color-icon-secondary-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-secondary:hover {
|
||||
fill: var(--color-icon-secondary-hover);
|
||||
color: var(--color-icon-secondary-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-secondary:hover {
|
||||
fill: var(--color-icon-secondary-hover-dark);
|
||||
color: var(--color-icon-secondary-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-muted {
|
||||
fill: var(--color-icon-muted);
|
||||
color: var(--color-icon-muted);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-muted {
|
||||
fill: var(--color-icon-muted-dark);
|
||||
color: var(--color-icon-muted-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-muted:hover {
|
||||
fill: var(--color-icon-muted-hover);
|
||||
color: var(--color-icon-muted-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-muted:hover {
|
||||
fill: var(--color-icon-muted-hover-dark);
|
||||
color: var(--color-icon-muted-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-accent {
|
||||
fill: var(--color-icon-accent);
|
||||
color: var(--color-icon-accent);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-accent {
|
||||
fill: var(--color-icon-accent-dark);
|
||||
color: var(--color-icon-accent-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-accent:hover {
|
||||
fill: var(--color-icon-accent-hover);
|
||||
color: var(--color-icon-accent-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-accent:hover {
|
||||
fill: var(--color-icon-accent-hover-dark);
|
||||
color: var(--color-icon-accent-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
fill: var(--color-icon-success);
|
||||
color: var(--color-icon-success);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-success {
|
||||
fill: var(--color-icon-success-dark);
|
||||
color: var(--color-icon-success-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-success:hover {
|
||||
fill: var(--color-icon-success-hover);
|
||||
color: var(--color-icon-success-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-success:hover {
|
||||
fill: var(--color-icon-success-hover-dark);
|
||||
color: var(--color-icon-success-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
fill: var(--color-icon-error);
|
||||
color: var(--color-icon-error);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-error {
|
||||
fill: var(--color-icon-error-dark);
|
||||
color: var(--color-icon-error-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-error:hover {
|
||||
fill: var(--color-icon-error-hover);
|
||||
color: var(--color-icon-error-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-error:hover {
|
||||
fill: var(--color-icon-error-hover-dark);
|
||||
color: var(--color-icon-error-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
fill: var(--color-icon-warning);
|
||||
color: var(--color-icon-warning);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-warning {
|
||||
fill: var(--color-icon-warning-dark);
|
||||
color: var(--color-icon-warning-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.icon-warning:hover {
|
||||
fill: var(--color-icon-warning-hover);
|
||||
color: var(--color-icon-warning-hover);
|
||||
filter: brightness(0);
|
||||
}
|
||||
.dark .icon-warning:hover {
|
||||
fill: var(--color-icon-warning-hover-dark);
|
||||
color: var(--color-icon-warning-hover-dark);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
|
||||
.icon-inverse {
|
||||
fill: var(--color-icon-inverse);
|
||||
color: var(--color-icon-inverse);
|
||||
filter: brightness(0) invert(100%);
|
||||
}
|
||||
.dark .icon-inverse {
|
||||
fill: var(--color-icon-inverse-dark);
|
||||
color: var(--color-icon-inverse-dark);
|
||||
filter: brightness(0);
|
||||
}
|
||||
|
||||
/* Logo Utility Classes - Automatic dark mode detection like Tailwind v4 */
|
||||
/* Logo Utility Classes - Using Tailwind utilities */
|
||||
.logo {
|
||||
filter: brightness(0); /* Black in light mode */
|
||||
@apply brightness-0 dark:invert;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: brightness(0) invert(1); /* White in dark mode */
|
||||
}
|
||||
|
||||
|
||||
/* PWA Header Safe Area Classes */
|
||||
.pwa-header {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Class-based override for manual toggles */
|
||||
.dark .logo {
|
||||
filter: brightness(0) invert(1); /* White when dark class is present */
|
||||
|
||||
.pwa-footer {
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* PWA Main Content Safe Area */
|
||||
.pwa-main {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,28 +5,76 @@ 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;
|
||||
// Theme management functions
|
||||
export function getTheme(): 'light' | 'dark' | 'system' {
|
||||
return (localStorage.getItem('theme') as 'light' | 'dark' | 'system') || 'system';
|
||||
}
|
||||
|
||||
export function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
localStorage.setItem('theme', theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = getTheme();
|
||||
let nextTheme: 'light' | 'dark' | 'system';
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
switch (currentTheme) {
|
||||
case 'light':
|
||||
nextTheme = 'dark';
|
||||
break;
|
||||
case 'dark':
|
||||
nextTheme = 'system';
|
||||
break;
|
||||
default:
|
||||
nextTheme = 'light';
|
||||
break;
|
||||
}
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setTheme(nextTheme);
|
||||
return nextTheme;
|
||||
}
|
||||
|
||||
function applyTheme(theme: 'light' | 'dark' | 'system') {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'system') {
|
||||
// Use system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (prefersDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode detection and setup
|
||||
function setupDarkMode() {
|
||||
// First, ensure we start with a clean slate
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
const savedTheme = getTheme();
|
||||
applyTheme(savedTheme);
|
||||
|
||||
// Listen for system theme changes (only when using system theme)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
// Only respond to system changes when we're in system mode
|
||||
if (getTheme() === 'system') {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
// Initialize dark mode
|
||||
|
||||
@@ -70,45 +70,52 @@ export const Album = () => {
|
||||
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Back Button */}
|
||||
<div className="mb-4 md:mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
|
||||
>
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
<img
|
||||
src={album.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={album.name}
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{album.name}</h1>
|
||||
<p className="text-lg text-content-secondary dark:text-content-secondary-dark">
|
||||
By{" "}
|
||||
{album.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < album.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||
</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p>
|
||||
|
||||
{/* Album Header - Mobile Optimized */}
|
||||
<div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6">
|
||||
<img
|
||||
src={album.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={album.name}
|
||||
className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-cover rounded-lg shadow-lg mx-auto"
|
||||
/>
|
||||
<div className="flex-grow space-y-2 text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{album.name}</h1>
|
||||
<p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">
|
||||
By{" "}
|
||||
{album.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline font-medium">
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < album.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||
</p>
|
||||
<p className="text-xs text-content-muted dark:text-content-muted-dark">{album.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
|
||||
{/* Download Button - Full Width on Mobile */}
|
||||
<div className="mt-4 md:mt-6">
|
||||
<button
|
||||
onClick={handleDownloadAlbum}
|
||||
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||
className="w-full px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed font-semibold shadow-sm"
|
||||
title={
|
||||
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
|
||||
}
|
||||
@@ -118,67 +125,70 @@ export const Album = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
|
||||
<div className="space-y-2">
|
||||
{album.tracks.items.map((track, index) => {
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
{/* Tracks Section */}
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark px-1">Tracks</h2>
|
||||
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
{album.tracks.items.map((track, index) => {
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 md:p-4 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
|
||||
<p className="font-medium text-content-muted dark:text-content-muted-dark text-sm md:text-base truncate">Explicit track filtered</p>
|
||||
</div>
|
||||
<span className="text-content-muted dark:text-content-muted-dark text-sm shrink-0">--:--</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-surface-muted dark:bg-surface-muted-dark rounded-lg opacity-50"
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-8 text-right">{index + 1}</span>
|
||||
<p className="font-medium text-content-muted dark:text-content-muted-dark">Explicit track filtered</p>
|
||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base truncate">{track.name}</p>
|
||||
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link
|
||||
to="/artist/$artistId"
|
||||
params={{
|
||||
artistId: artist.id,
|
||||
}}
|
||||
className="hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
||||
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
|
||||
title="Download"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-content-muted dark:text-content-muted-dark">--:--</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-8 text-right">{index + 1}</span>
|
||||
<div>
|
||||
<p className="font-medium text-content-primary dark:text-content-primary-dark">{track.name}</p>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link
|
||||
to="/artist/$artistId"
|
||||
params={{
|
||||
artistId: artist.id,
|
||||
}}
|
||||
className="hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-content-muted dark:text-content-muted-dark">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="p-2 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5 icon-secondary hover:icon-success" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,10 +130,10 @@ export const Artist = () => {
|
||||
|
||||
return (
|
||||
<div className="artist-page">
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 md:mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
|
||||
>
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
|
||||
@@ -14,8 +14,8 @@ const ConfigComponent = () => {
|
||||
const { settings: config, isLoading } = useSettings();
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (isLoading) return <p className="text-center text-content-muted dark:text-content-muted-dark">Loading configuration...</p>;
|
||||
if (!config) return <p className="text-center text-error-text bg-error-muted p-2 rounded">Error loading configuration.</p>;
|
||||
if (isLoading) return <div className="text-center py-12"><p className="text-content-muted dark:text-content-muted-dark">Loading configuration...</p></div>;
|
||||
if (!config) return <div className="text-center py-12"><p className="text-error-text bg-error-muted p-4 rounded-lg">Error loading configuration.</p></div>;
|
||||
|
||||
switch (activeTab) {
|
||||
case "general":
|
||||
@@ -36,55 +36,57 @@ const ConfigComponent = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">Configuration</h1>
|
||||
<p className="text-content-muted dark:text-content-muted-dark">Manage application settings and services.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<aside className="w-1/4">
|
||||
<nav className="flex flex-col space-y-1">
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-10">
|
||||
<aside className="w-full lg:w-1/4">
|
||||
<nav className="flex flex-row lg:flex-col overflow-x-auto lg:overflow-x-visible space-x-2 lg:space-x-0 lg:space-y-2 pb-2 lg:pb-0">
|
||||
<button
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "general" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "general" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("downloads")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "downloads" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "downloads" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
Downloads
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("formatting")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "formatting" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "formatting" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
Formatting
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("accounts")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "accounts" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "accounts" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
Accounts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("watch")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "watch" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "watch" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
Watch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("server")}
|
||||
className={`p-2 rounded-md text-left transition-colors ${activeTab === "server" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark"}`}
|
||||
className={`px-4 py-3 rounded-lg text-left transition-all whitespace-nowrap ${activeTab === "server" ? "bg-surface-accent dark:bg-surface-accent-dark font-semibold text-content-primary dark:text-content-primary-dark shadow-sm" : "text-content-secondary dark:text-content-secondary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark"}`}
|
||||
>
|
||||
Server
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="w-3/4">{renderTabContent()}</main>
|
||||
<main className="w-full lg:w-3/4 bg-surface dark:bg-surface-dark rounded-xl border border-border dark:border-border-dark p-6 md:p-8 shadow-sm">
|
||||
{renderTabContent()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -175,64 +175,66 @@ export const Playlist = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Back Button */}
|
||||
<div className="mb-4 md:mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
|
||||
>
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Playlist Header */}
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
<img
|
||||
src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={playlistMetadata.name}
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold text-content-primary dark:text-content-primary-dark">{playlistMetadata.name}</h1>
|
||||
{playlistMetadata.description && (
|
||||
<p className="text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
<p>
|
||||
By {playlistMetadata.owner.display_name} • {playlistMetadata.followers.total.toLocaleString()} followers •{" "}
|
||||
{totalTracks} songs
|
||||
{/* Playlist Header - Mobile Optimized */}
|
||||
<div className="bg-surface dark:bg-surface-dark border border-border dark:border-border-dark rounded-xl p-4 md:p-6 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6">
|
||||
<img
|
||||
src={playlistMetadata.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={playlistMetadata.name}
|
||||
className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 object-cover rounded-lg shadow-lg mx-auto"
|
||||
/>
|
||||
<div className="flex-grow space-y-2 text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-content-primary dark:text-content-primary-dark leading-tight">{playlistMetadata.name}</h1>
|
||||
{playlistMetadata.description && (
|
||||
<p className="text-base md:text-lg text-content-secondary dark:text-content-secondary-dark">{playlistMetadata.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
By {playlistMetadata.owner.display_name} • {playlistMetadata.followers.total.toLocaleString()} followers • {totalTracks} songs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-colors"
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
isWatched
|
||||
? "bg-error hover:bg-error-hover text-button-primary-text"
|
||||
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "icon-primary"}`}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Full Width on Mobile */}
|
||||
<div className="mt-4 md:mt-6 flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="flex-1 px-6 py-3 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-lg transition-all font-semibold shadow-sm"
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-lg transition-all font-semibold shadow-sm ${
|
||||
isWatched
|
||||
? "bg-error hover:bg-error-hover text-button-primary-text"
|
||||
: "bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark text-content-primary dark:text-content-primary-dark"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className={`w-5 h-5 ${isWatched ? "icon-inverse" : "logo"}`}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Tracks</h2>
|
||||
{tracks.length > 0 && (
|
||||
<span className="text-sm text-content-muted dark:text-content-muted-dark">
|
||||
@@ -241,72 +243,80 @@ export const Playlist = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredTracks.map(({ track }, index) => {
|
||||
if (!track) return null;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-8 text-right">{index + 1}</span>
|
||||
<img
|
||||
src={track.album.images.at(-1)?.url}
|
||||
alt={track.album.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium hover:underline text-content-primary dark:text-content-primary-dark">
|
||||
{track.name}
|
||||
<div className="bg-surface-muted dark:bg-surface-muted-dark rounded-xl p-2 md:p-4 shadow-sm">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
{filteredTracks.map(({ track }, index) => {
|
||||
if (!track) return null;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 md:p-4 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-lg transition-colors duration-200 group"
|
||||
>
|
||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||
<span className="text-content-muted dark:text-content-muted-dark w-6 md:w-8 text-right text-sm font-medium">{index + 1}</span>
|
||||
<Link to="/album/$albumId" params={{ albumId: track.album.id }}>
|
||||
<img
|
||||
src={track.album.images.at(-1)?.url}
|
||||
alt={track.album.name}
|
||||
className="w-10 h-10 md:w-12 md:h-12 object-cover rounded hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium text-content-primary dark:text-content-primary-dark text-sm md:text-base hover:underline block truncate">
|
||||
{track.name}
|
||||
</Link>
|
||||
<p className="text-xs md:text-sm text-content-secondary dark:text-content-secondary-dark truncate">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link
|
||||
to="/artist/$artistId"
|
||||
params={{ artistId: artist.id }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
||||
<span className="text-content-muted dark:text-content-muted-dark text-xs md:text-sm hidden sm:block">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="w-9 h-9 md:w-10 md:h-10 flex items-center justify-center bg-surface-muted dark:bg-surface-muted-dark hover:bg-surface-accent dark:hover:bg-surface-accent-dark border border-border-muted dark:border-border-muted-dark hover:border-border-accent dark:hover:border-border-accent-dark rounded-full transition-all hover:scale-105 hover:shadow-sm"
|
||||
title="Download"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-4 h-4 logo" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-content-muted dark:text-content-muted-dark">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="p-2 hover:bg-surface-secondary dark:hover:bg-surface-secondary-dark rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<FaDownload className="icon-secondary hover:icon-success" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loadingTracks && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loadingTracks && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intersection observer target */}
|
||||
{hasMoreTracks && (
|
||||
<div ref={loadingRef} className="h-4" />
|
||||
)}
|
||||
|
||||
{/* End of tracks indicator */}
|
||||
{!hasMoreTracks && tracks.length > 0 && (
|
||||
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
|
||||
All tracks loaded
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Intersection observer target */}
|
||||
{hasMoreTracks && (
|
||||
<div ref={loadingRef} className="h-4" />
|
||||
)}
|
||||
|
||||
{/* End of tracks indicator */}
|
||||
{!hasMoreTracks && tracks.length > 0 && (
|
||||
<div className="text-center py-4 text-content-muted dark:text-content-muted-dark">
|
||||
All tracks loaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,84 @@ 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";
|
||||
import { useContext, useState, useEffect } from "react";
|
||||
import { getTheme, toggleTheme } from "@/main";
|
||||
|
||||
function ThemeToggle() {
|
||||
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial theme
|
||||
setCurrentTheme(getTheme());
|
||||
|
||||
// Listen for theme changes (in case they happen elsewhere)
|
||||
const handleStorageChange = () => {
|
||||
setCurrentTheme(getTheme());
|
||||
};
|
||||
|
||||
// Listen for system theme changes that might affect our display
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemChange = () => {
|
||||
// Force a re-render when system preference changes
|
||||
// This ensures our toggle shows the correct state
|
||||
setCurrentTheme(getTheme());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
mediaQuery.addEventListener('change', handleSystemChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
mediaQuery.removeEventListener('change', handleSystemChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newTheme = toggleTheme();
|
||||
setCurrentTheme(newTheme);
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
switch (currentTheme) {
|
||||
case 'light':
|
||||
return <img src="/light.svg" alt="Light theme" className="w-5 h-5 logo" />;
|
||||
case 'dark':
|
||||
return <img src="/dark.svg" alt="Dark theme" className="w-5 h-5 logo" />;
|
||||
default:
|
||||
return <img src="/system.svg" alt="System theme" className="w-5 h-5 logo" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeLabel = () => {
|
||||
switch (currentTheme) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
default:
|
||||
return 'System';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="p-2 rounded-full hover:bg-icon-button-hover dark:hover:bg-icon-button-hover-dark flex items-center gap-1"
|
||||
title={`Current theme: ${getThemeLabel()}. Click to cycle through themes.`}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
<span className="hidden sm:inline text-sm font-medium text-content-secondary dark:text-content-secondary-dark">
|
||||
{getThemeLabel()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 flex flex-col">
|
||||
<div className="min-h-screen bg-gradient-to-br from-surface-secondary via-surface-muted to-surface-accent dark:from-surface-dark dark:via-surface-muted-dark dark:to-surface-secondary-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">
|
||||
@@ -17,6 +88,7 @@ function AppLayout() {
|
||||
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<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 logo" />
|
||||
</Link>
|
||||
@@ -33,40 +105,51 @@ function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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">
|
||||
{/* Desktop Main Content */}
|
||||
<main className="hidden md:block container mx-auto p-4 flex-1">
|
||||
<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>
|
||||
{/* Mobile Layout Container */}
|
||||
<div className="md:hidden flex flex-col h-screen">
|
||||
{/* Mobile Header - Fixed */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 border-b border-border dark:border-border-dark bg-surface-overlay dark:bg-surface-overlay-dark backdrop-blur-sm pwa-header">
|
||||
<div className="container mx-auto h-14 flex items-center justify-between px-4">
|
||||
<Link to="/" className="flex items-center">
|
||||
<img src="/spotizerr.svg" alt="Spotizerr" className="h-8 w-auto logo" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Main Content - Scrollable container */}
|
||||
<main className="flex-1 overflow-y-auto mt-14 mb-16 pwa-main">
|
||||
<div className="container mx-auto p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation - Fixed */}
|
||||
<nav className="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-md pwa-footer">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
</div>
|
||||
|
||||
@@ -64,10 +64,10 @@ export const Track = () => {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 md:mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark transition-colors"
|
||||
className="flex items-center gap-2 p-2 -ml-2 text-sm font-semibold text-content-secondary dark:text-content-secondary-dark hover:text-content-primary dark:hover:text-content-primary-dark hover:bg-surface-muted dark:hover:bg-surface-muted-dark rounded-lg transition-all"
|
||||
>
|
||||
<FaArrowLeft className="icon-secondary hover:icon-primary" />
|
||||
<span>Back to results</span>
|
||||
@@ -99,7 +99,7 @@ export const Track = () => {
|
||||
{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-3 py-1 rounded-full self-center md:self-auto">
|
||||
<span className="text-xs bg-surface-dark dark:bg-surface text-content-primary-dark dark:text-content-primary px-3 py-1 rounded-full self-center md:self-auto font-semibold">
|
||||
EXPLICIT
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -100,6 +100,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:7171",
|
||||
|
||||
Reference in New Issue
Block a user