gongxiangp2/components/home/FilterPanel.tsx

378 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
export interface CategoryItem {
id: string
name: string
count: number
}
export interface MonthItem {
value: string
label: string
count: number
}
export interface FacetTotals {
materialType: number
category: number
scene: number
platform: number
productCategory: number
month: number
}
interface FilterPanelProps {
selectedMaterialType: string
setSelectedMaterialType: (value: string) => void
selectedCategory: string
setSelectedCategory: (value: string) => void
selectedScene: string
setSelectedScene: (value: string) => void
selectedPlatform: string
setSelectedPlatform: (value: string) => void
selectedProductCategory: string
setSelectedProductCategory: (value: string) => void
selectedMonth: string
setSelectedMonth: (value: string) => void
sortBy: string
setSortBy: (value: string) => void
sortOrder: 'desc' | 'asc'
setSortOrder: (value: 'desc' | 'asc') => void
materialTypes: { value: string; label: string; count?: number }[]
categories: CategoryItem[]
scenes: CategoryItem[]
platforms: CategoryItem[]
productCategories: CategoryItem[]
months: MonthItem[]
totalMaterialsCount: number
materialsCount: number
facetTotals?: FacetTotals
isFilterCollapsed: boolean
setIsFilterCollapsed: (value: boolean) => void
themeColor: string
facetsLoading?: boolean
}
function FilterPanel(props: FilterPanelProps) {
const {
selectedMaterialType, setSelectedMaterialType,
selectedCategory, setSelectedCategory,
selectedScene, setSelectedScene,
selectedPlatform, setSelectedPlatform,
selectedProductCategory, setSelectedProductCategory,
selectedMonth, setSelectedMonth,
sortBy, setSortBy, sortOrder, setSortOrder,
materialTypes, categories, scenes, platforms, productCategories, months,
totalMaterialsCount, materialsCount, facetTotals,
isFilterCollapsed, setIsFilterCollapsed,
facetsLoading = false,
} = props
const handleSortChange = useCallback((newSortBy: string) => {
if (sortBy === newSortBy) {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')
} else {
setSortBy(newSortBy)
setSortOrder('desc')
}
}, [sortBy, sortOrder, setSortBy, setSortOrder])
const totals = {
materialType: facetTotals?.materialType ?? totalMaterialsCount,
category: facetTotals?.category ?? totalMaterialsCount,
scene: facetTotals?.scene ?? totalMaterialsCount,
platform: facetTotals?.platform ?? totalMaterialsCount,
productCategory: facetTotals?.productCategory ?? totalMaterialsCount,
month: facetTotals?.month ?? totalMaterialsCount,
}
const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length
const tabBase = 'relative shrink-0 whitespace-nowrap pb-3 text-[15px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm'
const tabActive = 'text-[#1a1c1d]'
const tabInactive = 'text-[#86898e] hover:text-[#1a1c1d]'
const chipBase = 'text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm'
const chipActive = 'text-[#1a1c1d]'
const chipInactive = 'text-[#86898e] hover:text-[#1a1c1d]'
const countMuted = (active: boolean) => (active ? 'text-[#6b7075]' : 'text-[#86898e]')
const countText = (value: number) => (facetsLoading ? '…' : value.toLocaleString('zh-CN'))
const rankedTypes = useMemo(() => {
const hasCounts = materialTypes.some((type) => (type.count || 0) > 0)
if (!hasCounts) return materialTypes
return [...materialTypes].sort((left, right) => (right.count || 0) - (left.count || 0))
}, [materialTypes])
const rankByCount = useCallback(<T extends { count: number }>(items: T[]) => {
if (!items.some((item) => (item.count || 0) > 0)) return items
return [...items].sort((left, right) => (right.count || 0) - (left.count || 0))
}, [])
const rankedCategories = useMemo(() => rankByCount(categories), [categories, rankByCount])
const rankedScenes = useMemo(() => rankByCount(scenes), [scenes, rankByCount])
const rankedPlatforms = useMemo(() => rankByCount(platforms), [platforms, rankByCount])
const rankedProductCategories = useMemo(() => rankByCount(productCategories), [productCategories, rankByCount])
const yearFacets = useMemo(() => {
const yearMap = new Map<string, number>()
for (const item of months) {
const year = item.value.slice(0, 4)
if (!/^\d{4}$/.test(year)) continue
yearMap.set(year, (yearMap.get(year) || 0) + item.count)
}
return Array.from(yearMap.entries())
.map(([year, count]) => ({ year, count }))
.sort((left, right) => right.year.localeCompare(left.year))
}, [months])
const focusedYear = selectedMonth.slice(0, 4) || yearFacets[0]?.year || ''
const monthsInFocusedYear = useMemo(
() => months.filter((item) => item.value.startsWith(`${focusedYear}-`)).sort((left, right) => right.value.localeCompare(left.value)),
[months, focusedYear],
)
const rowRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const moreWrapRef = useRef<HTMLDivElement>(null)
const [fitCount, setFitCount] = useState<number | null>(null)
const [moreOpen, setMoreOpen] = useState(false)
const measureFit = useCallback(() => {
const row = rowRef.current
const measure = measureRef.current
if (!row || !measure) return
const available = row.clientWidth
if (available < 8) {
setFitCount(null)
return
}
const kids = Array.from(measure.children) as HTMLElement[]
if (kids.length < 2) {
setFitCount(rankedTypes.length)
return
}
const moreWidth = kids[kids.length - 1].offsetWidth
const typeEls = kids.slice(1, -1)
const gap = 32
let used = kids[0].offsetWidth
let count = 0
for (let index = 0; index < typeEls.length; index += 1) {
const remaining = typeEls.length - (index + 1)
const extra = remaining > 0 ? gap + moreWidth : 0
if (used + gap + typeEls[index].offsetWidth + extra <= available) {
used += gap + typeEls[index].offsetWidth
count += 1
} else {
break
}
}
setFitCount(count)
}, [rankedTypes.length])
useLayoutEffect(() => {
measureFit()
const row = rowRef.current
if (!row || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => measureFit())
observer.observe(row)
return () => observer.disconnect()
}, [measureFit, rankedTypes, totals.materialType, facetsLoading])
const { visibleTypes, overflowTypes } = useMemo(() => {
if (fitCount === null || fitCount >= rankedTypes.length) {
return { visibleTypes: rankedTypes, overflowTypes: [] as typeof rankedTypes }
}
let visible = rankedTypes.slice(0, fitCount)
let overflow = rankedTypes.slice(fitCount)
if (selectedMaterialType) {
const hiddenSelected = overflow.find((type) => type.value === selectedMaterialType)
if (hiddenSelected && visible.length > 0) {
const displaced = visible[visible.length - 1]
visible = [...visible.slice(0, -1), hiddenSelected]
overflow = [displaced, ...overflow.filter((type) => type.value !== selectedMaterialType)]
}
}
return { visibleTypes: visible, overflowTypes: overflow }
}, [fitCount, rankedTypes, selectedMaterialType])
useEffect(() => {
if (!moreOpen) return
const onPointer = (event: MouseEvent) => {
if (!moreWrapRef.current?.contains(event.target as Node)) setMoreOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMoreOpen(false)
}
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [moreOpen])
useEffect(() => {
setMoreOpen(false)
}, [selectedMaterialType])
const renderTypeTab = (label: string, count: number, active: boolean, onClick: () => void) => (
<button onClick={onClick} className={`${tabBase} ${active ? tabActive : tabInactive}`}>
{label}
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(active)}`}>{countText(count)}</span>
{active && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
</button>
)
const moreActive = overflowTypes.some((type) => type.value === selectedMaterialType)
return (
<>
<div className="mb-6">
<div className="flex items-end justify-between gap-6 border-b border-[#e7e8ea]">
<div ref={rowRef} className="relative min-w-0 flex-1">
<div className="flex items-end gap-x-8" aria-busy={facetsLoading}>
{renderTypeTab('全部', totals.materialType, selectedMaterialType === '', () => setSelectedMaterialType(''))}
{visibleTypes.map((type) => (
<span key={type.value}>
{renderTypeTab(type.label, type.count || 0, selectedMaterialType === type.value, () => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value))}
</span>
))}
{overflowTypes.length > 0 && (
<div ref={moreWrapRef} className="relative shrink-0">
<button
type="button"
onClick={() => setMoreOpen((open) => !open)}
aria-expanded={moreOpen}
aria-haspopup="listbox"
className={`${tabBase} ${moreActive || moreOpen ? tabActive : tabInactive}`}
>
更多
<span className={`ml-2 font-en text-[14px] tabular-nums ${countMuted(moreActive || moreOpen)}`}>{overflowTypes.length}</span>
{(moreActive || moreOpen) && <span aria-hidden="true" className="absolute inset-x-0 -bottom-px h-px bg-[#1a1c1d]" />}
</button>
{moreOpen && (
<div role="listbox" className="absolute right-0 top-full z-40 mt-2 max-h-[320px] min-w-[240px] overflow-y-auto border border-[#e7e8ea] bg-white py-1">
{overflowTypes.map((type) => {
const active = selectedMaterialType === type.value
return (
<button
key={type.value}
role="option"
aria-selected={active}
onClick={() => setSelectedMaterialType(active ? '' : type.value)}
className={`flex w-full items-baseline justify-between gap-6 px-4 py-2.5 text-left text-[14px] tracking-[-0.02em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 ${active ? 'text-[#1a1c1d]' : 'text-[#6b7075] hover:bg-[#fafafa] hover:text-[#1a1c1d]'}`}
>
<span>{type.label}</span>
<span className={`font-en text-[13px] tabular-nums ${countMuted(active)}`}>{countText(type.count || 0)}</span>
</button>
)
})}
</div>
)}
</div>
)}
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
<span key={item} className="mb-2 h-4 w-16 bg-[#eceef0]" aria-hidden="true" />
))}
</div>
<div aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden">
<div ref={measureRef} className="flex w-max items-end gap-x-8 whitespace-nowrap">
<span className={tabBase}>全部<span className="ml-2 font-en text-[14px] tabular-nums">{countText(totals.materialType)}</span></span>
{rankedTypes.map((type) => (
<span key={type.value} className={tabBase}>{type.label}<span className="ml-2 font-en text-[14px] tabular-nums">{countText(type.count || 0)}</span></span>
))}
<span className={tabBase}>更多<span className="ml-2 font-en text-[14px] tabular-nums">00</span></span>
</div>
</div>
</div>
<button
onClick={() => setIsFilterCollapsed(!isFilterCollapsed)}
aria-label={isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
className="mb-2 shrink-0 whitespace-nowrap bg-[#fafafa] pl-4 text-[13px] tracking-[-0.02em] text-[#6b7075] hover:text-[#1a1c1d] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
>
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1.5 font-en text-[11px] text-[#5a66b8]">{activeFilterCount}</span>}
</button>
</div>
</div>
<div className="mb-8 flex flex-wrap items-baseline justify-between gap-3">
<p className="text-[13px] text-[#86898e]">
共 <span className="font-en tabular-nums text-[#6b7075]">{facetsLoading ? '…' : materialsCount.toLocaleString('zh-CN')}</span> 条
{selectedMaterialType && <span className="sr-only">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
</p>
<div className="flex items-center gap-4 text-[13px]" role="group" aria-labelledby="materials-sort-label">
<h2 id="materials-sort-label" className="sr-only">排序方式</h2>
<button onClick={() => handleSortChange('createdAt')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'createdAt' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
{sortBy === 'createdAt' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
<button onClick={() => handleSortChange('downloadCount')} className={`flex items-center gap-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${sortBy === 'downloadCount' ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'}`}>
{sortBy === 'downloadCount' && sortOrder === 'asc' ? '下载最少' : '下载最多'}
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
</div>
</div>
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">使用店铺</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedCategory('')} className={`${chipBase} ${selectedCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === '')}`}>{countText(totals.category)}</span></button>
{rankedCategories.map((item) => <button key={item.id} onClick={() => setSelectedCategory(item.name === selectedCategory ? '' : item.name)} className={`${chipBase} ${selectedCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">场景类别</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedScene('')} className={`${chipBase} ${selectedScene === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === '')}`}>{countText(totals.scene)}</span></button>
{rankedScenes.map((item) => <button key={item.id} onClick={() => setSelectedScene(item.name === selectedScene ? '' : item.name)} className={`${chipBase} ${selectedScene === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedScene === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>
{(rankedPlatforms.length > 0 || facetsLoading) && <div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">平台分类</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedPlatform('')} className={`${chipBase} ${selectedPlatform === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === '')}`}>{countText(totals.platform)}</span></button>
{rankedPlatforms.map((item) => <button key={item.id} onClick={() => setSelectedPlatform(item.name === selectedPlatform ? '' : item.name)} className={`${chipBase} ${selectedPlatform === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedPlatform === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>}
{(rankedProductCategories.length > 0 || facetsLoading) && <div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">产品类别</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedProductCategory('')} className={`${chipBase} ${selectedProductCategory === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === '')}`}>{countText(totals.productCategory)}</span></button>
{rankedProductCategories.map((item) => <button key={item.id} onClick={() => setSelectedProductCategory(item.name === selectedProductCategory ? '' : item.name)} className={`${chipBase} ${selectedProductCategory === item.name ? chipActive : chipInactive}`}>{item.name}<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedProductCategory === item.name)}`}>{countText(item.count || 0)}</span></button>)}
</div>
</div>}
<div className="mb-6">
<h2 className="mb-3 text-[13px] text-[#6b7075]">上传时间</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<button onClick={() => setSelectedMonth('')} className={`${chipBase} ${selectedMonth === '' ? chipActive : chipInactive}`}>全部<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === '')}`}>{countText(totals.month)}</span></button>
{yearFacets.map((item) => (
<button
key={item.year}
onClick={() => setSelectedMonth(selectedMonth === item.year ? '' : item.year)}
className={`${chipBase} ${selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`) ? chipActive : chipInactive}`}
>
{item.year}
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.year || selectedMonth.startsWith(`${item.year}-`))}`}>{countText(item.count)}</span>
</button>
))}
</div>
{monthsInFocusedYear.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2">
{monthsInFocusedYear.map((item) => {
const monthNumber = Number.parseInt(item.value.slice(5), 10)
return (
<button
key={item.value}
onClick={() => setSelectedMonth(item.value === selectedMonth ? '' : item.value)}
className={`${chipBase} ${selectedMonth === item.value ? chipActive : chipInactive}`}
>
{monthNumber}月
<span className={`ml-1.5 font-en text-[13px] tabular-nums ${countMuted(selectedMonth === item.value)}`}>{countText(item.count)}</span>
</button>
)
})}
</div>
)}
</div>
<p className="mb-6 text-xs text-[#86898e]">选了一个条件后,其它条件只保留还有结果的选项,数量会跟着变。</p>
</div>
</>
)
}
export default memo(FilterPanel)