mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(settings): G.53 — extract seven customizer components (#618)
Pull the 11 self-contained components at the bottom of Settings.tsx out into `src/components/settings/`: - `HomeCustomizer.tsx` — home-page section visibility toggles. - `QueueToolbarCustomizer.tsx` — drag-to-reorder queue toolbar buttons + per-button visibility. Includes the GripHandle + button icons/labels tables. - `SidebarCustomizer.tsx` — sidebar nav drag-reorder for library + system blocks. Includes the GripHandle and the random-nav-mode toggle. - `LyricsSourcesCustomizer.tsx` — lyrics fetch pipeline UI (mode switch + drag-reorder source list + static-only toggle). - `ArtistLayoutCustomizer.tsx` — artist page section drag-reorder. - `BackupSection.tsx` — export/import buttons with toast feedback. - `ServerGripHandle.tsx` — single-purpose grip handle used by the servers tab in the main Settings body. Each component owns its own DnD plumbing, label tables and drop target types. Settings.tsx now only imports the components; one local `ServerDropTarget` type kept inside `Settings()` because the main component still owns the server-list DnD state. Pure code-move. Settings.tsx: 5298 → 4568 LOC (−730). 13 unused imports trimmed (lucide icons, store types, shallow, layout helpers).
This commit is contained in:
committed by
GitHub
parent
e04980626d
commit
cec175c4bc
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||||
|
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '../../store/artistLayoutStore';
|
||||||
|
|
||||||
|
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
|
||||||
|
bio: 'settings.artistLayoutBio',
|
||||||
|
topTracks: 'settings.artistLayoutTopTracks',
|
||||||
|
similar: 'settings.artistLayoutSimilar',
|
||||||
|
albums: 'settings.artistLayoutAlbums',
|
||||||
|
featured: 'settings.artistLayoutFeatured',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArtistDropTarget = { idx: number; before: boolean } | null;
|
||||||
|
|
||||||
|
function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onMouseDown } = useDragSource(() => ({
|
||||||
|
data: JSON.stringify({ type: 'artist_section_reorder', index: idx }),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sidebar-customizer-grip"
|
||||||
|
data-tooltip={t('settings.sidebarDrag')}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistLayoutCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const sections = useArtistLayoutStore(s => s.sections);
|
||||||
|
const setSections = useArtistLayoutStore(s => s.setSections);
|
||||||
|
const toggleSection = useArtistLayoutStore(s => s.toggleSection);
|
||||||
|
const { isDragging: isPsyDragging } = useDragDrop();
|
||||||
|
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<ArtistDropTarget>(null);
|
||||||
|
const dropTargetRef = useRef<ArtistDropTarget>(null);
|
||||||
|
const sectionsRef = useRef(sections);
|
||||||
|
sectionsRef.current = sections;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||||
|
}, [isPsyDragging]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerEl) 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 !== 'artist_section_reorder' || parsed.index == null) return;
|
||||||
|
|
||||||
|
const fromIdx = parsed.index;
|
||||||
|
const target = dropTargetRef.current;
|
||||||
|
dropTargetRef.current = null; setDropTarget(null);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||||
|
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||||
|
|
||||||
|
const next = [...sectionsRef.current];
|
||||||
|
const [moved] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||||
|
setSections(next);
|
||||||
|
};
|
||||||
|
containerEl.addEventListener('psy-drop', onPsyDrop);
|
||||||
|
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
|
||||||
|
}, [containerEl, setSections]);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPsyDragging || !containerEl) return;
|
||||||
|
const rows = containerEl.querySelectorAll<HTMLElement>('[data-artist-idx]');
|
||||||
|
let target: ArtistDropTarget = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const idx = Number(row.dataset.artistIdx);
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
||||||
|
target = { idx, before: false };
|
||||||
|
}
|
||||||
|
dropTargetRef.current = target;
|
||||||
|
setDropTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||||
|
{t('settings.artistLayoutDesc')}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="settings-card"
|
||||||
|
style={{ padding: '4px 0' }}
|
||||||
|
ref={setContainerEl}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{sections.map((section: ArtistSectionConfig, i) => {
|
||||||
|
const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
|
||||||
|
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
||||||
|
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
data-artist-idx={i}
|
||||||
|
className="sidebar-customizer-row"
|
||||||
|
style={{
|
||||||
|
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||||
|
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtistSectionGripHandle idx={i} label={label} />
|
||||||
|
<span style={{ flex: 1, fontSize: 14, opacity: section.visible ? 1 : 0.45 }}>{label}</span>
|
||||||
|
<label className="toggle-switch" aria-label={label}>
|
||||||
|
<input type="checkbox" checked={section.visible} onChange={() => toggleSection(section.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Download, HardDrive, Upload } from 'lucide-react';
|
||||||
|
import { exportBackup, importBackup } from '../../utils/backup';
|
||||||
|
import { showToast } from '../../utils/toast';
|
||||||
|
|
||||||
|
export function BackupSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const path = await exportBackup();
|
||||||
|
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Export failed', e);
|
||||||
|
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!window.confirm(t('settings.backupImportConfirm'))) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
await importBackup();
|
||||||
|
// importBackup reloads the page — this toast will briefly show before reload
|
||||||
|
showToast(t('settings.backupImportSuccess'), 3000, 'info');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Import failed', e);
|
||||||
|
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<HardDrive size={18} />
|
||||||
|
<h2>{t('settings.backupTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{exporting ? '…' : t('settings.backupExport')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import */}
|
||||||
|
<div className="settings-card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-surface"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
{importing ? '…' : t('settings.backupImport')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useHomeStore, HomeSectionId } from '../../store/homeStore';
|
||||||
|
|
||||||
|
export function HomeCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { sections, toggleSection } = useHomeStore();
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<HomeSectionId, string> = {
|
||||||
|
hero: t('home.hero'),
|
||||||
|
recent: t('home.recent'),
|
||||||
|
discover: t('home.discover'),
|
||||||
|
becauseYouLike: t('home.becauseYouLike'),
|
||||||
|
discoverSongs: t('home.discoverSongs'),
|
||||||
|
discoverArtists: t('home.discoverArtists'),
|
||||||
|
recentlyPlayed: t('home.recentlyPlayed'),
|
||||||
|
starred: t('home.starred'),
|
||||||
|
mostPlayed: t('home.mostPlayed'),
|
||||||
|
losslessAlbums: t('home.losslessAlbums'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||||
|
{sections.map(sec => (
|
||||||
|
<div key={sec.id} className="sidebar-customizer-row">
|
||||||
|
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||||||
|
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||||||
|
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical, Music2 } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import type { LyricsSourceId } from '../../store/authStoreTypes';
|
||||||
|
|
||||||
|
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
|
||||||
|
server: 'settings.lyricsSourceServer',
|
||||||
|
lrclib: 'settings.lyricsSourceLrclib',
|
||||||
|
netease: 'settings.lyricsSourceNetease',
|
||||||
|
};
|
||||||
|
|
||||||
|
type LyricsDropTarget = { idx: number; before: boolean } | null;
|
||||||
|
|
||||||
|
function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onMouseDown } = useDragSource(() => ({
|
||||||
|
data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sidebar-customizer-grip"
|
||||||
|
data-tooltip={t('settings.sidebarDrag')}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LyricsSourcesCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
|
||||||
|
const setLyricsSources = useAuthStore(s => s.setLyricsSources);
|
||||||
|
const lyricsMode = useAuthStore(s => s.lyricsMode);
|
||||||
|
const setLyricsMode = useAuthStore(s => s.setLyricsMode);
|
||||||
|
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||||
|
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
|
||||||
|
const { isDragging: isPsyDragging } = useDragDrop();
|
||||||
|
// useState (not useRef) so the listener-effect re-runs when the container
|
||||||
|
// gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper.
|
||||||
|
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
|
||||||
|
const dropTargetRef = useRef<LyricsDropTarget>(null);
|
||||||
|
const sourcesRef = useRef(lyricsSources);
|
||||||
|
sourcesRef.current = lyricsSources;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||||
|
}, [isPsyDragging]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerEl) 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 !== 'lyrics_source_reorder' || parsed.index == null) return;
|
||||||
|
|
||||||
|
const fromIdx = parsed.index;
|
||||||
|
const target = dropTargetRef.current;
|
||||||
|
dropTargetRef.current = null; setDropTarget(null);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||||
|
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||||
|
|
||||||
|
const next = [...sourcesRef.current];
|
||||||
|
const [moved] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||||
|
setLyricsSources(next);
|
||||||
|
};
|
||||||
|
containerEl.addEventListener('psy-drop', onPsyDrop);
|
||||||
|
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
|
||||||
|
}, [containerEl, setLyricsSources]);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPsyDragging || !containerEl) return;
|
||||||
|
const rows = containerEl.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
|
||||||
|
let target: LyricsDropTarget = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const idx = Number(row.dataset.lyricsIdx);
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
||||||
|
target = { idx, before: false };
|
||||||
|
}
|
||||||
|
dropTargetRef.current = target;
|
||||||
|
setDropTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSource = (id: LyricsSourceId) => {
|
||||||
|
setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Music2 size={18} />
|
||||||
|
<h2>{t('settings.lyricsSourcesTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||||
|
{t('settings.lyricsSourcesDesc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke.
|
||||||
|
YouLyPlus misses silently fall back to the standard pipeline. */}
|
||||||
|
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeLyricsplusDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.lyricsModeLyricsplus')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={lyricsMode === 'lyricsplus'}
|
||||||
|
onChange={e => { if (e.target.checked) setLyricsMode('lyricsplus'); else setLyricsMode('standard'); }}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeStandardDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.lyricsModeStandard')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={lyricsMode === 'standard'}
|
||||||
|
onChange={e => { if (e.target.checked) setLyricsMode('standard'); else setLyricsMode('lyricsplus'); }}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lyricsMode === 'standard' && (
|
||||||
|
<div
|
||||||
|
className="settings-card"
|
||||||
|
style={{ padding: '4px 0', marginBottom: '0.75rem', marginLeft: '1rem' }}
|
||||||
|
ref={setContainerEl}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{lyricsSources.map((src, i) => {
|
||||||
|
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
|
||||||
|
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
||||||
|
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={src.id}
|
||||||
|
data-lyrics-idx={i}
|
||||||
|
className="sidebar-customizer-row"
|
||||||
|
style={{
|
||||||
|
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||||
|
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LyricsSourceGripHandle idx={i} label={label} />
|
||||||
|
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
|
||||||
|
<label className="toggle-switch" aria-label={label}>
|
||||||
|
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
|
||||||
|
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
|
||||||
|
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FolderOpen, GripVertical, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
|
||||||
|
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||||
|
import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore';
|
||||||
|
|
||||||
|
type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
|
||||||
|
|
||||||
|
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
|
||||||
|
shuffle: Shuffle,
|
||||||
|
save: Save,
|
||||||
|
load: FolderOpen,
|
||||||
|
share: Share2,
|
||||||
|
clear: Trash2,
|
||||||
|
separator: null, // No icon for separator
|
||||||
|
gapless: MoveRight,
|
||||||
|
crossfade: Waves,
|
||||||
|
infinite: Infinity,
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
|
||||||
|
shuffle: 'queue.shuffle',
|
||||||
|
save: 'queue.savePlaylist',
|
||||||
|
load: 'queue.loadPlaylist',
|
||||||
|
share: 'queue.shareQueue',
|
||||||
|
clear: 'queue.clear',
|
||||||
|
separator: 'settings.queueToolbarSeparator',
|
||||||
|
gapless: 'queue.gapless',
|
||||||
|
crossfade: 'queue.crossfade',
|
||||||
|
infinite: 'queue.infiniteQueue',
|
||||||
|
};
|
||||||
|
|
||||||
|
function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onMouseDown } = useDragSource(() => ({
|
||||||
|
data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sidebar-customizer-grip"
|
||||||
|
data-tooltip={t('settings.sidebarDrag')}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueToolbarCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { buttons, setButtons, toggleButton } = useQueueToolbarStore();
|
||||||
|
const { isDragging: isPsyDragging } = useDragDrop();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<QueueToolbarDropTarget>(null);
|
||||||
|
const dropTargetRef = useRef<QueueToolbarDropTarget>(null);
|
||||||
|
const buttonsRef = useRef(buttons);
|
||||||
|
buttonsRef.current = buttons;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||||
|
}, [isPsyDragging]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) 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 !== 'queue_toolbar_reorder' || parsed.index == null) return;
|
||||||
|
|
||||||
|
const fromIdx = parsed.index;
|
||||||
|
const target = dropTargetRef.current;
|
||||||
|
dropTargetRef.current = null; setDropTarget(null);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const insertBefore = target.before ? target.idx : target.idx + 1;
|
||||||
|
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
||||||
|
|
||||||
|
const next = [...buttonsRef.current];
|
||||||
|
const [moved] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
||||||
|
setButtons(next);
|
||||||
|
};
|
||||||
|
el.addEventListener('psy-drop', onPsyDrop);
|
||||||
|
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||||
|
}, [setButtons]);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPsyDragging || !containerRef.current) return;
|
||||||
|
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-queue-toolbar-idx]');
|
||||||
|
let target: QueueToolbarDropTarget = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const idx = Number(row.dataset.queueToolbarIdx);
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
||||||
|
target = { idx, before: false };
|
||||||
|
}
|
||||||
|
dropTargetRef.current = target;
|
||||||
|
setDropTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
|
||||||
|
{buttons.map((btn, idx) => {
|
||||||
|
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
|
||||||
|
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
|
||||||
|
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
|
||||||
|
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={btn.id}
|
||||||
|
data-queue-toolbar-idx={idx}
|
||||||
|
className="sidebar-customizer-row"
|
||||||
|
style={{
|
||||||
|
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||||
|
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QueueToolbarGripHandle idx={idx} label={label} />
|
||||||
|
{Icon ? (
|
||||||
|
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
|
||||||
|
<label className="toggle-switch" aria-label={label}>
|
||||||
|
<input type="checkbox" checked={btn.visible} onChange={() => toggleButton(btn.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import { useDragSource } from '../../contexts/DragDropContext';
|
||||||
|
|
||||||
|
export function ServerGripHandle({ idx, label }: { idx: number; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onMouseDown } = useDragSource(() => ({
|
||||||
|
data: JSON.stringify({ type: 'server_reorder', index: idx }),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sidebar-customizer-grip"
|
||||||
|
data-tooltip={t('settings.sidebarDrag')}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useSidebarStore, SidebarItemConfig } from '../../store/sidebarStore';
|
||||||
|
import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||||
|
import { ALL_NAV_ITEMS } from '../../config/navItems';
|
||||||
|
import { applySidebarDropReorder } from '../../utils/sidebarNavReorder';
|
||||||
|
|
||||||
|
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
||||||
|
|
||||||
|
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onMouseDown } = useDragSource(() => ({
|
||||||
|
data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sidebar-customizer-grip"
|
||||||
|
data-tooltip={t('settings.sidebarDrag')}
|
||||||
|
data-tooltip-pos="right"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { items, setItems, toggleItem } = useSidebarStore();
|
||||||
|
const { isDragging: isPsyDragging } = useDragDrop();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
|
||||||
|
const dropTargetRef = useRef<DropTarget>(null);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
itemsRef.current = items;
|
||||||
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
|
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
||||||
|
const luckyMixBase = useLuckyMixAvailable();
|
||||||
|
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||||
|
|
||||||
|
const libraryItems = items.filter(cfg => {
|
||||||
|
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
||||||
|
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
||||||
|
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||||
|
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||||
|
}, [isPsyDragging]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onPsyDrop = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.data) return;
|
||||||
|
let parsed: { type?: string; index?: number; section?: string };
|
||||||
|
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||||
|
if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return;
|
||||||
|
|
||||||
|
const fromIdx = parsed.index;
|
||||||
|
const fromSection = parsed.section as 'library' | 'system';
|
||||||
|
const target = dropTargetRef.current;
|
||||||
|
dropTargetRef.current = null; setDropTarget(null);
|
||||||
|
|
||||||
|
const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode);
|
||||||
|
if (next) setItems(next);
|
||||||
|
};
|
||||||
|
el.addEventListener('psy-drop', onPsyDrop);
|
||||||
|
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||||
|
}, [libraryItems, systemItems, setItems, randomNavMode]);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPsyDragging || !containerRef.current) return;
|
||||||
|
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
|
||||||
|
let target: DropTarget = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const idx = Number(row.dataset.sidebarIdx);
|
||||||
|
const section = row.dataset.sidebarSection as 'library' | 'system';
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; }
|
||||||
|
target = { idx, before: false, section };
|
||||||
|
}
|
||||||
|
dropTargetRef.current = target;
|
||||||
|
setDropTarget(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => {
|
||||||
|
const meta = ALL_NAV_ITEMS[cfg.id];
|
||||||
|
if (!meta) return null;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before;
|
||||||
|
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cfg.id}
|
||||||
|
data-sidebar-idx={localIdx}
|
||||||
|
data-sidebar-section={section}
|
||||||
|
className="sidebar-customizer-row"
|
||||||
|
style={{
|
||||||
|
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||||||
|
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
|
||||||
|
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
|
||||||
|
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
|
||||||
|
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={randomNavMode === 'separate'}
|
||||||
|
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{/* Library block */}
|
||||||
|
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||||
|
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||||||
|
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||||||
|
</div>
|
||||||
|
{/* System block */}
|
||||||
|
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||||
|
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||||||
|
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||||||
|
<div className="sidebar-customizer-fixed-hint">
|
||||||
|
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-746
@@ -1,4 +1,4 @@
|
|||||||
import type { ServerProfile, SeekbarStyle, LyricsSourceId, LyricsSourceConfig, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes';
|
import type { ServerProfile, SeekbarStyle, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes';
|
||||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, MIX_MIN_RATING_FILTER_MAX_STARS, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults';
|
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, MIX_MIN_RATING_FILTER_MAX_STARS, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults';
|
||||||
import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -7,11 +7,10 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
|
PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
|
||||||
Users, UserPlus, Shield, Wand2, Search, Scale, ListMusic, Save, Infinity, Share2, MoveRight
|
Users, UserPlus, Shield, Wand2, Search, Scale, ListMusic
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
@@ -26,9 +25,7 @@ import CustomSelect from '../components/CustomSelect';
|
|||||||
import SettingsSubSection from '../components/SettingsSubSection';
|
import SettingsSubSection from '../components/SettingsSubSection';
|
||||||
import LicensesPanel from '../components/LicensesPanel';
|
import LicensesPanel from '../components/LicensesPanel';
|
||||||
import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
|
import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
|
||||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
|
||||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||||
@@ -37,16 +34,21 @@ import { useFontStore, FontId } from '../store/fontStore';
|
|||||||
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
||||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||||
import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions';
|
import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions';
|
||||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
import { useSidebarStore } from '../store/sidebarStore';
|
||||||
import { useQueueToolbarStore, QueueToolbarButtonId, QueueToolbarButtonConfig } from '../store/queueToolbarStore';
|
import { useQueueToolbarStore } from '../store/queueToolbarStore';
|
||||||
import {
|
import {
|
||||||
effectiveLoudnessPreAnalysisAttenuationDb,
|
effectiveLoudnessPreAnalysisAttenuationDb,
|
||||||
} from '../utils/loudnessPreAnalysisSlider';
|
} from '../utils/loudnessPreAnalysisSlider';
|
||||||
import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore';
|
import { useArtistLayoutStore } from '../store/artistLayoutStore';
|
||||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
import { useHomeStore } from '../store/homeStore';
|
||||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ArtistLayoutCustomizer } from '../components/settings/ArtistLayoutCustomizer';
|
||||||
import { applySidebarDropReorder } from '../utils/sidebarNavReorder';
|
import { BackupSection } from '../components/settings/BackupSection';
|
||||||
|
import { HomeCustomizer } from '../components/settings/HomeCustomizer';
|
||||||
|
import { LyricsSourcesCustomizer } from '../components/settings/LyricsSourcesCustomizer';
|
||||||
|
import { QueueToolbarCustomizer } from '../components/settings/QueueToolbarCustomizer';
|
||||||
|
import { ServerGripHandle } from '../components/settings/ServerGripHandle';
|
||||||
|
import { SidebarCustomizer } from '../components/settings/SidebarCustomizer';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import {
|
import {
|
||||||
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
|
||||||
@@ -1647,6 +1649,7 @@ export default function Settings() {
|
|||||||
const searchResultsListRef = useRef<HTMLUListElement>(null);
|
const searchResultsListRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
// Server-Liste DnD
|
// Server-Liste DnD
|
||||||
|
type ServerDropTarget = { idx: number; before: boolean } | null;
|
||||||
const psyDragState = useDragDrop();
|
const psyDragState = useDragDrop();
|
||||||
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
|
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
|
||||||
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
|
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
|
||||||
@@ -4563,736 +4566,3 @@ const TAB_LABEL_KEY: Record<Tab, string> = {
|
|||||||
users: 'settings.tabUsers',
|
users: 'settings.tabUsers',
|
||||||
};
|
};
|
||||||
|
|
||||||
function HomeCustomizer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { sections, toggleSection } = useHomeStore();
|
|
||||||
|
|
||||||
const SECTION_LABELS: Record<HomeSectionId, string> = {
|
|
||||||
hero: t('home.hero'),
|
|
||||||
recent: t('home.recent'),
|
|
||||||
discover: t('home.discover'),
|
|
||||||
becauseYouLike: t('home.becauseYouLike'),
|
|
||||||
discoverSongs: t('home.discoverSongs'),
|
|
||||||
discoverArtists: t('home.discoverArtists'),
|
|
||||||
recentlyPlayed: t('home.recentlyPlayed'),
|
|
||||||
starred: t('home.starred'),
|
|
||||||
mostPlayed: t('home.mostPlayed'),
|
|
||||||
losslessAlbums: t('home.losslessAlbums'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
|
||||||
{sections.map(sec => (
|
|
||||||
<div key={sec.id} className="sidebar-customizer-row">
|
|
||||||
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
|
||||||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
|
||||||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Queue Toolbar Customizer ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
|
|
||||||
|
|
||||||
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
|
|
||||||
shuffle: Shuffle,
|
|
||||||
save: Save,
|
|
||||||
load: FolderOpen,
|
|
||||||
share: Share2,
|
|
||||||
clear: Trash2,
|
|
||||||
separator: null, // No icon for separator
|
|
||||||
gapless: MoveRight,
|
|
||||||
crossfade: Waves,
|
|
||||||
infinite: Infinity,
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
|
|
||||||
shuffle: 'queue.shuffle',
|
|
||||||
save: 'queue.savePlaylist',
|
|
||||||
load: 'queue.loadPlaylist',
|
|
||||||
share: 'queue.shareQueue',
|
|
||||||
clear: 'queue.clear',
|
|
||||||
separator: 'settings.queueToolbarSeparator',
|
|
||||||
gapless: 'queue.gapless',
|
|
||||||
crossfade: 'queue.crossfade',
|
|
||||||
infinite: 'queue.infiniteQueue',
|
|
||||||
};
|
|
||||||
|
|
||||||
function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onMouseDown } = useDragSource(() => ({
|
|
||||||
data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }),
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="sidebar-customizer-grip"
|
|
||||||
data-tooltip={t('settings.sidebarDrag')}
|
|
||||||
data-tooltip-pos="right"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QueueToolbarCustomizer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { buttons, setButtons, toggleButton } = useQueueToolbarStore();
|
|
||||||
const { isDragging: isPsyDragging } = useDragDrop();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [dropTarget, setDropTarget] = useState<QueueToolbarDropTarget>(null);
|
|
||||||
const dropTargetRef = useRef<QueueToolbarDropTarget>(null);
|
|
||||||
const buttonsRef = useRef(buttons);
|
|
||||||
buttonsRef.current = buttons;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
|
||||||
}, [isPsyDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) 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 !== 'queue_toolbar_reorder' || parsed.index == null) return;
|
|
||||||
|
|
||||||
const fromIdx = parsed.index;
|
|
||||||
const target = dropTargetRef.current;
|
|
||||||
dropTargetRef.current = null; setDropTarget(null);
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
|
||||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
|
||||||
|
|
||||||
const next = [...buttonsRef.current];
|
|
||||||
const [moved] = next.splice(fromIdx, 1);
|
|
||||||
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
|
||||||
setButtons(next);
|
|
||||||
};
|
|
||||||
el.addEventListener('psy-drop', onPsyDrop);
|
|
||||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
|
||||||
}, [setButtons]);
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (!isPsyDragging || !containerRef.current) return;
|
|
||||||
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-queue-toolbar-idx]');
|
|
||||||
let target: QueueToolbarDropTarget = null;
|
|
||||||
for (const row of rows) {
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const idx = Number(row.dataset.queueToolbarIdx);
|
|
||||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
|
||||||
target = { idx, before: false };
|
|
||||||
}
|
|
||||||
dropTargetRef.current = target;
|
|
||||||
setDropTarget(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
|
|
||||||
{buttons.map((btn, idx) => {
|
|
||||||
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
|
|
||||||
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
|
|
||||||
const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before;
|
|
||||||
const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={btn.id}
|
|
||||||
data-queue-toolbar-idx={idx}
|
|
||||||
className="sidebar-customizer-row"
|
|
||||||
style={{
|
|
||||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
|
||||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QueueToolbarGripHandle idx={idx} label={label} />
|
|
||||||
{Icon ? (
|
|
||||||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
|
||||||
) : (
|
|
||||||
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)', flexShrink: 0 }} />
|
|
||||||
)}
|
|
||||||
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
|
|
||||||
<label className="toggle-switch" aria-label={label}>
|
|
||||||
<input type="checkbox" checked={btn.visible} onChange={() => toggleButton(btn.id)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onMouseDown } = useDragSource(() => ({
|
|
||||||
data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }),
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="sidebar-customizer-grip"
|
|
||||||
data-tooltip={t('settings.sidebarDrag')}
|
|
||||||
data-tooltip-pos="right"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lyrics Sources Customizer ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
|
|
||||||
server: 'settings.lyricsSourceServer',
|
|
||||||
lrclib: 'settings.lyricsSourceLrclib',
|
|
||||||
netease: 'settings.lyricsSourceNetease',
|
|
||||||
};
|
|
||||||
|
|
||||||
type LyricsDropTarget = { idx: number; before: boolean } | null;
|
|
||||||
|
|
||||||
type ServerDropTarget = { idx: number; before: boolean } | null;
|
|
||||||
|
|
||||||
function ServerGripHandle({ idx, label }: { idx: number; label: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onMouseDown } = useDragSource(() => ({
|
|
||||||
data: JSON.stringify({ type: 'server_reorder', index: idx }),
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="sidebar-customizer-grip"
|
|
||||||
data-tooltip={t('settings.sidebarDrag')}
|
|
||||||
data-tooltip-pos="right"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onMouseDown } = useDragSource(() => ({
|
|
||||||
data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }),
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="sidebar-customizer-grip"
|
|
||||||
data-tooltip={t('settings.sidebarDrag')}
|
|
||||||
data-tooltip-pos="right"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LyricsSourcesCustomizer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
|
|
||||||
const setLyricsSources = useAuthStore(s => s.setLyricsSources);
|
|
||||||
const lyricsMode = useAuthStore(s => s.lyricsMode);
|
|
||||||
const setLyricsMode = useAuthStore(s => s.setLyricsMode);
|
|
||||||
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
|
||||||
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
|
|
||||||
const { isDragging: isPsyDragging } = useDragDrop();
|
|
||||||
// useState (not useRef) so the listener-effect re-runs when the container
|
|
||||||
// gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper.
|
|
||||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
|
||||||
const [dropTarget, setDropTarget] = useState<LyricsDropTarget>(null);
|
|
||||||
const dropTargetRef = useRef<LyricsDropTarget>(null);
|
|
||||||
const sourcesRef = useRef(lyricsSources);
|
|
||||||
sourcesRef.current = lyricsSources;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
|
||||||
}, [isPsyDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerEl) 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 !== 'lyrics_source_reorder' || parsed.index == null) return;
|
|
||||||
|
|
||||||
const fromIdx = parsed.index;
|
|
||||||
const target = dropTargetRef.current;
|
|
||||||
dropTargetRef.current = null; setDropTarget(null);
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
|
||||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
|
||||||
|
|
||||||
const next = [...sourcesRef.current];
|
|
||||||
const [moved] = next.splice(fromIdx, 1);
|
|
||||||
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
|
||||||
setLyricsSources(next);
|
|
||||||
};
|
|
||||||
containerEl.addEventListener('psy-drop', onPsyDrop);
|
|
||||||
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
|
|
||||||
}, [containerEl, setLyricsSources]);
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (!isPsyDragging || !containerEl) return;
|
|
||||||
const rows = containerEl.querySelectorAll<HTMLElement>('[data-lyrics-idx]');
|
|
||||||
let target: LyricsDropTarget = null;
|
|
||||||
for (const row of rows) {
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const idx = Number(row.dataset.lyricsIdx);
|
|
||||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
|
||||||
target = { idx, before: false };
|
|
||||||
}
|
|
||||||
dropTargetRef.current = target;
|
|
||||||
setDropTarget(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSource = (id: LyricsSourceId) => {
|
|
||||||
setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="settings-section">
|
|
||||||
<div className="settings-section-header">
|
|
||||||
<Music2 size={18} />
|
|
||||||
<h2>{t('settings.lyricsSourcesTitle')}</h2>
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
|
||||||
{t('settings.lyricsSourcesDesc')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke.
|
|
||||||
YouLyPlus misses silently fall back to the standard pipeline. */}
|
|
||||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeLyricsplusDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.lyricsModeLyricsplus')}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={lyricsMode === 'lyricsplus'}
|
|
||||||
onChange={e => { if (e.target.checked) setLyricsMode('lyricsplus'); else setLyricsMode('standard'); }}
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeStandardDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.lyricsModeStandard')}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={lyricsMode === 'standard'}
|
|
||||||
onChange={e => { if (e.target.checked) setLyricsMode('standard'); else setLyricsMode('lyricsplus'); }}
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lyricsMode === 'standard' && (
|
|
||||||
<div
|
|
||||||
className="settings-card"
|
|
||||||
style={{ padding: '4px 0', marginBottom: '0.75rem', marginLeft: '1rem' }}
|
|
||||||
ref={setContainerEl}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
|
||||||
{lyricsSources.map((src, i) => {
|
|
||||||
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
|
|
||||||
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
|
||||||
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={src.id}
|
|
||||||
data-lyrics-idx={i}
|
|
||||||
className="sidebar-customizer-row"
|
|
||||||
style={{
|
|
||||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
|
||||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LyricsSourceGripHandle idx={i} label={label} />
|
|
||||||
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
|
|
||||||
<label className="toggle-switch" aria-label={label}>
|
|
||||||
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
|
|
||||||
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
|
|
||||||
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sidebar Customizer ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
|
|
||||||
|
|
||||||
function SidebarCustomizer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { items, setItems, toggleItem } = useSidebarStore();
|
|
||||||
const { isDragging: isPsyDragging } = useDragDrop();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
|
|
||||||
const dropTargetRef = useRef<DropTarget>(null);
|
|
||||||
const itemsRef = useRef(items);
|
|
||||||
itemsRef.current = items;
|
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
|
||||||
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
|
|
||||||
const luckyMixBase = useLuckyMixAvailable();
|
|
||||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
|
||||||
|
|
||||||
const libraryItems = items.filter(cfg => {
|
|
||||||
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
|
|
||||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
|
|
||||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
|
||||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
|
||||||
}, [isPsyDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onPsyDrop = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail;
|
|
||||||
if (!detail?.data) return;
|
|
||||||
let parsed: { type?: string; index?: number; section?: string };
|
|
||||||
try { parsed = JSON.parse(detail.data); } catch { return; }
|
|
||||||
if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return;
|
|
||||||
|
|
||||||
const fromIdx = parsed.index;
|
|
||||||
const fromSection = parsed.section as 'library' | 'system';
|
|
||||||
const target = dropTargetRef.current;
|
|
||||||
dropTargetRef.current = null; setDropTarget(null);
|
|
||||||
|
|
||||||
const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode);
|
|
||||||
if (next) setItems(next);
|
|
||||||
};
|
|
||||||
el.addEventListener('psy-drop', onPsyDrop);
|
|
||||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
|
||||||
}, [libraryItems, systemItems, setItems, randomNavMode]);
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (!isPsyDragging || !containerRef.current) return;
|
|
||||||
const rows = containerRef.current.querySelectorAll<HTMLElement>('[data-sidebar-idx]');
|
|
||||||
let target: DropTarget = null;
|
|
||||||
for (const row of rows) {
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const idx = Number(row.dataset.sidebarIdx);
|
|
||||||
const section = row.dataset.sidebarSection as 'library' | 'system';
|
|
||||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; }
|
|
||||||
target = { idx, before: false, section };
|
|
||||||
}
|
|
||||||
dropTargetRef.current = target;
|
|
||||||
setDropTarget(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => {
|
|
||||||
const meta = ALL_NAV_ITEMS[cfg.id];
|
|
||||||
if (!meta) return null;
|
|
||||||
const Icon = meta.icon;
|
|
||||||
const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before;
|
|
||||||
const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cfg.id}
|
|
||||||
data-sidebar-idx={localIdx}
|
|
||||||
data-sidebar-section={section}
|
|
||||||
className="sidebar-customizer-row"
|
|
||||||
style={{
|
|
||||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
|
||||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
|
|
||||||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
|
|
||||||
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
|
|
||||||
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={randomNavMode === 'separate'}
|
|
||||||
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{/* Library block */}
|
|
||||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
|
||||||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
|
||||||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
|
||||||
</div>
|
|
||||||
{/* System block */}
|
|
||||||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
|
||||||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
|
||||||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
|
||||||
<div className="sidebar-customizer-fixed-hint">
|
|
||||||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Artist Page Sections Customizer ────────────────────────────────────────
|
|
||||||
|
|
||||||
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
|
|
||||||
bio: 'settings.artistLayoutBio',
|
|
||||||
topTracks: 'settings.artistLayoutTopTracks',
|
|
||||||
similar: 'settings.artistLayoutSimilar',
|
|
||||||
albums: 'settings.artistLayoutAlbums',
|
|
||||||
featured: 'settings.artistLayoutFeatured',
|
|
||||||
};
|
|
||||||
|
|
||||||
type ArtistDropTarget = { idx: number; before: boolean } | null;
|
|
||||||
|
|
||||||
function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onMouseDown } = useDragSource(() => ({
|
|
||||||
data: JSON.stringify({ type: 'artist_section_reorder', index: idx }),
|
|
||||||
label,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="sidebar-customizer-grip"
|
|
||||||
data-tooltip={t('settings.sidebarDrag')}
|
|
||||||
data-tooltip-pos="right"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArtistLayoutCustomizer() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const sections = useArtistLayoutStore(s => s.sections);
|
|
||||||
const setSections = useArtistLayoutStore(s => s.setSections);
|
|
||||||
const toggleSection = useArtistLayoutStore(s => s.toggleSection);
|
|
||||||
const { isDragging: isPsyDragging } = useDragDrop();
|
|
||||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
|
||||||
const [dropTarget, setDropTarget] = useState<ArtistDropTarget>(null);
|
|
||||||
const dropTargetRef = useRef<ArtistDropTarget>(null);
|
|
||||||
const sectionsRef = useRef(sections);
|
|
||||||
sectionsRef.current = sections;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
|
||||||
}, [isPsyDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerEl) 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 !== 'artist_section_reorder' || parsed.index == null) return;
|
|
||||||
|
|
||||||
const fromIdx = parsed.index;
|
|
||||||
const target = dropTargetRef.current;
|
|
||||||
dropTargetRef.current = null; setDropTarget(null);
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const insertBefore = target.before ? target.idx : target.idx + 1;
|
|
||||||
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
|
||||||
|
|
||||||
const next = [...sectionsRef.current];
|
|
||||||
const [moved] = next.splice(fromIdx, 1);
|
|
||||||
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
|
||||||
setSections(next);
|
|
||||||
};
|
|
||||||
containerEl.addEventListener('psy-drop', onPsyDrop);
|
|
||||||
return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
|
|
||||||
}, [containerEl, setSections]);
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (!isPsyDragging || !containerEl) return;
|
|
||||||
const rows = containerEl.querySelectorAll<HTMLElement>('[data-artist-idx]');
|
|
||||||
let target: ArtistDropTarget = null;
|
|
||||||
for (const row of rows) {
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const idx = Number(row.dataset.artistIdx);
|
|
||||||
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
|
||||||
target = { idx, before: false };
|
|
||||||
}
|
|
||||||
dropTargetRef.current = target;
|
|
||||||
setDropTarget(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
|
||||||
{t('settings.artistLayoutDesc')}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="settings-card"
|
|
||||||
style={{ padding: '4px 0' }}
|
|
||||||
ref={setContainerEl}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
|
||||||
{sections.map((section: ArtistSectionConfig, i) => {
|
|
||||||
const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
|
|
||||||
const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
|
|
||||||
const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
data-artist-idx={i}
|
|
||||||
className="sidebar-customizer-row"
|
|
||||||
style={{
|
|
||||||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
|
||||||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArtistSectionGripHandle idx={i} label={label} />
|
|
||||||
<span style={{ flex: 1, fontSize: 14, opacity: section.visible ? 1 : 0.45 }}>{label}</span>
|
|
||||||
<label className="toggle-switch" aria-label={label}>
|
|
||||||
<input type="checkbox" checked={section.visible} onChange={() => toggleSection(section.id)} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackupSection() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const path = await exportBackup();
|
|
||||||
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Export failed', e);
|
|
||||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
if (!window.confirm(t('settings.backupImportConfirm'))) return;
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
await importBackup();
|
|
||||||
// importBackup reloads the page — this toast will briefly show before reload
|
|
||||||
showToast(t('settings.backupImportSuccess'), 3000, 'info');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Import failed', e);
|
|
||||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="settings-section">
|
|
||||||
<div className="settings-section-header">
|
|
||||||
<HardDrive size={18} />
|
|
||||||
<h2>{t('settings.backupTitle')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export */}
|
|
||||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exporting}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<Upload size={14} />
|
|
||||||
{exporting ? '…' : t('settings.backupExport')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Import */}
|
|
||||||
<div className="settings-card">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-surface"
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={importing}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<Download size={14} />
|
|
||||||
{importing ? '…' : t('settings.backupImport')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user