mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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 表示自动计算
|
||||
})
|
||||
|
||||
374
apps/web/src/components/gallery/FilterPanel.tsx
Normal file
374
apps/web/src/components/gallery/FilterPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/providers/photos-provider.tsx
Normal file
15
apps/web/src/providers/photos-provider.tsx
Normal 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>
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认的构建器实例
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 个镜头`)
|
||||
}
|
||||
|
||||
// 检测并处理已删除的图片
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
Reference in New Issue
Block a user