From 8b7bce5b852e1591048410cd73caf149444f26da Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 18 Apr 2026 22:43:56 +0200 Subject: [PATCH] feat(ux): year filter as portal popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/YearFilterButton.tsx | 167 ++++++++++++++++++++++++++++ src/pages/Albums.tsx | 46 ++------ 2 files changed, 174 insertions(+), 39 deletions(-) create mode 100644 src/components/YearFilterButton.tsx diff --git a/src/components/YearFilterButton.tsx b/src/components/YearFilterButton.tsx new file mode 100644 index 00000000..7748b893 --- /dev/null +++ b/src/components/YearFilterButton.tsx @@ -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({}); + + const triggerRef = useRef(null); + const popRef = useRef(null); + const fromRef = useRef(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 ( + <> + + + {open && createPortal( +
+
+
+
+ + onChange(e.target.value, to)} + /> +
+ +
+ + onChange(from, e.target.value)} + /> +
+
+
+ + {active && ( +
+ +
+ )} +
, + document.body, + )} + + ); +} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index afc9d1bc..8495ed67 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; +import YearFilterButton from '../components/YearFilterButton'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -11,13 +12,12 @@ import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/toast'; 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 CompFilter = 'all' | 'only' | 'hide'; const PAGE_SIZE = 30; -const CURRENT_YEAR = new Date().getFullYear(); function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; @@ -191,8 +191,6 @@ export default function Albums() { return () => observer.disconnect(); }, [loadMore]); - const clearYear = () => { setYearFrom(''); setYearTo(''); }; - const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, @@ -231,41 +229,11 @@ export default function Albums() { ))} -
- - {t('albums.yearFilterLabel')} - - setYearFrom(e.target.value)} - style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }} - /> - - setYearTo(e.target.value)} - style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }} - /> - {yearActive && ( - - )} -
+ { setYearFrom(from); setYearTo(to); }} + />