mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7db42d74ef
- Add "Reset to defaults" button to column picker in AlbumTrackList, PlaylistDetail, Favorites (all 8 locales)
- Fix device sync cross-OS status detection: store filenameTemplate in manifest, use it for path computation on import
- Redesign filename template UI: preset buttons (Standard/Multi-Disc/Alt. Folder), clickable token chips ({artist}, {album}, etc.), clear button
- Fix suggestions section in PlaylistDetail missing album column rendering
- Add PRIVACY.md documenting all third-party integrations (Last.fm, LRCLIB, Apple Music, Discord); link from README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
|
|
export interface ColDef {
|
|
readonly key: string;
|
|
readonly i18nKey?: string | null;
|
|
readonly minWidth: number;
|
|
readonly defaultWidth: number;
|
|
readonly required: boolean;
|
|
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
|
|
readonly flex?: boolean;
|
|
}
|
|
|
|
function loadPrefs(
|
|
storageKey: string,
|
|
columns: readonly ColDef[],
|
|
): { widths: Record<string, number>; visible: Set<string> } {
|
|
const defaultWidths: Record<string, number> = Object.fromEntries(
|
|
columns.map(c => [c.key, c.defaultWidth]),
|
|
);
|
|
const defaultVisible = new Set<string>(columns.map(c => c.key));
|
|
try {
|
|
const raw = localStorage.getItem(storageKey);
|
|
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
|
|
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[]; known?: string[] };
|
|
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
|
|
columns.filter(c => c.required).forEach(c => visible.add(c.key));
|
|
// Auto-show columns that are new since prefs were last saved.
|
|
// "known" tracks every column seen at save time; absent = newly added column → default to visible.
|
|
if (parsed.known) {
|
|
const known = new Set<string>(parsed.known);
|
|
columns.filter(c => !c.required && !known.has(c.key)).forEach(c => visible.add(c.key));
|
|
}
|
|
const widths = { ...defaultWidths, ...(parsed.widths ?? {}) };
|
|
const durationCol = columns.find(c => c.key === 'duration');
|
|
if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) {
|
|
widths.duration = defaultWidths.duration;
|
|
}
|
|
return { widths, visible };
|
|
} catch {
|
|
return { widths: defaultWidths, visible: defaultVisible };
|
|
}
|
|
}
|
|
|
|
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
|
|
const known = Object.keys(widths);
|
|
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible], known }));
|
|
}
|
|
|
|
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
|
|
const [colWidths, setColWidths] = useState<Record<string, number>>(
|
|
() => loadPrefs(storageKey, columns).widths,
|
|
);
|
|
const [colVisible, setColVisible] = useState<Set<string>>(
|
|
() => loadPrefs(storageKey, columns).visible,
|
|
);
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
const tracklistRef = useRef<HTMLDivElement>(null);
|
|
const pickerRef = useRef<HTMLDivElement>(null);
|
|
// Refs to avoid stale closures in drag/save handlers
|
|
const colWidthsRef = useRef(colWidths);
|
|
const colVisibleRef = useRef(colVisible);
|
|
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
|
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
|
|
|
const visibleCols = useMemo(
|
|
() => columns.filter(c => colVisible.has(c.key)),
|
|
[columns, colVisible],
|
|
);
|
|
|
|
const gridTemplate = useMemo(
|
|
() =>
|
|
visibleCols
|
|
.map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
|
|
.join(' '),
|
|
[visibleCols, colWidths],
|
|
);
|
|
|
|
// Minimum total width so the grid never squishes below its current column sizes.
|
|
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
|
|
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
|
|
const gridMinWidth = useMemo(() => {
|
|
const gapPx = 12; // --space-3
|
|
const boxPaddingH = 24; // var(--space-3) * 2
|
|
const colSum = visibleCols.reduce<number>(
|
|
(s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
|
|
0,
|
|
);
|
|
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
|
|
return colSum + gaps + boxPaddingH;
|
|
}, [visibleCols, colWidths]);
|
|
|
|
const gridStyle = useMemo(
|
|
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
|
|
[gridTemplate, gridMinWidth],
|
|
);
|
|
|
|
// Excel-style column resize:
|
|
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
|
|
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
|
|
const startResize = useCallback(
|
|
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const visCols = visibleCols; // stable for the drag duration
|
|
const colDef = visCols[colIndex];
|
|
const colKey = colDef.key;
|
|
const colMin = columns.find(c => c.key === colKey)!.minWidth;
|
|
const startX = e.clientX;
|
|
const startW = colWidths[colKey];
|
|
|
|
let maxW = Infinity;
|
|
const el = tracklistRef.current;
|
|
if (el) {
|
|
const style = getComputedStyle(el);
|
|
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
|
const containerW = el.clientWidth - paddingH;
|
|
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
|
const gapPx = headerEl
|
|
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
|
|
: 12;
|
|
const totalGaps = (visCols.length - 1) * gapPx;
|
|
const otherFixed = visCols
|
|
.filter((_, i) => i !== colIndex)
|
|
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
|
|
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
|
|
}
|
|
|
|
const onMove = (me: MouseEvent) => {
|
|
const delta = me.clientX - startX;
|
|
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
|
|
setColWidths(prev => ({ ...prev, [colKey]: newW }));
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
|
|
};
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
},
|
|
[columns, visibleCols, colWidths, storageKey],
|
|
);
|
|
|
|
const toggleColumn = useCallback(
|
|
(key: string) => {
|
|
const def = columns.find(c => c.key === key)!;
|
|
if (def.required) return;
|
|
setColVisible(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
savePrefs(storageKey, colWidthsRef.current, next);
|
|
return next;
|
|
});
|
|
},
|
|
[columns, storageKey],
|
|
);
|
|
|
|
const resetColumns = useCallback(() => {
|
|
const defaultWidths = Object.fromEntries(columns.map(c => [c.key, c.defaultWidth]));
|
|
const defaultVisible = new Set(columns.map(c => c.key));
|
|
setColWidths(defaultWidths);
|
|
setColVisible(defaultVisible);
|
|
localStorage.removeItem(storageKey);
|
|
}, [columns, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (!pickerOpen) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [pickerOpen]);
|
|
|
|
return {
|
|
colWidths,
|
|
colVisible,
|
|
visibleCols,
|
|
gridStyle,
|
|
startResize,
|
|
toggleColumn,
|
|
resetColumns,
|
|
pickerOpen,
|
|
setPickerOpen,
|
|
pickerRef,
|
|
tracklistRef,
|
|
};
|
|
}
|