feat: add rating filter (#78)

This commit is contained in:
Wenzhuo Liu
2025-08-13 15:33:12 +08:00
committed by GitHub
parent ed679ac8e2
commit 1f52766c8f
10 changed files with 199 additions and 27 deletions

View File

@@ -9,9 +9,11 @@ export const gallerySettingAtom = atom({
selectedTags: [] as string[],
selectedCameras: [] as string[], // Selected camera display names
selectedLenses: [] as string[], // Selected lens display names
selectedRatings: null as number | null, // Selected minimum rating threshold
tagSearchQuery: '' as string,
cameraSearchQuery: '' as string, // Camera search query
lensSearchQuery: '' as string, // Lens search query
ratingSearchQuery: '' as string, // Rating search query
isTagsPanelOpen: false as boolean,
columns: 'auto' as number | 'auto', // 自定义列数auto 表示自动计算
})

View File

@@ -14,9 +14,9 @@ const allLenses = photoLoader.getAllLenses()
export const FilterPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
const [activeTab, setActiveTab] = useState<'tags' | 'cameras' | 'lenses'>(
'tags',
)
const [activeTab, setActiveTab] = useState<
'tags' | 'cameras' | 'lenses' | 'ratings'
>('tags')
const [tagSearchQuery, setTagSearchQuery] = useState(
gallerySetting.tagSearchQuery,
)
@@ -26,6 +26,9 @@ export const FilterPanel = () => {
const [lensSearchQuery, setLensSearchQuery] = useState(
gallerySetting.lensSearchQuery,
)
const [ratingSearchQuery, setRatingSearchQuery] = useState(
gallerySetting.ratingSearchQuery,
)
const inputRef = useRef<HTMLInputElement>(null)
// Auto-focus input when panel opens
@@ -112,6 +115,25 @@ export const FilterPanel = () => {
setLensSearchQuery('')
}, [setGallerySetting])
const setRating = useCallback(
(rating: number | null) => {
setGallerySetting((prev) => ({
...prev,
selectedRatings: rating,
}))
},
[setGallerySetting],
)
const clearRatings = useCallback(() => {
setGallerySetting((prev) => ({
...prev,
selectedRatings: null,
ratingSearchQuery: '',
}))
setRatingSearchQuery('')
}, [setGallerySetting])
// Search handlers with useCallback
const onTagSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -149,6 +171,18 @@ export const FilterPanel = () => {
[setGallerySetting],
)
const onRatingSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setRatingSearchQuery(query)
setGallerySetting((prev) => ({
...prev,
ratingSearchQuery: query,
}))
},
[setGallerySetting],
)
// Filter data based on regex search
const filterItems = (items: string[], searchQuery: string) => {
if (!searchQuery) return items
@@ -224,27 +258,48 @@ export const FilterPanel = () => {
onClear: clearLenses,
onSearchChange: onLensSearchChange,
},
{
id: 'ratings' as const,
label: t('action.rating.filter'),
icon: 'i-mingcute-star-line',
count: gallerySetting.selectedRatings !== null ? 1 : 0,
data: [] as string[],
filteredData: [] as string[],
selectedItems: [] as string[],
searchQuery: '',
searchPlaceholder: '',
emptyMessage: '',
notFoundMessage: '',
onToggle: () => {},
onClear: clearRatings,
onSearchChange: () => {},
},
],
[
t,
gallerySetting.selectedTags,
gallerySetting.selectedCameras,
gallerySetting.selectedLenses,
gallerySetting.selectedRatings,
filteredTags,
filteredCameras,
filteredLenses,
tagSearchQuery,
cameraSearchQuery,
lensSearchQuery,
ratingSearchQuery,
toggleTag,
toggleCamera,
toggleLens,
setRating,
clearTags,
clearCameras,
clearLenses,
clearRatings,
onTagSearchChange,
onCameraSearchChange,
onLensSearchChange,
onRatingSearchChange,
],
)
@@ -254,7 +309,7 @@ export const FilterPanel = () => {
)
return (
<div className="lg:pb-safe-2 w-full p-2 pb-0 text-sm lg:w-80 lg:p-0">
<div className="lg:pb-safe-2 w-full p-2 pb-0 text-sm lg:w-100 lg:p-0">
{/* Header with title */}
<div className="relative mb-2 flex items-center justify-between">
<h3 className="flex h-6 items-center px-2 text-base font-medium lg:h-8">
@@ -272,9 +327,11 @@ export const FilterPanel = () => {
selectedTags: [],
selectedCameras: [],
selectedLenses: [],
selectedRatings: null,
tagSearchQuery: '',
cameraSearchQuery: '',
lensSearchQuery: '',
ratingSearchQuery: '',
}))
}}
>
@@ -315,18 +372,20 @@ export const FilterPanel = () => {
{/* Search and Clear section - Aligned on same baseline */}
<div className="px-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
ref={activeTab === currentTab.id ? inputRef : undefined}
type="text"
placeholder={currentTab.searchPlaceholder}
value={currentTab.searchQuery}
onChange={currentTab.onSearchChange}
className="w-full rounded-md border border-gray-200 bg-transparent px-3 py-2 pr-9 text-sm placeholder:text-gray-500 focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-gray-500"
/>
<i className="i-mingcute-search-line absolute top-1/2 right-3 -translate-y-1/2 text-gray-400" />
</div>
{currentTab.count > 0 && (
{activeTab !== 'ratings' && (
<div className="relative flex-1">
<input
ref={activeTab === currentTab.id ? inputRef : undefined}
type="text"
placeholder={currentTab.searchPlaceholder}
value={currentTab.searchQuery}
onChange={currentTab.onSearchChange}
className="w-full rounded-md border border-gray-200 bg-transparent px-3 py-2 pr-9 text-sm placeholder:text-gray-500 focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-gray-500"
/>
<i className="i-mingcute-search-line absolute top-1/2 right-3 -translate-y-1/2 text-gray-400" />
</div>
)}
{currentTab.count > 0 && activeTab != 'ratings' && (
<Button
variant="ghost"
size="xs"
@@ -340,8 +399,15 @@ export const FilterPanel = () => {
</div>
</div>
{/* Content area - Clean list without background */}
{currentTab.data.length === 0 ? (
{/* Content area - Special handling for ratings tab */}
{activeTab === 'ratings' ? (
<div className="pb-safe-offset-4 lg:pb-safe -mx-4 -mb-4 max-h-64 overflow-y-auto px-4 lg:mx-0 lg:mb-0 lg:px-0">
<StarRating
value={gallerySetting.selectedRatings}
onChange={setRating}
/>
</div>
) : currentTab.data.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{currentTab.emptyMessage}
</div>
@@ -351,7 +417,7 @@ export const FilterPanel = () => {
</div>
) : (
<div className="pb-safe-offset-4 lg:pb-safe -mx-4 -mb-4 max-h-64 overflow-y-auto px-4 lg:mx-0 lg:mb-0 lg:px-0">
{currentTab.filteredData.map((item) => (
{(currentTab.filteredData).map((item) => (
<div
key={item}
onClick={() => currentTab.onToggle(item)}
@@ -372,3 +438,46 @@ export const FilterPanel = () => {
</div>
)
}
// 五星评分组件
const StarRating = ({
value,
onChange,
}: {
value: number | null
onChange: (rating: number | null) => void
}) => {
const { t } = useTranslation()
const [hoveredRating, setHoveredRating] = useState<number | null>(null)
return (
<div className="flex flex-col items-center space-y-3 py-3">
<div className="text-sm text-gray-600 dark:text-gray-400">
{value !== null
? t('action.rating.filter-above', { rating: value })
: t('action.rating.filter-all')}
</div>
<div className="flex space-x-1">
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
type="button"
className="cursor-pointer transition-all duration-200 hover:scale-110"
onClick={() => onChange(value === rating ? null : rating)}
onMouseEnter={() => setHoveredRating(rating)}
onMouseLeave={() => setHoveredRating(null)}
>
<i
className={clsxm(
'text-2xl',
rating <= (hoveredRating ?? value ?? 0)
? 'i-mingcute-star-fill text-yellow-400'
: 'i-mingcute-star-line text-gray-300 dark:text-gray-600',
)}
/>
</button>
))}
</div>
</div>
)
}

View File

@@ -17,9 +17,10 @@ const filterAndSortPhotos = (
selectedTags: string[],
selectedCameras: string[],
selectedLenses: string[],
selectedRatings: number | null,
sortOrder: 'asc' | 'desc',
) => {
// 根据 tags、cameraslenses 筛选
// 根据 tags、cameraslenses 和 ratings 筛选
let filteredPhotos = data
// Tags 筛选:照片必须包含至少一个选中的标签
@@ -49,6 +50,14 @@ const filterAndSortPhotos = (
})
}
// Ratings 筛选:照片的评分必须大于等于选中的最小阈值
if (selectedRatings !== null) {
filteredPhotos = filteredPhotos.filter((photo) => {
if (!photo.exif?.Rating) return false
return photo.exif.Rating >= selectedRatings
})
}
// 然后排序
const sortedPhotos = filteredPhotos.toSorted((a, b) => {
let aDateStr = ''
@@ -82,22 +91,35 @@ export const getFilteredPhotos = () => {
currentGallerySetting.selectedTags,
currentGallerySetting.selectedCameras,
currentGallerySetting.selectedLenses,
currentGallerySetting.selectedRatings,
currentGallerySetting.sortOrder,
)
}
export const usePhotos = () => {
const { sortOrder, selectedTags, selectedCameras, selectedLenses } =
useAtomValue(gallerySettingAtom)
const {
sortOrder,
selectedTags,
selectedCameras,
selectedLenses,
selectedRatings,
} = useAtomValue(gallerySettingAtom)
const masonryItems = useMemo(() => {
return filterAndSortPhotos(
selectedTags,
selectedCameras,
selectedLenses,
selectedRatings,
sortOrder,
)
}, [sortOrder, selectedTags, selectedCameras, selectedLenses])
}, [
sortOrder,
selectedTags,
selectedCameras,
selectedLenses,
selectedRatings,
])
return masonryItems
}

View File

@@ -92,24 +92,29 @@ const useStateRestoreFromUrl = () => {
const tagsFromSearchParams = searchParams.get('tags')?.split(',')
const camerasFromSearchParams = searchParams.get('cameras')?.split(',')
const lensesFromSearchParams = searchParams.get('lenses')?.split(',')
const ratingsFromSearchParams = searchParams.get('rating')
? Number(searchParams.get('rating'))
: null
if (
tagsFromSearchParams ||
camerasFromSearchParams ||
lensesFromSearchParams
lensesFromSearchParams ||
ratingsFromSearchParams !== null
) {
setGallerySetting((prev) => ({
...prev,
selectedTags: tagsFromSearchParams || prev.selectedTags,
selectedCameras: camerasFromSearchParams || prev.selectedCameras,
selectedLenses: lensesFromSearchParams || prev.selectedLenses,
selectedRatings: ratingsFromSearchParams ?? prev.selectedRatings,
}))
}
}, [openViewer, photoId, searchParams, setGallerySetting])
}
const useSyncStateToUrl = () => {
const { selectedTags, selectedCameras, selectedLenses } =
const { selectedTags, selectedCameras, selectedLenses, selectedRatings } =
useAtomValue(gallerySettingAtom)
const [_, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -143,17 +148,20 @@ const useSyncStateToUrl = () => {
const tags = selectedTags.join(',')
const cameras = selectedCameras.join(',')
const lenses = selectedLenses.join(',')
const rating = selectedRatings?.toString() ?? ''
setSearchParams((search) => {
const currentTags = search.get('tags')
const currentCameras = search.get('cameras')
const currentLenses = search.get('lenses')
const currentRating = search.get('rating')
// Check if anything has changed
if (
currentTags === tags &&
currentCameras === cameras &&
currentLenses === lenses
currentLenses === lenses &&
currentRating === rating
) {
return search
}
@@ -181,7 +189,14 @@ const useSyncStateToUrl = () => {
newer.delete('lenses')
}
// Update rating
if (rating) {
newer.set('rating', rating)
} else {
newer.delete('rating')
}
return newer
})
}, [selectedTags, selectedCameras, selectedLenses, setSearchParams])
}, [selectedTags, selectedCameras, selectedLenses, selectedRatings, setSearchParams])
}

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "No lenses match your search",
"action.lens.search": "Search Lenses",
"action.map.explore": "Map Explore",
"action.rating.filter": "Rating Filter",
"action.rating.filter-above": "Show {{rating}} stars and above",
"action.rating.filter-all": "Show all photos",
"action.rating.search": "Search Ratings",
"action.sort.mode": "Sort Mode",
"action.sort.newest.first": "Newest First",
"action.sort.oldest.first": "Oldest First",

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "検索に一致するレンズがありません",
"action.lens.search": "レンズ検索",
"action.map.explore": "マップ探索",
"action.rating.filter": "評価フィルター",
"action.rating.filter-above": "{{rating}} 星以上を表示",
"action.rating.filter-all": "すべての写真を表示",
"action.rating.search": "評価検索",
"action.sort.mode": "ソートモード",
"action.sort.newest.first": "新しい順",
"action.sort.oldest.first": "古い順",

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "검색과 일치하는 렌즈가 없습니다",
"action.lens.search": "렌즈 검색",
"action.map.explore": "지도 탐색",
"action.rating.filter": "평점 필터",
"action.rating.filter-above": "{{rating}} 별 이상 보기",
"action.rating.filter-all": "모든 사진 보기",
"action.rating.search": "평점 검색",
"action.sort.mode": "정렬 모드",
"action.sort.newest.first": "최신순",
"action.sort.oldest.first": "오래된순",

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "没有镜头匹配您的搜索",
"action.lens.search": "搜索镜头",
"action.map.explore": "地图探索",
"action.rating.filter": "评分筛选",
"action.rating.filter-above": "显示 {{rating}} 星及以上",
"action.rating.filter-all": "显示所有照片",
"action.rating.search": "搜索评分",
"action.sort.mode": "排序模式",
"action.sort.newest.first": "最新优先",
"action.sort.oldest.first": "最早优先",

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "沒有鏡頭符合您的搜尋",
"action.lens.search": "搜尋鏡頭",
"action.map.explore": "地圖探索",
"action.rating.filter": "評分篩選",
"action.rating.filter-above": "顯示 {{rating}} 星及以上",
"action.rating.filter-all": "顯示所有照片",
"action.rating.search": "搜尋評分",
"action.sort.mode": "排序模式",
"action.sort.newest.first": "最新優先",
"action.sort.oldest.first": "最早優先",

View File

@@ -13,6 +13,10 @@
"action.lens.not-found": "沒有鏡頭符合您的搜尋",
"action.lens.search": "搜尋鏡頭",
"action.map.explore": "地圖探索",
"action.rating.filter": "評分篩選",
"action.rating.filter-above": "顯示 {{rating}} 星及以上",
"action.rating.filter-all": "顯示所有照片",
"action.rating.search": "搜尋評分",
"action.sort.mode": "排序模式",
"action.sort.newest.first": "最新優先",
"action.sort.oldest.first": "最早優先",