mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1) Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and --text-on-accent on the :root base as --ctp-* mappings. They resolve per-theme automatically and nothing consumes them yet (zero behaviour change) — groundwork for replacing direct --ctp-* use in components. * refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2) Replace every direct --ctp-* reference in component/layout/track CSS and TSX inline styles with the readable semantic token (--bg-app, --accent, --highlight, --text-on-accent, …). --ctp-* now survives only as the Catppuccin palette layer the base maps from, and as the deliberate categorical rainbow in Composers/Genres/artistsHelpers (left untouched). This is the readable contract surface for the community theme store. Divergences (theme set a semantic var != its --ctp- source) are corrections — the element now uses the theme's real semantic colour. * feat(themes): add player-bar title/artist color tokens New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token. * refactor(themes): token-only theme library (flatten, whitelist, one file per theme) Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer): - Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens. - Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents. - Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging. Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins. * chore(themes): remove seven themes retired after the token refactor These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry. * feat(themes): granular tokens — track lists Wire track rows to per-region tokens: row hover (--row-hover), the now-playing row + indicator (--row-playing-bg / --row-playing-text), track title/artist/ number/duration text, column-header text, row dividers, and the resize-handle active colour. Covers the desktop tracklist, the shared song-row (Tracks hub / search), and the mobile tracklist. Drop a baked border fallback. Visual no-op. * feat(themes): granular tokens — cards Wire album and artist cards to per-region tokens (--card-hover-border, --card-title, --card-subtitle, --card-placeholder-bg). Visual no-op. * fix(themes): drop undefined/baked colour aliases Replace the undefined --bg-surface (resolved to nothing — broken placeholder backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the baked-hex aliases --color-error / --color-warning with --danger / --warning so themes can actually recolour them. * feat(themes): Spectrum demo theme + trim unused cascade tokens Add a loud built-in demo theme that gives each region its own hue (sidebar green, player pink, lists cyan, cards gold, menus red, controls blue) so the per-region granularity is obvious when you switch to it. Drop two unused cascade tokens (--sidebar-text-active, --row-active-bg) and the unused on-media block (those media surfaces stay static by design). * style(themes): make Spectrum demo brutally loud Full-saturation neon per region (toxic green sidebar, magenta player, cyan lists, acid-yellow cards, blood-red menus, electric-blue controls, purple scrollbar) so the per-region separation is unmistakable. The earlier soft tints were too subtle to read. * feat(themes): name the granular demo theme Braindead * feat(themes): granular per-region tokens — cascade layer + sidebar Add an optional per-region token layer (semantic-cascade.css) so a theme can recolour individual regions — sidebar hover, player controls, list rows, menus, inputs, on-media surfaces — independently of the global tokens. Every token defaults to its base token (or a media-safe literal), so this is a visual no-op until a theme overrides one; it only adds control points. Wire the sidebar region as the first consumer and drop the baked grey fallbacks (--bg-tertiary, etc.) that no theme could reach. * feat(themes): granular tokens — controls, menus, scrollbar Wire inputs, buttons, sliders, the custom-select, context menus, submenus and modals to per-region tokens, and tokenise the scrollbar. Complete B0 by dropping the last direct --ctp-* references in the input/button/progress/ scrollbar utility CSS. Fix three undefined-token bugs that fell back to nothing (so no theme could reach them): --surface-2 (context-menu hover had no highlight), --bg-surface (submenu create-input had no background), and the baked grey fallbacks in the custom-select. Every other new token defaults to today's value — a visual no-op that only adds override points. * feat(themes): granular tokens — player bar Wire the desktop player bar's transport controls, time toggle, and overflow menu to per-region tokens (--player-control, --player-time-toggle-*, etc.). Fix the undefined --surface-hover/--surface-active grey fallbacks on the time toggle. Visual no-op; defaults match today's values. * chore(themes): remove empty theme stub files Six theme CSS files were reduced to comment-only stubs by the flatten sweep but their files and @import lines remained. Five are empty structural companions of now-flattened themes (morpheus, p-dvd, aero-glass, luna-teal) and two are orphans of cut themes (order-of-the-phoenix, pandora). Removed the files and their imports. * feat(themes): runtime injection foundation for the theme store Plumbing for installed community themes ahead of the in-app store UI, nothing user-visible yet: - installedThemesStore: persisted (localStorage) record of installed community themes incl. their CSS text, so an active community theme is available synchronously at startup (no flash, fully offline). - themeInjection: reconcile <head> <style data-installed-theme> elements with the store; lightweight defense-in-depth sanitize on top of CI. - themeRegistry: jsDelivr registry client with a 12h localStorage cache and stale-on-error fallback. - App: inject installed themes before applying data-theme, in both webviews. - themeStore: widen the Theme type to accept dynamic installed ids. * feat(themes): dedicated Themes settings tab Move theme selection and the day/night scheduler out of Appearance into a new dedicated Themes tab — the future home of the community Theme Store. Appearance keeps grid columns, visual options, UI scale, font and seekbar. - ThemesTab: theme picker + scheduler (relocated verbatim). - AppearanceTab: drop the two relocated sections + now-unused imports. - Register the tab in settingsTabs (Tab union, resolveTab, search index) and Settings (tab bar, render, label map). - i18n: settings.tabThemes in all 9 locales. * feat(themes): community Theme Store browse + install Add the Theme Store section to the Themes tab: - Fetch the jsDelivr registry (12h cache, stale-on-error fallback). - Search by name/author/description + filter by light/dark + refresh. - Per-row CDN thumbnail, name, author, description and actions: Install / Apply / Update / Uninstall. Installing fetches the CSS, persists it (localStorage) and the runtime injection applies it; uninstalling the active theme falls back to the matching core. - Rating slot left reserved (deferred). - i18n: themeStore* keys in all 9 locales. * feat(themes): slim bundle to fixed cores + flat Themes tab Remove the 86 store palettes (incl. braindead) from the app bundle — the CSS files, their index.css imports, the Theme union and the picker data — leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave, Stark HUD, Vision Dark/Navy). Everything else installs from the store. Themes tab is rebuilt flat (no collapsible accordions): - "Your Themes": one card grid of the fixed cores + installed community themes; click to apply, uninstall on community ones (active theme falls back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe pill on the colour-blind-safe Vision themes. - Scheduler day/night options include installed themes. - Theme Store: alphabetical order, thumbnail lightbox, and a submit hint above the search linking to the themes repository. - Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, … - ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts. - i18n for all new strings across 9 locales. * feat(themes): reset removed-from-bundle themes to a bundled fallback After slimming the bundle to the six fixed core themes, a profile upgraded from an older build may have an active or scheduler theme that is now store-only and not installed — it has no [data-theme] block and would render as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled nor installed to a bundled fallback: Mocha for the main + night slots, Latte for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the persisted selection in localStorage before React mounts (no flash; Zustand rehydrates after first paint). No auto-install and no network — the fallback is always a bundled theme, so it works offline. * feat(themes): floating back-to-top button on the Themes tab The Themes tab can get long (theme grid + scheduler + full store list), so add a floating back-to-top affordance that appears once the page is scrolled and smooth-scrolls to the top. It is portalled into the route host and positioned absolute against it — the main scroll viewport sets contain: paint, which would otherwise make position: fixed resolve against the scrolling box and drift with the content. Reusable component (scroll viewport id + threshold props); i18n common.backToTop added in all nine locales. * feat(themes): accessibility + state polish for the Theme Store - Reuse the shared CoverLightbox for the thumbnail preview instead of a second inline dialog — gains a visible close button and a focus-managed, portalled dialog, and drops duplicated markup. - Theme cards expose aria-pressed so assistive tech announces the selected theme, not just the visual check. - Transient store messages get live-region roles (loading/empty/install failure = status, fetch error = alert). - Thumbnails degrade gracefully when offline/missing (hide the broken-image glyph; the thumbnail button no longer stretches with the row, so its background can't show as letterbox bars). * feat(themes): larger store-row thumbnails (120x75 -> 200x125) The list previews were too small to make a theme out; bump the display size (same 1.6 aspect). Thumbnails are now served at 720x450, so the larger display stays crisp. * fix(themes): bust thumbnail cache on registry change jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail is updated the webview keeps showing its cached old image (the path is unchanged). Append the registry's generatedAt as a cache-busting query to the thumbnail URLs (list + lightbox); it changes on every themes push, so a registry refresh makes the webview re-fetch and reflect the current CDN image instead of a stale one. * docs(themes): changelog + credits for the Theme Store Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009) and the matching line in the Psychotoxical credits. * fix(themes): address PR review (uninstall hygiene, validation, polish) Uninstall/scheduler & validation: - uninstallTheme() repairs every selection slot (active + day + night), not just the manual one, and is shared by both uninstall buttons (dedup). - Validate theme CSS at install time and skip persisting CSS that won't inject (no more "installed/active but renders nothing" with no feedback). - Harden the runtime validator: exactly one rule, scoped exactly to the theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no at-rules, url() only data:, no expression()/javascript:, size-capped. Tokens & polish: - Fix three dangling undefined tokens (--surface-2 x2, --bg-surface). - Finish the warning/success token sweep (--warning / --positive, themeable). - Apply the active theme synchronously before React mounts (no first-frame flash) and inject installed themes up front. - One-time, dismissible notice when the slim-bundle migration reset a theme. - Update badge uses semver, not string inequality. - Offline/stale indicator in the store; cross-window theme sync; drop the now dead REMOVED_THEME_REMAP and Card.mode field. Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/ malformed), uninstallTheme (slot repair), migration notice. i18n in all nine locales. Full suite green (1755 tests). * test(bootstrap): cover startup theme apply + cross-window sync The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3% and failing the frontend hot-path coverage gate (>=70%). Add unit tests for both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
This commit is contained in:
@@ -54,6 +54,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
+13
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from './utils/themes/themeInjection';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
@@ -13,10 +15,21 @@ export default function App() {
|
||||
useThemeStore(s => s.theme);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const installedThemes = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
// Document-attribute hooks are shared between both window kinds — each
|
||||
// webview has its own `document`, and theme / font / track-preview tokens
|
||||
// are read by CSS in both trees.
|
||||
|
||||
// Installed community themes have no build-time CSS — inject their
|
||||
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
|
||||
// store. Runs before the data-theme effect below so the matching style exists
|
||||
// when the attribute is applied. The store hydrates synchronously, so an
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
@@ -19,6 +19,7 @@ import ContextMenu from '../components/ContextMenu';
|
||||
import SongInfoModal from '../components/SongInfoModal';
|
||||
import DownloadFolderModal from '../components/DownloadFolderModal';
|
||||
import GlobalConfirmModal from '../components/GlobalConfirmModal';
|
||||
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
|
||||
import OrbitAccountPicker from '../components/OrbitAccountPicker';
|
||||
import OrbitHelpModal from '../components/OrbitHelpModal';
|
||||
import TooltipPortal from '../components/TooltipPortal';
|
||||
@@ -320,6 +321,7 @@ export function AppShell() {
|
||||
<SongInfoModal />
|
||||
<DownloadFolderModal />
|
||||
<GlobalConfirmModal />
|
||||
<ThemeMigrationNotice />
|
||||
<OrbitAccountPicker />
|
||||
<OrbitHelpModal />
|
||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||
|
||||
@@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import {
|
||||
applyThemeAtStartup,
|
||||
installCrossWindowThemeSync,
|
||||
pushLoggingModeToBackend,
|
||||
pushUserAgentToBackend,
|
||||
runPreReactBootstrap,
|
||||
} from './bootstrap';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
|
||||
|
||||
@@ -48,6 +52,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
setUserAgent(ORIGINAL_USER_AGENT);
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('pushUserAgentToBackend', () => {
|
||||
@@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => {
|
||||
// the bootstrap doesn't second-guess that decision.
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyThemeAtStartup', () => {
|
||||
const setPersistedTheme = (state: Record<string, unknown>) =>
|
||||
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
|
||||
|
||||
it('does nothing when there is no persisted theme', () => {
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets data-theme to the active theme (scheduler off)', () => {
|
||||
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
|
||||
applyThemeAtStartup();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
|
||||
});
|
||||
|
||||
it('injects installed community themes up front', () => {
|
||||
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
|
||||
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
|
||||
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
|
||||
version: 1,
|
||||
}));
|
||||
applyThemeAtStartup();
|
||||
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
|
||||
});
|
||||
|
||||
it('does not throw on malformed storage', () => {
|
||||
localStorage.setItem('psysonic_theme', '{not json');
|
||||
expect(() => applyThemeAtStartup()).not.toThrow();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installCrossWindowThemeSync', () => {
|
||||
it('rehydrates the matching store on a cross-window storage event', () => {
|
||||
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
|
||||
installCrossWindowThemeSync();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
|
||||
expect(themeRehydrate).toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
|
||||
expect(installedRehydrate).toHaveBeenCalled();
|
||||
|
||||
themeRehydrate.mockClear();
|
||||
installedRehydrate.mockClear();
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
|
||||
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||
expect(installedRehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '../utils/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
export function pushUserAgentToBackend(): void {
|
||||
@@ -35,6 +39,63 @@ export function pushLoggingModeToBackend(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes(): InstalledTheme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_installed_themes');
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
|
||||
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active theme synchronously, before React mounts, so the first paint
|
||||
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
|
||||
* first paint, so without this a non-Mocha active theme (every light theme and
|
||||
* every installed community theme) flashes the `:root` Mocha default for a
|
||||
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
|
||||
* inject installed community themes' CSS up front. Runs after the migration so
|
||||
* the persisted ids are already resolved.
|
||||
*/
|
||||
export function applyThemeAtStartup(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_theme');
|
||||
if (!raw) return; // fresh profile — the :root default is correct
|
||||
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
const effective = getScheduledTheme({
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme ?? 'mocha'),
|
||||
themeDay: String(s.themeDay ?? 'latte'),
|
||||
themeNight: String(s.themeNight ?? 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart ?? '07:00'),
|
||||
timeNightStart: String(s.timeNightStart ?? '19:00'),
|
||||
});
|
||||
if (effective) document.documentElement.setAttribute('data-theme', effective);
|
||||
} catch {
|
||||
// Non-fatal — App's effects apply the theme after mount.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep theme state in sync across webviews (main ↔ mini player). Zustand
|
||||
* persist does not sync across windows on its own; the `storage` event fires in
|
||||
* *other* windows when localStorage changes, so rehydrate the relevant store
|
||||
* there. Installing/applying/uninstalling in one window then live-updates the
|
||||
* other (App's effects re-run and re-apply `data-theme` / injected styles).
|
||||
*/
|
||||
export function installCrossWindowThemeSync(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
|
||||
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
|
||||
export function markDevBuildDocument(): void {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -46,6 +107,12 @@ export function markDevBuildDocument(): void {
|
||||
export function runPreReactBootstrap(): void {
|
||||
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
|
||||
getWindowKind();
|
||||
// Reset any persisted theme that is no longer bundled and not installed, so
|
||||
// the store hydrates onto a paintable theme (no unstyled-:root flash).
|
||||
migrateThemeSelection();
|
||||
// Paint the correct theme on the very first frame (no Mocha flash).
|
||||
applyThemeAtStartup();
|
||||
installCrossWindowThemeSync();
|
||||
markDevBuildDocument();
|
||||
pushUserAgentToBackend();
|
||||
pushLoggingModeToBackend();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
|
||||
interface BackToTopButtonProps {
|
||||
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
|
||||
viewportId?: string;
|
||||
/** Show the button once the viewport is scrolled past this many pixels. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating "back to top" affordance for long pages. Watches a scroll viewport
|
||||
* (the overlay-scroll element wrapping the routes by default) and, once it is
|
||||
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
|
||||
* top — reusing the same `getElementById(...).scrollTo` pattern AppShell uses
|
||||
* for its route-change scroll reset.
|
||||
*
|
||||
* The button is portalled into `.app-shell-route-host` and positioned
|
||||
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
|
||||
* would otherwise make a `position: fixed` child resolve against the *scrolling*
|
||||
* box (so it would drift with the content). The route host is a non-contained,
|
||||
* `position: relative` ancestor that spans exactly the content area (between the
|
||||
* sidebar and queue, above the player bar), so the button stays pinned to the
|
||||
* visible viewport corner regardless of scroll.
|
||||
*/
|
||||
export default function BackToTopButton({
|
||||
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
|
||||
threshold = 400,
|
||||
}: BackToTopButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [host, setHost] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
|
||||
const el = document.getElementById(viewportId);
|
||||
if (!el) return;
|
||||
const onScroll = () => setVisible(el.scrollTop > threshold);
|
||||
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [viewportId, threshold]);
|
||||
|
||||
if (!visible || !host) return null;
|
||||
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top-btn"
|
||||
onClick={() =>
|
||||
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
aria-label={t('common.backToTop')}
|
||||
data-tooltip={t('common.backToTop')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<ArrowUp size={18} aria-hidden="true" />
|
||||
</button>,
|
||||
host,
|
||||
);
|
||||
}
|
||||
@@ -247,7 +247,7 @@ export default function CachedImage({
|
||||
};
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function LosslessFilterButton({ active, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
|
||||
{visible.length > 0 && (
|
||||
<span style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--ctp-crust)',
|
||||
color: 'var(--text-on-accent)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 6px',
|
||||
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
|
||||
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
|
||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
|
||||
{stream.albumId && stream.coverArt ? (
|
||||
<TrackCoverArtImage
|
||||
song={{
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function PSmallLogo({ className, style }: Props) {
|
||||
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--ctp-blue)" />
|
||||
<stop offset="100%" stopColor="var(--accent-2)" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function PlayerBar() {
|
||||
}, [volume, setVolume, utilityOverflow]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
const playerBarContent = (
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PsysonicLogo({ className, style, gradientIdSuffix }: Pro
|
||||
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
|
||||
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
|
||||
<stop offset="100%" stopColor="var(--logo-color-end, var(--accent-2))" />
|
||||
</linearGradient>
|
||||
</defs><g
|
||||
id="layer1"
|
||||
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { readThemeMigrationNotice, clearThemeMigrationNotice } from '../utils/themes/themeMigration';
|
||||
|
||||
/**
|
||||
* One-time, dismissible notice shown after the slim-bundle migration reset a
|
||||
* theme that is now store-only or retired. Reads the flag written by
|
||||
* `migrateThemeSelection` at startup, points the user at the Theme Store, and
|
||||
* clears the flag on dismiss so it never shows again.
|
||||
*/
|
||||
export default function ThemeMigrationNotice() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [themes] = useState(() => readThemeMigrationNotice());
|
||||
const [open, setOpen] = useState(themes.length > 0);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => {
|
||||
clearThemeMigrationNotice();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t('settings.themeMigrationNoticeTitle')}
|
||||
message={t('settings.themeMigrationNoticeBody', { themes: themes.join(', ') })}
|
||||
confirmLabel={t('settings.themeMigrationNoticeOpen')}
|
||||
cancelLabel={t('common.close')}
|
||||
onConfirm={() => {
|
||||
close();
|
||||
navigate('/settings', { state: { tab: 'themes' } });
|
||||
}}
|
||||
onCancel={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
family?: string;
|
||||
}
|
||||
|
||||
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Games',
|
||||
themes: [
|
||||
{ id: 'gw1', label: 'GW1', bg: '#0e0b08', card: '#1a1208', accent: '#c8960c' },
|
||||
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
|
||||
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
|
||||
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#06060f', card: '#0a0a1a', accent: '#FCEE0A' },
|
||||
{ id: 'tetrastack', label: 'TetraStack', bg: '#060614', card: '#0c0c20', accent: '#00f0f0' },
|
||||
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
|
||||
{ id: 'horde', label: 'Horde', bg: '#1a0500', card: '#2e0a02', accent: '#cc2200' },
|
||||
{ id: 'alliance', label: 'Alliance', bg: '#06101e', card: '#0c1e34', accent: '#3388cc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Movies',
|
||||
themes: [
|
||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||
{ id: 'dune', label: 'Dune', bg: '#1c1408', card: '#0e0c1a', accent: '#c8780a' },
|
||||
{ id: 'hill-valley-85', label: 'Hill Valley 85', bg: '#0d0b18', card: '#141120', accent: '#ff8c00' },
|
||||
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f0e0b0', card: '#241a0e', accent: '#d4a820' },
|
||||
{ id: 'morpheus', label: 'Morpheus', bg: '#050905', card: '#0a120a', accent: '#00ff41' },
|
||||
{ id: 'spider-tech', label: 'Spider-Tech', bg: '#0e0c18', card: '#181428', accent: '#E62429' },
|
||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||
{ id: 't-800', label: 'T-800', bg: '#140e0e', card: '#1a0a0a', accent: '#ff2000' },
|
||||
{ id: 'barb-and-ken', label: 'Barb & Ken', bg: '#1a000f', card: '#2e0019', accent: '#FF1B8D' },
|
||||
{ id: 'toy-tale', label: 'Toy Tale', bg: '#1a1208', card: '#2a1c10', accent: '#FFD600' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Open Source Classics',
|
||||
themes: [
|
||||
// ── 1984 (juanmnl/vs-1984 — only variants with a distinct palette)
|
||||
{ id: 'vs-1984', label: 'Default', bg: '#0d0f31', card: '#161a4a', accent: '#46BDFF', family: '1984' },
|
||||
{ id: 'vs-1984-cyberpunk', label: 'Cyberpunk', bg: '#1C1E27', card: '#232631', accent: '#85EEA7', family: '1984' },
|
||||
{ id: 'vs-1984-light', label: 'Light', bg: '#e4e5f5', card: '#d4d6e7', accent: '#4d5eff', family: '1984' },
|
||||
{ id: 'vs-1984-orwell', label: 'Orwell', bg: '#2e2923', card: '#3a342c', accent: '#fcd395', family: '1984' },
|
||||
// ── Atom One (Th3Whit3Wolf/one-nvim — Atom One Dark / Light derivative)
|
||||
{ id: 'one-dark', label: 'Dark', bg: '#282c34', card: '#2c323c', accent: '#61afef', family: 'Atom One' },
|
||||
{ id: 'one-light', label: 'Light', bg: '#fafafa', card: '#ececed', accent: '#4078f2', family: 'Atom One' },
|
||||
// ── Catppuccin (dark → light)
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7', family: 'Catppuccin' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6', family: 'Catppuccin' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6', family: 'Catppuccin' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef', family: 'Catppuccin' },
|
||||
// ── Dracula
|
||||
{ id: 'dracula', label: 'Dracula', bg: '#282a36', card: '#44475a', accent: '#bd93f9', family: 'Dracula' },
|
||||
// ── Gruvbox (dark → light, hard → soft)
|
||||
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614', family: 'Gruvbox' },
|
||||
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614', family: 'Gruvbox' },
|
||||
// ── Kanagawa (default → variants)
|
||||
{ id: 'kanagawa-wave', label: 'Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8', family: 'Kanagawa' },
|
||||
{ id: 'kanagawa-dragon', label: 'Dragon', bg: '#181616', card: '#282727', accent: '#8ba4b0', family: 'Kanagawa' },
|
||||
{ id: 'kanagawa-lotus', label: 'Lotus', bg: '#f2ecbc', card: '#d5cea3', accent: '#4d699b', family: 'Kanagawa' },
|
||||
// ── Nightfox (default → variants, alphabetical)
|
||||
{ id: 'nightfox', label: 'Nightfox', bg: '#192330', card: '#212e3f', accent: '#719cd6', family: 'Nightfox' },
|
||||
{ id: 'carbonfox', label: 'Carbonfox', bg: '#161616', card: '#1c1c1c', accent: '#be95ff', family: 'Nightfox' },
|
||||
{ id: 'dawnfox', label: 'Dawnfox', bg: '#faf4ed', card: '#ebe0df', accent: '#907aa9', family: 'Nightfox' },
|
||||
{ id: 'dayfox', label: 'Dayfox', bg: '#f6f2ee', card: '#dbd1dd', accent: '#2848a9', family: 'Nightfox' },
|
||||
{ id: 'duskfox', label: 'Duskfox', bg: '#232136', card: '#2d2a45', accent: '#c4a7e7', family: 'Nightfox' },
|
||||
{ id: 'nordfox', label: 'Nordfox', bg: '#2e3440', card: '#39404f', accent: '#81a1c1', family: 'Nightfox' },
|
||||
{ id: 'terafox', label: 'Terafox', bg: '#152528', card: '#1d3337', accent: '#a1cdd8', family: 'Nightfox' },
|
||||
// ── Nord (Polar Night → Snowstorm → Frost → Aurora, official ordering)
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0', family: 'Nord' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac', family: 'Nord' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0', family: 'Nord' },
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead', family: 'Nord' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Operating Systems',
|
||||
themes: [
|
||||
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
|
||||
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
|
||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
|
||||
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
|
||||
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
|
||||
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
|
||||
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
|
||||
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
|
||||
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
|
||||
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
|
||||
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'COMMUNITY',
|
||||
themes: [
|
||||
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#050505', accent: '#ffffff' },
|
||||
{ id: 'obsidian-black', label: 'Obsidian Black', bg: '#070706', card: '#11110f', accent: '#dfddd8' },
|
||||
{ id: 'carbon-grey', label: 'Carbon Grey', bg: '#0f0f10', card: '#18181b', accent: '#b4bcc8' },
|
||||
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
|
||||
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
|
||||
{ id: 'volcanic-dark', label: 'Volcanic Dark', bg: '#1b1512', card: '#231b17', accent: '#c97a56' },
|
||||
{ id: 'deep-forest', label: 'Deep Forest', bg: '#141c17', card: '#19231d', accent: '#6aa37c' },
|
||||
{ id: 'violet-haze', label: 'Violet Haze', bg: '#12101d', card: '#171425', accent: '#b4a0ff' },
|
||||
{ id: 'copper-oxide', label: 'Copper Oxide', bg: '#101a18', card: '#131f1c', accent: '#45b8b0' },
|
||||
{ id: 'sakura-night', label: 'Sakura Night', bg: '#1c1218', card: '#241821', accent: '#d8a6bc' },
|
||||
{ id: 'obsidian-gold', label: 'Obsidian Gold', bg: '#0f0d09', card: '#15120d', accent: '#c9a227' }
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mediaplayer',
|
||||
themes: [
|
||||
{ id: 'winmedplayer', label: 'WinMedPlayer', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
|
||||
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
|
||||
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
|
||||
{ id: 'muma-jukebox', label: 'MuMa Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
|
||||
{ id: 'p-dvd', label: 'P-DVD', bg: '#141414', card: '#000000', accent: '#00aaff' },
|
||||
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
|
||||
{ id: 'jayfin', label: 'Jayfin', bg: '#141414', card: '#1e1e1e', accent: '#AA5CC3' },
|
||||
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#d4cc46' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Series',
|
||||
themes: [
|
||||
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#100c08', card: '#090c10', accent: '#c41e1e' },
|
||||
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
|
||||
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
|
||||
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
|
||||
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Famous Albums',
|
||||
themes: [
|
||||
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
|
||||
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Accessibility',
|
||||
themes: [
|
||||
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
|
||||
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Social Media',
|
||||
themes: [
|
||||
{ id: 'insta', label: 'Insta', bg: '#121212', card: '#000000', accent: '#E1306C' },
|
||||
{ id: 'readit', label: 'ReadIt', bg: '#030303', card: '#1A1A1B', accent: '#FF4500' },
|
||||
{ id: 'the-book', label: 'The Book', bg: '#F0F2F5', card: '#FFFFFF', accent: '#1877F2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ThemePicker({ value, onChange }: Props) {
|
||||
// All groups collapsed on first render. The blue dot in the header surfaces
|
||||
// which group holds the active theme, so auto-expanding it on mount just
|
||||
// adds visual noise to a screen that already has a long sub-section list.
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null);
|
||||
|
||||
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
|
||||
|
||||
return (
|
||||
<div className="theme-accordion">
|
||||
{THEME_GROUPS.map(({ group, themes }) => {
|
||||
const isOpen = openGroup === group;
|
||||
const hasActive = themes.some(t => t.id === value);
|
||||
return (
|
||||
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
|
||||
<button className="theme-accordion-header" onClick={() => toggle(group)}>
|
||||
<span>
|
||||
{group}
|
||||
{hasActive && !isOpen && (
|
||||
<span className="theme-accordion-active-dot" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={15} className="theme-accordion-chevron" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="theme-accordion-content">
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{(() => {
|
||||
let lastFamily: string | undefined;
|
||||
return themes.map((t, i) => {
|
||||
const isActive = value === t.id;
|
||||
const showFamilyHeader = !!t.family && t.family !== lastFamily;
|
||||
lastFamily = t.family;
|
||||
return (
|
||||
<Fragment key={t.id}>
|
||||
{showFamilyHeader && (
|
||||
<div
|
||||
className="theme-family-header"
|
||||
style={{ gridColumn: '1 / -1', marginTop: i === 0 ? 0 : 10 }}
|
||||
>
|
||||
{t.family}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="theme-card-btn"
|
||||
onClick={() => onChange(t.id)}
|
||||
>
|
||||
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,11 +59,11 @@ export function SeekbarPreview({ style, label, selected, onClick }: Props) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--ctp-surface1)'}`,
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--ctp-base))',
|
||||
: 'var(--bg-card, var(--bg-app))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function YearFilterButton({
|
||||
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}),
|
||||
}}
|
||||
>
|
||||
<CalendarRange size={14} />
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function RadioDirectoryModal({ onClose, onAdded }: RadioDirectory
|
||||
width: '80vw',
|
||||
maxWidth: 800,
|
||||
height: '80vh',
|
||||
background: 'var(--ctp-surface0)',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
@@ -148,7 +148,7 @@ export default function RadioDirectoryModal({ onClose, onAdded }: RadioDirectory
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: 20,
|
||||
background: 'var(--ctp-surface0)',
|
||||
background: 'var(--bg-card)',
|
||||
zIndex: 10,
|
||||
position: 'relative',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
|
||||
@@ -38,8 +38,8 @@ function renderStars(rating?: number) {
|
||||
<div className="np-stars-inline">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Star key={i} size={13}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--ctp-overlay1)'}
|
||||
fill={i <= rating ? 'var(--highlight)' : 'none'}
|
||||
color={i <= rating ? 'var(--highlight)' : 'var(--border-subtle)'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
||||
<div className="np-dash-hero-actions">
|
||||
<button onClick={onToggleStar} className="np-dash-icon-btn"
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}>
|
||||
<Heart size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'currentColor'} />
|
||||
<Heart size={18} fill={starred ? 'var(--highlight)' : 'none'} color={starred ? 'var(--highlight)' : 'currentColor'} />
|
||||
</button>
|
||||
{lfmLoveEnabled && (
|
||||
<button onClick={onToggleLfmLove}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function PlaylistsHeader({
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
|
||||
|
||||
@@ -66,12 +66,12 @@ export function LoadPlaylistModal({ onClose, onLoad }: Props) {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--bg-hover)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'replace')} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => onLoad(p.id, p.name, 'append')} data-tooltip={t('queue.appendToQueue')} style={{ width: '28px', height: '28px', background: 'transparent' }}><ListPlus size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
|
||||
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--danger)' }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -427,7 +427,7 @@ export default function AnalyticsStrategySection() {
|
||||
role="note"
|
||||
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--warning, #f59e0b)' }} />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Clock, LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
@@ -11,13 +11,11 @@ import type { SeekbarStyle } from '../../store/authStoreTypes';
|
||||
import { useFontStore, FontId } from '../../store/fontStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import ThemePicker, { THEME_GROUPS } from '../ThemePicker';
|
||||
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||
|
||||
export function AppearanceTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const fontStore = useFontStore();
|
||||
@@ -30,88 +28,6 @@ export function AppearanceTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSubSection
|
||||
title={t('settings.theme')}
|
||||
icon={<Palette size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{theme.enableThemeScheduler && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeSchedulerActiveHint')}
|
||||
</div>
|
||||
)}
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.themeSchedulerTitle')}
|
||||
icon={<Clock size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
|
||||
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{theme.enableThemeScheduler && (() => {
|
||||
const themeOptions = THEME_GROUPS.flatMap(g =>
|
||||
g.themes.map(th => ({
|
||||
value: th.id,
|
||||
label: th.family ? `${th.family} ${th.label}` : th.label,
|
||||
group: g.group,
|
||||
}))
|
||||
);
|
||||
const use12h = i18n.language === 'en';
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => {
|
||||
const value = String(i).padStart(2, '0');
|
||||
const label = use12h
|
||||
? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}`
|
||||
: value;
|
||||
return { value, label };
|
||||
});
|
||||
const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m }));
|
||||
const dayH = theme.timeDayStart.split(':')[0];
|
||||
const dayM = theme.timeDayStart.split(':')[1];
|
||||
const nightH = theme.timeNightStart.split(':')[0];
|
||||
const nightM = theme.timeNightStart.split(':')[1];
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
|
||||
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
|
||||
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.libraryGridMaxColumnsTitle')}
|
||||
icon={<LayoutGrid size={16} />}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function InputTab() {
|
||||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@@ -156,7 +156,7 @@ export function InputTab() {
|
||||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
color: isListening ? 'var(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { FIXED_THEMES } from './fixedThemes';
|
||||
|
||||
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
|
||||
function swatch(css: string): { bg: string; card: string; accent: string } {
|
||||
const read = (name: string, fallback: string) => {
|
||||
const m = css.match(new RegExp(`--${name}\\s*:\\s*([^;]+);`));
|
||||
return m ? m[1].trim() : fallback;
|
||||
};
|
||||
return {
|
||||
bg: read('bg-app', '#1e1e2e'),
|
||||
card: read('bg-card', '#313244'),
|
||||
accent: read('accent', '#cba6f7'),
|
||||
};
|
||||
}
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
fixed: boolean;
|
||||
accessibility: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat card grid of the user's available themes: the fixed cores plus every
|
||||
* installed community theme. Click a card to apply it; community themes carry an
|
||||
* uninstall control (the fixed cores do not). No accordion — this page is only
|
||||
* about themes, so everything is shown at once.
|
||||
*/
|
||||
export function InstalledThemes() {
|
||||
const { t } = useTranslation();
|
||||
const active = useThemeStore(s => s.theme);
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
const cards: Card[] = [
|
||||
...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility })),
|
||||
...installed.map(it => {
|
||||
const s = swatch(it.css);
|
||||
return { id: it.id, label: it.name, bg: s.bg, card: s.card, accent: s.accent, fixed: false, accessibility: (it.tags || []).includes('accessibility') };
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
|
||||
{cards.map(c => {
|
||||
const isActive = active === c.id;
|
||||
return (
|
||||
<div key={c.id} style={{ position: 'relative' }}>
|
||||
<button className="theme-card-btn" style={{ width: '100%' }} aria-pressed={isActive} onClick={() => setTheme(c.id)}>
|
||||
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
|
||||
<div style={{ background: c.bg, height: '55%' }} />
|
||||
<div style={{ background: c.card, height: '20%' }} />
|
||||
<div style={{ background: c.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: c.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
{c.accessibility && (
|
||||
<span
|
||||
aria-label={t('settings.themesCvdTooltip')}
|
||||
data-tooltip={t('settings.themesCvdTooltip')}
|
||||
data-tooltip-pos="top"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent-dim)',
|
||||
color: 'var(--accent)',
|
||||
letterSpacing: 0.3,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
CVD-safe
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!c.fixed && (
|
||||
<button
|
||||
onClick={() => uninstallTheme(c.id)}
|
||||
aria-label={t('settings.themeStoreUninstall')}
|
||||
data-tooltip={t('settings.themeStoreUninstall')}
|
||||
data-tooltip-pos="top"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'var(--bg-elevated)',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -400,7 +400,7 @@ export function ServersTab({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
</span>
|
||||
)}
|
||||
@@ -500,7 +500,7 @@ export function ServersTab({
|
||||
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
style={{ color: 'var(--color-warning, #f59e0b)', flexShrink: 0 }}
|
||||
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
|
||||
data-tooltip={t('settings.audiomuseIssueHint')}
|
||||
aria-label={t('settings.audiomuseIssueHint')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Download, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
|
||||
import {
|
||||
cdnUrl,
|
||||
fetchRegistry,
|
||||
fetchThemeCss,
|
||||
type RegistryTheme,
|
||||
} from '../../utils/themes/themeRegistry';
|
||||
import { validateThemeCss } from '../../utils/themes/themeInjection';
|
||||
import { uninstallTheme } from '../../utils/themes/uninstallTheme';
|
||||
import { isNewer } from '../../utils/componentHelpers/appUpdaterHelpers';
|
||||
|
||||
type ModeFilter = 'all' | 'dark' | 'light';
|
||||
|
||||
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
|
||||
|
||||
/**
|
||||
* The community Theme Store: browse the jsDelivr-hosted registry, filter by name
|
||||
* and light/dark, install (fetch + persist + runtime inject), apply, update and
|
||||
* uninstall. Built-in themes are not in the registry, so they never appear here.
|
||||
*/
|
||||
export function ThemeStoreSection() {
|
||||
const { t } = useTranslation();
|
||||
const activeTheme = useThemeStore(s => s.theme);
|
||||
const setTheme = useThemeStore(s => s.setTheme);
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const install = useInstalledThemesStore(s => s.install);
|
||||
|
||||
const [themes, setThemes] = useState<RegistryTheme[] | null>(null);
|
||||
const [generatedAt, setGeneratedAt] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<ModeFilter>('all');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null);
|
||||
|
||||
const load = (force = false) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
fetchRegistry({ force })
|
||||
.then(r => { setThemes(r.registry.themes); setGeneratedAt(r.registry.generatedAt); setStale(r.stale); })
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
// Thumbnails live at a stable CDN path, so the webview caches them hard
|
||||
// (jsDelivr sends max-age 7d). Tie a cache-buster to the registry's
|
||||
// generatedAt — it changes on every themes push — so refreshed thumbnails
|
||||
// show up after a registry refresh instead of being stuck on the old image.
|
||||
const thumbUrl = (rel: string) =>
|
||||
generatedAt ? `${cdnUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : cdnUrl(rel);
|
||||
|
||||
useEffect(() => { load(false); }, []);
|
||||
|
||||
const installedMap = useMemo(() => {
|
||||
const m = new Map<string, InstalledTheme>();
|
||||
for (const it of installed) m.set(it.id, it);
|
||||
return m;
|
||||
}, [installed]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!themes) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
return themes.filter(th => {
|
||||
if (mode !== 'all' && th.mode !== mode) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
th.name.toLowerCase().includes(q) ||
|
||||
th.author.toLowerCase().includes(q) ||
|
||||
th.description.toLowerCase().includes(q) ||
|
||||
(th.tags || []).some(tag => tag.includes(q))
|
||||
);
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [themes, query, mode]);
|
||||
|
||||
const handleInstall = async (th: RegistryTheme) => {
|
||||
setBusyId(th.id);
|
||||
setFailedId(null);
|
||||
try {
|
||||
const css = await fetchThemeCss(th.css);
|
||||
// Don't persist CSS that won't inject — otherwise the theme would show as
|
||||
// installed/active but render nothing. Validate before storing.
|
||||
if (validateThemeCss(css, th.id) == null) {
|
||||
setFailedId(th.id);
|
||||
return;
|
||||
}
|
||||
install({
|
||||
id: th.id,
|
||||
name: th.name,
|
||||
author: th.author,
|
||||
version: th.version,
|
||||
description: th.description,
|
||||
mode: th.mode,
|
||||
tags: th.tags,
|
||||
css,
|
||||
installedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
setFailedId(th.id);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const modeBtns: { key: ModeFilter; label: string }[] = [
|
||||
{ key: 'all', label: t('settings.themeStoreModeAll') },
|
||||
{ key: 'dark', label: t('settings.themeStoreModeDark') },
|
||||
{ key: 'light', label: t('settings.themeStoreModeLight') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-card">
|
||||
{/* Submit-your-own-theme hint */}
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
|
||||
{t('settings.themeStoreSubmitText')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl(THEMES_REPO_URL)}
|
||||
style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
>
|
||||
{t('settings.themeStoreSubmitLink')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: search + mode filter + refresh */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('settings.themeStoreSearchPlaceholder')}
|
||||
aria-label={t('settings.themeStoreSearchPlaceholder')}
|
||||
style={{ flex: '1 1 180px', minWidth: 140 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
|
||||
{modeBtns.map(b => (
|
||||
<button
|
||||
key={b.key}
|
||||
className={`btn ${mode === b.key ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
aria-pressed={mode === b.key}
|
||||
onClick={() => setMode(b.key)}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: '4px 10px' }}
|
||||
onClick={() => load(true)}
|
||||
disabled={loading}
|
||||
aria-label={t('settings.themeStoreRefresh')}
|
||||
data-tooltip={t('settings.themeStoreRefresh')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loading && stale && (
|
||||
<div className="settings-hint settings-hint-info" role="status" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeStoreOffline')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreLoading')}</p>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div role="alert" style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
<p style={{ marginBottom: 8 }}>{t('settings.themeStoreError')}</p>
|
||||
<button className="btn btn-ghost" onClick={() => load(true)}>{t('settings.themeStoreRetry')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreEmpty')}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map(th => {
|
||||
const inst = installedMap.get(th.id);
|
||||
const isInstalled = !!inst;
|
||||
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
|
||||
const isActive = activeTheme === th.id;
|
||||
const busy = busyId === th.id;
|
||||
return (
|
||||
<div
|
||||
key={th.id}
|
||||
className="theme-store-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 14,
|
||||
padding: 12,
|
||||
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||||
borderRadius: 'var(--radius-md, 10px)',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox({ src: thumbUrl(th.thumbnail), name: th.name })}
|
||||
aria-label={t('settings.themeStoreEnlarge')}
|
||||
data-tooltip={t('settings.themeStoreEnlarge')}
|
||||
data-tooltip-pos="right"
|
||||
style={{ padding: 0, border: 'none', background: 'none', cursor: 'zoom-in', flexShrink: 0, alignSelf: 'flex-start', lineHeight: 0, borderRadius: 6 }}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(th.thumbnail)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width={200}
|
||||
height={125}
|
||||
// Offline / missing thumbnail: hide the broken-image glyph; the
|
||||
// image's own neutral background stands in as a placeholder.
|
||||
onError={e => { e.currentTarget.style.opacity = '0'; }}
|
||||
style={{ width: 200, height: 125, objectFit: 'cover', borderRadius: 6, background: 'var(--bg-deep)' }}
|
||||
/>
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600 }}>{th.name}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
<Check size={12} /> {t('settings.themeStoreActive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.themeStoreByAuthor', { author: th.author })}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4 }}>
|
||||
{th.description}
|
||||
</div>
|
||||
{/* Rating slot reserved — see Theme Store roadmap (deferred). */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
{!isInstalled && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
||||
onClick={() => handleInstall(th)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Download size={14} /> {busy ? t('settings.themeStoreInstalling') : t('settings.themeStoreInstall')}
|
||||
</button>
|
||||
)}
|
||||
{isInstalled && !isActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
onClick={() => setTheme(th.id)}
|
||||
>
|
||||
{t('settings.themeStoreApply')}
|
||||
</button>
|
||||
)}
|
||||
{updateAvailable && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
onClick={() => handleInstall(th)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
|
||||
</button>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
||||
onClick={() => uninstallTheme(th.id)}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.themeStoreUninstall')}
|
||||
</button>
|
||||
)}
|
||||
{failedId === th.id && (
|
||||
<span role="status" style={{ fontSize: 12, color: 'var(--danger)', alignSelf: 'center' }}>
|
||||
{t('settings.themeStoreInstallFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightbox && (
|
||||
<CoverLightbox src={lightbox.src} alt={lightbox.name} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock, Palette, Store } from 'lucide-react';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import BackToTopButton from '../BackToTopButton';
|
||||
import { FIXED_THEMES } from './fixedThemes';
|
||||
import { InstalledThemes } from './InstalledThemes';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
|
||||
/**
|
||||
* A flat, always-visible section. The Themes tab has a single purpose, so its
|
||||
* parts are laid out one below the other with no collapsing — deliberately not
|
||||
* the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
|
||||
* search` keeps each section reachable from the global settings search.
|
||||
*/
|
||||
function ThemesSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
|
||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 600, margin: '0 0 0.75rem' }}>
|
||||
<span style={{ display: 'inline-flex', color: 'var(--accent)' }}>{icon}</span>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated Themes tab: pick a theme (fixed cores + installed community themes),
|
||||
* the day/night scheduler, and the community Theme Store — all flat on one page.
|
||||
*/
|
||||
export function ThemesTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useThemeStore();
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')}>
|
||||
{theme.enableThemeScheduler && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeSchedulerActiveHint')}
|
||||
</div>
|
||||
)}
|
||||
<InstalledThemes />
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')}>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
|
||||
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{theme.enableThemeScheduler && (() => {
|
||||
const themeOptions = [
|
||||
...FIXED_THEMES.map(f => ({ value: f.id, label: f.label })),
|
||||
...installed.map(it => ({
|
||||
value: it.id,
|
||||
label: it.name,
|
||||
group: t('settings.themesYourThemesTitle'),
|
||||
})),
|
||||
];
|
||||
const use12h = i18n.language === 'en';
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => {
|
||||
const value = String(i).padStart(2, '0');
|
||||
const label = use12h
|
||||
? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}`
|
||||
: value;
|
||||
return { value, label };
|
||||
});
|
||||
const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m }));
|
||||
const dayH = theme.timeDayStart.split(':')[0];
|
||||
const dayM = theme.timeDayStart.split(':')[1];
|
||||
const nightH = theme.timeNightStart.split(':')[0];
|
||||
const nightM = theme.timeNightStart.split(':')[1];
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
|
||||
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
|
||||
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Store size={16} />} title={t('settings.themeStoreTitle')}>
|
||||
<ThemeStoreSection />
|
||||
</ThemesSection>
|
||||
|
||||
<BackToTopButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -294,8 +294,8 @@ export function UserForm({
|
||||
marginBottom: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
@@ -326,8 +326,8 @@ export function UserForm({
|
||||
marginBottom: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The fixed, bundled core themes — always present, never uninstallable. Every
|
||||
* other palette lives in the community Theme Store and is applied by id once
|
||||
* installed. `bg`/`card`/`accent` drive the 3-band swatch preview.
|
||||
*/
|
||||
export interface FixedTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
/** Colour-blind-safe accessibility theme — flagged with a badge in the UI. */
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
export const FIXED_THEMES: FixedTheme[] = [
|
||||
{ id: 'mocha', label: 'Catppuccin Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'latte', label: 'Catppuccin Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
{ id: 'kanagawa-wave', label: 'Kanagawa Wave', bg: '#1F1F28', card: '#2A2A37', accent: '#7E9CD8' },
|
||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700', accessibility: true },
|
||||
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700', accessibility: true },
|
||||
];
|
||||
@@ -4,6 +4,7 @@ export type Tab =
|
||||
| 'audio'
|
||||
| 'lyrics'
|
||||
| 'appearance'
|
||||
| 'themes'
|
||||
| 'personalisation'
|
||||
| 'integrations'
|
||||
| 'input'
|
||||
@@ -23,7 +24,7 @@ export function resolveTab(input: string | undefined | null): Tab {
|
||||
if (!input) return 'servers';
|
||||
const aliased = LEGACY_TAB_ALIAS[input];
|
||||
if (aliased) return aliased;
|
||||
const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users'];
|
||||
const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'themes', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users'];
|
||||
return (known as string[]).includes(input) ? (input as Tab) : 'servers';
|
||||
}
|
||||
|
||||
@@ -59,8 +60,9 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'storage', titleKey: 'settings.mediaDirTitle', keywords: 'media folder offline library cache directory local playback' },
|
||||
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
|
||||
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
||||
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
|
||||
{ tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
|
||||
{ tab: 'themes', titleKey: 'settings.themesYourThemesTitle', keywords: 'theme color palette dark light install uninstall apply your' },
|
||||
{ tab: 'themes', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
|
||||
{ tab: 'themes', titleKey: 'settings.themeStoreTitle', keywords: 'theme store community download install browse marketplace' },
|
||||
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
|
||||
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
||||
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
||||
|
||||
@@ -126,8 +126,8 @@ export function MagicStringModal({
|
||||
marginBottom: '1rem',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -75,13 +75,13 @@ export function UserMgmtRow({
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', flexShrink: 0 }}>· {u.name}</span>
|
||||
)}
|
||||
{isSelf && (
|
||||
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
|
||||
{t('settings.userMgmtYouBadge')}
|
||||
</span>
|
||||
)}
|
||||
{u.isAdmin && (
|
||||
<span
|
||||
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)', flexShrink: 0 }}
|
||||
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)', flexShrink: 0 }}
|
||||
data-tooltip={t('settings.userMgmtRoleAdmin')}
|
||||
>
|
||||
<Shield size={10} />
|
||||
|
||||
@@ -356,6 +356,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, and 80+ palettes moved to an on-demand CDN repo (PR #1009)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -122,7 +122,7 @@ function DragGhost({ state }: { state: DragState }) {
|
||||
size={16}
|
||||
strokeWidth={2.25}
|
||||
aria-hidden
|
||||
style={{ flexShrink: 0, color: 'var(--danger, var(--ctp-red, #f38ba8))' }}
|
||||
style={{ flexShrink: 0, color: 'var(--danger, var(--danger, #f38ba8))' }}
|
||||
/>
|
||||
)}
|
||||
{coverUrl && (
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Lade…',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Zurück',
|
||||
backToTop: 'Nach oben',
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
save: 'Speichern',
|
||||
|
||||
@@ -321,6 +321,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Offline & Cache',
|
||||
tabAppearance: 'Darstellung',
|
||||
tabThemes: 'Themes',
|
||||
themesYourThemesTitle: 'Deine Themes',
|
||||
themesCvdTooltip: 'Farbfehlsichtigkeits-sicher – Deuteranopie, Protanopie, Tritanopie',
|
||||
themeStoreTitle: 'Theme-Store',
|
||||
themeStoreSubmitText: 'Eigenes Theme gebaut? Teil es mit der Community — mehr Infos im Themes-Repository.',
|
||||
themeStoreSubmitLink: 'Themes-Repository öffnen',
|
||||
themeStoreSearchPlaceholder: 'Themes suchen…',
|
||||
themeStoreFilterMode: 'Nach Modus filtern',
|
||||
themeStoreModeAll: 'Alle',
|
||||
themeStoreModeDark: 'Dunkel',
|
||||
themeStoreModeLight: 'Hell',
|
||||
themeStoreRefresh: 'Aktualisieren',
|
||||
themeStoreLoading: 'Themes werden geladen…',
|
||||
themeStoreError: 'Theme-Store konnte nicht geladen werden. Prüfe deine Verbindung.',
|
||||
themeStoreRetry: 'Erneut versuchen',
|
||||
themeStoreEmpty: 'Keine Themes passen zu deinen Filtern.',
|
||||
themeStoreActive: 'Aktiv',
|
||||
themeStoreEnlarge: 'Vorschau vergrößern',
|
||||
themeStoreByAuthor: 'von {{author}}',
|
||||
themeMigrationNoticeTitle: 'Theme in den Store gezogen',
|
||||
themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.',
|
||||
themeMigrationNoticeOpen: 'Theme-Store öffnen',
|
||||
themeStoreOffline: 'Zeige den zuletzt gecachten Katalog — du scheinst offline zu sein.',
|
||||
themeStoreInstall: 'Installieren',
|
||||
themeStoreInstalling: 'Installiere…',
|
||||
themeStoreApply: 'Anwenden',
|
||||
themeStoreUpdate: 'Aktualisieren',
|
||||
themeStoreUpdating: 'Aktualisiere…',
|
||||
themeStoreUninstall: 'Deinstallieren',
|
||||
themeStoreInstallFailed: 'Installation fehlgeschlagen',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabServers: 'Server',
|
||||
tabLyrics: 'Songtexte',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Downloading…',
|
||||
downloadZip: 'Download (ZIP)',
|
||||
back: 'Back',
|
||||
backToTop: 'Back to top',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
|
||||
@@ -388,6 +388,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Offline & Cache',
|
||||
tabAppearance: 'Appearance',
|
||||
tabThemes: 'Themes',
|
||||
themesYourThemesTitle: 'Your Themes',
|
||||
themesCvdTooltip: 'Colour-blind safe — deuteranopia, protanopia, tritanopia',
|
||||
themeStoreTitle: 'Theme Store',
|
||||
themeStoreSubmitText: 'Made your own theme? Share it with the community — more info in the themes repository.',
|
||||
themeStoreSubmitLink: 'Open the themes repository',
|
||||
themeStoreSearchPlaceholder: 'Search themes…',
|
||||
themeStoreFilterMode: 'Filter by mode',
|
||||
themeStoreModeAll: 'All',
|
||||
themeStoreModeDark: 'Dark',
|
||||
themeStoreModeLight: 'Light',
|
||||
themeStoreRefresh: 'Refresh',
|
||||
themeStoreLoading: 'Loading themes…',
|
||||
themeStoreError: 'Could not load the theme store. Check your connection.',
|
||||
themeStoreRetry: 'Retry',
|
||||
themeStoreEmpty: 'No themes match your filters.',
|
||||
themeStoreActive: 'Active',
|
||||
themeStoreEnlarge: 'Enlarge preview',
|
||||
themeStoreByAuthor: 'by {{author}}',
|
||||
themeMigrationNoticeTitle: 'Theme moved to the Store',
|
||||
themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.',
|
||||
themeMigrationNoticeOpen: 'Open Theme Store',
|
||||
themeStoreOffline: 'Showing the last cached catalogue — you appear to be offline.',
|
||||
themeStoreInstall: 'Install',
|
||||
themeStoreInstalling: 'Installing…',
|
||||
themeStoreApply: 'Apply',
|
||||
themeStoreUpdate: 'Update',
|
||||
themeStoreUpdating: 'Updating…',
|
||||
themeStoreUninstall: 'Uninstall',
|
||||
themeStoreInstallFailed: 'Install failed',
|
||||
tabLibrary: 'Library',
|
||||
tabServers: 'Servers',
|
||||
tabLyrics: 'Lyrics',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Descargando…',
|
||||
downloadZip: 'Descargar (ZIP)',
|
||||
back: 'Volver',
|
||||
backToTop: 'Volver arriba',
|
||||
cancel: 'Cancelar',
|
||||
save: 'Guardar',
|
||||
delete: 'Eliminar',
|
||||
|
||||
@@ -319,6 +319,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Sin conexión y caché',
|
||||
tabAppearance: 'Apariencia',
|
||||
tabThemes: 'Temas',
|
||||
themesYourThemesTitle: 'Tus temas',
|
||||
themesCvdTooltip: 'Apto para daltonismo: deuteranopía, protanopía, tritanopía',
|
||||
themeStoreTitle: 'Tienda de temas',
|
||||
themeStoreSubmitText: '¿Has creado tu propio tema? Compártelo con la comunidad — más información en el repositorio de temas.',
|
||||
themeStoreSubmitLink: 'Abrir el repositorio de temas',
|
||||
themeStoreSearchPlaceholder: 'Buscar temas…',
|
||||
themeStoreFilterMode: 'Filtrar por modo',
|
||||
themeStoreModeAll: 'Todos',
|
||||
themeStoreModeDark: 'Oscuro',
|
||||
themeStoreModeLight: 'Claro',
|
||||
themeStoreRefresh: 'Actualizar',
|
||||
themeStoreLoading: 'Cargando temas…',
|
||||
themeStoreError: 'No se pudo cargar la tienda de temas. Comprueba tu conexión.',
|
||||
themeStoreRetry: 'Reintentar',
|
||||
themeStoreEmpty: 'Ningún tema coincide con tus filtros.',
|
||||
themeStoreActive: 'Activo',
|
||||
themeStoreEnlarge: 'Ampliar vista previa',
|
||||
themeStoreByAuthor: 'por {{author}}',
|
||||
themeMigrationNoticeTitle: 'Tema movido a la tienda',
|
||||
themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.',
|
||||
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
|
||||
themeStoreOffline: 'Mostrando el último catálogo en caché: parece que estás sin conexión.',
|
||||
themeStoreInstall: 'Instalar',
|
||||
themeStoreInstalling: 'Instalando…',
|
||||
themeStoreApply: 'Aplicar',
|
||||
themeStoreUpdate: 'Actualizar',
|
||||
themeStoreUpdating: 'Actualizando…',
|
||||
themeStoreUninstall: 'Desinstalar',
|
||||
themeStoreInstallFailed: 'Error al instalar',
|
||||
tabLibrary: 'Biblioteca',
|
||||
tabServers: 'Servidores',
|
||||
tabLyrics: 'Letras',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Téléchargement…',
|
||||
downloadZip: 'Télécharger (ZIP)',
|
||||
back: 'Retour',
|
||||
backToTop: 'Retour en haut',
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
delete: 'Supprimer',
|
||||
|
||||
@@ -317,6 +317,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Hors ligne & Cache',
|
||||
tabAppearance: 'Apparence',
|
||||
tabThemes: 'Thèmes',
|
||||
themesYourThemesTitle: 'Vos thèmes',
|
||||
themesCvdTooltip: 'Adapté au daltonisme — deutéranopie, protanopie, tritanopie',
|
||||
themeStoreTitle: 'Boutique de thèmes',
|
||||
themeStoreSubmitText: "Vous avez créé votre propre thème ? Partagez-le avec la communauté — plus d'infos dans le dépôt de thèmes.",
|
||||
themeStoreSubmitLink: 'Ouvrir le dépôt de thèmes',
|
||||
themeStoreSearchPlaceholder: 'Rechercher des thèmes…',
|
||||
themeStoreFilterMode: 'Filtrer par mode',
|
||||
themeStoreModeAll: 'Tous',
|
||||
themeStoreModeDark: 'Sombre',
|
||||
themeStoreModeLight: 'Clair',
|
||||
themeStoreRefresh: 'Actualiser',
|
||||
themeStoreLoading: 'Chargement des thèmes…',
|
||||
themeStoreError: 'Impossible de charger la boutique de thèmes. Vérifiez votre connexion.',
|
||||
themeStoreRetry: 'Réessayer',
|
||||
themeStoreEmpty: 'Aucun thème ne correspond à vos filtres.',
|
||||
themeStoreActive: 'Actif',
|
||||
themeStoreEnlarge: "Agrandir l'aperçu",
|
||||
themeStoreByAuthor: 'par {{author}}',
|
||||
themeMigrationNoticeTitle: 'Thème déplacé vers la boutique',
|
||||
themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.",
|
||||
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
|
||||
themeStoreOffline: 'Affichage du dernier catalogue en cache — vous semblez hors ligne.',
|
||||
themeStoreInstall: 'Installer',
|
||||
themeStoreInstalling: 'Installation…',
|
||||
themeStoreApply: 'Appliquer',
|
||||
themeStoreUpdate: 'Mettre à jour',
|
||||
themeStoreUpdating: 'Mise à jour…',
|
||||
themeStoreUninstall: 'Désinstaller',
|
||||
themeStoreInstallFailed: "Échec de l'installation",
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabServers: 'Serveurs',
|
||||
tabLyrics: 'Paroles',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Laster ned…',
|
||||
downloadZip: 'Last ned (ZIP)',
|
||||
back: 'Tilbake',
|
||||
backToTop: 'Til toppen',
|
||||
cancel: 'Avbryt',
|
||||
save: 'Lagre',
|
||||
delete: 'Slett',
|
||||
|
||||
@@ -320,6 +320,36 @@ export const settings = {
|
||||
tabPersonalisation: 'Personalisering',
|
||||
tabIntegrations: 'Integrasjoner',
|
||||
tabAppearance: 'Utseende',
|
||||
tabThemes: 'Temaer',
|
||||
themesYourThemesTitle: 'Dine temaer',
|
||||
themesCvdTooltip: 'Fargeblind-sikker – deuteranopi, protanopi, tritanopi',
|
||||
themeStoreTitle: 'Temabutikk',
|
||||
themeStoreSubmitText: 'Laget ditt eget tema? Del det med fellesskapet — mer info i tema-repoet.',
|
||||
themeStoreSubmitLink: 'Åpne tema-repoet',
|
||||
themeStoreSearchPlaceholder: 'Søk i temaer…',
|
||||
themeStoreFilterMode: 'Filtrer etter modus',
|
||||
themeStoreModeAll: 'Alle',
|
||||
themeStoreModeDark: 'Mørk',
|
||||
themeStoreModeLight: 'Lys',
|
||||
themeStoreRefresh: 'Oppdater',
|
||||
themeStoreLoading: 'Laster temaer…',
|
||||
themeStoreError: 'Kunne ikke laste temabutikken. Sjekk tilkoblingen din.',
|
||||
themeStoreRetry: 'Prøv igjen',
|
||||
themeStoreEmpty: 'Ingen temaer samsvarer med filtrene dine.',
|
||||
themeStoreActive: 'Aktiv',
|
||||
themeStoreEnlarge: 'Forstørr forhåndsvisning',
|
||||
themeStoreByAuthor: 'av {{author}}',
|
||||
themeMigrationNoticeTitle: 'Temaet er flyttet til butikken',
|
||||
themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.',
|
||||
themeMigrationNoticeOpen: 'Åpne temabutikken',
|
||||
themeStoreOffline: 'Viser den sist bufrede katalogen — du ser ut til å være frakoblet.',
|
||||
themeStoreInstall: 'Installer',
|
||||
themeStoreInstalling: 'Installerer…',
|
||||
themeStoreApply: 'Bruk',
|
||||
themeStoreUpdate: 'Oppdater',
|
||||
themeStoreUpdating: 'Oppdaterer…',
|
||||
themeStoreUninstall: 'Avinstaller',
|
||||
themeStoreInstallFailed: 'Installasjonen mislyktes',
|
||||
tabStorage: 'Frakoblet & Cache',
|
||||
inputKeybindingsTitle: 'Tastatursnarveier',
|
||||
aboutContributorsCount_one: '{{count}} bidrag',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Downloaden…',
|
||||
downloadZip: 'Downloaden (ZIP)',
|
||||
back: 'Terug',
|
||||
backToTop: 'Terug naar boven',
|
||||
cancel: 'Annuleren',
|
||||
save: 'Opslaan',
|
||||
delete: 'Verwijderen',
|
||||
|
||||
@@ -317,6 +317,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Offline & Cache',
|
||||
tabAppearance: 'Weergave',
|
||||
tabThemes: "Thema's",
|
||||
themesYourThemesTitle: "Jouw thema's",
|
||||
themesCvdTooltip: 'Kleurenblind-veilig – deuteranopie, protanopie, tritanopie',
|
||||
themeStoreTitle: 'Themawinkel',
|
||||
themeStoreSubmitText: 'Eigen thema gemaakt? Deel het met de community — meer info in de themarepository.',
|
||||
themeStoreSubmitLink: 'Themarepository openen',
|
||||
themeStoreSearchPlaceholder: "Thema's zoeken…",
|
||||
themeStoreFilterMode: 'Filteren op modus',
|
||||
themeStoreModeAll: 'Alle',
|
||||
themeStoreModeDark: 'Donker',
|
||||
themeStoreModeLight: 'Licht',
|
||||
themeStoreRefresh: 'Vernieuwen',
|
||||
themeStoreLoading: "Thema's laden…",
|
||||
themeStoreError: 'Kan de themawinkel niet laden. Controleer je verbinding.',
|
||||
themeStoreRetry: 'Opnieuw proberen',
|
||||
themeStoreEmpty: "Geen thema's komen overeen met je filters.",
|
||||
themeStoreActive: 'Actief',
|
||||
themeStoreEnlarge: 'Voorbeeld vergroten',
|
||||
themeStoreByAuthor: 'door {{author}}',
|
||||
themeMigrationNoticeTitle: 'Thema verplaatst naar de store',
|
||||
themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.',
|
||||
themeMigrationNoticeOpen: 'Theme Store openen',
|
||||
themeStoreOffline: 'De laatst gecachte catalogus wordt getoond — je lijkt offline te zijn.',
|
||||
themeStoreInstall: 'Installeren',
|
||||
themeStoreInstalling: 'Installeren…',
|
||||
themeStoreApply: 'Toepassen',
|
||||
themeStoreUpdate: 'Bijwerken',
|
||||
themeStoreUpdating: 'Bijwerken…',
|
||||
themeStoreUninstall: 'Verwijderen',
|
||||
themeStoreInstallFailed: 'Installatie mislukt',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabServers: 'Servers',
|
||||
tabLyrics: 'Songteksten',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Se descarcă…',
|
||||
downloadZip: 'Descarcă (ZIP)',
|
||||
back: 'Înapoi',
|
||||
backToTop: 'Înapoi sus',
|
||||
cancel: 'Anulează',
|
||||
close: 'Închide',
|
||||
save: 'Salvează',
|
||||
|
||||
@@ -323,6 +323,36 @@ export const settings = {
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Offline & Cache',
|
||||
tabAppearance: 'Aparență',
|
||||
tabThemes: 'Teme',
|
||||
themesYourThemesTitle: 'Temele tale',
|
||||
themesCvdTooltip: 'Sigur pentru daltonism – deuteranopie, protanopie, tritanopie',
|
||||
themeStoreTitle: 'Magazin de teme',
|
||||
themeStoreSubmitText: 'Ți-ai creat propria temă? Împărtășește-o cu comunitatea — mai multe informații în depozitul de teme.',
|
||||
themeStoreSubmitLink: 'Deschide depozitul de teme',
|
||||
themeStoreSearchPlaceholder: 'Caută teme…',
|
||||
themeStoreFilterMode: 'Filtrează după mod',
|
||||
themeStoreModeAll: 'Toate',
|
||||
themeStoreModeDark: 'Întunecat',
|
||||
themeStoreModeLight: 'Luminos',
|
||||
themeStoreRefresh: 'Reîmprospătează',
|
||||
themeStoreLoading: 'Se încarcă temele…',
|
||||
themeStoreError: 'Magazinul de teme nu a putut fi încărcat. Verifică conexiunea.',
|
||||
themeStoreRetry: 'Reîncearcă',
|
||||
themeStoreEmpty: 'Nicio temă nu corespunde filtrelor tale.',
|
||||
themeStoreActive: 'Activă',
|
||||
themeStoreEnlarge: 'Mărește previzualizarea',
|
||||
themeStoreByAuthor: 'de {{author}}',
|
||||
themeMigrationNoticeTitle: 'Tema a fost mutată în magazin',
|
||||
themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.',
|
||||
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
|
||||
themeStoreOffline: 'Se afișează ultimul catalog din cache — pari să fii offline.',
|
||||
themeStoreInstall: 'Instalează',
|
||||
themeStoreInstalling: 'Se instalează…',
|
||||
themeStoreApply: 'Aplică',
|
||||
themeStoreUpdate: 'Actualizează',
|
||||
themeStoreUpdating: 'Se actualizează…',
|
||||
themeStoreUninstall: 'Dezinstalează',
|
||||
themeStoreInstallFailed: 'Instalarea a eșuat',
|
||||
tabLibrary: 'Librărie',
|
||||
tabServers: 'Servere',
|
||||
tabLyrics: 'Versuri',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: 'Скачивание…',
|
||||
downloadZip: 'Скачать (ZIP)',
|
||||
back: 'Назад',
|
||||
backToTop: 'Наверх',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
delete: 'Удалить',
|
||||
|
||||
@@ -399,6 +399,36 @@ export const settings = {
|
||||
tabAudio: 'Звук',
|
||||
tabStorage: 'Офлайн и кэш',
|
||||
tabAppearance: 'Внешний вид',
|
||||
tabThemes: 'Темы',
|
||||
themesYourThemesTitle: 'Ваши темы',
|
||||
themesCvdTooltip: 'Безопасно при дальтонизме — дейтеранопия, протанопия, тританопия',
|
||||
themeStoreTitle: 'Магазин тем',
|
||||
themeStoreSubmitText: 'Создали свою тему? Поделитесь с сообществом — подробности в репозитории тем.',
|
||||
themeStoreSubmitLink: 'Открыть репозиторий тем',
|
||||
themeStoreSearchPlaceholder: 'Поиск тем…',
|
||||
themeStoreFilterMode: 'Фильтр по режиму',
|
||||
themeStoreModeAll: 'Все',
|
||||
themeStoreModeDark: 'Тёмные',
|
||||
themeStoreModeLight: 'Светлые',
|
||||
themeStoreRefresh: 'Обновить',
|
||||
themeStoreLoading: 'Загрузка тем…',
|
||||
themeStoreError: 'Не удалось загрузить магазин тем. Проверьте подключение.',
|
||||
themeStoreRetry: 'Повторить',
|
||||
themeStoreEmpty: 'Нет тем, соответствующих фильтрам.',
|
||||
themeStoreActive: 'Активна',
|
||||
themeStoreEnlarge: 'Увеличить превью',
|
||||
themeStoreByAuthor: 'от {{author}}',
|
||||
themeMigrationNoticeTitle: 'Тема перенесена в магазин',
|
||||
themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.',
|
||||
themeMigrationNoticeOpen: 'Открыть магазин тем',
|
||||
themeStoreOffline: 'Показан последний кэшированный каталог — похоже, вы офлайн.',
|
||||
themeStoreInstall: 'Установить',
|
||||
themeStoreInstalling: 'Установка…',
|
||||
themeStoreApply: 'Применить',
|
||||
themeStoreUpdate: 'Обновить',
|
||||
themeStoreUpdating: 'Обновление…',
|
||||
themeStoreUninstall: 'Удалить',
|
||||
themeStoreInstallFailed: 'Ошибка установки',
|
||||
tabLibrary: 'Библиотека',
|
||||
tabServers: 'Серверы',
|
||||
tabLyrics: 'Тексты песен',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const common = {
|
||||
downloading: '正在下载…',
|
||||
downloadZip: '下载 (ZIP)',
|
||||
back: '返回',
|
||||
backToTop: '回到顶部',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
|
||||
@@ -316,6 +316,36 @@ export const settings = {
|
||||
tabAudio: '音频',
|
||||
tabStorage: '离线与缓存',
|
||||
tabAppearance: '外观',
|
||||
tabThemes: '主题',
|
||||
themesYourThemesTitle: '你的主题',
|
||||
themesCvdTooltip: '色觉障碍友好 — 绿色盲、红色盲、蓝色盲',
|
||||
themeStoreTitle: '主题商店',
|
||||
themeStoreSubmitText: '做了自己的主题?与社区分享——更多信息见主题仓库。',
|
||||
themeStoreSubmitLink: '打开主题仓库',
|
||||
themeStoreSearchPlaceholder: '搜索主题…',
|
||||
themeStoreFilterMode: '按模式筛选',
|
||||
themeStoreModeAll: '全部',
|
||||
themeStoreModeDark: '深色',
|
||||
themeStoreModeLight: '浅色',
|
||||
themeStoreRefresh: '刷新',
|
||||
themeStoreLoading: '正在加载主题…',
|
||||
themeStoreError: '无法加载主题商店。请检查网络连接。',
|
||||
themeStoreRetry: '重试',
|
||||
themeStoreEmpty: '没有符合筛选条件的主题。',
|
||||
themeStoreActive: '使用中',
|
||||
themeStoreEnlarge: '放大预览',
|
||||
themeStoreByAuthor: '作者:{{author}}',
|
||||
themeMigrationNoticeTitle: '主题已移至商店',
|
||||
themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。',
|
||||
themeMigrationNoticeOpen: '打开主题商店',
|
||||
themeStoreOffline: '正在显示上次缓存的目录——你似乎处于离线状态。',
|
||||
themeStoreInstall: '安装',
|
||||
themeStoreInstalling: '正在安装…',
|
||||
themeStoreApply: '应用',
|
||||
themeStoreUpdate: '更新',
|
||||
themeStoreUpdating: '正在更新…',
|
||||
themeStoreUninstall: '卸载',
|
||||
themeStoreInstallFailed: '安装失败',
|
||||
tabLibrary: '媒体库',
|
||||
tabServers: '服务器',
|
||||
tabLyrics: '歌词',
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function Albums() {
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}),
|
||||
}}
|
||||
>
|
||||
<Disc3 size={14} />
|
||||
@@ -439,7 +439,7 @@ export default function Albums() {
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
<span className="toolbar-btn-label">{selectionMode ? t('albums.cancelSelect') : t('albums.select')}</span>
|
||||
@@ -534,7 +534,7 @@ export default function Albums() {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
|
||||
@@ -329,7 +329,7 @@ export default function Artists() {
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
data-tooltip-pos="bottom"
|
||||
@@ -339,7 +339,7 @@ export default function Artists() {
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
@@ -348,7 +348,7 @@ export default function Artists() {
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
@@ -361,7 +361,7 @@ export default function Artists() {
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function GenreDetail() {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
|
||||
@@ -328,7 +328,7 @@ export default function LosslessAlbums() {
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
|
||||
@@ -266,7 +266,7 @@ export default function NewReleases() {
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
@@ -337,7 +337,7 @@ export default function NewReleases() {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
|
||||
@@ -330,7 +330,7 @@ export default function RandomAlbums() {
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
||||
@@ -393,7 +393,7 @@ export default function RandomAlbums() {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '3rem',
|
||||
background: 'var(--ctp-base)',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AudioLines, HardDrive, Info, Keyboard, LayoutGrid, Music2, Palette, Search, Server, Sparkles, Users, X,
|
||||
AudioLines, HardDrive, Info, Keyboard, LayoutGrid, Music2, Paintbrush, Palette, Search, Server, Sparkles, Users, X,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { IS_MACOS } from '../utils/platform';
|
||||
import { AppearanceTab } from '../components/settings/AppearanceTab';
|
||||
import { ThemesTab } from '../components/settings/ThemesTab';
|
||||
import { AudioTab } from '../components/settings/AudioTab';
|
||||
import { InputTab } from '../components/settings/InputTab';
|
||||
import { IntegrationsTab } from '../components/settings/IntegrationsTab';
|
||||
@@ -141,8 +142,9 @@ export default function Settings() {
|
||||
{ id: 'servers', label: t('settings.tabServers'), icon: <Server size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Music2 size={15} /> },
|
||||
{ id: 'audio', label: t('settings.tabAudio'), icon: <AudioLines size={15} /> },
|
||||
{ id: 'lyrics', label: t('settings.tabLyrics'), icon: <Music2 size={15} /> },
|
||||
{ id: 'themes', label: t('settings.tabThemes'), icon: <Paintbrush size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'lyrics', label: t('settings.tabLyrics'), icon: <Music2 size={15} /> },
|
||||
{ id: 'personalisation', label: t('settings.tabPersonalisation'), icon: <LayoutGrid size={15} /> },
|
||||
{ id: 'integrations', label: t('settings.tabIntegrations'), icon: <Sparkles size={15} /> },
|
||||
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
||||
@@ -306,6 +308,9 @@ export default function Settings() {
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
|
||||
{/* ── Themes ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'themes' && <ThemesTab />}
|
||||
|
||||
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'input' && <InputTab />}
|
||||
|
||||
@@ -338,6 +343,7 @@ const TAB_LABEL_KEY: Record<Tab, string> = {
|
||||
audio: 'settings.tabAudio',
|
||||
lyrics: 'settings.tabLyrics',
|
||||
appearance: 'settings.tabAppearance',
|
||||
themes: 'settings.tabThemes',
|
||||
personalisation: 'settings.tabPersonalisation',
|
||||
integrations: 'settings.tabIntegrations',
|
||||
input: 'settings.tabInput',
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function Statistics() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
|
||||
{track.nowPlaying && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--bg-app)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* A community theme the user installed from the Theme Store. The full CSS text
|
||||
* lives here (in localStorage via the persist middleware) so it is available
|
||||
* *synchronously* at startup — the runtime <style> injection can run before the
|
||||
* first paint with no network round-trip and no flash of the wrong theme.
|
||||
* Built-in themes are NOT tracked here; they ship bundled and are never
|
||||
* uninstallable.
|
||||
*/
|
||||
export interface InstalledTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
version: string;
|
||||
description: string;
|
||||
mode: 'dark' | 'light';
|
||||
tags?: string[];
|
||||
/** The `[data-theme='<id>']` block — the only CSS, already CI-validated. */
|
||||
css: string;
|
||||
installedAt: number;
|
||||
}
|
||||
|
||||
interface InstalledThemesState {
|
||||
themes: InstalledTheme[];
|
||||
/** Insert or replace by id (used for both install and update). */
|
||||
install: (theme: InstalledTheme) => void;
|
||||
uninstall: (id: string) => void;
|
||||
isInstalled: (id: string) => boolean;
|
||||
getInstalled: (id: string) => InstalledTheme | undefined;
|
||||
}
|
||||
|
||||
export const useInstalledThemesStore = create<InstalledThemesState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
themes: [],
|
||||
install: (theme) =>
|
||||
set((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] })),
|
||||
uninstall: (id) =>
|
||||
set((s) => ({ themes: s.themes.filter((t) => t.id !== id) })),
|
||||
isInstalled: (id) => get().themes.some((t) => t.id === id),
|
||||
getInstalled: (id) => get().themes.find((t) => t.id === id),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_installed_themes',
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
+20
-28
@@ -1,7 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula' | 'kanagawa-wave' | 'kanagawa-dragon' | 'kanagawa-lotus' | 'one-dark' | 'one-light' | 'vs-1984' | 'vs-1984-cyberpunk' | 'vs-1984-light' | 'vs-1984-orwell' | 'vision-dark' | 'vision-navy';
|
||||
/** Themes that ship bundled with the app and can never be uninstalled. */
|
||||
export type BuiltinTheme =
|
||||
| 'mocha'
|
||||
| 'latte'
|
||||
| 'kanagawa-wave'
|
||||
| 'stark-hud'
|
||||
| 'vision-dark'
|
||||
| 'vision-navy';
|
||||
|
||||
/**
|
||||
* A theme id. Built-in ids get autocomplete; installed community themes apply
|
||||
* any string id (the `& {}` keeps the literal hints without collapsing to a
|
||||
* bare `string`). Non-core palettes now live in the community Theme Store and
|
||||
* are applied by their string id once installed.
|
||||
*/
|
||||
export type Theme = BuiltinTheme | (string & {});
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
@@ -44,23 +59,6 @@ export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler'
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
/** Themes removed in PR #490 (community theme redesign). Each key maps to the
|
||||
* closest surviving palette so persisted state from older builds doesn't land
|
||||
* on a non-existent `data-theme` attribute and silently fall back to :root. */
|
||||
const REMOVED_THEME_REMAP: Record<string, string> = {
|
||||
'amber-night': 'obsidian-gold', // warm gold/amber dark family
|
||||
'ice-blue': 'carbon-grey', // cool neutral dark (no surviving cyan)
|
||||
'monochrome': 'carbon-grey', // neutral grey dark
|
||||
'phosphor-green': 'deep-forest', // green dark family
|
||||
'rose-dark': 'sakura-night', // pink/rose dark family
|
||||
};
|
||||
|
||||
function remapTheme(value: unknown): unknown {
|
||||
return typeof value === 'string' && value in REMOVED_THEME_REMAP
|
||||
? REMOVED_THEME_REMAP[value]
|
||||
: value;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -92,16 +90,10 @@ export const useThemeStore = create<ThemeState>()(
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
version: 1,
|
||||
migrate: (persistedState, _version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') return persistedState;
|
||||
const s = persistedState as Record<string, unknown>;
|
||||
return {
|
||||
...s,
|
||||
theme: remapTheme(s.theme),
|
||||
themeDay: remapTheme(s.themeDay),
|
||||
themeNight: remapTheme(s.themeNight),
|
||||
};
|
||||
},
|
||||
// Identity migrate: preserve persisted state from older versions as-is.
|
||||
// Theme-id repair for removed / store-only themes now happens in the
|
||||
// pre-React bootstrap migration (see utils/themes/themeMigration).
|
||||
migrate: (persistedState) => persistedState,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md);
|
||||
box-shadow: inset 0 0 0 1px var(--card-hover-border), var(--shadow-md);
|
||||
}
|
||||
|
||||
.album-card-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-hover);
|
||||
background: var(--card-placeholder-bg);
|
||||
contain: paint;
|
||||
isolation: isolate;
|
||||
transform: translateZ(0);
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
}
|
||||
|
||||
.album-card-more:hover {
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@@ -199,7 +199,7 @@
|
||||
.album-card-offline-badge {
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent) 85%, transparent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 4px;
|
||||
display: flex;
|
||||
@@ -210,7 +210,7 @@
|
||||
.album-card-new-badge {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 6px;
|
||||
@@ -243,7 +243,7 @@
|
||||
|
||||
.album-card-details-btn {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-full);
|
||||
@@ -386,6 +386,6 @@
|
||||
.album-card-select-check--on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
|
||||
border-bottom: 1px solid var(--row-divider);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: 6px;
|
||||
@@ -20,11 +20,11 @@
|
||||
}
|
||||
|
||||
.tracklist-mobile-row.active .tracklist-mobile-title {
|
||||
color: var(--accent);
|
||||
color: var(--row-playing-text);
|
||||
}
|
||||
|
||||
.tracklist-mobile-row.context-active {
|
||||
background: var(--bg-hover);
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.tracklist-mobile-main {
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
.tracklist-mobile-num {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--row-text-muted);
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
@@ -54,7 +54,7 @@
|
||||
.tracklist-mobile-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
color: var(--row-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
.tracklist-mobile-duration {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
color: var(--row-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.alphabet-filter-btn:hover { background: var(--bg-hover); }
|
||||
.alphabet-filter-btn.active { background: var(--accent); color: var(--ctp-crust); }
|
||||
.alphabet-filter-btn.active { background: var(--accent); color: var(--text-on-accent); }
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
.artist-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border);
|
||||
border-color: var(--card-hover-border);
|
||||
}
|
||||
|
||||
.artist-card-avatar {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-hover);
|
||||
background: var(--card-placeholder-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -78,7 +78,7 @@
|
||||
.artist-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
color: var(--card-title);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -86,6 +86,6 @@
|
||||
|
||||
.artist-card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--card-subtitle);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
.artist-card-select-check--on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.artist-card--selectable {
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
}
|
||||
.artists-alpha-btn:not(.artists-alpha-btn--active):hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
.artists-alpha-btn--active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.alphabet-filter-btn.empty { opacity: 0.28; pointer-events: none; }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* ─── Back to top button ─── */
|
||||
@keyframes back-to-top-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Pinned to the bottom-right of the content area (its portal host
|
||||
`.app-shell-route-host` spans the region between sidebar/queue and above the
|
||||
player bar). Absolute against that host so it never drifts with the scrolling
|
||||
content — the scroll viewport's `contain: paint` rules out `position: fixed`
|
||||
here. */
|
||||
.back-to-top-btn {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 20px;
|
||||
z-index: 50;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
animation: back-to-top-in 0.25s ease both;
|
||||
transition: background 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-to-top-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.back-to-top-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.back-to-top-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -5,14 +5,14 @@
|
||||
|
||||
.context-menu-item--submenu.active,
|
||||
.context-menu-item--submenu:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
background: var(--menu-item-hover);
|
||||
color: var(--menu-item-active-text);
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
background: var(--bg-card);
|
||||
background: var(--menu-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) 0;
|
||||
@@ -45,7 +45,7 @@
|
||||
.context-submenu-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-surface);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
@@ -63,7 +63,7 @@
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--bg-card);
|
||||
background: var(--menu-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) 0;
|
||||
@@ -24,14 +24,14 @@
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
background: var(--menu-item-hover);
|
||||
color: var(--menu-item-active-text);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-keyboard-active,
|
||||
.context-menu-rating-row.context-menu-keyboard-active {
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
background: var(--menu-item-hover);
|
||||
color: var(--menu-item-active-text);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-keyboard-active {
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
|
||||
.context-menu-rating-row.context-menu-keyboard-active {
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--menu-item-hover));
|
||||
box-shadow: inset 3px 0 0 var(--accent), inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
background: var(--menu-divider);
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
.album-detail-badge {
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
/* Konsistenz mit `.album-card-new-badge`: eckig, nicht pill. */
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -281,14 +281,14 @@
|
||||
.download-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--surface-2);
|
||||
background: var(--bg-elevated, var(--bg-card));
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.download-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-dropdown, var(--border));
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dropdown);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
.custom-select-trigger:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
background: var(--button-hover);
|
||||
}
|
||||
.custom-select-label {
|
||||
flex: 1;
|
||||
@@ -34,10 +34,10 @@
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.custom-select-dropdown {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dropdown, var(--border));
|
||||
background: var(--menu-bg);
|
||||
border: 1px solid var(--border-dropdown);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 32px var(--shadow-dropdown, rgba(0, 0, 0, 0.4));
|
||||
box-shadow: 0 8px 32px var(--shadow-dropdown);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -49,10 +49,10 @@
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.custom-select-option:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--menu-item-hover);
|
||||
}
|
||||
.custom-select-option.selected {
|
||||
color: var(--accent);
|
||||
color: var(--menu-item-active-text);
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-top: 1px solid var(--menu-divider);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.custom-select-group-label:first-child {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 5px 8px;
|
||||
background: var(--bg-input);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -262,14 +262,14 @@
|
||||
font-size: 0.78rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.device-sync-template-preset-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@
|
||||
font-family: monospace;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
@@ -339,7 +339,7 @@
|
||||
}
|
||||
|
||||
.device-sync-template-token:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--ctp-surface0));
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--bg-card));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -531,8 +531,8 @@
|
||||
}
|
||||
|
||||
.device-sync-badge.synced {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
background: color-mix(in srgb, var(--positive, #4ade80) 15%, transparent);
|
||||
color: var(--positive, #4ade80);
|
||||
}
|
||||
|
||||
.device-sync-badge.pending {
|
||||
@@ -573,7 +573,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-status-icon.synced { color: var(--success, #4ade80); }
|
||||
.device-sync-status-icon.synced { color: var(--positive, #4ade80); }
|
||||
.device-sync-status-icon.pending { color: var(--warning, #f59e0b); }
|
||||
.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); }
|
||||
|
||||
@@ -659,12 +659,12 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent);
|
||||
background: color-mix(in srgb, var(--positive, #4ade80) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-input);
|
||||
background: var(--bg-card);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -685,7 +685,7 @@
|
||||
}
|
||||
|
||||
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
|
||||
.color-success { color: var(--success, #4ade80); }
|
||||
.color-success { color: var(--positive, #4ade80); }
|
||||
|
||||
/* ── Browser panel ── */
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input, var(--bg-sidebar));
|
||||
background: var(--bg-card, var(--bg-sidebar));
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
}
|
||||
|
||||
.filter-quick-clear--on-active-chip:hover {
|
||||
background: color-mix(in srgb, var(--ctp-crust) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-deep) 22%, transparent);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
margin-left: 0.35rem;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -99,7 +99,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-overlay0);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.hero-placeholder {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
|
||||
background: linear-gradient(135deg, var(--bg-card), var(--bg-sidebar));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -89,3 +89,4 @@
|
||||
@import './_banner.css';
|
||||
@import './np-dash.css';
|
||||
@import './orbit-session-top-strip.css';
|
||||
@import './back-to-top.css';
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-overlay0);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
@@ -190,7 +190,7 @@
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: var(--ctp-mantle);
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
@@ -67,7 +67,7 @@
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
.mini-player__titlebar-btn--close:hover {
|
||||
background: var(--danger);
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
.mini-player {
|
||||
@@ -213,7 +213,7 @@
|
||||
position: relative;
|
||||
width: 5px;
|
||||
height: 110px;
|
||||
background: var(--ctp-surface1);
|
||||
background: var(--bg-hover);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -271,7 +271,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.mini-player__btn--primary:hover {
|
||||
background: var(--accent);
|
||||
@@ -293,7 +293,7 @@
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--ctp-surface1);
|
||||
background: var(--bg-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-player__progress-fill {
|
||||
@@ -416,11 +416,11 @@
|
||||
cursor: default;
|
||||
}
|
||||
.mini-player__tool--active {
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
background: var(--accent);
|
||||
}
|
||||
.mini-player__tool--active:hover:not(:disabled) {
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
|
||||
.np-info-section {
|
||||
display: block;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle, var(--ctp-surface0)) 70%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle, var(--bg-card)) 70%, transparent);
|
||||
}
|
||||
.np-info-section > * + * { margin-top: 12px; }
|
||||
.np-info-section:last-child {
|
||||
@@ -819,7 +819,7 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: var(--ctp-base, #1e1e2e);
|
||||
color: var(--bg-app, #1e1e2e);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 17, 27, 0.85);
|
||||
background: var(--modal-scrim);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--modal-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
@@ -99,7 +99,7 @@
|
||||
margin: 12px 0 10px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
}
|
||||
|
||||
@@ -122,12 +122,12 @@
|
||||
|
||||
.sidebar-perf-modal__tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-hover, var(--ctp-surface1)) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-hover, var(--bg-hover)) 70%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-perf-modal__tab--active {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-card, var(--ctp-base)));
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-card, var(--bg-app)));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 82%, transparent);
|
||||
}
|
||||
|
||||
.perf-live-poll__title {
|
||||
@@ -365,7 +365,7 @@
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 82%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@
|
||||
padding: 10px 10px 9px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 14%, transparent);
|
||||
background: color-mix(in srgb, var(--ctp-mantle, var(--ctp-base)) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sidebar, var(--bg-app)) 72%, transparent);
|
||||
min-height: 84px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -540,7 +540,7 @@
|
||||
.perf-tree-group {
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 16%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 78%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 78%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 82%, transparent);
|
||||
}
|
||||
|
||||
.perf-overlay-mode__title {
|
||||
@@ -656,7 +656,7 @@
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 70%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
@@ -670,7 +670,7 @@
|
||||
|
||||
.perf-overlay-mode__segment--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-card, var(--ctp-base)));
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-card, var(--bg-app)));
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
@@ -687,7 +687,7 @@
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 82%, transparent);
|
||||
}
|
||||
|
||||
.perf-overlay-appearance__title {
|
||||
@@ -734,7 +734,7 @@
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--ctp-mantle, var(--ctp-base)) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sidebar, var(--bg-app)) 70%, transparent);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -815,14 +815,14 @@
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px 10px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 78%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 78%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-perf-modal__phase--nested {
|
||||
margin-left: 10px;
|
||||
margin-right: 2px;
|
||||
padding-top: 6px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 68%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 68%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-perf-modal__phase-title {
|
||||
@@ -843,7 +843,7 @@
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-card, var(--bg-app)) 80%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-perf-modal__cpu-title {
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
}
|
||||
|
||||
.mp-artist-avatar--placeholder {
|
||||
background: var(--bg-surface);
|
||||
background: var(--card-placeholder-bg);
|
||||
}
|
||||
|
||||
.mp-artist-info {
|
||||
@@ -226,7 +226,7 @@
|
||||
}
|
||||
|
||||
.mp-album-cover--placeholder {
|
||||
background: var(--bg-surface);
|
||||
background: var(--card-placeholder-bg);
|
||||
}
|
||||
|
||||
.mp-album-meta {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
container-type: inline-size;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--accent-2) 8%, var(--bg-main)) 0%, transparent 60%),
|
||||
var(--bg-main);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
max-height: 300px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ctp-teal) transparent;
|
||||
scrollbar-color: var(--accent-2) transparent;
|
||||
}
|
||||
|
||||
.np-bio-text.expanded::-webkit-scrollbar {
|
||||
@@ -221,7 +221,7 @@
|
||||
}
|
||||
|
||||
.np-bio-text.expanded::-webkit-scrollbar-thumb {
|
||||
background: var(--ctp-teal);
|
||||
background: var(--accent-2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
|
||||
.np-badge-hires {
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 30%, transparent);
|
||||
color: var(--ctp-yellow);
|
||||
background: color-mix(in srgb, var(--highlight) 30%, transparent);
|
||||
color: var(--highlight);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -200,7 +200,7 @@
|
||||
}
|
||||
.np-dash-toolbar-badge {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base, #1e1e2e);
|
||||
color: var(--bg-app, #1e1e2e);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
@@ -214,7 +214,7 @@
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--ctp-base, #1e1e2e);
|
||||
background: var(--bg-app, #1e1e2e);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
}
|
||||
|
||||
.offline-library-delete:hover {
|
||||
color: var(--color-error, #e05050);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.offline-library-delete--spacer {
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
.offline-filter-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -187,8 +187,8 @@
|
||||
transition: color 150ms ease, background 150ms ease, transform 180ms ease;
|
||||
}
|
||||
.orbit-bar__exit:hover {
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 12%, transparent);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
min-width: 280px;
|
||||
max-width: 380px;
|
||||
padding: 6px;
|
||||
background: var(--ctp-base, #1e1e2e);
|
||||
background: var(--bg-app, #1e1e2e);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
@@ -228,7 +228,7 @@
|
||||
min-width: 320px;
|
||||
max-width: 380px;
|
||||
padding: 12px 14px 14px;
|
||||
background: var(--ctp-base, #1e1e2e);
|
||||
background: var(--bg-app, #1e1e2e);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
@@ -356,19 +356,19 @@
|
||||
transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.orbit-participants-pop__kick:hover {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 35%, transparent);
|
||||
color: var(--highlight, #f9e2af);
|
||||
background: color-mix(in srgb, var(--highlight, #f9e2af) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--highlight, #f9e2af) 35%, transparent);
|
||||
}
|
||||
.orbit-participants-pop__kick--ban:hover {
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger, #f38ba8) 35%, transparent);
|
||||
}
|
||||
.orbit-participants-pop__kick.is-active {
|
||||
color: var(--ctp-peach, #fab387);
|
||||
background: color-mix(in srgb, var(--ctp-peach, #fab387) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-peach, #fab387) 45%, transparent);
|
||||
color: var(--accent-2, #fab387);
|
||||
background: color-mix(in srgb, var(--accent-2, #fab387) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-2, #fab387) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ── Exit modal ────────────────────────────────────────────────────── */
|
||||
@@ -405,7 +405,7 @@
|
||||
/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */
|
||||
.orbit-start-trigger {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--ctp-surface1));
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--bg-hover));
|
||||
transition: color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
.orbit-start-trigger__spin { transition: transform 260ms ease; }
|
||||
@@ -520,15 +520,15 @@
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight, #f9e2af) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--highlight, #f9e2af) 40%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.orbit-help-modal__warn strong {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -696,15 +696,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.orbit-start-modal__tip--warn {
|
||||
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight, #f9e2af) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--highlight, #f9e2af) 45%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.orbit-start-modal__tip--warn svg {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
}
|
||||
.orbit-start-modal__tip--warn strong {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
}
|
||||
|
||||
.orbit-start-modal__field {
|
||||
@@ -810,7 +810,7 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
background: var(--ctp-base);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
@@ -848,7 +848,7 @@
|
||||
min-width: 320px;
|
||||
max-width: 380px;
|
||||
padding: 14px;
|
||||
background: var(--ctp-base, #1e1e2e);
|
||||
background: var(--bg-app, #1e1e2e);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
@@ -919,7 +919,7 @@
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-input);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
@@ -927,7 +927,7 @@
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.orbit-diag-pop__btn:hover {
|
||||
background: var(--bg-hover, var(--bg-input));
|
||||
background: var(--bg-hover, var(--bg-card));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.orbit-diag-pop__log {
|
||||
@@ -937,7 +937,7 @@
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.45;
|
||||
background: var(--bg-input);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -1121,20 +1121,20 @@
|
||||
border-style: solid;
|
||||
}
|
||||
.orbit-queue-head__presence--online {
|
||||
color: var(--ctp-green, #a6e3a1);
|
||||
background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-green, #a6e3a1) 40%, transparent);
|
||||
color: var(--positive, #a6e3a1);
|
||||
background: color-mix(in srgb, var(--positive, #a6e3a1) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--positive, #a6e3a1) 40%, transparent);
|
||||
}
|
||||
.orbit-queue-head__presence--online svg {
|
||||
color: var(--ctp-green, #a6e3a1);
|
||||
color: var(--positive, #a6e3a1);
|
||||
}
|
||||
.orbit-queue-head__presence--away {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent);
|
||||
color: var(--highlight, #f9e2af);
|
||||
background: color-mix(in srgb, var(--highlight, #f9e2af) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--highlight, #f9e2af) 40%, transparent);
|
||||
}
|
||||
.orbit-queue-head__presence--away svg {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
}
|
||||
.orbit-queue-head__meta {
|
||||
display: flex;
|
||||
@@ -1173,7 +1173,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-crust, #11111b);
|
||||
color: var(--text-on-accent, #11111b);
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -1384,9 +1384,9 @@
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.host-approval__btn--approve:hover {
|
||||
background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 28%, transparent);
|
||||
border-color: var(--ctp-green, #a6e3a1);
|
||||
color: var(--ctp-green, #a6e3a1);
|
||||
background: color-mix(in srgb, var(--positive, #a6e3a1) 28%, transparent);
|
||||
border-color: var(--positive, #a6e3a1);
|
||||
color: var(--positive, #a6e3a1);
|
||||
}
|
||||
.host-approval__btn--decline:hover {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 28%, transparent);
|
||||
@@ -1404,10 +1404,10 @@
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.orbit-guest-queue__section-head--pending {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
}
|
||||
.orbit-guest-queue__section-head--pending svg {
|
||||
color: var(--ctp-yellow, #f9e2af);
|
||||
color: var(--highlight, #f9e2af);
|
||||
}
|
||||
.orbit-guest-queue__item--pending {
|
||||
opacity: 0.85;
|
||||
@@ -1456,9 +1456,9 @@
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--ctp-red, #f38ba8) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent);
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--danger, #f38ba8) 35%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
.orbit-start-modal__actions {
|
||||
@@ -1474,7 +1474,7 @@
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
padding: 10px 12px;
|
||||
background: var(--ctp-base);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono, ui-monospace, 'Courier New', monospace);
|
||||
@@ -1522,7 +1522,7 @@
|
||||
--fps-overlay-opacity: 0.82;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--ctp-crust, #111) calc(var(--fps-overlay-opacity) * 100%),
|
||||
var(--text-on-accent, #111) calc(var(--fps-overlay-opacity) * 100%),
|
||||
transparent
|
||||
);
|
||||
border: 1px solid color-mix(
|
||||
@@ -1743,7 +1743,7 @@
|
||||
height: 160px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-input, rgba(0, 0, 0, 0.15));
|
||||
background: var(--bg-card, rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
.because-card-cover {
|
||||
width: 100%;
|
||||
@@ -1768,7 +1768,7 @@
|
||||
.because-card--skeleton .because-card-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--text-primary) 14%, var(--bg-input, rgba(0, 0, 0, 0.15)));
|
||||
background: color-mix(in srgb, var(--text-primary) 14%, var(--bg-card, rgba(0, 0, 0, 0.15)));
|
||||
animation: because-cover-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
.because-card-skeleton-line {
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
transform: rotate(90deg) scale(0.94);
|
||||
}
|
||||
|
||||
.playback-delay-modal--pause { --pd-accent: var(--ctp-lavender, #b4befe); }
|
||||
.playback-delay-modal--start { --pd-accent: var(--ctp-peach, #fab387); }
|
||||
.playback-delay-modal--idle { --pd-accent: var(--ctp-overlay1, #7f849c); }
|
||||
.playback-delay-modal--pause { --pd-accent: var(--accent-2, #b4befe); }
|
||||
.playback-delay-modal--start { --pd-accent: var(--accent-2, #fab387); }
|
||||
.playback-delay-modal--idle { --pd-accent: var(--border-subtle, #7f849c); }
|
||||
|
||||
.playback-delay-modal__head {
|
||||
display: flex;
|
||||
@@ -127,7 +127,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
background: var(--accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
@@ -165,7 +165,7 @@
|
||||
|
||||
.player-btn-preview-ring-track {
|
||||
fill: none;
|
||||
stroke: var(--ctp-overlay0);
|
||||
stroke: var(--border);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
button carries the mode distinction, so the ring stays consistent with
|
||||
the rest of the app. */
|
||||
.playback-schedule-ring__grad-a { stop-color: var(--accent); }
|
||||
.playback-schedule-ring__grad-b { stop-color: var(--ctp-lavender, #b4befe); }
|
||||
.playback-schedule-ring__grad-b { stop-color: var(--accent-2, #b4befe); }
|
||||
|
||||
/* Replaces the Play/Pause icon while a schedule timer is armed. Two-line
|
||||
stack: mode icon (Moon for sleep, Sunrise for delayed-start) above the
|
||||
@@ -358,7 +358,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
background: color-mix(in srgb, var(--ctp-base) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-app) 40%, transparent);
|
||||
}
|
||||
|
||||
.playback-delay-section--disabled {
|
||||
@@ -406,7 +406,7 @@
|
||||
padding: 7px 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
background: var(--ctp-surface1);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -419,7 +419,7 @@
|
||||
|
||||
.playback-delay-chip:hover {
|
||||
border-color: color-mix(in srgb, var(--pd-accent, var(--accent)) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 14%, var(--ctp-surface1));
|
||||
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 14%, var(--bg-hover));
|
||||
color: var(--pd-accent, var(--accent));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, transparent);
|
||||
@@ -429,7 +429,7 @@
|
||||
|
||||
.playback-delay-chip--on {
|
||||
border-color: var(--pd-accent, var(--accent));
|
||||
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, var(--ctp-surface1));
|
||||
background: color-mix(in srgb, var(--pd-accent, var(--accent)) 22%, var(--bg-hover));
|
||||
color: var(--pd-accent, var(--accent));
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--ctp-base);
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item--danger {
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* Right side fields */
|
||||
@@ -216,7 +216,7 @@
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border: 1px solid var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -227,7 +227,7 @@
|
||||
.artist-ext-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--ctp-overlay0);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Bio section */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ─ Playlist Search Panel ─ */
|
||||
.playlist-search-panel {
|
||||
background: var(--surface-2);
|
||||
background: var(--bg-elevated, var(--bg-card));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) var(--space-3) var(--space-2);
|
||||
@@ -123,7 +123,7 @@
|
||||
|
||||
.playlist-search-add-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
.playlist-suggestions {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.playlist-filter-input {
|
||||
background: var(--bg-surface);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
@@ -89,7 +89,7 @@
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
color: var(--bg-app);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
.playlist-delete-btn {
|
||||
opacity: 0;
|
||||
color: var(--ctp-red) !important;
|
||||
color: var(--danger) !important;
|
||||
padding: 4px;
|
||||
height: auto;
|
||||
min-height: unset;
|
||||
@@ -146,7 +146,7 @@
|
||||
}
|
||||
|
||||
.fav-remove-btn:hover {
|
||||
color: var(--ctp-red);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.track-row:hover .fav-remove-btn,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--text-secondary);
|
||||
color: var(--ctp-surface1);
|
||||
color: var(--bg-hover);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
.radio-card-chip:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* Radio card: buttons row always pinned to bottom */
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
}
|
||||
|
||||
.np-queue-remove:hover {
|
||||
color: var(--ctp-red);
|
||||
color: var(--danger);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border: 1px solid var(--bg-hover);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.radio-filter-chip:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.radio-filter-chip.active { background: var(--accent); color: var(--ctp-crust); border-color: var(--accent); }
|
||||
.radio-filter-chip.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
|
||||
.radio-card-drop-before {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
min-height: clamp(200px, 22vh, 320px);
|
||||
padding: var(--space-6);
|
||||
border-radius: 16px;
|
||||
background: var(--ctp-surface0);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
@@ -460,13 +460,13 @@
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-warning, #f59e0b) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-privacy-notice-icon {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
color: var(--warning);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user