feat: add camera and lens filtering functionality

- Introduced a new FilterPanel component for selecting tags, cameras, and lenses.
- Updated gallery settings to include selected cameras and lenses.
- Enhanced photo filtering logic to support camera and lens criteria.
- Updated UI to reflect changes in filtering options and improved user experience.
- Added localization for new filter options in English.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-08-04 01:44:12 +08:00
parent 7cc1c267ea
commit 54b8bd334c
20 changed files with 739 additions and 240 deletions

View File

@@ -7,7 +7,11 @@ export const gallerySettingAtom = atom({
sortBy: 'date' as GallerySortBy,
sortOrder: 'desc' as GallerySortOrder,
selectedTags: [] as string[],
selectedCameras: [] as string[], // Selected camera display names
selectedLenses: [] as string[], // Selected lens display names
tagSearchQuery: '' as string,
cameraSearchQuery: '' as string, // Camera search query
lensSearchQuery: '' as string, // Lens search query
isTagsPanelOpen: false as boolean,
columns: 'auto' as number | 'auto', // 自定义列数auto 表示自动计算
})

View File

@@ -0,0 +1,374 @@
import { photoLoader } from '@afilmory/data'
import { useAtom } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { gallerySettingAtom } from '~/atoms/app'
import { Button } from '~/components/ui/button'
import { clsxm } from '~/lib/cn'
const allTags = photoLoader.getAllTags()
const allCameras = photoLoader.getAllCameras()
const allLenses = photoLoader.getAllLenses()
export const FilterPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
const [activeTab, setActiveTab] = useState<'tags' | 'cameras' | 'lenses'>(
'tags',
)
const [tagSearchQuery, setTagSearchQuery] = useState(
gallerySetting.tagSearchQuery,
)
const [cameraSearchQuery, setCameraSearchQuery] = useState(
gallerySetting.cameraSearchQuery,
)
const [lensSearchQuery, setLensSearchQuery] = useState(
gallerySetting.lensSearchQuery,
)
const inputRef = useRef<HTMLInputElement>(null)
// Auto-focus input when panel opens
useEffect(() => {
if (gallerySetting.isTagsPanelOpen && inputRef.current) {
inputRef.current?.focus()
}
}, [gallerySetting.isTagsPanelOpen])
// Toggle handlers with useCallback to prevent re-creation
const toggleTag = useCallback(
(tag: string) => {
setGallerySetting((prev) => {
const newSelectedTags = prev.selectedTags.includes(tag)
? prev.selectedTags.filter((t) => t !== tag)
: [...prev.selectedTags, tag]
return {
...prev,
selectedTags: newSelectedTags,
}
})
},
[setGallerySetting],
)
const toggleCamera = useCallback(
(camera: string) => {
setGallerySetting((prev) => {
const newSelectedCameras = prev.selectedCameras.includes(camera)
? prev.selectedCameras.filter((c) => c !== camera)
: [...prev.selectedCameras, camera]
return {
...prev,
selectedCameras: newSelectedCameras,
}
})
},
[setGallerySetting],
)
const toggleLens = useCallback(
(lens: string) => {
setGallerySetting((prev) => {
const newSelectedLenses = prev.selectedLenses.includes(lens)
? prev.selectedLenses.filter((l) => l !== lens)
: [...prev.selectedLenses, lens]
return {
...prev,
selectedLenses: newSelectedLenses,
}
})
},
[setGallerySetting],
)
// Clear handlers with useCallback
const clearTags = useCallback(() => {
setGallerySetting((prev) => ({
...prev,
selectedTags: [],
tagSearchQuery: '',
}))
setTagSearchQuery('')
}, [setGallerySetting])
const clearCameras = useCallback(() => {
setGallerySetting((prev) => ({
...prev,
selectedCameras: [],
cameraSearchQuery: '',
}))
setCameraSearchQuery('')
}, [setGallerySetting])
const clearLenses = useCallback(() => {
setGallerySetting((prev) => ({
...prev,
selectedLenses: [],
lensSearchQuery: '',
}))
setLensSearchQuery('')
}, [setGallerySetting])
// Search handlers with useCallback
const onTagSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setTagSearchQuery(query)
setGallerySetting((prev) => ({
...prev,
tagSearchQuery: query,
}))
},
[setGallerySetting],
)
const onCameraSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setCameraSearchQuery(query)
setGallerySetting((prev) => ({
...prev,
cameraSearchQuery: query,
}))
},
[setGallerySetting],
)
const onLensSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setLensSearchQuery(query)
setGallerySetting((prev) => ({
...prev,
lensSearchQuery: query,
}))
},
[setGallerySetting],
)
// Filter data based on regex search
const filterItems = (items: string[], searchQuery: string) => {
if (!searchQuery) return items
try {
const regex = new RegExp(searchQuery, 'i')
return items.filter((item) => regex.test(item))
} catch {
return items.filter((item) =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
}
}
const filteredTags = filterItems(allTags, tagSearchQuery)
const filteredCameras = filterItems(
allCameras.map((camera) => camera.displayName),
cameraSearchQuery,
)
const filteredLenses = filterItems(
allLenses.map((lens) => lens.displayName),
lensSearchQuery,
)
// Tab data configuration with useMemo to prevent recreation
const tabs = useMemo(
() => [
{
id: 'tags' as const,
label: t('action.tag.filter'),
icon: 'i-mingcute-tag-line',
count: gallerySetting.selectedTags.length,
data: allTags,
filteredData: filteredTags,
selectedItems: gallerySetting.selectedTags,
searchQuery: tagSearchQuery,
searchPlaceholder: t('action.tag.search'),
emptyMessage: t('action.tag.empty'),
notFoundMessage: t('action.tag.not-found'),
onToggle: toggleTag,
onClear: clearTags,
onSearchChange: onTagSearchChange,
},
{
id: 'cameras' as const,
label: t('action.camera.filter'),
icon: 'i-mingcute-camera-line',
count: gallerySetting.selectedCameras.length,
data: allCameras.map((camera) => camera.displayName),
filteredData: filteredCameras,
selectedItems: gallerySetting.selectedCameras,
searchQuery: cameraSearchQuery,
searchPlaceholder: t('action.camera.search'),
emptyMessage: t('action.camera.empty'),
notFoundMessage: t('action.camera.not-found'),
onToggle: toggleCamera,
onClear: clearCameras,
onSearchChange: onCameraSearchChange,
},
{
id: 'lenses' as const,
label: t('action.lens.filter'),
icon: 'i-mingcute-camera-lens-line',
count: gallerySetting.selectedLenses.length,
data: allLenses.map((lens) => lens.displayName),
filteredData: filteredLenses,
selectedItems: gallerySetting.selectedLenses,
searchQuery: lensSearchQuery,
searchPlaceholder: t('action.lens.search'),
emptyMessage: t('action.lens.empty'),
notFoundMessage: t('action.lens.not-found'),
onToggle: toggleLens,
onClear: clearLenses,
onSearchChange: onLensSearchChange,
},
],
[
t,
gallerySetting.selectedTags,
gallerySetting.selectedCameras,
gallerySetting.selectedLenses,
filteredTags,
filteredCameras,
filteredLenses,
tagSearchQuery,
cameraSearchQuery,
lensSearchQuery,
toggleTag,
toggleCamera,
toggleLens,
clearTags,
clearCameras,
clearLenses,
onTagSearchChange,
onCameraSearchChange,
onLensSearchChange,
],
)
const currentTab = useMemo(
() => tabs.find((tab) => tab.id === activeTab)!,
[tabs, activeTab],
)
return (
<div className="lg:pb-safe-2 w-full p-2 pb-0 text-sm lg:w-80 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">
{t('action.filter.title')}
</h3>
{/* Reset all filters */}
<Button
variant="ghost"
className="opacity-80"
size="xs"
onClick={() => {
setGallerySetting((prev) => ({
...prev,
selectedTags: [],
selectedCameras: [],
selectedLenses: [],
tagSearchQuery: '',
cameraSearchQuery: '',
lensSearchQuery: '',
}))
}}
>
<i className="i-mingcute-refresh-1-line mr-1 text-sm" />
Reset
</Button>
</div>
{/* Tab Navigation - Improved spacing and layout */}
<div className="mb-2 flex rounded-lg bg-zinc-100 p-1 dark:bg-zinc-800">
{tabs.map((tab) => (
<button
type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsxm(
'min-w-0 flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-2.5 text-xs font-medium transition-all duration-200',
activeTab === tab.id
? 'bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-white'
: 'text-zinc-600 hover:bg-zinc-200/50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700/50 dark:hover:text-zinc-300',
)}
>
<i className={clsxm(tab.icon, 'shrink-0 text-sm')} />
<span className="truncate text-center leading-tight">
{tab.label}
</span>
{tab.count > 0 && (
<span className="bg-accent flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-xs text-white">
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Tab Content */}
<div className="space-y-3">
{/* 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 && (
<Button
variant="ghost"
size="xs"
onClick={currentTab.onClear}
className="flex h-9 items-center gap-1 rounded-md px-2 text-xs whitespace-nowrap"
>
<i className="i-mingcute-delete-line text-sm" />
{t('action.tag.clear')}
</Button>
)}
</div>
</div>
{/* Content area - Clean list without background */}
{currentTab.data.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{currentTab.emptyMessage}
</div>
) : currentTab.filteredData.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{currentTab.notFoundMessage}
</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) => (
<div
key={item}
onClick={() => currentTab.onToggle(item)}
className={clsxm(
'hover:bg-accent/50 flex cursor-pointer items-center rounded-md bg-transparent px-2 py-2.5 transition-colors lg:py-2',
currentTab.selectedItems.includes(item) && 'bg-accent/20',
)}
>
<span className="mr-2 flex-1 truncate">{item}</span>
{currentTab.selectedItems.includes(item) && (
<i className="i-mingcute-check-line ml-auto text-green-600 dark:text-green-400" />
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { photoLoader } from '@afilmory/data'
import { atom, useAtom, useAtomValue } from 'jotai'
import { useCallback, useMemo } from 'react'
import { use, useCallback, useMemo } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { jotaiStore } from '~/lib/jotai'
import { trackView } from '~/lib/tracker'
import { PhotosContext } from '~/providers/photos-provider'
const openAtom = atom(false)
const currentIndexAtom = atom(0)
@@ -14,16 +15,40 @@ const data = photoLoader.getPhotos()
// 抽取照片筛选和排序逻辑为独立函数
const filterAndSortPhotos = (
selectedTags: string[],
selectedCameras: string[],
selectedLenses: string[],
sortOrder: 'asc' | 'desc',
) => {
// 首先根据 tags 筛选
// 根据 tags、cameras 和 lenses 筛选
let filteredPhotos = data
// Tags 筛选:照片必须包含至少一个选中的标签
if (selectedTags.length > 0) {
filteredPhotos = data.filter((photo) =>
filteredPhotos = filteredPhotos.filter((photo) =>
selectedTags.some((tag) => photo.tags.includes(tag)),
)
}
// Cameras 筛选:照片的相机必须匹配选中的相机之一
if (selectedCameras.length > 0) {
filteredPhotos = filteredPhotos.filter((photo) => {
if (!photo.exif?.Make || !photo.exif?.Model) return false
const cameraDisplayName = `${photo.exif.Make.trim()} ${photo.exif.Model.trim()}`
return selectedCameras.includes(cameraDisplayName)
})
}
// Lenses 筛选:照片的镜头必须匹配选中的镜头之一
if (selectedLenses.length > 0) {
filteredPhotos = filteredPhotos.filter((photo) => {
if (!photo.exif?.LensModel) return false
const lensModel = photo.exif.LensModel.trim()
const lensMake = photo.exif.LensMake?.trim()
const lensDisplayName = lensMake ? `${lensMake} ${lensModel}` : lensModel
return selectedLenses.includes(lensDisplayName)
})
}
// 然后排序
const sortedPhotos = filteredPhotos.toSorted((a, b) => {
let aDateStr = ''
@@ -55,20 +80,36 @@ export const getFilteredPhotos = () => {
const currentGallerySetting = jotaiStore.get(gallerySettingAtom)
return filterAndSortPhotos(
currentGallerySetting.selectedTags,
currentGallerySetting.selectedCameras,
currentGallerySetting.selectedLenses,
currentGallerySetting.sortOrder,
)
}
export const usePhotos = () => {
const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
const { sortOrder, selectedTags, selectedCameras, selectedLenses } =
useAtomValue(gallerySettingAtom)
const masonryItems = useMemo(() => {
return filterAndSortPhotos(selectedTags, sortOrder)
}, [sortOrder, selectedTags])
return filterAndSortPhotos(
selectedTags,
selectedCameras,
selectedLenses,
sortOrder,
)
}, [sortOrder, selectedTags, selectedCameras, selectedLenses])
return masonryItems
}
export const useContextPhotos = () => {
const photos = use(PhotosContext)
if (!photos) {
throw new Error('PhotosContext is not initialized')
}
return photos
}
export const usePhotoViewer = () => {
const photos = usePhotos()
const [isOpen, setIsOpen] = useAtom(openAtom)
@@ -76,7 +117,7 @@ export const usePhotoViewer = () => {
const [triggerElement, setTriggerElement] = useAtom(triggerElementAtom)
const id = useMemo(() => {
return photos[currentIndex].id
return photos[currentIndex]?.id
}, [photos, currentIndex])
const openViewer = useCallback(
(index: number, element?: HTMLElement) => {

View File

@@ -1,11 +1,11 @@
import { photoLoader } from '@afilmory/data'
import { useAtom, useSetAtom } from 'jotai'
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Drawer } from 'vaul'
import { gallerySettingAtom } from '~/atoms/app'
import { FilterPanel } from '~/components/gallery/FilterPanel'
import { Button } from '~/components/ui/button'
import {
DropdownMenu,
@@ -16,8 +16,6 @@ import { Slider } from '~/components/ui/slider'
import { useMobile } from '~/hooks/useMobile'
import { clsxm } from '~/lib/cn'
const allTags = photoLoader.getAllTags()
const SortPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
@@ -62,131 +60,6 @@ const SortPanel = () => {
)
}
const TagsPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
const [searchQuery, setSearchQuery] = useState(gallerySetting.tagSearchQuery)
const inputRef = useRef<HTMLInputElement>(null)
// 当面板打开时自动聚焦输入框
useEffect(() => {
if (gallerySetting.isTagsPanelOpen && inputRef.current) {
inputRef.current?.focus()
}
}, [gallerySetting.isTagsPanelOpen])
const toggleTag = (tag: string) => {
const newSelectedTags = gallerySetting.selectedTags.includes(tag)
? gallerySetting.selectedTags.filter((t) => t !== tag)
: [...gallerySetting.selectedTags, tag]
setGallerySetting({
...gallerySetting,
selectedTags: newSelectedTags,
})
}
const clearAllTags = () => {
setGallerySetting({
...gallerySetting,
selectedTags: [],
tagSearchQuery: '', // 清除搜索查询
isTagsPanelOpen: false, // 关闭标签面板
})
}
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value
setSearchQuery(query)
setGallerySetting((prev) => ({
...prev,
tagSearchQuery: query, // 同步搜索查询
}))
}
// 根据正则查询过滤标签
const filteredTags = allTags.filter((tag) => {
if (!searchQuery) return true
try {
const regex = new RegExp(searchQuery, 'i')
return regex.test(tag)
} catch {
// 如果正则表达式无效,回退到简单的包含查询
return tag.toLowerCase().includes(searchQuery.toLowerCase())
}
})
return (
<div className="lg:pb-safe-2 w-full p-2 pb-0 text-sm lg:w-64 lg:p-0">
<div className="relative mb-2">
<h3 className="flex h-6 items-center px-2 font-medium lg:h-8">
{t('action.tag.filter')}
</h3>
{gallerySetting.selectedTags.length > 0 && (
<Button
variant="ghost"
size="xs"
onClick={clearAllTags}
className="absolute top-0 right-0 h-8 rounded-md px-2 text-xs"
>
{t('action.tag.clear')}
</Button>
)}
</div>
{/* 搜索栏 */}
<div className="mb-3 px-2">
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder={t('action.tag.search')}
value={searchQuery}
onChange={onSearchChange}
className="w-full rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm placeholder:text-gray-500 focus:border-gray-400 focus:outline-none 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>
</div>
{allTags.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('action.tag.empty')}
</div>
) : filteredTags.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('action.tag.not-found')}
</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">
{filteredTags.map((tag) => (
<div
key={tag}
onClick={() => toggleTag(tag)}
className="hover:bg-accent/50 flex cursor-pointer items-center rounded-md bg-transparent px-2 py-3 lg:py-1"
>
<span className="flex-1">{tag}</span>
{gallerySetting.selectedTags.includes(tag) && (
<i className="i-mingcute-check-line ml-auto" />
)}
</div>
))}
</div>
)}
</div>
)
}
const onTagsPanelOpenChange = (
open: boolean,
setGallerySetting: (setting: any) => void,
) => {
setGallerySetting((prev) => ({
...prev,
isTagsPanelOpen: open,
}))
}
const ColumnsPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
@@ -394,9 +267,16 @@ const ResponsiveActionButton = ({
export const ActionGroup = () => {
const { t } = useTranslation()
const [gallerySetting] = useAtom(gallerySettingAtom)
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
const navigate = useNavigate()
const onTagsPanelOpenChange = (open: boolean) => {
setGallerySetting((prev: any) => ({
...prev,
isTagsPanelOpen: open,
}))
}
return (
<div className="flex items-center justify-center gap-3">
{/* 地图探索按钮 */}
@@ -410,20 +290,25 @@ export const ActionGroup = () => {
<i className="i-mingcute-map-pin-line text-base text-gray-600 dark:text-gray-300" />
</Button>
{/* 标签筛选按钮 */}
{/* 过滤按钮 */}
<ResponsiveActionButton
icon="i-mingcute-tag-line"
title={t('action.tag.filter')}
icon="i-mingcute-filter-line"
title={t('action.filter.title')}
badge={
gallerySetting.selectedTags.length > 0
? gallerySetting.selectedTags.length
gallerySetting.selectedTags.length +
gallerySetting.selectedCameras.length +
gallerySetting.selectedLenses.length >
0
? gallerySetting.selectedTags.length +
gallerySetting.selectedCameras.length +
gallerySetting.selectedLenses.length
: undefined
}
// 使用全局状态实现滚动时自动收起标签面板
globalOpen={gallerySetting.isTagsPanelOpen}
onGlobalOpenChange={onTagsPanelOpenChange}
>
<TagsPanel />
<FilterPanel />
</ResponsiveActionButton>
{/* 列数调整按钮 */}
@@ -455,7 +340,7 @@ export const ActionGroup = () => {
const panelMap = {
sort: SortPanel,
tags: TagsPanel,
tags: FilterPanel,
columns: ColumnsPanel,
}

View File

@@ -19,7 +19,7 @@ const actions: {
icon: 'i-mingcute-sort-descending-line',
title: 'action.sort.mode',
},
{ id: 'tags', icon: 'i-mingcute-tag-line', title: 'action.tag.filter' },
{ id: 'tags', icon: 'i-mingcute-filter-line', title: 'action.filter.title' },
{
id: 'columns',
icon: 'i-mingcute-grid-line',

View File

@@ -4,6 +4,10 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Thumbhash } from '~/components/ui/thumbhash'
import {
useContextPhotos,
usePhotoViewer,
} from '~/hooks/usePhotoViewer'
import {
CarbonIsoOutline,
MaterialSymbolsShutterSpeed,
@@ -19,15 +23,13 @@ export const MasonryPhotoItem = ({
data,
width,
index: _,
onPhotoClick,
photos,
}: {
data: PhotoManifest
width: number
index: number
onPhotoClick: (index: number, element?: HTMLElement) => void
photos: PhotoManifest[]
}) => {
const photos = useContextPhotos()
const photoViewer = usePhotoViewer()
const { t } = useTranslation()
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
@@ -55,7 +57,7 @@ export const MasonryPhotoItem = ({
const handleClick = () => {
const photoIndex = photos.findIndex((photo) => photo.id === data.id)
if (photoIndex !== -1 && imageRef.current) {
onPhotoClick(photoIndex, imageRef.current)
photoViewer.openViewer(photoIndex, imageRef.current)
}
}

View File

@@ -6,7 +6,9 @@ import { gallerySettingAtom } from '~/atoms/app'
import { DateRangeIndicator } from '~/components/ui/date-range-indicator'
import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks'
import { useMobile } from '~/hooks/useMobile'
import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import {
useContextPhotos,
} from '~/hooks/usePhotoViewer'
import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
import { clsxm } from '~/lib/cn'
@@ -51,15 +53,12 @@ export const MasonryRoot = () => {
const [showFloatingActions, setShowFloatingActions] = useState(false)
const [containerWidth, setContainerWidth] = useState(0)
const photos = usePhotos()
const photos = useContextPhotos()
const masonryRef = useRef<MasonryRef>(null)
// useEffect(() => {
// nextFrame(() => masonryRef.current?.reposition())
// }, [photos])
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
const scrollElement = useScrollViewElement()
const photoViewer = usePhotoViewer()
const handleAnimationComplete = useCallback(() => {
hasAnimatedRef.current = true
}, [])
@@ -177,13 +176,11 @@ export const MasonryRoot = () => {
(props) => (
<MasonryItem
{...props}
onPhotoClick={photoViewer.openViewer}
photos={photos}
hasAnimated={hasAnimatedRef.current}
onAnimationComplete={handleAnimationComplete}
/>
),
[handleAnimationComplete, photoViewer.openViewer, photos],
[handleAnimationComplete],
)}
onRender={handleRender}
columnWidth={columnWidth}
@@ -217,16 +214,13 @@ export const MasonryItem = memo(
data,
width,
index,
onPhotoClick,
photos,
hasAnimated,
onAnimationComplete,
}: {
data: MasonryItemType
width: number
index: number
onPhotoClick: (index: number, element?: HTMLElement) => void
photos: PhotoManifest[]
hasAnimated: boolean
onAnimationComplete: () => void
}) => {
@@ -269,7 +263,7 @@ export const MasonryItem = memo(
}
if (data instanceof MasonryHeaderItem) {
return <MasonryHeaderMasonryItem style={{ width }} />
return <MasonryHeaderMasonryItem style={{ width }} key={itemKey} />
} else {
return (
<m.div
@@ -283,8 +277,6 @@ export const MasonryItem = memo(
data={data as PhotoManifest}
width={width}
index={index}
onPhotoClick={onPhotoClick}
photos={photos}
/>
</m.div>
)

View File

@@ -192,7 +192,7 @@ export const Component = () => {
const photos = photoLoader.getPhotos()
const manifestData = {
version: 'v5',
version: 'v6',
data: photos,
}
@@ -355,7 +355,7 @@ export const Component = () => {
<JsonHighlight
data={
searchTerm
? { version: 'v5', data: filteredPhotos }
? { version: 'v6', data: filteredPhotos }
: manifestData
}
/>

View File

@@ -6,11 +6,14 @@ import { PhotoViewer } from '~/components/ui/photo-viewer'
import { RootPortal } from '~/components/ui/portal'
import { RootPortalProvider } from '~/components/ui/portal/provider'
import { useTitle } from '~/hooks/common'
import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import {
useContextPhotos,
usePhotoViewer,
} from '~/hooks/usePhotoViewer'
export const Component = () => {
const photoViewer = usePhotoViewer()
const photos = usePhotos()
const photos = useContextPhotos()
const ref = useRef<HTMLDivElement>(null)
const rootPortalValue = useMemo(() => {

View File

@@ -1,4 +1,5 @@
import { photoLoader } from '@afilmory/data'
import siteConfig from '@config'
import { useAtomValue, useSetAtom } from 'jotai'
// import { AnimatePresence } from 'motion/react'
import { useEffect, useRef } from 'react'
@@ -14,8 +15,13 @@ import { gallerySettingAtom } from '~/atoms/app'
import { ScrollElementContext } from '~/components/ui/scroll-areas/ctx'
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
import { useMobile } from '~/hooks/useMobile'
import { getFilteredPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import {
getFilteredPhotos,
usePhotos,
usePhotoViewer,
} from '~/hooks/usePhotoViewer'
import { MasonryRoot } from '~/modules/gallery/MasonryRoot'
import { PhotosProvider } from '~/providers/photos-provider'
export const Component = () => {
useStateRestoreFromUrl()
@@ -24,22 +30,38 @@ export const Component = () => {
// const location = useLocation()
const isMobile = useMobile()
const photos = usePhotos()
return (
<>
{isMobile ? (
<ScrollElementContext value={document.body}>
<MasonryRoot />
</ScrollElementContext>
) : (
<ScrollArea
rootClassName={'h-svh w-full'}
viewportClassName="size-full"
>
<MasonryRoot />
</ScrollArea>
)}
<PhotosProvider photos={photos}>
{siteConfig.accentColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root:has(input.theme-controller[value=dark]:checked), [data-theme="dark"] {
--color-primary: ${siteConfig.accentColor};
--color-accent: ${siteConfig.accentColor};
--color-secondary: ${siteConfig.accentColor};
}
`,
}}
/>
)}
{isMobile ? (
<ScrollElementContext value={document.body}>
<MasonryRoot />
</ScrollElementContext>
) : (
<ScrollArea
rootClassName={'h-svh w-full'}
viewportClassName="size-full"
>
<MasonryRoot />
</ScrollArea>
)}
<Outlet />
<Outlet />
</PhotosProvider>
</>
)
}
@@ -68,17 +90,27 @@ const useStateRestoreFromUrl = () => {
}
const tagsFromSearchParams = searchParams.get('tags')?.split(',')
if (tagsFromSearchParams) {
const camerasFromSearchParams = searchParams.get('cameras')?.split(',')
const lensesFromSearchParams = searchParams.get('lenses')?.split(',')
if (
tagsFromSearchParams ||
camerasFromSearchParams ||
lensesFromSearchParams
) {
setGallerySetting((prev) => ({
...prev,
selectedTags: tagsFromSearchParams,
selectedTags: tagsFromSearchParams || prev.selectedTags,
selectedCameras: camerasFromSearchParams || prev.selectedCameras,
selectedLenses: lensesFromSearchParams || prev.selectedLenses,
}))
}
}, [openViewer, photoId, searchParams, setGallerySetting])
}
const useSyncStateToUrl = () => {
const { selectedTags } = useAtomValue(gallerySettingAtom)
const { selectedTags, selectedCameras, selectedLenses } =
useAtomValue(gallerySettingAtom)
const [_, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -107,25 +139,49 @@ const useSyncStateToUrl = () => {
useEffect(() => {
if (!isRestored) return
const tags = selectedTags.join(',')
if (tags) {
setSearchParams((search) => {
const currentTags = search.get('tags')
if (currentTags === tags) return search
const cameras = selectedCameras.join(',')
const lenses = selectedLenses.join(',')
const newer = new URLSearchParams(search)
setSearchParams((search) => {
const currentTags = search.get('tags')
const currentCameras = search.get('cameras')
const currentLenses = search.get('lenses')
// Check if anything has changed
if (
currentTags === tags &&
currentCameras === cameras &&
currentLenses === lenses
) {
return search
}
const newer = new URLSearchParams(search)
// Update tags
if (tags) {
newer.set('tags', tags)
return newer
})
} else {
setSearchParams((search) => {
const currentTags = search.get('tags')
if (!currentTags) return search
const newer = new URLSearchParams(search)
} else {
newer.delete('tags')
return newer
})
}
}, [selectedTags, setSearchParams])
}
// Update cameras
if (cameras) {
newer.set('cameras', cameras)
} else {
newer.delete('cameras')
}
// Update lenses
if (lenses) {
newer.set('lenses', lenses)
} else {
newer.delete('lenses')
}
return newer
})
}, [selectedTags, selectedCameras, selectedLenses, setSearchParams])
}

View File

@@ -1,23 +0,0 @@
import siteConfig from '@config'
import { Outlet } from 'react-router'
export const Component = () => {
return (
<>
{siteConfig.accentColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root:has(input.theme-controller[value=dark]:checked), [data-theme="dark"] {
--color-primary: ${siteConfig.accentColor};
--color-accent: ${siteConfig.accentColor};
--color-secondary: ${siteConfig.accentColor};
}
`,
}}
/>
)}
<Outlet />
</>
)
}

View File

@@ -0,0 +1,15 @@
import { createContext } from 'react'
import type { PhotoManifest } from '~/types/photo'
export const PhotosContext = createContext<PhotoManifest[]>(null!)
export const PhotosProvider = ({
children,
photos,
}: {
children: React.ReactNode
photos: PhotoManifest[]
}) => {
return <PhotosContext value={photos}>{children}</PhotosContext>
}

View File

@@ -1,6 +1,17 @@
{
"action.auto": "Auto",
"action.camera.clear": "Clear",
"action.camera.empty": "No cameras available",
"action.camera.filter": "Camera Filter",
"action.camera.not-found": "No cameras match your search",
"action.camera.search": "Search Cameras",
"action.columns.setting": "Column Settings",
"action.filter.title": "Filter Photos",
"action.lens.clear": "Clear",
"action.lens.empty": "No lenses available",
"action.lens.filter": "Lens Filter",
"action.lens.not-found": "No lenses match your search",
"action.lens.search": "Search Lenses",
"action.map.explore": "Map Explore",
"action.sort.mode": "Sort Mode",
"action.sort.newest.first": "Newest First",

View File

@@ -28,6 +28,7 @@
},
"devDependencies": {
"@innei/prettier": "0.15.0",
"@types/node": "24.0.4",
"consola": "3.4.2",
"dotenv-expand": "catalog:",
"eslint": "9.29.0",

View File

@@ -14,7 +14,11 @@ import {
import type { PhotoProcessorOptions } from '../photo/processor.js'
import { processPhoto } from '../photo/processor.js'
import { StorageManager } from '../storage/index.js'
import type { AfilmoryManifest } from '../types/manifest.js'
import type {
AfilmoryManifest,
CameraInfo,
LensInfo,
} from '../types/manifest.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import { ClusterPool } from '../worker/cluster-pool.js'
import { WorkerPool } from '../worker/pool.js'
@@ -278,8 +282,13 @@ class PhotoGalleryBuilder {
// 检测并处理已删除的图片
deletedCount = await handleDeletedPhotos(manifest)
// 生成相机和镜头集合
const cameras = this.generateCameraCollection(manifest)
const lenses = this.generateLensCollection(manifest)
// 保存 manifest
await saveManifest(manifest)
await saveManifest(manifest, cameras, lenses)
// 显示构建结果
if (this.config.options.showDetailedStats) {
@@ -312,8 +321,10 @@ class PhotoGalleryBuilder {
): Promise<AfilmoryManifest> {
return options.isForceMode || options.isForceManifest
? {
version: 'v5',
version: 'v6',
data: [],
cameras: [],
lenses: [],
}
: await loadExistingManifest()
}
@@ -450,6 +461,72 @@ class PhotoGalleryBuilder {
return tasksToProcess
}
/**
* 生成相机信息集合
* @param manifest 照片清单数组
* @returns 唯一相机信息数组
*/
private generateCameraCollection(
manifest: PhotoManifestItem[],
): CameraInfo[] {
const cameraMap = new Map<string, CameraInfo>()
for (const photo of manifest) {
if (!photo.exif?.Make || !photo.exif?.Model) continue
const make = photo.exif.Make.trim()
const model = photo.exif.Model.trim()
const displayName = `${make} ${model}`
// 使用 displayName 作为唯一键,避免重复
if (!cameraMap.has(displayName)) {
cameraMap.set(displayName, {
make,
model,
displayName,
})
}
}
// 按 displayName 排序返回
return Array.from(cameraMap.values()).sort((a, b) =>
a.displayName.localeCompare(b.displayName),
)
}
/**
* 生成镜头信息集合
* @param manifest 照片清单数组
* @returns 唯一镜头信息数组
*/
private generateLensCollection(manifest: PhotoManifestItem[]): LensInfo[] {
const lensMap = new Map<string, LensInfo>()
for (const photo of manifest) {
if (!photo.exif?.LensModel) continue
const lensModel = photo.exif.LensModel.trim()
const lensMake = photo.exif.LensMake?.trim()
// 生成显示名称:如果有厂商信息则包含,否则只显示型号
const displayName = lensMake ? `${lensMake} ${lensModel}` : lensModel
// 使用 displayName 作为唯一键,避免重复
if (!lensMap.has(displayName)) {
lensMap.set(displayName, {
make: lensMake,
model: lensModel,
displayName,
})
}
}
// 按 displayName 排序返回
return Array.from(lensMap.values()).sort((a, b) =>
a.displayName.localeCompare(b.displayName),
)
}
}
// 导出默认的构建器实例

View File

@@ -1,5 +1,10 @@
export * from './lib/u8array.js'
export type { StorageConfig } from './storage/interfaces.js'
export type {
AfilmoryManifest,
CameraInfo,
LensInfo,
} from './types/manifest.js'
export type {
FujiRecipe,
PhotoManifestItem,

View File

@@ -5,7 +5,11 @@ import { workdir } from '@afilmory/builder/path.js'
import type { _Object } from '@aws-sdk/client-s3'
import { logger } from '../logger/index.js'
import type { AfilmoryManifest } from '../types/manifest.js'
import type {
AfilmoryManifest,
CameraInfo,
LensInfo,
} from '../types/manifest.js'
import type { PhotoManifestItem } from '../types/photo.js'
const manifestPath = path.join(workdir, 'src/data/photos-manifest.json')
@@ -20,18 +24,31 @@ export async function loadExistingManifest(): Promise<AfilmoryManifest> {
'🔍 未找到 manifest 文件/解析失败,创建新的 manifest 文件...',
)
return {
version: 'v5',
version: 'v6',
data: [],
cameras: [],
lenses: [],
}
}
if (manifest.version !== 'v5') {
if (manifest.version !== 'v6') {
logger.fs.error('🔍 无效的 manifest 版本,创建新的 manifest 文件...')
return {
version: 'v5',
version: 'v6',
data: [],
cameras: [],
lenses: [],
}
}
// 向后兼容:如果现有 manifest 没有 cameras 和 lenses 字段,则添加空数组
if (!manifest.cameras) {
manifest.cameras = []
}
if (!manifest.lenses) {
manifest.lenses = []
}
return manifest
}
@@ -50,7 +67,11 @@ export function needsUpdate(
}
// 保存 manifest
export async function saveManifest(items: PhotoManifestItem[]): Promise<void> {
export async function saveManifest(
items: PhotoManifestItem[],
cameras: CameraInfo[] = [],
lenses: LensInfo[] = [],
): Promise<void> {
// 按日期排序(最新的在前)
const sortedManifest = [...items].sort(
(a, b) => new Date(b.dateTaken).getTime() - new Date(a.dateTaken).getTime(),
@@ -61,8 +82,10 @@ export async function saveManifest(items: PhotoManifestItem[]): Promise<void> {
manifestPath,
JSON.stringify(
{
version: 'v5',
version: 'v6',
data: sortedManifest,
cameras,
lenses,
} as AfilmoryManifest,
null,
2,
@@ -70,6 +93,7 @@ export async function saveManifest(items: PhotoManifestItem[]): Promise<void> {
)
logger.fs.info(`📁 Manifest 保存至: ${manifestPath}`)
logger.fs.info(`📷 包含 ${cameras.length} 个相机,🔍 ${lenses.length} 个镜头`)
}
// 检测并处理已删除的图片

View File

@@ -1,6 +1,20 @@
import type { PhotoManifestItem } from './photo'
export type AfilmoryManifest = {
version: 'v5'
data: PhotoManifestItem[]
export interface CameraInfo {
make: string // e.g., "Canon", "Sony", "Fujifilm"
model: string // e.g., "EOS R5", "α7R V", "X-T5"
displayName: string // e.g., "Canon EOS R5"
}
export interface LensInfo {
make?: string // e.g., "Canon", "Sony", "Sigma" (can be empty)
model: string // e.g., "RF 24-70mm F2.8 L IS USM"
displayName: string // e.g., "Canon RF 24-70mm F2.8 L IS USM"
}
export type AfilmoryManifest = {
version: 'v6'
data: PhotoManifestItem[]
cameras: CameraInfo[] // Unique cameras found in all photos
lenses: LensInfo[] // Unique lenses found in all photos
}

View File

@@ -1,15 +1,21 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import type { CameraInfo, LensInfo, PhotoManifestItem } from '@afilmory/builder'
class PhotoLoader {
private photos: PhotoManifestItem[] = []
private photoMap: Record<string, PhotoManifestItem> = {}
private cameras: CameraInfo[] = []
private lenses: LensInfo[] = []
constructor() {
this.getAllTags = this.getAllTags.bind(this)
this.getAllCameras = this.getAllCameras.bind(this)
this.getAllLenses = this.getAllLenses.bind(this)
this.getPhotos = this.getPhotos.bind(this)
this.getPhoto = this.getPhoto.bind(this)
this.photos = __MANIFEST__.data as unknown as PhotoManifestItem[]
this.cameras = __MANIFEST__.cameras as unknown as CameraInfo[]
this.lenses = __MANIFEST__.lenses as unknown as LensInfo[]
this.photos.forEach((photo) => {
this.photoMap[photo.id] = photo
@@ -31,5 +37,13 @@ class PhotoLoader {
})
return Array.from(tagSet).sort()
}
getAllCameras() {
return this.cameras
}
getAllLenses() {
return this.lenses
}
}
export const photoLoader = new PhotoLoader()

4
pnpm-lock.yaml generated
View File

@@ -72,6 +72,9 @@ importers:
'@innei/prettier':
specifier: 0.15.0
version: 0.15.0
'@types/node':
specifier: 24.0.4
version: 24.0.4
consola:
specifier: 3.4.2
version: 3.4.2
@@ -8436,6 +8439,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}