diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 8c33923b..b21305ea 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -44,7 +44,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props const goServerSettings = () => { setMenuOpen(false); - navigate('/settings', { state: { tab: 'server' } }); + navigate('/settings', { state: { tab: 'servers' } }); }; const onTriggerClick = () => { diff --git a/src/components/LastfmIndicator.tsx b/src/components/LastfmIndicator.tsx index 2f9bf1f8..3c70cc7b 100644 --- a/src/components/LastfmIndicator.tsx +++ b/src/components/LastfmIndicator.tsx @@ -18,7 +18,7 @@ export default function LastfmIndicator() {
navigate('/settings', { state: { tab: 'server' } })} + onClick={() => navigate('/settings', { state: { tab: 'integrations' } })} data-tooltip={tooltip} data-tooltip-pos="bottom" > diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index 6f08f559..8a199849 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -23,7 +23,7 @@ export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, s {showSettingsLink && ( + ) : ( +
+
+ )} +
+ {/* Tab navigation */} + {searchQuery && searchHits === 0 && ( +
+ {t('settings.searchNoResults')} +
+ )} + {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && ( <> {/* Audio Output Device */} -
-
- -

{t('settings.audioOutputDevice')}

-
+ } + defaultOpen + >
{IS_MACOS ? (
@@ -1704,14 +1808,13 @@ export default function Settings() { )}
-
+ {/* Native Hi-Res Playback */} -
-
- -

{t('settings.hiResTitle')}

-
+ } + >
@@ -1729,25 +1832,23 @@ export default function Settings() {
-
+ {/* Equalizer */} -
-
- -

{t('settings.eqTitle')}

-
+ } + >
-
+ {/* Replay Gain + Crossfade + Gapless */} -
-
- -

{t('settings.playbackTitle')}

-
+ } + >
{/* Replay Gain */}
@@ -1881,14 +1982,13 @@ export default function Settings() {
-
+ {/* Next Track Buffering */} -
-
- -

{t('settings.nextTrackBufferingTitle')}

-
+ } + >
{t('settings.preloadHotCacheMutualExclusive')} @@ -2063,107 +2163,149 @@ export default function Settings() { )}
-
+ )} - {/* ── General ──────────────────────────────────────────────────────────── */} - {activeTab === 'general' && ( + {/* ── Lyrics ───────────────────────────────────────────────────────────── */} + {activeTab === 'lyrics' && ( <> - {/* App behaviour */} -
-
- -

{t('settings.behavior')}

-
+ } + defaultOpen + > + + + + } + >
-
-
-
{t('settings.showTrayIcon')}
-
{t('settings.showTrayIconDesc')}
-
- +
+ {(['classic', 'apple'] as const).map(style => { + const key = style === 'classic' ? 'Classic' : 'Apple'; + return ( + + ); + })}
-
-
-
-
{t('settings.minimizeToTray')}
-
{t('settings.minimizeToTrayDesc')}
-
- -
- {!IS_WINDOWS && ( - <> -
+
+ + + )} + + {/* ── Integrations ─────────────────────────────────────────────────────── */} + {activeTab === 'integrations' && ( + <> +
+
+ + + )} - {/* Lyrics Sources */} - + {/* ── Personalisation ──────────────────────────────────────────────────── */} + {activeTab === 'personalisation' && ( + <> + } + defaultOpen + action={ + + } + > + + + } + action={ + + } + > + + + + } + action={ + + } + > + + + + )} + + {/* ── Library (legacy 'general' + 'server') ────────────────────────────── */} + {activeTab === 'library' && ( + <> {/* Random Mix */} -
-
- -

{t('settings.randomMixTitle')}

-
+ } + >

{t('settings.randomMixBlacklistDesc')} @@ -2380,14 +2556,13 @@ export default function Settings() { ))}

-
+ - {/* Ratings (single block under Random Mix) */} -
-
- -

{t('settings.ratingsSectionTitle')}

-
+ {/* Ratings */} + } + >
@@ -2485,9 +2660,8 @@ export default function Settings() { )}
-
+ - )} @@ -2495,11 +2669,11 @@ export default function Settings() { {activeTab === 'storage' && ( <> {/* Offline Library (In-App) — includes cache settings */} -
-
- -

{t('settings.offlineDirTitle')}

-
+ } + defaultOpen + >
{t('settings.offlineDirDesc')} @@ -2588,14 +2762,13 @@ export default function Settings() { )}
-
+ {/* ZIP Export & Archiving */} -
-
- -

{t('settings.downloadsTitle')}

-
+ } + >
{t('settings.downloadsFolderDesc')} @@ -2624,43 +2797,18 @@ export default function Settings() {
-
+ )} {/* ── Appearance ───────────────────────────────────────────────────────── */} {activeTab === 'appearance' && ( <> -
-
- -

{t('settings.language')}

-
-
-
- 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') }, - ]} - /> -
-
-
- -
-
- -

{t('settings.theme')}

-
+ } + defaultOpen + >
{theme.enableThemeScheduler && (
@@ -2669,13 +2817,12 @@ export default function Settings() { )} theme.setTheme(v as any)} />
-
+ -
-
- -

{t('settings.themeSchedulerTitle')}

-
+ } + >
@@ -2734,13 +2881,12 @@ export default function Settings() { ); })()}
-
+ -
-
- -

{t('settings.visualOptionsTitle')}

-
+ } + >
@@ -2785,14 +2931,58 @@ export default function Settings() {
+
+
+
+
{t('settings.showArtistImages')}
+
{t('settings.showArtistImagesDesc')}
+
+ +
+ {!IS_WINDOWS && ( + <> +
+
+
+
{t('settings.preloadMiniPlayer')}
+
{t('settings.preloadMiniPlayerDesc')}
+
+ +
+ + )} + {IS_LINUX && !isTilingWm && ( + <> +
+
+
+
{t('settings.useCustomTitlebar')}
+
{t('settings.useCustomTitlebarDesc')}
+
+ +
+ + )}
-
+ -
-
- -

{t('settings.uiScaleTitle')}

-
+ } + >
@@ -2846,13 +3036,12 @@ export default function Settings() { })()}
-
+ -
-
- -

{t('settings.font')}

-
+ } + >
-
+ -
-
- -

{t('settings.fsPlayerSection')}

-
+ } + >
@@ -2947,47 +3135,12 @@ export default function Settings() {
)}
-
+ -
-
- -

{t('settings.sidebarLyricsStyle')}

-
-
-
- {(['classic', 'apple'] as const).map(style => { - const key = style === 'classic' ? 'Classic' : 'Apple'; - return ( - - ); - })} -
-
-
- -
-
- -

{t('settings.seekbarStyle')}

-
+ } + >
{t('settings.seekbarStyleDesc')} @@ -3004,31 +3157,31 @@ export default function Settings() { ))}
-
+ - - - )} {/* ── Input ────────────────────────────────────────────────────────────── */} {activeTab === 'input' && ( <> -
-
- -

{t('settings.tabInput')}

-
-
+ } + defaultOpen + action={ + } + >
{([ @@ -3106,26 +3259,25 @@ export default function Settings() { })}
-
-
+ -
-
- -

{t('settings.globalShortcutsTitle')}

-
-

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

-
+ } + description={t('settings.globalShortcutsNote')} + action={ + } + >
{([ @@ -3194,13 +3346,12 @@ export default function Settings() { })}
-
-
+ )} {/* ── Server ───────────────────────────────────────────────────────────── */} - {activeTab === 'server' && ( + {activeTab === 'servers' && ( <>
@@ -3346,75 +3497,6 @@ export default function Settings() { )}
- {/* Last.fm */} -
-
- -

{t('settings.lfmTitle')}

-
-
- {auth.lastfmSessionKey ? ( - /* ── Connected state ── */ -
-
-
-
-
@{auth.lastfmUsername}
- {lfmUserInfo && ( -
- {t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} - {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })} -
- )} -
- -
-
-
-
{t('settings.scrobbleEnabled')}
-
{t('settings.scrobbleDesc')}
-
- -
-
- ) : lfmState === 'waiting' ? ( - /* ── Waiting for browser auth — auto-polling ── */ -
-
-
- {t('settings.lfmConnecting')} -
- -
- ) : ( - /* ── Not connected ── */ -
-

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

- {lfmState === 'error' && ( -

{lfmError}

- )} - -
- )} -
-
-
+ + ))} +
{t('settings.aboutReleaseNotesLabel')} @@ -3513,74 +3691,22 @@ export default function Settings() { {t('settings.aboutReleaseNotesLink')}
-
- +
- {contributorsOpen && ( -
- {CONTRIBUTORS.map(c => ( -
- {c.github} -
-
- - - v{c.since} - -
-
    - {c.contributions.map(item =>
  • {item}
  • )} -
-
-
- ))} -
- )} - -
- {t('settings.aboutSpecialThanksLabel')} -
- {SPECIAL_THANKS.map(s => ( -
- {s.github} - - — {s.reason} -
- ))} -
+
+
+
+
{t('settings.showChangelogOnUpdate')}
+
{t('settings.showChangelogOnUpdateDesc')}
+
@@ -3594,34 +3720,64 @@ export default function Settings() {
-
+ + + } + > +
+ {[...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}
  • )} +
+
+ ))} +
+
- )} ); } -// ─── Changelog renderer ─────────────────────────────────────────────────────── - -function renderInline(text: string): React.ReactNode[] { - // Splits on **bold**, *italic*, `code` and renders each part. - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); - return parts.map((part, i) => { - if (part.startsWith('**') && part.endsWith('**')) - return {part.slice(2, -2)}; - if (part.startsWith('*') && part.endsWith('*')) - return {part.slice(1, -1)}; - if (part.startsWith('`') && part.endsWith('`')) - return {part.slice(1, -1)}; - return part; - }); -} - function HomeCustomizer() { const { t } = useTranslation(); - const { sections, toggleSection, reset } = useHomeStore(); + const { sections, toggleSection } = useHomeStore(); const SECTION_LABELS: Record = { hero: t('home.hero'), @@ -3634,33 +3790,17 @@ function HomeCustomizer() { }; return ( -
-
- -

{t('settings.homeCustomizerTitle')}

-
-
- -
- {sections.map(sec => ( -
- {SECTION_LABELS[sec.id]} - -
- ))} +
+ {sections.map(sec => ( +
+ {SECTION_LABELS[sec.id]} +
-
-
+ ))} + ); } @@ -3889,7 +4029,7 @@ type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' function SidebarCustomizer() { const { t } = useTranslation(); - const { items, setItems, toggleItem, reset } = useSidebarStore(); + const { items, setItems, toggleItem } = useSidebarStore(); const { isDragging: isPsyDragging } = useDragDrop(); const containerRef = useRef(null); const [dropTarget, setDropTarget] = useState(null); @@ -3993,19 +4133,7 @@ function SidebarCustomizer() { }; return ( -
-
- -

{t('settings.sidebarTitle')}

- -
+ <>
@@ -4037,7 +4165,7 @@ function SidebarCustomizer() {
-
+ ); } @@ -4076,7 +4204,6 @@ function ArtistLayoutCustomizer() { const sections = useArtistLayoutStore(s => s.sections); const setSections = useArtistLayoutStore(s => s.setSections); const toggleSection = useArtistLayoutStore(s => s.toggleSection); - const reset = useArtistLayoutStore(s => s.reset); const { isDragging: isPsyDragging } = useDragDrop(); const [containerEl, setContainerEl] = useState(null); const [dropTarget, setDropTarget] = useState(null); @@ -4129,19 +4256,7 @@ function ArtistLayoutCustomizer() { }; return ( -
-
- -

{t('settings.artistLayoutTitle')}

- -
+ <>

{t('settings.artistLayoutDesc')}

@@ -4175,7 +4290,7 @@ function ArtistLayoutCustomizer() { ); })} -
+ ); } @@ -4259,68 +4374,3 @@ function BackupSection() { ); } -function ChangelogSection() { - const { t } = useTranslation(); - const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate); - const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate); - - const versions = useMemo(() => { - const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## [')); - return blocks.map(block => { - const lines = block.split('\n'); - const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18" - const versionMatch = headerLine.match(/## \[([^\]]+)\]/); - const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/); - const version = versionMatch?.[1] ?? ''; - const date = dateMatch?.[1] ?? ''; - - // Parse the rest into rendered lines - const body = lines.slice(1).join('\n').trim(); - return { version, date, body }; - }); - }, []); - - return ( -
-
- -

{t('settings.changelog')}

-
-
-
-
{t('settings.showChangelogOnUpdate')}
-
{t('settings.showChangelogOnUpdateDesc')}
-
- -
-
- {versions.slice(0, 3).map(({ version, date, body }) => ( -
- - v{version} - {date} - -
- {body.split('\n').map((line, i) => { - if (line.startsWith('### ')) { - return
{renderInline(line.slice(4))}
; - } - if (line.startsWith('#### ')) { - return
{renderInline(line.slice(5))}
; - } - if (line.startsWith('- ')) { - return
{renderInline(line.slice(2))}
; - } - if (line.trim() === '') return null; - return
{renderInline(line)}
; - })} -
-
- ))} -
-
- ); -} diff --git a/src/styles/components.css b/src/styles/components.css index 2aef9a5c..4cdcac08 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2449,6 +2449,296 @@ color: var(--text-primary); } +/* ── Settings Sub-Section (Accordion) ────────────────────────────────────── + Gruppiert verwandte Einstellungen innerhalb eines Tabs. Nutzt
+ fuer gratis Keyboard + ARIA. */ +.settings-sub-section { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); + overflow: hidden; +} + +.settings-sub-section-summary { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + cursor: pointer; + list-style: none; + user-select: none; + transition: background 120ms ease; +} + +.settings-sub-section-summary::-webkit-details-marker { + display: none; +} + +.settings-sub-section-summary:hover { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.settings-sub-section-icon { + display: inline-flex; + color: var(--accent); + flex-shrink: 0; +} + +.settings-sub-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + flex: 1; + min-width: 0; +} + +.settings-sub-section-action { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.settings-sub-section-chevron { + color: var(--text-muted); + flex-shrink: 0; + transition: transform 180ms ease; +} + +.settings-sub-section[open] > .settings-sub-section-summary .settings-sub-section-chevron { + transform: rotate(180deg); +} + +.settings-sub-section-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; + margin: 0; + padding: 0 var(--space-3) var(--space-2); +} + +.settings-sub-section-content { + padding: var(--space-2) var(--space-3) var(--space-3); +} + +/* Wenn das Accordion eine
wrappt (Legacy- + Content, der noch seine eigenen Header traegt), blenden wir den internen + Section-Header aus — der Accordion-Summary uebernimmt dessen Rolle. */ +.settings-sub-section-content > .settings-section { + margin-bottom: 0; +} +.settings-sub-section-content > .settings-section > .settings-section-header { + display: none; +} + +/* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */ +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: 1.5rem; +} + +.settings-header .page-title { + margin-bottom: 0; +} + +.settings-search { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.settings-search-wrap { + position: relative; + display: flex; + align-items: center; + width: 240px; +} + +.settings-search-icon { + position: absolute; + left: 10px; + color: var(--text-muted); + pointer-events: none; +} + +.settings-search-input { + width: 100%; + padding: 6px 32px; + font-size: 13px; +} + +.settings-search-clear { + position: absolute; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.settings-search-clear:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.settings-sub-section--hidden { + display: none; +} + +.settings-search-empty { + padding: var(--space-3) var(--space-4); + font-size: 13px; + color: var(--text-muted); + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px dashed var(--border-subtle); + text-align: center; +} + +/* ── Mitwirkende: Card-Grid mit klappbaren Beitragslisten ────────────────── */ +.contributors-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: var(--space-2); +} + +.contributor-card { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + transition: border-color 120ms ease, background 120ms ease; +} + +.contributor-card[open] { + grid-column: 1 / -1; + background: color-mix(in srgb, var(--accent) 4%, var(--bg-elevated)); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-subtle)); +} + +.contributor-card-summary { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + cursor: pointer; + list-style: none; + user-select: none; +} + +.contributor-card-summary::-webkit-details-marker { + display: none; +} + +.contributor-card-summary:hover { + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.contributor-card-avatar { + border-radius: 50%; + flex-shrink: 0; +} + +.contributor-card-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.contributor-card-name { + color: var(--accent); + font-weight: 600; + font-size: 13px; + cursor: pointer; + width: fit-content; +} + +.contributor-card-name:hover { + text-decoration: underline; +} + +.contributor-card-sub { + display: flex; + gap: 6px; + align-items: center; + font-size: 11px; + color: var(--text-muted); +} + +.contributor-card-since { + background: var(--accent-dim); + color: var(--accent); + padding: 1px 6px; + border-radius: 99px; + font-weight: 600; +} + +.contributor-card-chevron { + color: var(--text-muted); + flex-shrink: 0; + transition: transform 180ms ease; +} + +.contributor-card[open] .contributor-card-chevron { + transform: rotate(180deg); +} + +.contributor-card-list { + margin: 0; + padding: 0 var(--space-3) var(--space-3) calc(var(--space-3) + 40px); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.7; +} + +/* ── Datenschutz-Hinweis-Banner (z.B. oben im Integrations-Tab) ──────────── */ +.settings-privacy-notice { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-warning, #f59e0b) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 40%, transparent); + color: var(--text-primary); +} + +.settings-privacy-notice-icon { + color: var(--color-warning, #f59e0b); + flex-shrink: 0; + margin-top: 2px; +} + +.settings-privacy-notice-title { + font-weight: 600; + font-size: 13px; + margin-bottom: var(--space-1); +} + +.settings-privacy-notice-body { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.55; +} + +.settings-privacy-notice-body strong { + color: var(--text-primary); + font-weight: 600; +} + .settings-card { background: var(--bg-card); border: 1px solid var(--border-subtle);