mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(ux): year filter as portal popover
Replaces the inline From/To number inputs in the Albums header with a single button that opens a popover — same pattern as the genre filter. Button shows the active range (e.g. 2020–2024) with accent styling. Header is now a homogeneous row of buttons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { CalendarRange, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (from: string, to: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
|
||||||
|
export default function YearFilterButton({ from, to, onChange }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fromRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fromNum = parseInt(from, 10);
|
||||||
|
const toNum = parseInt(to, 10);
|
||||||
|
const active = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||||
|
|
||||||
|
const updatePopStyle = () => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
const MARGIN = 6;
|
||||||
|
const WIDTH = 260;
|
||||||
|
const MAX_H = 200;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||||
|
const spaceAbove = rect.top - MARGIN;
|
||||||
|
const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(rect.left, 8),
|
||||||
|
window.innerWidth - WIDTH - 8,
|
||||||
|
);
|
||||||
|
setPopStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left,
|
||||||
|
width: WIDTH,
|
||||||
|
...(useAbove
|
||||||
|
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||||
|
: { top: rect.bottom + MARGIN }),
|
||||||
|
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||||
|
zIndex: 99998,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePopStyle();
|
||||||
|
setTimeout(() => fromRef.current?.focus(), 0);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onResize = () => updatePopStyle();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('scroll', onResize, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('scroll', onResize, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
!triggerRef.current?.contains(e.target as Node) &&
|
||||||
|
!popRef.current?.contains(e.target as Node)
|
||||||
|
) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
onChange('', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarRange size={14} />
|
||||||
|
{active ? `${fromNum}–${toNum}` : t('albums.yearFilterLabel')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
className="genre-filter-popover"
|
||||||
|
style={popStyle}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div style={{ padding: '0.75rem 0.75rem 0.5rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
|
||||||
|
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{t('albums.yearFrom')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fromRef}
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1900}
|
||||||
|
max={CURRENT_YEAR}
|
||||||
|
placeholder="1970"
|
||||||
|
value={from}
|
||||||
|
onChange={e => onChange(e.target.value, to)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ alignSelf: 'flex-end', paddingBottom: '0.4rem', color: 'var(--text-muted)' }}>–</span>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, gap: '0.2rem' }}>
|
||||||
|
<label style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{t('albums.yearTo')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1900}
|
||||||
|
max={CURRENT_YEAR}
|
||||||
|
placeholder={String(CURRENT_YEAR)}
|
||||||
|
value={to}
|
||||||
|
onChange={e => onChange(from, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div className="genre-filter-popover__footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={clear}
|
||||||
|
style={{ padding: '0.3rem 0.55rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
{t('albums.yearFilterClear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-38
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
|
import YearFilterButton from '../components/YearFilterButton';
|
||||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -11,13 +12,12 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3 } from 'lucide-react';
|
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3 } from 'lucide-react';
|
||||||
|
|
||||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||||
type CompFilter = 'all' | 'only' | 'hide';
|
type CompFilter = 'all' | 'only' | 'hide';
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
const CURRENT_YEAR = new Date().getFullYear();
|
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
||||||
@@ -191,8 +191,6 @@ export default function Albums() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore]);
|
}, [loadMore]);
|
||||||
|
|
||||||
const clearYear = () => { setYearFrom(''); setYearTo(''); };
|
|
||||||
|
|
||||||
const sortOptions: { value: SortType; label: string }[] = [
|
const sortOptions: { value: SortType; label: string }[] = [
|
||||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||||
@@ -231,41 +229,11 @@ export default function Albums() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<YearFilterButton
|
||||||
<span style={{ fontSize: 14, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
from={yearFrom}
|
||||||
{t('albums.yearFilterLabel')}
|
to={yearTo}
|
||||||
</span>
|
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
min={1900}
|
|
||||||
max={CURRENT_YEAR}
|
|
||||||
placeholder={t('albums.yearFrom')}
|
|
||||||
value={yearFrom}
|
|
||||||
onChange={e => setYearFrom(e.target.value)}
|
|
||||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: 14, color: 'var(--text-muted)' }}>–</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
min={1900}
|
|
||||||
max={CURRENT_YEAR}
|
|
||||||
placeholder={t('albums.yearTo')}
|
|
||||||
value={yearTo}
|
|
||||||
onChange={e => setYearTo(e.target.value)}
|
|
||||||
style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }}
|
|
||||||
/>
|
|
||||||
{yearActive && (
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={clearYear}
|
|
||||||
data-tooltip={t('albums.yearFilterClear')}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user