feat: implement active filters feature in gallery module

- Added `useHasActiveFilters` hook to determine if any filters are active based on selected tags, cameras, lenses, and ratings.
- Introduced `ActiveFiltersHero` component to display active filters and related actions, including options to edit or clear filters.
- Created `FilterChip`, `FilterChips`, and `FilterStats` components for managing and displaying individual filter selections.
- Updated `PhotosRoot` to integrate the new active filters UI, enhancing user experience by providing immediate feedback on applied filters.
- Added localization strings for active filters in multiple languages.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-30 20:38:40 +08:00
parent 48bfa238d6
commit b9d06fe6f2
14 changed files with 450 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
import { useAtomValue } from 'jotai'
import { gallerySettingAtom } from '~/atoms/app'
export const useHasActiveFilters = () => {
const gallerySetting = useAtomValue(gallerySettingAtom)
return (
gallerySetting.selectedTags.length > 0 ||
gallerySetting.selectedCameras.length > 0 ||
gallerySetting.selectedLenses.length > 0 ||
gallerySetting.selectedRatings !== null
)
}

View File

@@ -0,0 +1,54 @@
import { Spring } from '@afilmory/utils'
import { m as motion } from 'motion/react'
interface FilterChipProps {
type: 'tag' | 'camera' | 'lens' | 'rating'
label: string
onRemove: () => void
icon?: string
}
export const FilterChip = ({ type, label, onRemove, icon }: FilterChipProps) => {
const getIcon = () => {
if (icon) return icon
switch (type) {
case 'tag': {
return 'i-lucide-tag'
}
case 'camera': {
return 'i-lucide-camera'
}
case 'lens': {
return 'i-lucide-aperture'
}
case 'rating': {
return 'i-lucide-star'
}
default: {
return 'i-lucide-filter'
}
}
}
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={Spring.presets.snappy}
className="group flex max-w-[280px] min-w-0 items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-sm backdrop-blur-md transition-all duration-200 hover:border-white/30 hover:bg-white/15 sm:max-w-[320px]"
>
<i className={`${getIcon()} shrink-0 text-xs text-white/70`} />
<span className="min-w-0 truncate text-white/90">{label}</span>
<button
type="button"
onClick={onRemove}
className="ml-0.5 flex shrink-0 items-center justify-center rounded-full p-0.5 text-white/60 transition-colors hover:bg-white/10 hover:text-white/90"
aria-label="Remove filter"
>
<i className="i-lucide-x text-xs" />
</button>
</motion.div>
)
}

View File

@@ -0,0 +1,48 @@
import { AnimatePresence } from 'motion/react'
import { FilterChip } from './FilterChip'
interface FilterChipsProps {
tags: string[]
cameras: string[]
lenses: string[]
rating: number | null
onRemoveTag: (tag: string) => void
onRemoveCamera: (camera: string) => void
onRemoveLens: (lens: string) => void
onRemoveRating: () => void
}
export const FilterChips = ({
tags,
cameras,
lenses,
rating,
onRemoveTag,
onRemoveCamera,
onRemoveLens,
onRemoveRating,
}: FilterChipsProps) => {
const hasFilters = tags.length > 0 || cameras.length > 0 || lenses.length > 0 || rating !== null
if (!hasFilters) return null
return (
<div className="flex w-full flex-wrap items-center gap-2">
<AnimatePresence mode="popLayout">
{tags.map((tag) => (
<FilterChip key={`tag-${tag}`} type="tag" label={tag} onRemove={() => onRemoveTag(tag)} />
))}
{cameras.map((camera) => (
<FilterChip key={`camera-${camera}`} type="camera" label={camera} onRemove={() => onRemoveCamera(camera)} />
))}
{lenses.map((lens) => (
<FilterChip key={`lens-${lens}`} type="lens" label={lens} onRemove={() => onRemoveLens(lens)} />
))}
{rating !== null && (
<FilterChip key="rating" type="rating" label={`${rating}+`} onRemove={onRemoveRating} icon="i-lucide-star" />
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next'
interface FilterStatsProps {
count: number
}
export const FilterStats = ({ count }: FilterStatsProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-2">
<i className="i-mingcute-filter-line text-base text-white/70" />
<span className="text-sm font-medium text-white/90">{t('gallery.filter.results', { count })}</span>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { Spring } from '@afilmory/utils'
import { m as motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
interface HeroActionsProps {
onClearAll: () => void
onEditFilters: () => void
}
export const HeroActions = ({ onClearAll, onEditFilters }: HeroActionsProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-2">
<motion.button
type="button"
onClick={onEditFilters}
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-all duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={Spring.presets.snappy}
>
<i className="i-lucide-pencil text-xs" />
<span>{t('gallery.filter.edit')}</span>
</motion.button>
<motion.button
type="button"
onClick={onClearAll}
className="flex items-center gap-1.5 rounded-lg bg-transparent px-4 py-2 text-sm font-medium text-white/90 backdrop-blur-sm transition-all duration-200 hover:border-white/20 hover:bg-white/10 hover:text-white"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={Spring.presets.snappy}
>
<i className="i-lucide-x text-xs" />
<span>{t('gallery.filter.clearAll')}</span>
</motion.button>
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { Spring } from '@afilmory/utils'
import { useAtom, useSetAtom } from 'jotai'
import { m as motion } from 'motion/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app'
import { useContextPhotos } from '~/hooks/usePhotoViewer'
import type { PhotoManifest } from '~/types/photo'
import { FilterChips } from './FilterChips'
import { HeroActions } from './HeroActions'
// 从照片中随机选择一些作为背景拼贴
const getRandomPhotos = (photos: PhotoManifest[], count = 12): PhotoManifest[] => {
if (photos.length === 0) return []
const shuffled = [...photos].sort(() => Math.random() - 0.5)
return shuffled.slice(0, Math.min(count, photos.length))
}
export const ActiveFiltersHero = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
const setCommandPaletteOpen = useSetAtom(isCommandPaletteOpenAtom)
const photos = useContextPhotos()
// 获取背景照片(使用筛选后的照片)
const backgroundPhotos = useMemo(() => getRandomPhotos(photos, 12), [photos])
const summarizeValues = (items: string[], limit = 3) => {
if (items.length === 0) return ''
const summary = items.slice(0, limit).join(' · ')
const remaining = items.length - limit
return remaining > 0 ? `${summary} +${remaining}` : summary
}
const headline = useMemo(() => {
const fragments: string[] = []
if (gallerySetting.selectedTags.length > 0) {
fragments.push(summarizeValues(gallerySetting.selectedTags, 4))
}
if (gallerySetting.selectedCameras.length > 0) {
fragments.push(summarizeValues(gallerySetting.selectedCameras, 2))
}
if (gallerySetting.selectedLenses.length > 0) {
fragments.push(summarizeValues(gallerySetting.selectedLenses, 2))
}
if (gallerySetting.selectedRatings !== null) {
fragments.push(`${gallerySetting.selectedRatings}+ ★`)
}
if (fragments.length === 0) return t('gallery.filter.active')
return fragments.slice(0, 2).join(' · ')
}, [
gallerySetting.selectedCameras,
gallerySetting.selectedLenses,
gallerySetting.selectedRatings,
gallerySetting.selectedTags,
t,
])
const infoItems = useMemo(() => {
const items: Array<{ key: string; icon: string; label: string; value: string }> = []
if (gallerySetting.selectedTags.length > 0) {
items.push({
key: 'tags',
icon: 'i-lucide-tag',
label: t('exif.tags'),
value: gallerySetting.selectedTags.join(' · '),
})
}
if (gallerySetting.selectedCameras.length > 0) {
items.push({
key: 'cameras',
icon: 'i-lucide-camera',
label: t('exif.camera'),
value: gallerySetting.selectedCameras.join(' · '),
})
}
if (gallerySetting.selectedLenses.length > 0) {
items.push({
key: 'lenses',
icon: 'i-lucide-aperture',
label: t('exif.lens'),
value: gallerySetting.selectedLenses.join(' · '),
})
}
if (gallerySetting.selectedRatings !== null) {
items.push({
key: 'rating',
icon: 'i-lucide-star',
label: t('exif.rating'),
value: `${gallerySetting.selectedRatings}+`,
})
}
return items
}, [
gallerySetting.selectedCameras,
gallerySetting.selectedLenses,
gallerySetting.selectedRatings,
gallerySetting.selectedTags,
t,
])
const tagModeLabel =
gallerySetting.selectedTags.length > 1
? gallerySetting.tagFilterMode === 'intersection'
? t('action.tag.mode.and')
: t('action.tag.mode.or')
: null
const handleRemoveTag = (tag: string) => {
setGallerySetting((prev) => ({
...prev,
selectedTags: prev.selectedTags.filter((t) => t !== tag),
}))
}
const handleRemoveCamera = (camera: string) => {
setGallerySetting((prev) => ({
...prev,
selectedCameras: prev.selectedCameras.filter((c) => c !== camera),
}))
}
const handleRemoveLens = (lens: string) => {
setGallerySetting((prev) => ({
...prev,
selectedLenses: prev.selectedLenses.filter((l) => l !== lens),
}))
}
const handleRemoveRating = () => {
setGallerySetting((prev) => ({
...prev,
selectedRatings: null,
}))
}
const handleClearAll = () => {
setGallerySetting((prev) => ({
...prev,
selectedTags: [],
selectedCameras: [],
selectedLenses: [],
selectedRatings: null,
tagFilterMode: 'union',
}))
}
const handleEditFilters = () => {
setCommandPaletteOpen(true)
}
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={Spring.presets.smooth}
className="relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border border-white/5 bg-black shadow-[0_25px_60px_rgba(0,0,0,0.55)]"
>
{/* Background Photo Grid */}
<div className="pointer-events-none absolute inset-0">
<div className="grid h-full w-full grid-cols-4 grid-rows-3 gap-[2px] opacity-90">
{Array.from({ length: 12 }).map((_, index) => {
const photo = backgroundPhotos[index]
if (photo) {
return (
<div key={photo.id} className="relative overflow-hidden">
<img
src={photo.thumbnailUrl || photo.originalUrl}
alt=""
className="h-full w-full scale-105 object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-linear-to-br from-black/80 via-black/50 to-black/80" />
</div>
)
}
return <div key={`empty-${index}`} className="bg-linear-to-br from-zinc-900 via-black to-zinc-900" />
})}
</div>
</div>
{/* Dark Overlay Gradient */}
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-black/80 via-black/90 to-black/95" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),rgba(0,0,0,0)_60%)] opacity-70" />
{/* Content */}
<div className="relative z-10 flex min-h-[320px] flex-col">
<div className="flex flex-1 flex-col items-center justify-center px-6 pt-20 pb-12 text-center lg:px-12 lg:pt-28 lg:pb-16">
<p className="text-xs font-medium tracking-[0.5em] text-white/60 uppercase">
{t('gallery.filter.results', { count: photos.length })}
</p>
<h2 className="mt-4 text-4xl font-semibold text-white drop-shadow-[0_15px_30px_rgba(0,0,0,0.65)] lg:text-5xl">
{headline}
</h2>
{infoItems.length > 0 && (
<div className="mt-8 grid w-full max-w-4xl grid-cols-1 gap-3 text-left sm:grid-cols-2 lg:grid-cols-4">
{infoItems.map((item) => (
<div
key={item.key}
className="flex h-full flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white/90 backdrop-blur"
>
<div className="flex items-center gap-2 text-xs font-semibold tracking-[0.3em] text-white/50 uppercase">
<i className={`${item.icon} text-sm text-white/70`} />
<span>{item.label}</span>
</div>
<p className="line-clamp-2 text-base font-semibold text-white">{item.value}</p>
</div>
))}
</div>
)}
{tagModeLabel && (
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-[0.3em] text-white/65 uppercase backdrop-blur">
<i className="i-lucide-git-branch text-sm text-white/70" />
<span>
{t('action.tag.filter')} · {tagModeLabel}
</span>
</div>
)}
<div className="mt-8 flex w-full max-w-4xl flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
<div className="flex min-w-0 flex-1 justify-center lg:justify-start">
<FilterChips
tags={gallerySetting.selectedTags}
cameras={gallerySetting.selectedCameras}
lenses={gallerySetting.selectedLenses}
rating={gallerySetting.selectedRatings}
onRemoveTag={handleRemoveTag}
onRemoveCamera={handleRemoveCamera}
onRemoveLens={handleRemoveLens}
onRemoveRating={handleRemoveRating}
/>
</div>
<div className="flex shrink-0 justify-center lg:justify-end">
<HeroActions onClearAll={handleClearAll} onEditFilters={handleEditFilters} />
</div>
</div>
</div>
</div>
</motion.div>
)
}

View File

@@ -225,7 +225,7 @@ export const MasonryPhotoItem = memo(({ data, width }: { data: PhotoManifest; wi
src={data.thumbnailUrl}
alt={data.title}
loading="lazy"
className={clsx('absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105')}
className={'absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105'}
onLoad={handleImageLoad}
onError={handleImageError}
/>

View File

@@ -1,13 +1,16 @@
import { useScrollViewElement } from '@afilmory/ui'
import clsx from 'clsx'
import { useAtomValue } from 'jotai'
import { useEffect, useState } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { useHasActiveFilters } from '~/hooks/useHasActiveFilters'
import { useContextPhotos } from '~/hooks/usePhotoViewer'
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
import type { PanelType } from './ActionPanel'
import { ActionPanel } from './ActionPanel'
import { ActiveFiltersHero } from './ActiveFiltersHero'
import { ListView } from './ListView'
import { MasonryView } from './MasonryView'
import { PageHeader } from './PageHeader'
@@ -37,6 +40,8 @@ export const PhotosRoot = () => {
}
}, [scrollElement])
const hasActiveFilters = useHasActiveFilters()
return (
<>
<PageHeader
@@ -45,7 +50,9 @@ export const PhotosRoot = () => {
showDateRange={showFloatingActions && !!dateRange.formattedRange}
/>
<div className="mt-12 p-1 **:select-none! lg:px-0 lg:pb-0">
{hasActiveFilters && <ActiveFiltersHero />}
<div className={clsx('p-1 **:select-none! lg:px-0 lg:pb-0', !hasActiveFilters && 'mt-12')}>
{viewMode === 'list' ? <ListView photos={photos} /> : <MasonryView photos={photos} onRender={handleRender} />}
</div>

View File

@@ -337,6 +337,10 @@
"explory.range.separator": "to",
"explory.shooting.range": "Shooting Range:",
"gallery.built.at": "Built at ",
"gallery.filter.active": "Active Filters",
"gallery.filter.clearAll": "Clear All",
"gallery.filter.edit": "Edit Filters",
"gallery.filter.results": "Showing {{count}} photos",
"gallery.photos_one": "{{count}} photo",
"gallery.photos_other": "{{count}} photos",
"gallery.search": "Search",

View File

@@ -308,6 +308,10 @@
"explory.range.separator": "から",
"explory.shooting.range": "撮影範囲:",
"gallery.built.at": "ビルド日時 ",
"gallery.filter.active": "アクティブなフィルター",
"gallery.filter.clearAll": "すべてクリア",
"gallery.filter.edit": "フィルターを編集",
"gallery.filter.results": "{{count}} 枚の写真を表示",
"gallery.photos_one": "写真{{count}}枚",
"gallery.photos_other": "写真{{count}}枚",
"gallery.search": "検索",

View File

@@ -308,6 +308,10 @@
"explory.range.separator": "~",
"explory.shooting.range": "촬영 범위:",
"gallery.built.at": "빌드 날짜 ",
"gallery.filter.active": "활성 필터",
"gallery.filter.clearAll": "모두 지우기",
"gallery.filter.edit": "필터 편집",
"gallery.filter.results": "{{count}}장의 사진 표시",
"gallery.photos_one": "사진 {{count}}장",
"gallery.photos_other": "사진 {{count}}장",
"gallery.search": "검색",

View File

@@ -334,6 +334,10 @@
"explory.range.separator": "至",
"explory.shooting.range": "拍摄范围:",
"gallery.built.at": "构建于 ",
"gallery.filter.active": "活跃筛选",
"gallery.filter.clearAll": "清除全部",
"gallery.filter.edit": "编辑筛选",
"gallery.filter.results": "显示 {{count}} 张照片",
"gallery.photos_one": "{{count}} 张照片",
"gallery.photos_other": "{{count}} 张照片",
"gallery.search": "搜索",

View File

@@ -308,6 +308,10 @@
"explory.range.separator": "至",
"explory.shooting.range": "拍攝範圍:",
"gallery.built.at": "建置於 ",
"gallery.filter.active": "活躍篩選",
"gallery.filter.clearAll": "清除全部",
"gallery.filter.edit": "編輯篩選",
"gallery.filter.results": "顯示 {{count}} 張照片",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",
"gallery.search": "搜尋",

View File

@@ -307,6 +307,10 @@
"explory.range.separator": "至",
"explory.shooting.range": "拍攝範圍:",
"gallery.built.at": "建置於 ",
"gallery.filter.active": "活躍篩選",
"gallery.filter.clearAll": "清除全部",
"gallery.filter.edit": "編輯篩選",
"gallery.filter.results": "顯示 {{count}} 張照片",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",
"gallery.search": "搜尋",