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(resolveTab((routeState as { tab?: string } | null)?.tab)); const [searchQuery, setSearchQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); const [searchResults, setSearchResults] = useState([]); const [selectedResultIdx, setSelectedResultIdx] = useState(0); const [pendingFocusTitle, setPendingFocusTitle] = useState(null); const [pendingServerInvite, setPendingServerInvite] = useState(null); const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null); const [ndAuthChecked, setNdAuthChecked] = useState(false); const searchInputRef = useRef(null); const searchResultsListRef = useRef(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( `[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: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, { id: 'audio', label: t('settings.tabAudio'), icon: }, { id: 'themes', label: t('settings.tabThemes'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'lyrics', label: t('settings.tabLyrics'), icon: }, { id: 'personalisation', label: t('settings.tabPersonalisation'), icon: }, { id: 'integrations', label: t('settings.tabIntegrations'), icon: }, { id: 'input', label: t('settings.tabInput'), icon: }, { id: 'storage', label: t('settings.tabStorage'), icon: }, { id: 'system', label: t('settings.tabSystem'), icon: }, ...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: }] : []), ]; return (

{t('settings.title')}

{!searchOpen ? ( ) : (
)}
{/* Tab navigation */} {searchQuery && searchResults.length === 0 && (
{t('settings.searchNoResults')}
)} {searchQuery && searchResults.length > 0 && (
    {searchResults.map((hit, idx) => { const tabLabelKey = TAB_LABEL_KEY[hit.tab]; const selected = idx === selectedResultIdx; return (
  • ); })}
)} {!searchQuery && <> {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && } {/* ── Lyrics ───────────────────────────────────────────────────────────── */} {activeTab === 'lyrics' && } {/* ── Integrations ─────────────────────────────────────────────────────── */} {activeTab === 'integrations' && } {/* ── Personalisation ──────────────────────────────────────────────────── */} {activeTab === 'personalisation' && } {/* ── Library (legacy 'general' + 'server') ────────────────────────────── */} {activeTab === 'library' && } {/* ── Offline & Cache ──────────────────────────────────────────────────── */} {activeTab === 'storage' && } {/* ── Appearance ───────────────────────────────────────────────────────── */} {activeTab === 'appearance' && } {/* ── Themes ───────────────────────────────────────────────────────────── */} {activeTab === 'themes' && } {/* ── Input ────────────────────────────────────────────────────────────── */} {activeTab === 'input' && } {/* ── Server ───────────────────────────────────────────────────────────── */} {activeTab === 'servers' && ( )} {/* ── Users ────────────────────────────────────────────────────────────── */} {activeTab === 'users' && ndAdminAuth && ( )} {/* ── System ───────────────────────────────────────────────────────────── */} {activeTab === 'system' && } }
); } const TAB_LABEL_KEY: Record = { 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', };