From 8adad2be6f3003ee80b7b158f8c3100840b9717b Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 02:42:44 +0200 Subject: [PATCH] =?UTF-8?q?refactor(settings):=20G.60=20=E2=80=94=20extrac?= =?UTF-8?q?t=20Storage=20+=20Servers=20+=20System=20tabs=20(cluster)=20(#6?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-tab cluster cut. Settings.tsx 1393 → 345 LOC (−1048); the page now keeps only the tab header, search UI, route-state handling, ndAdminAuth probe, and tab dispatch — every section body lives in its own file. StorageTab — owns offline dir + cache size readouts + cache-clear flow + waveform-cache clear + buffering toggles (preload mode / hot cache incl. dir picker, sliders, clear) + ZIP downloads dir. The hot-cache state (imageCacheBytes / offlineCacheBytes / hotCacheBytes / showClearConfirm / clearing), the hotCacheTrackCount memo, all three hot-cache useEffects, handleClearCache / handleClearWaveformCache, and pickOfflineDir / pickHotCacheDir / pickDownloadFolder move with it. Side effect: the two live hotCacheBytes-refresh useEffects were gated on `activeTab === 'audio'` (a stale leftover from when hot cache lived on the audio tab); they now run while StorageTab is mounted, which is the only place hotCacheBytes is actually displayed. ServersTab — owns the server list, DnD reorder (psy-drop listener + drop-target hover state + serverContainerEl + handleServerDragMove), connStatus map, AddServerForm flow (showAddForm + pastedServerInvite + addServerInviteAnchorRef + the scroll-into-view useLayoutEffect), testConnection / switchToServer / deleteServer / handleAddServer / closeAddServerForm / handleLogout. Settings still owns the route-state useEffect that catches `openAddServerInvite` and flips to the servers tab; it passes the invite as `initialInvite` and ServersTab consumes it on mount + on later prop changes. SystemTab — owns Language picker, behavior toggles (tray, minimize to tray, Linux kinetic scroll), Backup section, logging mode + export runtime logs, About card (maintainers, release notes link, show-changelog-on-update), Contributors grid, Licenses panel. The exportRuntimeLogs handler moves with it. UsersTab stays inline in Settings.tsx — it's an 8-line wrapper around UserManagementSection gated on ndAdminAuth, which Settings already owns for the tab-bar visibility check. The Settings.tsx import list drops 30+ names that only the extracted tabs used: many lucide icons, openDialog/saveDialog, openUrl, Trans, showToast, invoke, getImageCacheSize/clearImageCache, usePlayerStore, useOfflineStore, useHotCacheStore, useDragDrop, pingWithCredentials, scheduleInstantMixProbeForServer, switchActiveServer, formatBytes, snapHotCacheMb, MAINTAINERS, CONTRIBUTORS, LicensesPanel, AboutPsysonicBrandHeader, BackupSection, AddServerForm, ServerGripHandle, serverListDisplayLabel, showAudiomuseNavidromeServerSetting, shortHostFromServerUrl, ServerProfile, LoggingMode, LoudnessLufsPreset, appVersion, i18n, IS_LINUX/IS_WINDOWS, CustomSelect, SettingsSubSection, plus useMemo/useCallback/useLayoutEffect. Pure code move otherwise — no behaviour change. --- src/components/settings/ServersTab.tsx | 366 ++++++++ src/components/settings/StorageTab.tsx | 445 ++++++++++ src/components/settings/SystemTab.tsx | 293 +++++++ src/pages/Settings.tsx | 1084 +----------------------- 4 files changed, 1122 insertions(+), 1066 deletions(-) create mode 100644 src/components/settings/ServersTab.tsx create mode 100644 src/components/settings/StorageTab.tsx create mode 100644 src/components/settings/SystemTab.tsx diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx new file mode 100644 index 00000000..e9cafed2 --- /dev/null +++ b/src/components/settings/ServersTab.tsx @@ -0,0 +1,366 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { open as openUrl } from '@tauri-apps/plugin-shell'; +import { AlertTriangle, CheckCircle2, Lock, LogOut, Plus, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; +import { useAuthStore } from '../../store/authStore'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; +import { useDragDrop } from '../../contexts/DragDropContext'; +import { type ServerMagicPayload } from '../../utils/serverMagicString'; +import { showAudiomuseNavidromeServerSetting } from '../../utils/subsonicServerIdentity'; +import { serverListDisplayLabel } from '../../utils/serverDisplayName'; +import { switchActiveServer } from '../../utils/switchActiveServer'; +import { AddServerForm } from './AddServerForm'; +import { ServerGripHandle } from './ServerGripHandle'; + +const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; + +type ServerDropTarget = { idx: number; before: boolean } | null; + +export function ServersTab({ + initialInvite, +}: { + initialInvite: ServerMagicPayload | null; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const auth = useAuthStore(); + const psyDragState = useDragDrop(); + + const [connStatus, setConnStatus] = useState>({}); + const [showAddForm, setShowAddForm] = useState(initialInvite != null); + const [pastedServerInvite, setPastedServerInvite] = useState(initialInvite); + const [serverContainerEl, setServerContainerEl] = useState(null); + const [serverDropTarget, setServerDropTarget] = useState(null); + const serverDropTargetRef = useRef(null); + const serversRef = useRef(auth.servers); + serversRef.current = auth.servers; + const addServerInviteAnchorRef = useRef(null); + + useLayoutEffect(() => { + if (!showAddForm || !pastedServerInvite) return; + addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [showAddForm, pastedServerInvite]); + + // Pick up later invites that arrive via the parent route handler while + // ServersTab is already mounted (initial mount is handled via useState). + useEffect(() => { + if (initialInvite) { + setPastedServerInvite(initialInvite); + setShowAddForm(true); + } + }, [initialInvite]); + + // Clear drop target when drag ends + useEffect(() => { + if (!psyDragState.isDragging) { + serverDropTargetRef.current = null; + setServerDropTarget(null); + } + }, [psyDragState.isDragging]); + + // psy-drop listener for server reorder + useEffect(() => { + if (!serverContainerEl) return; + const onPsyDrop = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: { type?: string; index?: number }; + try { parsed = JSON.parse(detail.data as string); } catch { return; } + if (parsed.type !== 'server_reorder' || parsed.index == null) return; + + const fromIdx = parsed.index; + const target = serverDropTargetRef.current; + serverDropTargetRef.current = null; setServerDropTarget(null); + if (!target) return; + + const insertBefore = target.before ? target.idx : target.idx + 1; + if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; + + const next = [...serversRef.current]; + const [moved] = next.splice(fromIdx, 1); + next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + auth.setServers(next); + }; + serverContainerEl.addEventListener('psy-drop', onPsyDrop); + return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop); + }, [serverContainerEl, auth]); + + const handleServerDragMove = (e: React.MouseEvent) => { + if (!psyDragState.isDragging || !serverContainerEl) return; + const rows = serverContainerEl.querySelectorAll('[data-server-idx]'); + let target: ServerDropTarget = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.serverIdx); + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } + target = { idx, before: false }; + } + serverDropTargetRef.current = target; + setServerDropTarget(target); + }; + + const testConnection = async (server: ServerProfile) => { + setConnStatus(s => ({ ...s, [server.id]: 'testing' })); + try { + const ping = await pingWithCredentials(server.url, server.username, server.password); + if (ping.ok) { + const identity = { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + }; + auth.setSubsonicServerIdentity(server.id, identity); + scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); + } + setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); + } catch { + setConnStatus(s => ({ ...s, [server.id]: 'error' })); + } + }; + + const switchToServer = async (server: ServerProfile) => { + setConnStatus(s => ({ ...s, [server.id]: 'testing' })); + const ok = await switchActiveServer(server); + if (ok) { + setConnStatus(s => ({ ...s, [server.id]: 'ok' })); + // Auf der Servers-Seite bleiben, damit der User seinen Switch hier + // sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge). + } else { + setConnStatus(s => ({ ...s, [server.id]: 'error' })); + } + }; + + const deleteServer = (server: ServerProfile) => { + if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { + auth.removeServer(server.id); + } + }; + + const closeAddServerForm = () => { + setShowAddForm(false); + setPastedServerInvite(null); + }; + + const handleAddServer = async (data: Omit) => { + setShowAddForm(false); + setPastedServerInvite(null); + const tempId = '_new'; + setConnStatus(s => ({ ...s, [tempId]: 'testing' })); + try { + const ping = await pingWithCredentials(data.url, data.username, data.password); + if (ping.ok) { + const id = auth.addServer(data); + const identity = { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + }; + auth.setSubsonicServerIdentity(id, identity); + scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); + setConnStatus(s => ({ ...s, [id]: 'ok' })); + } else { + setConnStatus(s => ({ ...s, [tempId]: 'error' })); + } + } catch { + setConnStatus(s => ({ ...s, [tempId]: 'error' })); + } + }; + + const handleLogout = () => { + auth.logout(); + navigate('/login'); + }; + + return ( + <> +
+
+ +

{t('settings.servers')}

+
+
+ {t('settings.serverCompatible')} +
+ + {auth.servers.length === 0 && !showAddForm ? ( +
+ {t('settings.noServers')} +
+ ) : ( +
+ {auth.servers.map((srv, srvIdx) => { + const isActive = srv.id === auth.activeServerId; + const status = connStatus[srv.id]; + const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; + const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; + return ( +
+
+ +
+
+
+ {serverListDisplayLabel(srv, auth.servers)} + {isActive && ( + + {t('settings.serverActive')} + + )} +
+
+ {srv.url.startsWith('https://') && ( + + )} + + {srv.url.replace(/^https?:\/\//, '')} + +
+
+ + {srv.username} +
+
+
+ {status === 'ok' && } + {status === 'error' && } + {status === 'testing' &&
} + + {!isActive && ( + + )} + +
+
+
+ {showAudiomuseNavidromeServerSetting( + auth.subsonicServerIdentityByServer[srv.id], + auth.instantMixProbeByServer[srv.id], + ) && ( +
+
+ +
+
+ {t('settings.audiomuseTitle')} + {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( + + )} +
+
+ { + e.preventDefault(); + void openUrl(AUDIOMUSE_NV_PLUGIN_URL); + }} + style={{ color: 'var(--accent)', textDecoration: 'underline' }} + /> + ), + }} + /> +
+
+
+ +
+ )} +
+ ); + })} +
+ )} + +
+ {showAddForm ? ( + + ) : ( + + )} +
+
+ +
+ +
+ + ); +} diff --git a/src/components/settings/StorageTab.tsx b/src/components/settings/StorageTab.tsx new file mode 100644 index 00000000..079ae921 --- /dev/null +++ b/src/components/settings/StorageTab.tsx @@ -0,0 +1,445 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { Download, FolderOpen, Trash2, X } from 'lucide-react'; +import { useAuthStore } from '../../store/authStore'; +import { useHotCacheStore } from '../../store/hotCacheStore'; +import { useOfflineStore } from '../../store/offlineStore'; +import { usePlayerStore } from '../../store/playerStore'; +import { clearImageCache, getImageCacheSize } from '../../utils/imageCache'; +import { formatBytes, snapHotCacheMb } from '../../utils/formatBytes'; +import { showToast } from '../../utils/toast'; +import SettingsSubSection from '../SettingsSubSection'; + +export function StorageTab() { + const { t } = useTranslation(); + const auth = useAuthStore(); + const serverId = auth.activeServerId ?? ''; + const clearAllOffline = useOfflineStore(s => s.clearAll); + const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk); + const hotCacheEntries = useHotCacheStore(s => s.entries); + const [imageCacheBytes, setImageCacheBytes] = useState(null); + const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); + const [hotCacheBytes, setHotCacheBytes] = useState(null); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [clearing, setClearing] = useState(false); + + const hotCacheTrackCount = useMemo(() => { + const prefix = `${serverId}:`; + return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length; + }, [hotCacheEntries, serverId]); + + // Load all three size readouts on mount. + useEffect(() => { + getImageCacheSize().then(setImageCacheBytes); + invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); + invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); + }, [auth.offlineDownloadDir, auth.hotCacheDownloadDir]); + + /** Live disk usage for hot cache (interval + refresh when index changes). */ + useEffect(() => { + const customDir = auth.hotCacheDownloadDir || null; + const refresh = () => { + invoke('get_hot_cache_size', { customDir }) + .then(setHotCacheBytes) + .catch(() => setHotCacheBytes(0)); + }; + refresh(); + if (!auth.hotCacheEnabled) return; + const interval = window.setInterval(refresh, 2000); + return () => window.clearInterval(interval); + }, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]); + + useEffect(() => { + if (!auth.hotCacheEnabled) return; + const handle = window.setTimeout(() => { + invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }) + .then(setHotCacheBytes) + .catch(() => setHotCacheBytes(0)); + }, 400); + return () => window.clearTimeout(handle); + }, [hotCacheEntries, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); + + const handleClearCache = useCallback(async () => { + setClearing(true); + await clearImageCache(); + await clearAllOffline(serverId); + const [imgBytes, offBytes] = await Promise.all([ + getImageCacheSize(), + invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0), + ]); + setImageCacheBytes(imgBytes); + setOfflineCacheBytes(offBytes); + setShowClearConfirm(false); + setClearing(false); + }, [clearAllOffline, serverId, auth.offlineDownloadDir]); + + const handleClearWaveformCache = useCallback(async () => { + setClearing(true); + try { + const deleted = await invoke('analysis_delete_all_waveforms'); + usePlayerStore.setState({ + waveformBins: null, + }); + showToast( + t('settings.waveformCacheCleared', { count: deleted }), + 3500, + 'success', + ); + } catch (e) { + console.error(e); + showToast(t('settings.waveformCacheClearFailed'), 4500, 'error'); + } finally { + setClearing(false); + } + }, [t]); + + const pickOfflineDir = async () => { + const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') }); + if (selected && typeof selected === 'string') { + auth.setOfflineDownloadDir(selected); + } + }; + + const pickHotCacheDir = async () => { + const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') }); + if (selected && typeof selected === 'string') { + auth.setHotCacheDownloadDir(selected); + useHotCacheStore.setState({ entries: {} }); + invoke('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); + } + }; + + const pickDownloadFolder = async () => { + const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') }); + if (selected && typeof selected === 'string') { + auth.setDownloadFolder(selected); + } + }; + + return ( + <> + {/* Offline Library (In-App) — includes cache settings */} + } + > +
+
+ {t('settings.offlineDirDesc')} +
+
+ + {auth.offlineDownloadDir && ( + + )} + +
+ {auth.offlineDownloadDir && ( +
+ {t('settings.offlineDirHint')} +
+ )} + +
+ + {(imageCacheBytes !== null || offlineCacheBytes !== null) && ( +
+
+ {t('settings.cacheUsedImages')} + {imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'} +
+
+ {t('settings.cacheUsedOffline')} + {offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'} +
+
+ )} + +
+ {t('settings.cacheMaxLabel')} + { + const v = Number(e.target.value); + if (v >= 100) auth.setMaxCacheMb(v); + }} + style={{ width: 80, padding: '4px 8px', fontSize: 13 }} + id="cache-size-input" + /> + MB +
+ {showClearConfirm ? ( +
+
{t('settings.cacheClearWarning')}
+
+ + +
+
+ ) : ( + + )} +
+ +
+
+ + + {/* Buffering */} + } + > +
+
+ {t('settings.preloadHotCacheMutualExclusive')} +
+ + {/* Preload mode */} +
+
+
{t('settings.preloadMode')}
+
{t('settings.preloadModeDesc')}
+
+ +
+ {auth.preloadMode !== 'off' && ( + <> +
+ {(['balanced', 'early', 'custom'] as const).map(mode => ( + + ))} +
+ {auth.preloadMode === 'custom' && ( +
+ auth.setPreloadCustomSeconds(parseInt(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + /> + + {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} + +
+ )} + + )} + +
+ + {/* Hot Cache */} +
+
+
{t('settings.hotCacheTitle')}
+
{t('settings.hotCacheDisclaimer')}
+
+ +
+ + {auth.hotCacheEnabled && ( +
+
+ + {auth.hotCacheDownloadDir && ( + + )} + +
+ {auth.hotCacheDownloadDir && ( +
+ {t('settings.hotCacheDirHint')} +
+ )} + +
+ +
+
+ {t('settings.cacheUsedHot')} + {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} +
+
+ {t('settings.hotCacheTrackCount')} + {hotCacheTrackCount} +
+
+ +
+
{t('settings.hotCacheMaxMb')}
+
+ auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> + {snapHotCacheMb(auth.hotCacheMaxMb)} MB +
+
+
+
{t('settings.hotCacheDebounce')}
+
+ auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" /> + + {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 + ? t('settings.hotCacheDebounceImmediate') + : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} + +
+
+ +
+ +
+ )} + +
+ + + {/* ZIP Export & Archiving */} + } + > +
+
+ {t('settings.downloadsFolderDesc')} +
+
+ + {auth.downloadFolder && ( + + )} + +
+
+
+ + ); +} diff --git a/src/components/settings/SystemTab.tsx b/src/components/settings/SystemTab.tsx new file mode 100644 index 00000000..fd562b4f --- /dev/null +++ b/src/components/settings/SystemTab.tsx @@ -0,0 +1,293 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { invoke } from '@tauri-apps/api/core'; +import { save as saveDialog } from '@tauri-apps/plugin-dialog'; +import { open as openUrl } from '@tauri-apps/plugin-shell'; +import { AppWindow, ChevronDown, Download, ExternalLink, Globe, HardDrive, Info, Scale, Sliders, Users } from 'lucide-react'; +import { version as appVersion } from '../../../package.json'; +import i18n from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import type { LoggingMode } from '../../store/authStoreTypes'; +import { IS_LINUX } from '../../utils/platform'; +import { showToast } from '../../utils/toast'; +import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol'; +import CustomSelect from '../CustomSelect'; +import LicensesPanel from '../LicensesPanel'; +import SettingsSubSection from '../SettingsSubSection'; +import { BackupSection } from './BackupSection'; +import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits'; + +export function SystemTab() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const auth = useAuthStore(); + + const exportRuntimeLogs = async () => { + const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const selected = await saveDialog({ + defaultPath: suggestedName, + filters: [{ name: 'Log files', extensions: ['log', 'txt'] }], + title: t('settings.loggingExport'), + }); + if (!selected || Array.isArray(selected)) return; + try { + const lines = await invoke('export_runtime_logs', { path: selected }); + showToast(t('settings.loggingExportSuccess', { count: lines }), 3500, 'info'); + } catch (e) { + console.error(e); + showToast(t('settings.loggingExportError'), 4500, 'error'); + } + }; + + return ( + <> + } + > +
+
+ i18n.changeLanguage(v)} + options={[ + { value: 'en', label: t('settings.languageEn') }, + { value: 'de', label: t('settings.languageDe') }, + { value: 'es', label: t('settings.languageEs') }, + { value: 'fr', label: t('settings.languageFr') }, + { value: 'nl', label: t('settings.languageNl') }, + { value: 'nb', label: t('settings.languageNb') }, + { value: 'ru', label: t('settings.languageRu') }, + { value: 'zh', label: t('settings.languageZh') }, + ]} + /> +
+
+
+ + {/* App-Verhalten (aus altem library/general Behavior-Block) */} + } + > +
+
+
+
{t('settings.showTrayIcon')}
+
{t('settings.showTrayIconDesc')}
+
+ +
+
+
+
+
{t('settings.minimizeToTray')}
+
{t('settings.minimizeToTrayDesc')}
+
+ +
+ {IS_LINUX && ( + <> +
+
+
+
{t('settings.linuxWebkitSmoothScroll')}
+
{t('settings.linuxWebkitSmoothScrollDesc')}
+
+ +
+ + )} +
+ + + } + > + + + + } + > +
+
+ {t('settings.loggingModeDesc')} +
+ auth.setLoggingMode(v as LoggingMode)} + options={[ + { value: 'off', label: t('settings.loggingModeOff') }, + { value: 'normal', label: t('settings.loggingModeNormal') }, + { value: 'debug', label: t('settings.loggingModeDebug') }, + ]} + /> + {auth.loggingMode === 'debug' && ( +
+ +
+ )} +
+
+ + } + > +
+ + +

+ {t('settings.aboutDesc')} +

+ +
+ +
+
+ {t('settings.aboutLicense')} + {t('settings.aboutLicenseText')} +
+
+ Stack + {t('settings.aboutBuiltWith')} +
+
+ {t('settings.aboutMaintainersLabel')} +
+ {MAINTAINERS.map(m => ( +
+ {m.github} + +
+ ))} +
+
+
+ {t('settings.aboutReleaseNotesLabel')} + +
+
+ +
+
+
+
{t('settings.showChangelogOnUpdate')}
+
{t('settings.showChangelogOnUpdateDesc')}
+
+ +
+ +
+ +
+
+ + + } + > +
+ {[...CONTRIBUTORS].sort((a, b) => b.contributions.length - a.contributions.length).map(c => ( +
+ + {c.github} +
+ { e.stopPropagation(); openUrl(`https://github.com/${c.github}`); }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + e.preventDefault(); + openUrl(`https://github.com/${c.github}`); + } + }} + > + @{c.github} + + + v{c.since} + · + {t('settings.aboutContributorsCount', { count: c.contributions.length })} + +
+ +
+
    + {c.contributions.map(item =>
  • {item}
  • )} +
+
+ ))} +
+
+ + } + > + + + + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 246af3f5..e9146f64 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,71 +1,33 @@ -import type { ServerProfile, LoggingMode, LoudnessLufsPreset } from '../store/authStoreTypes'; -import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; -import { version as appVersion } from '../../package.json'; -import { useNavigate, useLocation } from 'react-router-dom'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router-dom'; import { - Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, - Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, X, Keyboard, ChevronDown, - RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Sparkles, AlertTriangle, AudioLines, User, Lock, - Users, Search, Scale + AudioLines, HardDrive, Info, Keyboard, LayoutGrid, Music2, Palette, Search, Server, Sparkles, Users, X, } from 'lucide-react'; -import i18n from '../i18n'; -import { showToast } from '../utils/toast'; -import { invoke } from '@tauri-apps/api/core'; -import { open as openUrl } from '@tauri-apps/plugin-shell'; -import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; -import { useOfflineStore } from '../store/offlineStore'; -import { useHotCacheStore } from '../store/hotCacheStore'; -import { usePlayerStore } from '../store/playerStore'; -import CustomSelect from '../components/CustomSelect'; -import SettingsSubSection from '../components/SettingsSubSection'; -import LicensesPanel from '../components/LicensesPanel'; -import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useAuthStore } from '../store/authStore'; -import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; -import { useDragDrop } from '../contexts/DragDropContext'; -import { AddServerForm } from '../components/settings/AddServerForm'; +import { IS_MACOS } from '../utils/platform'; import { AppearanceTab } from '../components/settings/AppearanceTab'; import { AudioTab } from '../components/settings/AudioTab'; -import { BackupSection } from '../components/settings/BackupSection'; 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 { ServerGripHandle } from '../components/settings/ServerGripHandle'; +import { ServersTab } from '../components/settings/ServersTab'; +import { StorageTab } from '../components/settings/StorageTab'; +import { SystemTab } from '../components/settings/SystemTab'; import { SETTINGS_INDEX, type Tab, matchScore, resolveTab } from '../components/settings/settingsTabs'; import { UserManagementSection } from '../components/settings/UserManagementSection'; -import { CONTRIBUTORS, MAINTAINERS } from '../config/settingsCredits'; -import { formatBytes, snapHotCacheMb } from '../utils/formatBytes'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin } from '../api/navidromeAdmin'; -import { switchActiveServer } from '../utils/switchActiveServer'; -import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; -import { Trans, useTranslation } from 'react-i18next'; -import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; import { type ServerMagicPayload } from '../utils/serverMagicString'; -import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName'; - -const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; export default function Settings() { const auth = useAuthStore(); - const serverId = auth.activeServerId ?? ''; - const clearAllOffline = useOfflineStore(s => s.clearAll); - const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk); - const hotCacheEntries = useHotCacheStore(s => s.entries); - - const hotCacheTrackCount = useMemo(() => { - if (!serverId) return 0; - const prefix = `${serverId}:`; - return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length; - }, [hotCacheEntries, serverId]); - const navigate = useNavigate(); const location = useLocation(); const routeState = location.state; - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(resolveTab((routeState as { tab?: string } | null)?.tab)); const [searchQuery, setSearchQuery] = useState(''); @@ -73,40 +35,17 @@ export default function Settings() { const [searchResults, setSearchResults] = useState>([]); const [selectedResultIdx, setSelectedResultIdx] = useState(0); const [pendingFocusTitle, setPendingFocusTitle] = useState(null); - const searchInputRef = useRef(null); - const searchResultsListRef = useRef(null); - - // Server-Liste DnD - type ServerDropTarget = { idx: number; before: boolean } | null; - const psyDragState = useDragDrop(); - const [serverContainerEl, setServerContainerEl] = useState(null); - const [serverDropTarget, setServerDropTarget] = useState(null); - const serverDropTargetRef = useRef(null); - const serversRef = useRef(auth.servers); - serversRef.current = auth.servers; - const [connStatus, setConnStatus] = useState>({}); - const [showAddForm, setShowAddForm] = useState(false); - const [pastedServerInvite, setPastedServerInvite] = useState(null); - const [imageCacheBytes, setImageCacheBytes] = useState(null); - const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); - const [hotCacheBytes, setHotCacheBytes] = useState(null); - const [showClearConfirm, setShowClearConfirm] = useState(false); - const [clearing, setClearing] = useState(false); + const [pendingServerInvite, setPendingServerInvite] = useState(null); const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null); const [ndAuthChecked, setNdAuthChecked] = useState(false); - const addServerInviteAnchorRef = useRef(null); - - useLayoutEffect(() => { - if (!showAddForm || !pastedServerInvite) return; - addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, [showAddForm, pastedServerInvite]); + const searchInputRef = useRef(null); + const searchResultsListRef = useRef(null); useEffect(() => { const st = routeState as { openAddServerInvite?: ServerMagicPayload; tab?: Tab } | null; const inv = st?.openAddServerInvite; if (inv) { - setPastedServerInvite(inv); - setShowAddForm(true); + setPendingServerInvite(inv); setActiveTab('servers'); navigate( { pathname: location.pathname, search: location.search, hash: location.hash }, @@ -213,233 +152,6 @@ export default function Settings() { if (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('servers'); }, [activeTab, ndAdminAuth, ndAuthChecked]); - useEffect(() => { - if (activeTab !== 'storage') return; - getImageCacheSize().then(setImageCacheBytes); - invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); - invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); - }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); - - /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ - useEffect(() => { - if (activeTab !== 'audio') return; - const customDir = auth.hotCacheDownloadDir || null; - const refresh = () => { - invoke('get_hot_cache_size', { customDir }) - .then(setHotCacheBytes) - .catch(() => setHotCacheBytes(0)); - }; - refresh(); - if (!auth.hotCacheEnabled) return; - const interval = window.setInterval(refresh, 2000); - return () => window.clearInterval(interval); - }, [activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); - - useEffect(() => { - if (activeTab !== 'audio' || !auth.hotCacheEnabled) return; - const t = window.setTimeout(() => { - invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }) - .then(setHotCacheBytes) - .catch(() => setHotCacheBytes(0)); - }, 400); - return () => window.clearTimeout(t); - }, [hotCacheEntries, activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); - - const handleClearCache = useCallback(async () => { - setClearing(true); - await clearImageCache(); - await clearAllOffline(serverId); - const [imgBytes, offBytes] = await Promise.all([ - getImageCacheSize(), - invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0), - ]); - setImageCacheBytes(imgBytes); - setOfflineCacheBytes(offBytes); - setShowClearConfirm(false); - setClearing(false); - }, [clearAllOffline, serverId]); - - const handleClearWaveformCache = useCallback(async () => { - setClearing(true); - try { - const deleted = await invoke('analysis_delete_all_waveforms'); - usePlayerStore.setState({ - waveformBins: null, - }); - showToast( - t('settings.waveformCacheCleared', { count: deleted }), - 3500, - 'success', - ); - } catch (e) { - console.error(e); - showToast(t('settings.waveformCacheClearFailed'), 4500, 'error'); - } finally { - setClearing(false); - } - }, [t]); - - const testConnection = async (server: ServerProfile) => { - setConnStatus(s => ({ ...s, [server.id]: 'testing' })); - try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { - const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, - }; - auth.setSubsonicServerIdentity(server.id, identity); - scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); - } - setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); - } catch { - setConnStatus(s => ({ ...s, [server.id]: 'error' })); - } - }; - - // Clear drop target when drag ends - useEffect(() => { - if (!psyDragState.isDragging) { - serverDropTargetRef.current = null; - setServerDropTarget(null); - } - }, [psyDragState.isDragging]); - - // psy-drop listener for server reorder - useEffect(() => { - if (!serverContainerEl) return; - const onPsyDrop = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (!detail?.data) return; - let parsed: { type?: string; index?: number }; - try { parsed = JSON.parse(detail.data as string); } catch { return; } - if (parsed.type !== 'server_reorder' || parsed.index == null) return; - - const fromIdx = parsed.index; - const target = serverDropTargetRef.current; - serverDropTargetRef.current = null; setServerDropTarget(null); - if (!target) return; - - const insertBefore = target.before ? target.idx : target.idx + 1; - if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; - - const next = [...serversRef.current]; - const [moved] = next.splice(fromIdx, 1); - next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - auth.setServers(next); - }; - serverContainerEl.addEventListener('psy-drop', onPsyDrop); - return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop); - }, [serverContainerEl, auth]); - - const handleServerDragMove = (e: React.MouseEvent) => { - if (!psyDragState.isDragging || !serverContainerEl) return; - const rows = serverContainerEl.querySelectorAll('[data-server-idx]'); - let target: ServerDropTarget = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.serverIdx); - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } - target = { idx, before: false }; - } - serverDropTargetRef.current = target; - setServerDropTarget(target); - }; - - const switchToServer = async (server: ServerProfile) => { - setConnStatus(s => ({ ...s, [server.id]: 'testing' })); - const ok = await switchActiveServer(server); - if (ok) { - setConnStatus(s => ({ ...s, [server.id]: 'ok' })); - // Auf der Servers-Seite bleiben, damit der User seinen Switch hier - // sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge). - } else { - setConnStatus(s => ({ ...s, [server.id]: 'error' })); - } - }; - - const deleteServer = (server: ServerProfile) => { - if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { - auth.removeServer(server.id); - } - }; - - const closeAddServerForm = () => { - setShowAddForm(false); - setPastedServerInvite(null); - }; - - const handleAddServer = async (data: Omit) => { - setShowAddForm(false); - setPastedServerInvite(null); - const tempId = '_new'; - setConnStatus(s => ({ ...s, [tempId]: 'testing' })); - try { - const ping = await pingWithCredentials(data.url, data.username, data.password); - if (ping.ok) { - const id = auth.addServer(data); - const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, - }; - auth.setSubsonicServerIdentity(id, identity); - scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); - setConnStatus(s => ({ ...s, [id]: 'ok' })); - } else { - setConnStatus(s => ({ ...s, [tempId]: 'error' })); - } - } catch { - setConnStatus(s => ({ ...s, [tempId]: 'error' })); - } - }; - - const handleLogout = () => { - auth.logout(); - navigate('/login'); - }; - - const pickOfflineDir = async () => { - const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') }); - if (selected && typeof selected === 'string') { - auth.setOfflineDownloadDir(selected); - } - }; - - const pickHotCacheDir = async () => { - const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') }); - if (selected && typeof selected === 'string') { - auth.setHotCacheDownloadDir(selected); - useHotCacheStore.setState({ entries: {} }); - invoke('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); - } - }; - - const pickDownloadFolder = async () => { - const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') }); - if (selected && typeof selected === 'string') { - auth.setDownloadFolder(selected); - } - }; - - const exportRuntimeLogs = async () => { - const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; - const selected = await saveDialog({ - defaultPath: suggestedName, - filters: [{ name: 'Log files', extensions: ['log', 'txt'] }], - title: t('settings.loggingExport'), - }); - if (!selected || Array.isArray(selected)) return; - try { - const lines = await invoke('export_runtime_logs', { path: selected }); - showToast(t('settings.loggingExportSuccess', { count: lines }), 3500, 'info'); - } catch (e) { - console.error(e); - showToast(t('settings.loggingExportError'), 4500, 'error'); - } - }; - const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'servers', label: t('settings.tabServers'), icon: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, @@ -586,330 +298,7 @@ export default function Settings() { {/* ── Offline & Cache ──────────────────────────────────────────────────── */} - {activeTab === 'storage' && ( - <> - {/* Offline Library (In-App) — includes cache settings */} - } - > -
-
- {t('settings.offlineDirDesc')} -
-
- - {auth.offlineDownloadDir && ( - - )} - -
- {auth.offlineDownloadDir && ( -
- {t('settings.offlineDirHint')} -
- )} - -
- - {(imageCacheBytes !== null || offlineCacheBytes !== null) && ( -
-
- {t('settings.cacheUsedImages')} - {imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'} -
-
- {t('settings.cacheUsedOffline')} - {offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'} -
-
- )} - -
- {t('settings.cacheMaxLabel')} - { - const v = Number(e.target.value); - if (v >= 100) auth.setMaxCacheMb(v); - }} - style={{ width: 80, padding: '4px 8px', fontSize: 13 }} - id="cache-size-input" - /> - MB -
- {showClearConfirm ? ( -
-
{t('settings.cacheClearWarning')}
-
- - -
-
- ) : ( - - )} -
- -
-
- - - {/* Buffering */} - } - > -
-
- {t('settings.preloadHotCacheMutualExclusive')} -
- - {/* Preload mode */} -
-
-
{t('settings.preloadMode')}
-
{t('settings.preloadModeDesc')}
-
- -
- {auth.preloadMode !== 'off' && ( - <> -
- {(['balanced', 'early', 'custom'] as const).map(mode => ( - - ))} -
- {auth.preloadMode === 'custom' && ( -
- auth.setPreloadCustomSeconds(parseInt(e.target.value))} - style={{ flex: 1, minWidth: 80, maxWidth: 200 }} - /> - - {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} - -
- )} - - )} - -
- - {/* Hot Cache */} -
-
-
{t('settings.hotCacheTitle')}
-
{t('settings.hotCacheDisclaimer')}
-
- -
- - {auth.hotCacheEnabled && ( -
-
- - {auth.hotCacheDownloadDir && ( - - )} - -
- {auth.hotCacheDownloadDir && ( -
- {t('settings.hotCacheDirHint')} -
- )} - -
- -
-
- {t('settings.cacheUsedHot')} - {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} -
-
- {t('settings.hotCacheTrackCount')} - {hotCacheTrackCount} -
-
- -
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> - {snapHotCacheMb(auth.hotCacheMaxMb)} MB -
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" /> - - {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 - ? t('settings.hotCacheDebounceImmediate') - : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} - -
-
- -
- -
- )} - -
- - - {/* ZIP Export & Archiving */} - } - > -
-
- {t('settings.downloadsFolderDesc')} -
-
- - {auth.downloadFolder && ( - - )} - -
-
-
- - )} + {activeTab === 'storage' && } {/* ── Appearance ───────────────────────────────────────────────────────── */} {activeTab === 'appearance' && } @@ -920,198 +309,10 @@ export default function Settings() { {/* ── Server ───────────────────────────────────────────────────────────── */} {activeTab === 'servers' && ( - <> -
-
- -

{t('settings.servers')}

-
-
- {t('settings.serverCompatible')} -
- - {auth.servers.length === 0 && !showAddForm ? ( -
- {t('settings.noServers')} -
- ) : ( -
- {auth.servers.map((srv, srvIdx) => { - const isActive = srv.id === auth.activeServerId; - const status = connStatus[srv.id]; - const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; - const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; - return ( -
-
- -
-
-
- {serverListDisplayLabel(srv, auth.servers)} - {isActive && ( - - {t('settings.serverActive')} - - )} -
-
- {srv.url.startsWith('https://') && ( - - )} - - {srv.url.replace(/^https?:\/\//, '')} - -
-
- - {srv.username} -
-
-
- {status === 'ok' && } - {status === 'error' && } - {status === 'testing' &&
} - - {!isActive && ( - - )} - -
-
-
- {showAudiomuseNavidromeServerSetting( - auth.subsonicServerIdentityByServer[srv.id], - auth.instantMixProbeByServer[srv.id], - ) && ( -
-
- -
-
- {t('settings.audiomuseTitle')} - {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( - - )} -
-
- { - e.preventDefault(); - void openUrl(AUDIOMUSE_NV_PLUGIN_URL); - }} - style={{ color: 'var(--accent)', textDecoration: 'underline' }} - /> - ), - }} - /> -
-
-
- -
- )} -
- ); - })} -
- )} - -
- {showAddForm ? ( - - ) : ( - - )} -
-
- -
- -
- - + )} - {/* ── System ───────────────────────────────────────────────────────────── */} + {/* ── Users ────────────────────────────────────────────────────────────── */} {activeTab === 'users' && ndAdminAuth && ( )} - {activeTab === 'system' && ( - <> - } - > -
-
- i18n.changeLanguage(v)} - options={[ - { value: 'en', label: t('settings.languageEn') }, - { value: 'de', label: t('settings.languageDe') }, - { value: 'es', label: t('settings.languageEs') }, - { value: 'fr', label: t('settings.languageFr') }, - { value: 'nl', label: t('settings.languageNl') }, - { value: 'nb', label: t('settings.languageNb') }, - { value: 'ru', label: t('settings.languageRu') }, - { value: 'zh', label: t('settings.languageZh') }, - ]} - /> -
-
-
+ {/* ── System ───────────────────────────────────────────────────────────── */} + {activeTab === 'system' && } - {/* App-Verhalten (aus altem library/general Behavior-Block) */} - } - > -
-
-
-
{t('settings.showTrayIcon')}
-
{t('settings.showTrayIconDesc')}
-
- -
-
-
-
-
{t('settings.minimizeToTray')}
-
{t('settings.minimizeToTrayDesc')}
-
- -
- {IS_LINUX && ( - <> -
-
-
-
{t('settings.linuxWebkitSmoothScroll')}
-
{t('settings.linuxWebkitSmoothScrollDesc')}
-
- -
- - )} -
- - - } - > - - - - } - > -
-
- {t('settings.loggingModeDesc')} -
- auth.setLoggingMode(v as LoggingMode)} - options={[ - { value: 'off', label: t('settings.loggingModeOff') }, - { value: 'normal', label: t('settings.loggingModeNormal') }, - { value: 'debug', label: t('settings.loggingModeDebug') }, - ]} - /> - {auth.loggingMode === 'debug' && ( -
- -
- )} -
-
- - } - > -
- - -

- {t('settings.aboutDesc')} -

- -
- -
-
- {t('settings.aboutLicense')} - {t('settings.aboutLicenseText')} -
-
- Stack - {t('settings.aboutBuiltWith')} -
-
- {t('settings.aboutMaintainersLabel')} -
- {MAINTAINERS.map(m => ( -
- {m.github} - -
- ))} -
-
-
- {t('settings.aboutReleaseNotesLabel')} - -
-
- -
-
-
-
{t('settings.showChangelogOnUpdate')}
-
{t('settings.showChangelogOnUpdateDesc')}
-
- -
- -
- -
-
- - - } - > -
- {[...CONTRIBUTORS].sort((a, b) => b.contributions.length - a.contributions.length).map(c => ( -
- - {c.github} -
- { e.stopPropagation(); openUrl(`https://github.com/${c.github}`); }} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - e.preventDefault(); - openUrl(`https://github.com/${c.github}`); - } - }} - > - @{c.github} - - - v{c.since} - · - {t('settings.aboutContributorsCount', { count: c.contributions.length })} - -
- -
-
    - {c.contributions.map(item =>
  • {item}
  • )} -
-
- ))} -
-
- - } - > - - - - - )} }
);