mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
f9df918c72
* 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%.
355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
AudioLines, HardDrive, Info, Keyboard, LayoutGrid, Music2, Paintbrush, Palette, Search, Server, Sparkles, Users, X,
|
|
} from 'lucide-react';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { IS_MACOS } from '../utils/platform';
|
|
import { AppearanceTab } from '../components/settings/AppearanceTab';
|
|
import { ThemesTab } from '../components/settings/ThemesTab';
|
|
import { AudioTab } from '../components/settings/AudioTab';
|
|
import { InputTab } from '../components/settings/InputTab';
|
|
import { IntegrationsTab } from '../components/settings/IntegrationsTab';
|
|
import { LibraryTab } from '../components/settings/LibraryTab';
|
|
import { LyricsTab } from '../components/settings/LyricsTab';
|
|
import { PersonalisationTab } from '../components/settings/PersonalisationTab';
|
|
import { ServersTab } from '../components/settings/ServersTab';
|
|
import { StorageTab } from '../components/settings/StorageTab';
|
|
import { SystemTab } from '../components/settings/SystemTab';
|
|
import { searchSettings, type SettingsSearchHit } from '../components/settings/settingsSearch';
|
|
import { type Tab, resolveTab } from '../components/settings/settingsTabs';
|
|
import { UserManagementSection } from '../components/settings/UserManagementSection';
|
|
import { ndLogin } from '../api/navidromeAdmin';
|
|
import { type ServerMagicPayload } from '../utils/server/serverMagicString';
|
|
|
|
|
|
export default function Settings() {
|
|
const auth = useAuthStore();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const routeState = location.state;
|
|
const { t } = useTranslation();
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [searchResults, setSearchResults] = useState<SettingsSearchHit[]>([]);
|
|
const [selectedResultIdx, setSelectedResultIdx] = useState(0);
|
|
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(null);
|
|
const [pendingServerInvite, setPendingServerInvite] = useState<ServerMagicPayload | null>(null);
|
|
const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null);
|
|
const [ndAuthChecked, setNdAuthChecked] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const searchResultsListRef = useRef<HTMLUListElement>(null);
|
|
|
|
useEffect(() => {
|
|
const st = routeState as { openAddServerInvite?: ServerMagicPayload; tab?: Tab } | null;
|
|
const inv = st?.openAddServerInvite;
|
|
if (inv) {
|
|
setPendingServerInvite(inv);
|
|
setActiveTab('servers');
|
|
navigate(
|
|
{ pathname: location.pathname, search: location.search, hash: location.hash },
|
|
{ replace: true, state: { tab: 'servers' as Tab } },
|
|
);
|
|
return;
|
|
}
|
|
if (st?.tab) setActiveTab(st.tab);
|
|
}, [routeState, location.pathname, location.search, location.hash, navigate]);
|
|
|
|
// Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy).
|
|
// Ergebnis ist eine flache Liste; aktueller Tab zuerst, dann nach Score. Wenn
|
|
// eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
|
|
// die Ergebnisliste angezeigt.
|
|
useEffect(() => {
|
|
setSearchResults(searchSettings(searchQuery, activeTab, t));
|
|
setSelectedResultIdx(0);
|
|
}, [searchQuery, activeTab, t]);
|
|
|
|
// Selektion ins Blickfeld scrollen (nur wenn das Item out-of-view ist).
|
|
useEffect(() => {
|
|
if (!searchQuery || searchResults.length === 0) return;
|
|
const list = searchResultsListRef.current;
|
|
if (!list) return;
|
|
const item = list.children[selectedResultIdx] as HTMLElement | undefined;
|
|
item?.scrollIntoView({ block: 'nearest' });
|
|
}, [selectedResultIdx, searchQuery, searchResults.length]);
|
|
|
|
// Ctrl/Cmd+F oeffnet die Settings-Suche (nur auf der Settings-Seite — dieser
|
|
// Effect ist ja an Settings gebunden). Fokussiert das Feld auch wenn's schon
|
|
// offen ist. preventDefault blockt die native WebKit-Find-Bar.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== 'f' && e.key !== 'F') return;
|
|
if (!(e.ctrlKey || e.metaKey)) return;
|
|
if (e.altKey || e.shiftKey) return;
|
|
e.preventDefault();
|
|
setSearchOpen(true);
|
|
window.setTimeout(() => {
|
|
searchInputRef.current?.focus();
|
|
searchInputRef.current?.select();
|
|
}, 0);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
|
|
// Nach Klick auf ein Ergebnis: Ziel-Sub-Section oeffnen, scrollen und kurz
|
|
// highlighten, damit der User auf dem neuen Tab sofort weiss welcher Eintrag
|
|
// gemeint war.
|
|
useEffect(() => {
|
|
if (!pendingFocusTitle) return;
|
|
const el = document.querySelector<HTMLElement>(
|
|
`[data-settings-search="${CSS.escape(pendingFocusTitle)}"]`,
|
|
);
|
|
if (!el) return;
|
|
if (el instanceof HTMLDetailsElement) el.open = true;
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
el.classList.remove('settings-sub-section--flash');
|
|
// reflow, damit die Animation bei wiederholtem Klick auf dasselbe Ziel
|
|
// erneut abspielt.
|
|
void el.offsetWidth;
|
|
el.classList.add('settings-sub-section--flash');
|
|
const timer = window.setTimeout(() => {
|
|
el.classList.remove('settings-sub-section--flash');
|
|
}, 1500);
|
|
setPendingFocusTitle(null);
|
|
return () => window.clearTimeout(timer);
|
|
}, [pendingFocusTitle, activeTab]);
|
|
|
|
useEffect(() => {
|
|
const server = auth.getActiveServer();
|
|
setNdAuthChecked(false);
|
|
if (!server) { setNdAdminAuth(null); setNdAuthChecked(true); return; }
|
|
const serverUrl = (server.url.startsWith('http') ? server.url : `http://${server.url}`).replace(/\/$/, '');
|
|
let cancelled = false;
|
|
ndLogin(serverUrl, server.username, server.password)
|
|
.then(res => {
|
|
if (cancelled) return;
|
|
setNdAdminAuth(res.isAdmin ? { token: res.token, serverUrl, username: server.username } : null);
|
|
})
|
|
.catch(() => { if (!cancelled) setNdAdminAuth(null); })
|
|
.finally(() => { if (!cancelled) setNdAuthChecked(true); });
|
|
return () => { cancelled = true; };
|
|
}, [auth.activeServerId]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('servers');
|
|
}, [activeTab, ndAdminAuth, ndAuthChecked]);
|
|
|
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: 'servers', label: t('settings.tabServers'), icon: <Server size={15} /> },
|
|
{ id: 'library', label: t('settings.tabLibrary'), icon: <Music2 size={15} /> },
|
|
{ id: 'audio', label: t('settings.tabAudio'), icon: <AudioLines size={15} /> },
|
|
{ id: 'themes', label: t('settings.tabThemes'), icon: <Paintbrush size={15} /> },
|
|
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
|
{ id: 'lyrics', label: t('settings.tabLyrics'), icon: <Music2 size={15} /> },
|
|
{ id: 'personalisation', label: t('settings.tabPersonalisation'), icon: <LayoutGrid size={15} /> },
|
|
{ id: 'integrations', label: t('settings.tabIntegrations'), icon: <Sparkles size={15} /> },
|
|
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
|
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
|
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
|
|
...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: <Users size={15} /> }] : []),
|
|
];
|
|
|
|
return (
|
|
<div className="content-body animate-fade-in">
|
|
<div className="settings-header">
|
|
<h1 className="page-title">{t('settings.title')}</h1>
|
|
<div className="settings-search">
|
|
{!searchOpen ? (
|
|
<button
|
|
type="button"
|
|
className="icon-btn"
|
|
onClick={() => setSearchOpen(true)}
|
|
aria-label={t('settings.searchPlaceholder')}
|
|
data-tooltip={t('settings.searchPlaceholder')}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<Search size={16} />
|
|
</button>
|
|
) : (
|
|
<div className="settings-search-wrap">
|
|
<Search size={14} className="settings-search-icon" aria-hidden="true" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="search"
|
|
className="input settings-search-input"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={`${t('settings.searchPlaceholder')} (${IS_MACOS ? '⌘F' : 'Ctrl+F'})`}
|
|
aria-label={t('settings.searchPlaceholder')}
|
|
autoFocus
|
|
onKeyDown={e => {
|
|
if (e.key === 'Escape') {
|
|
setSearchQuery('');
|
|
setSearchOpen(false);
|
|
return;
|
|
}
|
|
if (searchResults.length === 0) return;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedResultIdx(i => Math.min(i + 1, searchResults.length - 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedResultIdx(i => Math.max(i - 1, 0));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const hit = searchResults[selectedResultIdx];
|
|
if (!hit) return;
|
|
setSearchQuery('');
|
|
setSearchOpen(false);
|
|
setPendingFocusTitle(hit.focusTitle);
|
|
setActiveTab(hit.tab);
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="settings-search-clear"
|
|
onClick={() => { setSearchQuery(''); setSearchOpen(false); }}
|
|
aria-label={t('common.clear')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<label
|
|
className="settings-advanced-toggle"
|
|
data-tooltip={t('settings.advancedModeTooltip')}
|
|
data-tooltip-pos="left"
|
|
>
|
|
<span className="settings-advanced-toggle-label">
|
|
{t('settings.advancedMode')}
|
|
</span>
|
|
<span className="toggle-switch">
|
|
<input
|
|
type="checkbox"
|
|
checked={auth.advancedSettingsEnabled}
|
|
onChange={e => auth.setAdvancedSettingsEnabled(e.target.checked)}
|
|
aria-label={t('settings.advancedMode')}
|
|
/>
|
|
<span className="toggle-track" />
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Tab navigation */}
|
|
<nav className="settings-tabs" aria-label="Settings navigation">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{searchQuery && searchResults.length === 0 && (
|
|
<div className="settings-search-empty" role="status">
|
|
{t('settings.searchNoResults')}
|
|
</div>
|
|
)}
|
|
|
|
{searchQuery && searchResults.length > 0 && (
|
|
<ul ref={searchResultsListRef} className="settings-search-results">
|
|
{searchResults.map((hit, idx) => {
|
|
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
|
const selected = idx === selectedResultIdx;
|
|
return (
|
|
<li key={hit.key}>
|
|
<button
|
|
type="button"
|
|
className="settings-search-result-item"
|
|
data-selected={selected ? 'true' : undefined}
|
|
onMouseEnter={() => setSelectedResultIdx(idx)}
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setSearchOpen(false);
|
|
setPendingFocusTitle(hit.focusTitle);
|
|
setActiveTab(hit.tab);
|
|
}}
|
|
>
|
|
<span className="settings-search-result-badge">{t(tabLabelKey as any)}</span>
|
|
<span className="settings-search-result-title">{hit.title}</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{!searchQuery && <>
|
|
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'audio' && <AudioTab />}
|
|
|
|
{/* ── Lyrics ───────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'lyrics' && <LyricsTab />}
|
|
|
|
{/* ── Integrations ─────────────────────────────────────────────────────── */}
|
|
{activeTab === 'integrations' && <IntegrationsTab />}
|
|
|
|
|
|
{/* ── Personalisation ──────────────────────────────────────────────────── */}
|
|
{activeTab === 'personalisation' && <PersonalisationTab />}
|
|
|
|
{/* ── Library (legacy 'general' + 'server') ────────────────────────────── */}
|
|
{activeTab === 'library' && <LibraryTab />}
|
|
|
|
|
|
{/* ── Offline & Cache ──────────────────────────────────────────────────── */}
|
|
{activeTab === 'storage' && <StorageTab />}
|
|
|
|
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
|
{activeTab === 'appearance' && <AppearanceTab />}
|
|
|
|
{/* ── Themes ───────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'themes' && <ThemesTab />}
|
|
|
|
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'input' && <InputTab />}
|
|
|
|
|
|
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'servers' && (
|
|
<ServersTab initialInvite={pendingServerInvite} />
|
|
)}
|
|
|
|
{/* ── Users ────────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'users' && ndAdminAuth && (
|
|
<UserManagementSection
|
|
serverUrl={ndAdminAuth.serverUrl}
|
|
token={ndAdminAuth.token}
|
|
currentUsername={ndAdminAuth.username}
|
|
/>
|
|
)}
|
|
|
|
{/* ── System ───────────────────────────────────────────────────────────── */}
|
|
{activeTab === 'system' && <SystemTab />}
|
|
|
|
</>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const TAB_LABEL_KEY: Record<Tab, string> = {
|
|
library: 'settings.tabLibrary',
|
|
servers: 'settings.tabServers',
|
|
audio: 'settings.tabAudio',
|
|
lyrics: 'settings.tabLyrics',
|
|
appearance: 'settings.tabAppearance',
|
|
themes: 'settings.tabThemes',
|
|
personalisation: 'settings.tabPersonalisation',
|
|
integrations: 'settings.tabIntegrations',
|
|
input: 'settings.tabInput',
|
|
storage: 'settings.tabStorage',
|
|
system: 'settings.tabSystem',
|
|
users: 'settings.tabUsers',
|
|
};
|
|
|