mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Per bcorporaal's request in #252: album-oriented listeners want a cleaner artist page focused on the album grid, with the freedom to reorder/hide bio, top tracks, and similar artists. Five sections are now reorder- and toggle-able from Settings → Personalisation → "Artist page sections": Bio, Top tracks, Similar artists, Albums, Also featured on. The fixed header block (artist photo, name, action buttons) stays at the top. Implementation reuses the existing sidebar customizer pattern: - new `src/store/artistLayoutStore.ts` modelled on `sidebarStore.ts` with the same persist + onRehydrateStorage migration so future-added sections don't silently disappear from existing users' configs. - `ArtistDetail.tsx` render block refactored from a flat sequence of conditional JSX into a `renderableSectionIds.map()` driven by the store. The "first rendered section gets marginTop: 0" rule now works regardless of the configured order — both hidden-by-toggle and empty-data sections are filtered before the layout decides what's first. - `ArtistLayoutCustomizer` component in Settings, lifted from `LyricsSourcesCustomizer` (drag via `useDragSource` + `psy-drop` event, per-row toggle, reset button). - 7 new i18n keys per locale across all 8 languages. The store hook MUST stay above the loading / !artist early returns — otherwise React's hook call order mismatches between renders and the component fails silently. Lesson learned during the refactor. Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
c40243dfea
commit
f7a721b7b1
@@ -30,6 +30,7 @@ import { useFontStore, FontId } from '../store/fontStore';
|
||||
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
|
||||
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
|
||||
import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore';
|
||||
import { useHomeStore, HomeSectionId } from '../store/homeStore';
|
||||
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
@@ -2560,6 +2561,8 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
<SidebarCustomizer />
|
||||
|
||||
<ArtistLayoutCustomizer />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3592,6 +3595,144 @@ function SidebarCustomizer() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 reset = useArtistLayoutStore(s => s.reset);
|
||||
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 (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Users size={18} />
|
||||
<h2>{t('settings.artistLayoutTitle')}</h2>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||||
onClick={reset}
|
||||
data-tooltip={t('settings.artistLayoutReset')}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupSection() {
|
||||
const { t } = useTranslation();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user