mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user