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:
Psychotoxical
2026-06-07 02:47:33 +02:00
committed by GitHub
parent 03a1ba9582
commit f9df918c72
221 changed files with 2470 additions and 15759 deletions
+64
View File
@@ -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,
);
}
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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={{
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 = (
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 (
+40
View File
@@ -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}
/>
);
}
-271
View File
@@ -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>
);
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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)',
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -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>
+2 -86
View File
@@ -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} />}
+2 -2
View File
@@ -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',
}}
+137
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
);
}
+123
View File
@@ -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 />
</>
);
}
+4 -4
View File
@@ -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)',
}}
>
+23
View File
@@ -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 },
];
+5 -3
View File
@@ -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} />