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:
Psychotoxical
2026-04-18 22:43:56 +02:00
parent 89e8f43add
commit 8b7bce5b85
2 changed files with 174 additions and 39 deletions
+167
View File
@@ -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,
)}
</>
);
}
+7 -39
View File
@@ -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} />