mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: add rating filter (#78)
This commit is contained in:
@@ -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 表示自动计算
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ const filterAndSortPhotos = (
|
||||
selectedTags: string[],
|
||||
selectedCameras: string[],
|
||||
selectedLenses: string[],
|
||||
selectedRatings: number | null,
|
||||
sortOrder: 'asc' | 'desc',
|
||||
) => {
|
||||
// 根据 tags、cameras 和 lenses 筛选
|
||||
// 根据 tags、cameras、lenses 和 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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "古い順",
|
||||
|
||||
@@ -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": "오래된순",
|
||||
|
||||
@@ -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": "最早优先",
|
||||
|
||||
@@ -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": "最早優先",
|
||||
|
||||
@@ -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": "最早優先",
|
||||
|
||||
Reference in New Issue
Block a user