mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
feat(gallery): implement global search functionality for photos (#115)
Signed-off-by: Innei <tukon479@gmail.com> Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
import { CommandPalette } from './components/gallery/CommandPalette'
|
||||
import { useCommandPaletteShortcut } from './hooks/useCommandPaletteShortcut'
|
||||
import { RootProviders } from './providers/root-providers'
|
||||
|
||||
// prefetch preview page route
|
||||
@@ -8,13 +10,19 @@ function App() {
|
||||
useEffect(() => {
|
||||
import('~/pages/(main)/[photoId]/index')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<RootProviders>
|
||||
<div className="overflow-hidden lg:h-svh">
|
||||
<Outlet />
|
||||
<CommandPaletteContainer />
|
||||
</div>
|
||||
</RootProviders>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandPaletteContainer = () => {
|
||||
const { isOpen, setIsOpen } = useCommandPaletteShortcut()
|
||||
return <CommandPalette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
}
|
||||
export default App
|
||||
|
||||
@@ -11,12 +11,11 @@ export const gallerySettingAtom = atom({
|
||||
selectedLenses: [] as string[], // Selected lens display names
|
||||
selectedRatings: null as number | null, // Selected minimum rating threshold
|
||||
tagFilterMode: 'union' as 'union' | 'intersection', // Tag filtering logic mode
|
||||
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 表示自动计算
|
||||
})
|
||||
|
||||
export const isExiftoolLoadedAtom = atom(false)
|
||||
|
||||
// Command Palette state
|
||||
export const isCommandPaletteOpenAtom = atom(false)
|
||||
|
||||
598
apps/web/src/components/gallery/CommandPalette.tsx
Normal file
598
apps/web/src/components/gallery/CommandPalette.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import { photoLoader } from '@afilmory/data'
|
||||
import { useAtom } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import { MageLens } from '~/icons'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
// Command types
|
||||
type CommandType = 'search' | 'filter' | 'action' | 'photo'
|
||||
|
||||
interface Command {
|
||||
id: string
|
||||
type: CommandType
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon: string | React.ReactNode
|
||||
action: () => void
|
||||
keywords?: string[]
|
||||
badge?: string | number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const allTags = photoLoader.getAllTags()
|
||||
const allCameras = photoLoader.getAllCameras()
|
||||
const allLenses = photoLoader.getAllLenses()
|
||||
|
||||
// Fuzzy search utility
|
||||
const fuzzyMatch = (text: string, query: string): boolean => {
|
||||
const lowerText = text.toLowerCase()
|
||||
const lowerQuery = query.toLowerCase()
|
||||
|
||||
if (lowerText.includes(lowerQuery)) return true
|
||||
|
||||
let queryIndex = 0
|
||||
for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIndex]) {
|
||||
queryIndex++
|
||||
}
|
||||
}
|
||||
return queryIndex === lowerQuery.length
|
||||
}
|
||||
|
||||
// Search photos utility
|
||||
const searchPhotos = (
|
||||
photos: ReturnType<typeof photoLoader.getPhotos>,
|
||||
query: string,
|
||||
) => {
|
||||
const lowerQuery = query.trim().toLowerCase()
|
||||
if (!lowerQuery) return []
|
||||
|
||||
return photos.filter((photo) => {
|
||||
const matchesTitle = photo.title?.toLowerCase().includes(lowerQuery)
|
||||
const matchesDescription = photo.description
|
||||
?.toLowerCase()
|
||||
.includes(lowerQuery)
|
||||
const matchesTags = photo.tags?.some((tag) =>
|
||||
tag.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
const matchesCamera =
|
||||
photo.exif?.Make?.toLowerCase().includes(lowerQuery) ||
|
||||
photo.exif?.Model?.toLowerCase().includes(lowerQuery)
|
||||
const matchesLens =
|
||||
photo.exif?.LensModel?.toLowerCase().includes(lowerQuery)
|
||||
|
||||
return (
|
||||
matchesTitle ||
|
||||
matchesDescription ||
|
||||
matchesTags ||
|
||||
matchesCamera ||
|
||||
matchesLens
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const CommandPalette = ({ isOpen, onClose }: CommandPaletteProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
const navigate = useNavigate()
|
||||
const { openViewer } = usePhotoViewer()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const updateTagFilterMode = useCallback(
|
||||
(mode: 'union' | 'intersection') => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
tagFilterMode: mode,
|
||||
}))
|
||||
},
|
||||
[setGallerySetting],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: [],
|
||||
selectedCameras: [],
|
||||
selectedLenses: [],
|
||||
selectedRatings: null,
|
||||
tagFilterMode: 'union',
|
||||
}))
|
||||
}, [setGallerySetting])
|
||||
|
||||
// Reset state when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
// Generate commands
|
||||
const commands = useMemo((): Command[] => {
|
||||
const cmds: Command[] = []
|
||||
|
||||
// Filter commands - Tags
|
||||
if (allTags.length > 0) {
|
||||
allTags.forEach((tag) => {
|
||||
const isActive = gallerySetting.selectedTags.includes(tag)
|
||||
cmds.push({
|
||||
id: `tag-${tag}`,
|
||||
type: 'filter',
|
||||
title: tag,
|
||||
subtitle: t('action.tag.filter'),
|
||||
icon: 'i-mingcute-tag-line',
|
||||
active: isActive,
|
||||
action: () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: isActive
|
||||
? prev.selectedTags.filter((t) => t !== tag)
|
||||
: [...prev.selectedTags, tag],
|
||||
}))
|
||||
},
|
||||
keywords: ['tag', 'filter', tag],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Filter commands - Cameras
|
||||
if (allCameras.length > 0) {
|
||||
allCameras.forEach((camera) => {
|
||||
const isActive = gallerySetting.selectedCameras.includes(
|
||||
camera.displayName,
|
||||
)
|
||||
cmds.push({
|
||||
id: `camera-${camera.displayName}`,
|
||||
type: 'filter',
|
||||
title: camera.displayName,
|
||||
subtitle: t('action.camera.filter'),
|
||||
icon: 'i-mingcute-camera-line',
|
||||
active: isActive,
|
||||
action: () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedCameras: isActive
|
||||
? prev.selectedCameras.filter((c) => c !== camera.displayName)
|
||||
: [...prev.selectedCameras, camera.displayName],
|
||||
}))
|
||||
},
|
||||
keywords: [
|
||||
'camera',
|
||||
'filter',
|
||||
camera.displayName,
|
||||
camera.make,
|
||||
camera.model,
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Filter commands - Lenses
|
||||
if (allLenses.length > 0) {
|
||||
allLenses.forEach((lens) => {
|
||||
const isActive = gallerySetting.selectedLenses.includes(
|
||||
lens.displayName,
|
||||
)
|
||||
cmds.push({
|
||||
id: `lens-${lens.displayName}`,
|
||||
type: 'filter',
|
||||
title: lens.displayName,
|
||||
subtitle: t('action.lens.filter'),
|
||||
icon: <MageLens />,
|
||||
active: isActive,
|
||||
action: () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedLenses: isActive
|
||||
? prev.selectedLenses.filter((l) => l !== lens.displayName)
|
||||
: [...prev.selectedLenses, lens.displayName],
|
||||
}))
|
||||
},
|
||||
keywords: ['lens', 'filter', lens.displayName],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Tag filter mode toggle
|
||||
if (allTags.length > 0) {
|
||||
const isUnionMode = gallerySetting.tagFilterMode === 'union'
|
||||
cmds.push({
|
||||
id: 'tag-filter-mode-toggle',
|
||||
type: 'action',
|
||||
title: isUnionMode
|
||||
? t('action.tag.match.any')
|
||||
: t('action.tag.match.all'),
|
||||
subtitle: t('action.tag.match.label'),
|
||||
icon: 'i-mingcute-switch-line',
|
||||
badge: isUnionMode ? t('action.tag.mode.or') : t('action.tag.mode.and'),
|
||||
action: () =>
|
||||
updateTagFilterMode(isUnionMode ? 'intersection' : 'union'),
|
||||
keywords: ['tag', 'filter', 'mode', 'toggle'],
|
||||
})
|
||||
}
|
||||
|
||||
// Filter commands - Ratings
|
||||
for (let rating = 1; rating <= 5; rating++) {
|
||||
const isActive = gallerySetting.selectedRatings === rating
|
||||
cmds.push({
|
||||
id: `rating-${rating}`,
|
||||
type: 'filter',
|
||||
title: t('action.rating.filter-above', { rating }),
|
||||
subtitle: t('action.rating.filter'),
|
||||
icon: 'i-mingcute-star-line',
|
||||
active: isActive,
|
||||
action: () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedRatings: isActive ? null : rating,
|
||||
}))
|
||||
},
|
||||
keywords: ['rating', 'filter', 'star', rating.toString()],
|
||||
})
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
const hasFilters =
|
||||
gallerySetting.selectedTags.length > 0 ||
|
||||
gallerySetting.selectedCameras.length > 0 ||
|
||||
gallerySetting.selectedLenses.length > 0 ||
|
||||
gallerySetting.selectedRatings !== null
|
||||
|
||||
if (hasFilters) {
|
||||
cmds.push({
|
||||
id: 'clear-filters',
|
||||
type: 'action',
|
||||
title: t('action.search.clear'),
|
||||
subtitle: 'Clear all active filters',
|
||||
icon: 'i-mingcute-close-line',
|
||||
action: () => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: [],
|
||||
selectedCameras: [],
|
||||
selectedLenses: [],
|
||||
selectedRatings: null,
|
||||
tagFilterMode: 'union',
|
||||
}))
|
||||
},
|
||||
keywords: ['clear', 'reset', 'remove', 'filter'],
|
||||
})
|
||||
}
|
||||
|
||||
// Photo search results
|
||||
if (query.trim()) {
|
||||
const photos = searchPhotos(photoLoader.getPhotos(), query)
|
||||
photos.slice(0, 10).forEach((photo) => {
|
||||
cmds.push({
|
||||
id: `photo-${photo.id}`,
|
||||
type: 'photo',
|
||||
title: photo.title || photo.id,
|
||||
subtitle: photo.description || `${photo.exif?.Model || 'Photo'}`,
|
||||
icon: (
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.title || 'Photo'}
|
||||
className="h-6 w-6 rounded object-cover"
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
const allPhotos = photoLoader.getPhotos()
|
||||
const photoIndex = allPhotos.findIndex((p) => p.id === photo.id)
|
||||
if (photoIndex !== -1) {
|
||||
openViewer(photoIndex)
|
||||
navigate(`/${photo.id}`)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
keywords: [
|
||||
photo.title,
|
||||
photo.description,
|
||||
...(photo.tags || []),
|
||||
].filter(Boolean) as string[],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
t,
|
||||
gallerySetting,
|
||||
query,
|
||||
navigate,
|
||||
onClose,
|
||||
setGallerySetting,
|
||||
openViewer,
|
||||
updateTagFilterMode,
|
||||
])
|
||||
|
||||
// Filter commands based on query
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
// Show all filters when no query - group by type
|
||||
const activeFilters = commands.filter((cmd) => cmd.active)
|
||||
const allFilters = commands.filter((cmd) => cmd.type === 'filter')
|
||||
|
||||
// Prioritize active filters, then show all available filters
|
||||
const uniqueFilters = new Map<string, Command>()
|
||||
|
||||
// First add active filters
|
||||
activeFilters.forEach((cmd) => uniqueFilters.set(cmd.id, cmd))
|
||||
|
||||
// Then add remaining filters
|
||||
allFilters.forEach((cmd) => {
|
||||
if (!uniqueFilters.has(cmd.id)) {
|
||||
uniqueFilters.set(cmd.id, cmd)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(uniqueFilters.values()).slice(0, 30)
|
||||
}
|
||||
|
||||
return commands
|
||||
.filter((cmd) => {
|
||||
const searchText = `${cmd.title} ${cmd.subtitle || ''} ${cmd.keywords?.join(' ') || ''}`
|
||||
return fuzzyMatch(searchText, query)
|
||||
})
|
||||
.slice(0, 20)
|
||||
}, [commands, query])
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(prev + 1, filteredCommands.length - 1),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (filteredCommands[selectedIndex]) {
|
||||
filteredCommands[selectedIndex].action()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[filteredCommands, selectedIndex],
|
||||
)
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[
|
||||
selectedIndex
|
||||
] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
// Reset selected index when filtered commands change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [filteredCommands.length])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-end justify-center lg:items-start lg:pt-[15vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop with blur */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-lg transition-all duration-200" />
|
||||
|
||||
{/* Command Palette Panel */}
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-4 lg:slide-in-from-top-4 border-border relative w-full max-w-2xl overflow-hidden rounded-2xl rounded-b-none border shadow-2xl backdrop-blur-[120px] duration-200 lg:!rounded-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="border-border flex items-center gap-3 border-b px-4 py-4">
|
||||
<i className="i-mingcute-search-line text-text-tertiary shrink-0 text-xl" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('action.search.placeholder')}
|
||||
className="text-text placeholder-text-tertiary flex-1 bg-transparent text-base outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className={clsxm(
|
||||
'inline-flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs font-medium transition-colors',
|
||||
'text-text-secondary hover:bg-fill-tertiary hover:text-text',
|
||||
)}
|
||||
>
|
||||
<i className="i-mingcute-refresh-1-line text-sm" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={clsxm(
|
||||
'inline-flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs font-medium transition-colors',
|
||||
'text-text-secondary hover:bg-fill-tertiary hover:text-text',
|
||||
)}
|
||||
>
|
||||
<i className="i-mingcute-close-line text-sm" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex items-center justify-between gap-3 px-4 py-2 text-xs',
|
||||
'border-b border-border bg-fill-tertiary/40 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="i-mingcute-filter-3-line text-sm" />
|
||||
<span>{t('action.tag.match.label')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTagFilterMode('union')}
|
||||
className={clsxm(
|
||||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||
gallerySetting.tagFilterMode === 'union'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-fill-secondary text-text-secondary hover:bg-fill-tertiary hover:text-text',
|
||||
)}
|
||||
>
|
||||
{t('action.tag.match.any')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateTagFilterMode('intersection')}
|
||||
className={clsxm(
|
||||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||
gallerySetting.tagFilterMode === 'intersection'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-fill-secondary text-text-secondary hover:bg-fill-tertiary hover:text-text',
|
||||
)}
|
||||
>
|
||||
{t('action.tag.match.all')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands List */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[60vh] overflow-y-auto overscroll-contain py-2"
|
||||
>
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<i className="i-mingcute-search-line text-text-quaternary mb-3 text-4xl" />
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('action.search.no-results')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCommands.map((cmd, index) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
type="button"
|
||||
onClick={cmd.action}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={clsxm(
|
||||
'flex w-full items-center gap-3 px-4 py-3 text-left transition-all duration-100',
|
||||
selectedIndex === index
|
||||
? 'bg-fill-secondary'
|
||||
: 'hover:bg-fill-tertiary',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg transition-colors',
|
||||
cmd.active
|
||||
? 'bg-accent/10 ring-accent/20 ring-1 ring-inset'
|
||||
: 'bg-fill-secondary',
|
||||
'text-lg',
|
||||
cmd.active ? 'text-accent' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{typeof cmd.icon === 'string' ? (
|
||||
<i className={cmd.icon} />
|
||||
) : (
|
||||
cmd.icon
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text truncate text-sm font-medium">
|
||||
{cmd.title}
|
||||
</span>
|
||||
{cmd.badge !== undefined && (
|
||||
<span className="bg-fill-tertiary text-text-secondary rounded-full px-2 py-0.5 text-xs">
|
||||
{cmd.badge}
|
||||
</span>
|
||||
)}
|
||||
{cmd.active && (
|
||||
<span className="bg-accent flex h-5 w-5 items-center justify-center rounded-full text-white">
|
||||
<i className="i-mingcute-check-line text-xs" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cmd.subtitle && (
|
||||
<p className="text-text-secondary truncate text-xs">
|
||||
{cmd.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-border border-t px-4 py-2">
|
||||
<div className="text-text-secondary flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="bg-fill-secondary border-border rounded border px-1.5 py-0.5 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="bg-fill-secondary border-border rounded border px-1.5 py-0.5 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
</div>
|
||||
{filteredCommands.length > 0 && (
|
||||
<span>{filteredCommands.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
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'
|
||||
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
|
||||
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' | 'ratings'
|
||||
>('tags')
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState(
|
||||
gallerySetting.tagSearchQuery,
|
||||
)
|
||||
const [cameraSearchQuery, setCameraSearchQuery] = useState(
|
||||
gallerySetting.cameraSearchQuery,
|
||||
)
|
||||
const [lensSearchQuery, setLensSearchQuery] = useState(
|
||||
gallerySetting.lensSearchQuery,
|
||||
)
|
||||
const [ratingSearchQuery, setRatingSearchQuery] = useState(
|
||||
gallerySetting.ratingSearchQuery,
|
||||
)
|
||||
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])
|
||||
|
||||
const setRating = useCallback(
|
||||
(rating: number | null) => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedRatings: rating,
|
||||
}))
|
||||
},
|
||||
[setGallerySetting],
|
||||
)
|
||||
|
||||
const clearRatings = useCallback(() => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedRatings: null,
|
||||
ratingSearchQuery: '',
|
||||
}))
|
||||
setRatingSearchQuery('')
|
||||
}, [setGallerySetting])
|
||||
|
||||
const setTagFilterMode = useCallback(
|
||||
(mode: 'union' | 'intersection') => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
tagFilterMode: mode,
|
||||
}))
|
||||
},
|
||||
[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],
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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-ri-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,
|
||||
},
|
||||
{
|
||||
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: ratingSearchQuery,
|
||||
searchPlaceholder: t('action.rating.search'),
|
||||
emptyMessage: '',
|
||||
notFoundMessage: '',
|
||||
onToggle: () => {},
|
||||
onClear: clearRatings,
|
||||
onSearchChange: onRatingSearchChange,
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
gallerySetting.selectedTags,
|
||||
gallerySetting.selectedCameras,
|
||||
gallerySetting.selectedLenses,
|
||||
gallerySetting.selectedRatings,
|
||||
filteredTags,
|
||||
filteredCameras,
|
||||
filteredLenses,
|
||||
tagSearchQuery,
|
||||
cameraSearchQuery,
|
||||
lensSearchQuery,
|
||||
ratingSearchQuery,
|
||||
toggleTag,
|
||||
toggleCamera,
|
||||
toggleLens,
|
||||
clearTags,
|
||||
clearCameras,
|
||||
clearLenses,
|
||||
clearRatings,
|
||||
onTagSearchChange,
|
||||
onCameraSearchChange,
|
||||
onLensSearchChange,
|
||||
onRatingSearchChange,
|
||||
],
|
||||
)
|
||||
|
||||
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-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">
|
||||
{t('action.filter.title')}
|
||||
</h3>
|
||||
|
||||
{/* Reset all filters */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="opacity-80"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setGallerySetting((prev) => ({
|
||||
...prev,
|
||||
selectedTags: [],
|
||||
selectedCameras: [],
|
||||
selectedLenses: [],
|
||||
selectedRatings: null,
|
||||
tagFilterMode: 'union',
|
||||
tagSearchQuery: '',
|
||||
cameraSearchQuery: '',
|
||||
lensSearchQuery: '',
|
||||
ratingSearchQuery: '',
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{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"
|
||||
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 - 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>
|
||||
) : currentTab.filteredData.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{currentTab.notFoundMessage}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tag Filter Mode Toggle - Only show for tags tab when tags are selected */}
|
||||
{activeTab === 'tags' && (
|
||||
<div className="mb-3 flex items-center gap-3 px-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{t('action.tag.match.label')}</span>
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={gallerySetting.tagFilterMode === 'union'}
|
||||
onCheckedChange={() => setTagFilterMode('union')}
|
||||
/>
|
||||
<span>{t('action.tag.match.any')}</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={gallerySetting.tagFilterMode === 'intersection'}
|
||||
onCheckedChange={() => setTagFilterMode('intersection')}
|
||||
/>
|
||||
<span>{t('action.tag.match.all')}</span>
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// 五星评分组件
|
||||
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 dark:text-yellow-500'
|
||||
: 'i-mingcute-star-line text-gray-300 dark:text-gray-600',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export const Slider = ({
|
||||
{/* 背景轨道 */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="absolute top-1/2 h-1.5 w-full -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
className="absolute top-1/2 h-1.5 w-full -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700"
|
||||
>
|
||||
{/* 自动档区域指示 */}
|
||||
<div className="absolute top-0 left-0 h-full w-[12%] rounded-l-full bg-green-100 dark:bg-green-900/50" />
|
||||
@@ -143,7 +143,7 @@ export const Slider = ({
|
||||
/>
|
||||
|
||||
{/* 数值刻度 */}
|
||||
<div className="absolute top-full mt-1 flex w-full text-xs text-gray-400">
|
||||
<div className="text-text-secondary absolute top-full mt-1 flex w-full text-xs">
|
||||
<div className="w-[15%] text-left">
|
||||
<span
|
||||
className={clsxm(
|
||||
|
||||
23
apps/web/src/hooks/useCommandPaletteShortcut.ts
Normal file
23
apps/web/src/hooks/useCommandPaletteShortcut.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { isCommandPaletteOpenAtom } from '~/atoms/app'
|
||||
|
||||
export const useCommandPaletteShortcut = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(isCommandPaletteOpenAtom)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// cmd+k on macOS, ctrl+k on Windows/Linux
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setIsOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [setIsOpen])
|
||||
|
||||
return { isOpen, setIsOpen }
|
||||
}
|
||||
@@ -105,3 +105,27 @@ export function MaterialSymbolsExposure(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MageLens(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
{/* Icon from Mage Icons by MageIcons - https://github.com/Mage-Icons/mage-icons/blob/main/License.txt */}
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M12 21.5a9.5 9.5 0 1 0 0-19a9.5 9.5 0 0 0 0 19" />
|
||||
<path d="M16.113 14.375v-4.75L12 7.25L7.886 9.625v4.75L12 16.75zm1.255-10.213L12.01 7.25m9.462 5.425l-5.359-3.05m0 10.935v-6.185m-9.49 5.453l5.368-3.078m-9.462-5.443l5.358 3.068M7.886 3.44v6.185" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,284 +1,50 @@
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
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 { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui/dropdown-menu'
|
||||
import { Slider } from '~/components/ui/slider'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
const SortPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
|
||||
const setSortOrder = (order: 'asc' | 'desc') => {
|
||||
setGallerySetting({
|
||||
...gallerySetting,
|
||||
sortOrder: order,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="pb-safe flex flex-col gap-2 p-0 lg:gap-0 lg:pt-0 lg:pb-0 lg:text-sm">
|
||||
<h3 className="flex h-6 items-center px-2 text-sm font-medium lg:h-8">
|
||||
{t('action.sort.mode')}
|
||||
</h3>
|
||||
<div className="bg-border mx-2 my-1 h-px" />
|
||||
<div
|
||||
className={clsxm(
|
||||
'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-3 transition-colors hover:backdrop-blur-3xl lg:py-1',
|
||||
)}
|
||||
onClick={() => setSortOrder('desc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-descending-line" />
|
||||
<span>{t('action.sort.newest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'desc' && (
|
||||
<i className="i-mingcute-check-line ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsxm(
|
||||
'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-3 transition-colors hover:backdrop-blur-3xl lg:py-1',
|
||||
)}
|
||||
onClick={() => setSortOrder('asc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-ascending-line" />
|
||||
<span>{t('action.sort.oldest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'asc' && (
|
||||
<i className="i-mingcute-check-line ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ColumnsPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
const isMobile = useMobile()
|
||||
|
||||
const setColumns = (columns: number | 'auto') => {
|
||||
setGallerySetting({
|
||||
...gallerySetting,
|
||||
columns,
|
||||
})
|
||||
}
|
||||
// 根据设备类型提供不同的列数范围
|
||||
const columnRange = isMobile
|
||||
? { min: 2, max: 4 } // 移动端适合的列数范围
|
||||
: { min: 2, max: 8 } // 桌面端适合的列数范围
|
||||
|
||||
return (
|
||||
<div className="pb-safe lg:pb-safe-2 w-full lg:w-80 lg:p-2">
|
||||
<h3 className="mb-3 px-2 text-sm font-medium">
|
||||
{t('action.columns.setting')}
|
||||
</h3>
|
||||
|
||||
<div className="px-2">
|
||||
<Slider
|
||||
value={gallerySetting.columns}
|
||||
onChange={setColumns}
|
||||
min={columnRange.min}
|
||||
max={columnRange.max}
|
||||
autoLabel={t('action.auto')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 通用的操作按钮组件
|
||||
const ActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
onClick,
|
||||
ref,
|
||||
...props
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
onClick: () => void
|
||||
ref?: React.RefObject<HTMLButtonElement>
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<i
|
||||
className={clsxm(icon, 'text-base text-gray-600 dark:text-gray-300')}
|
||||
/>
|
||||
{badge && (
|
||||
<span className="bg-accent absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full text-xs font-medium text-white shadow-sm">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 桌面端的下拉菜单按钮
|
||||
const DesktopActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
contentClassName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
contentClassName?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (
|
||||
open: boolean,
|
||||
setGallerySetting: (setting: any) => void,
|
||||
) => void
|
||||
}) => {
|
||||
const setGallerySetting = useSetAtom(gallerySettingAtom)
|
||||
return (
|
||||
<DropdownMenu
|
||||
defaultOpen={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange?.(open, setGallerySetting)
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className={contentClassName}>
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// 移动端的抽屉按钮
|
||||
const MobileActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
/>
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed right-0 bottom-0 left-0 z-50 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white/80 p-4 backdrop-blur-xl dark:border-zinc-800 dark:bg-black/80">
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
{children}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 响应式操作按钮组件
|
||||
const ResponsiveActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
contentClassName,
|
||||
globalOpen,
|
||||
onGlobalOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
contentClassName?: string
|
||||
globalOpen?: boolean
|
||||
onGlobalOpenChange?: (
|
||||
open: boolean,
|
||||
setGallerySetting: (setting: any) => void,
|
||||
) => void
|
||||
}) => {
|
||||
const isMobile = useMobile()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
{children}
|
||||
</MobileActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
contentClassName={contentClassName}
|
||||
open={globalOpen}
|
||||
onOpenChange={onGlobalOpenChange}
|
||||
>
|
||||
{children}
|
||||
</DesktopActionButton>
|
||||
)
|
||||
}
|
||||
import { ResponsiveActionButton } from './components/ActionButton'
|
||||
import { ViewPanel } from './panels/ViewPanel'
|
||||
|
||||
export const ActionGroup = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
const [gallerySetting] = useAtom(gallerySettingAtom)
|
||||
const setCommandPaletteOpen = useSetAtom(isCommandPaletteOpenAtom)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onTagsPanelOpenChange = (open: boolean) => {
|
||||
setGallerySetting((prev: any) => ({
|
||||
...prev,
|
||||
isTagsPanelOpen: open,
|
||||
}))
|
||||
}
|
||||
// 计算视图设置是否有自定义配置
|
||||
const hasViewCustomization =
|
||||
gallerySetting.columns !== 'auto' || gallerySetting.sortOrder !== 'desc'
|
||||
|
||||
// 计算过滤器数量
|
||||
const filterCount =
|
||||
gallerySetting.selectedTags.length +
|
||||
gallerySetting.selectedCameras.length +
|
||||
gallerySetting.selectedLenses.length +
|
||||
(gallerySetting.selectedRatings !== null ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{/* 搜索和过滤按钮 - 打开命令面板 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCommandPaletteOpen(true)
|
||||
}}
|
||||
className="relative h-10 min-w-10 rounded-full border-0 bg-gray-100 px-3 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
title={t('action.search.unified.title')}
|
||||
>
|
||||
<i className="i-mingcute-search-line text-base text-gray-600 dark:text-gray-300" />
|
||||
{filterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs font-medium text-white">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 地图探索按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -290,83 +56,14 @@ export const ActionGroup = () => {
|
||||
<i className="i-mingcute-map-pin-line text-base text-gray-600 dark:text-gray-300" />
|
||||
</Button>
|
||||
|
||||
{/* 过滤按钮 */}
|
||||
{/* 视图设置按钮(合并排序和列数) */}
|
||||
<ResponsiveActionButton
|
||||
icon="i-mingcute-filter-line"
|
||||
title={t('action.filter.title')}
|
||||
badge={
|
||||
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}
|
||||
icon="i-mingcute-layout-grid-line"
|
||||
title={t('action.view.title')}
|
||||
badge={hasViewCustomization ? '●' : undefined}
|
||||
>
|
||||
<FilterPanel />
|
||||
</ResponsiveActionButton>
|
||||
|
||||
{/* 列数调整按钮 */}
|
||||
<ResponsiveActionButton
|
||||
icon="i-mingcute-grid-line"
|
||||
title={t('action.columns.setting')}
|
||||
badge={
|
||||
gallerySetting.columns !== 'auto' ? gallerySetting.columns : undefined
|
||||
}
|
||||
>
|
||||
<ColumnsPanel />
|
||||
</ResponsiveActionButton>
|
||||
|
||||
{/* 排序按钮 */}
|
||||
<ResponsiveActionButton
|
||||
icon={
|
||||
gallerySetting.sortOrder === 'desc'
|
||||
? 'i-mingcute-sort-descending-line'
|
||||
: 'i-mingcute-sort-ascending-line'
|
||||
}
|
||||
title={t('action.sort.mode')}
|
||||
contentClassName="w-48"
|
||||
>
|
||||
<SortPanel />
|
||||
<ViewPanel />
|
||||
</ResponsiveActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const panelMap = {
|
||||
sort: SortPanel,
|
||||
tags: FilterPanel,
|
||||
columns: ColumnsPanel,
|
||||
}
|
||||
|
||||
export type PanelType = keyof typeof panelMap
|
||||
// 导出 ActionType 以保持与 FloatingActionButton 的一致性
|
||||
export type ActionType = PanelType
|
||||
|
||||
export const ActionPanel = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
type: PanelType | null
|
||||
}) => {
|
||||
const Panel = type ? panelMap[type] : null
|
||||
return (
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed right-0 bottom-0 left-0 z-50 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white/80 p-4 backdrop-blur-xl dark:border-zinc-800 dark:bg-black/80">
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
{Panel && <Panel />}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
|
||||
32
apps/web/src/modules/gallery/ActionPanel.tsx
Normal file
32
apps/web/src/modules/gallery/ActionPanel.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Drawer } from 'vaul'
|
||||
|
||||
import { ViewPanel } from './panels/ViewPanel'
|
||||
|
||||
const panelMap = {
|
||||
view: ViewPanel,
|
||||
}
|
||||
|
||||
export type PanelType = keyof typeof panelMap
|
||||
|
||||
export const ActionPanel = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
type: PanelType | null
|
||||
}) => {
|
||||
const Panel = type ? panelMap[type] : null
|
||||
return (
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed right-0 bottom-0 left-0 z-50 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white/80 p-4 backdrop-blur-xl dark:border-zinc-800 dark:bg-black/80">
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
{Panel && <Panel />}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
@@ -10,20 +10,14 @@ import { Spring } from '~/lib/spring'
|
||||
type TranslationKeys = keyof typeof en
|
||||
|
||||
const actions: {
|
||||
id: 'sort' | 'tags' | 'columns'
|
||||
id: 'view'
|
||||
icon: string
|
||||
title: TranslationKeys
|
||||
}[] = [
|
||||
{
|
||||
id: 'sort',
|
||||
icon: 'i-mingcute-sort-descending-line',
|
||||
title: 'action.sort.mode',
|
||||
},
|
||||
{ id: 'tags', icon: 'i-mingcute-filter-line', title: 'action.filter.title' },
|
||||
{
|
||||
id: 'columns',
|
||||
icon: 'i-mingcute-grid-line',
|
||||
title: 'action.columns.setting',
|
||||
id: 'view',
|
||||
icon: 'i-mingcute-settings-3-line',
|
||||
title: 'action.view.settings',
|
||||
},
|
||||
] as const
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import { clsxm } from '~/lib/cn'
|
||||
import { Spring } from '~/lib/spring'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import type { ActionType } from './ActionGroup'
|
||||
import { ActionGroup, ActionPanel } from './ActionGroup'
|
||||
import { FloatingActionButton } from './FloatingActionButton'
|
||||
import { ActionGroup } from './ActionGroup'
|
||||
import type { PanelType } from './ActionPanel'
|
||||
import { ActionPanel } from './ActionPanel'
|
||||
import type { MasonryRef } from './Masonic'
|
||||
import { Masonry } from './Masonic'
|
||||
import { MasonryHeaderMasonryItem } from './MasonryHeaderMasonryItem'
|
||||
@@ -62,10 +62,7 @@ export const MasonryRoot = () => {
|
||||
}, [])
|
||||
const isMobile = useMobile()
|
||||
|
||||
const [activePanel, setActivePanel] = useState<ActionType | null>(null)
|
||||
const handleActionClick = (action: ActionType) => {
|
||||
setActivePanel(action)
|
||||
}
|
||||
const [activePanel, setActivePanel] = useState<PanelType | null>(null)
|
||||
|
||||
// 监听容器宽度变化
|
||||
useEffect(() => {
|
||||
@@ -155,13 +152,6 @@ export const MasonryRoot = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<FloatingActionButton
|
||||
isVisible={showFloatingActions}
|
||||
onActionClick={handleActionClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-1 lg:px-0 lg:pb-0 [&_*]:!select-none">
|
||||
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
|
||||
<Masonry<MasonryItemType>
|
||||
|
||||
183
apps/web/src/modules/gallery/components/ActionButton.tsx
Normal file
183
apps/web/src/modules/gallery/components/ActionButton.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { Drawer } from 'vaul'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui/dropdown-menu'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
// 通用的操作按钮组件
|
||||
export const ActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
onClick,
|
||||
ref,
|
||||
...props
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
onClick: () => void
|
||||
ref?: React.RefObject<HTMLButtonElement>
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<i
|
||||
className={clsxm(icon, 'text-base text-gray-600 dark:text-gray-300')}
|
||||
/>
|
||||
{badge && (
|
||||
<span className="bg-accent absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full text-xs font-medium text-white shadow-sm">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 桌面端的下拉菜单按钮
|
||||
export const DesktopActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
contentClassName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
contentClassName?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (
|
||||
open: boolean,
|
||||
setGallerySetting: (setting: any) => void,
|
||||
) => void
|
||||
}) => {
|
||||
const setGallerySetting = useSetAtom(gallerySettingAtom)
|
||||
return (
|
||||
<DropdownMenu
|
||||
defaultOpen={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange?.(open, setGallerySetting)
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className={contentClassName}>
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// 移动端的抽屉按钮
|
||||
export const MobileActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
/>
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed right-0 bottom-0 left-0 z-50 flex flex-col rounded-t-2xl border-t border-zinc-200 bg-white/80 p-4 backdrop-blur-xl dark:border-zinc-800 dark:bg-black/80">
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
{children}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 响应式操作按钮组件
|
||||
export const ResponsiveActionButton = ({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
contentClassName,
|
||||
globalOpen,
|
||||
onGlobalOpenChange,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
badge?: number | string
|
||||
children: React.ReactNode
|
||||
contentClassName?: string
|
||||
globalOpen?: boolean
|
||||
onGlobalOpenChange?: (
|
||||
open: boolean,
|
||||
setGallerySetting: (setting: any) => void,
|
||||
) => void
|
||||
}) => {
|
||||
const isMobile = useMobile()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
{children}
|
||||
</MobileActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopActionButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
badge={badge}
|
||||
contentClassName={contentClassName}
|
||||
open={globalOpen}
|
||||
onOpenChange={onGlobalOpenChange}
|
||||
>
|
||||
{children}
|
||||
</DesktopActionButton>
|
||||
)
|
||||
}
|
||||
35
apps/web/src/modules/gallery/panels/ColumnsPanel.tsx
Normal file
35
apps/web/src/modules/gallery/panels/ColumnsPanel.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { Slider } from '~/components/ui/slider'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
|
||||
export const ColumnsPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
const isMobile = useMobile()
|
||||
|
||||
const setColumns = (columns: number | 'auto') => {
|
||||
setGallerySetting({
|
||||
...gallerySetting,
|
||||
columns,
|
||||
})
|
||||
}
|
||||
// 根据设备类型提供不同的列数范围
|
||||
const columnRange = isMobile
|
||||
? { min: 2, max: 4 } // 移动端适合的列数范围
|
||||
: { min: 2, max: 8 } // 桌面端适合的列数范围
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-80">
|
||||
<Slider
|
||||
value={gallerySetting.columns}
|
||||
onChange={setColumns}
|
||||
min={columnRange.min}
|
||||
max={columnRange.max}
|
||||
autoLabel={t('action.auto')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/modules/gallery/panels/SortPanel.tsx
Normal file
44
apps/web/src/modules/gallery/panels/SortPanel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
|
||||
export const SortPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
|
||||
|
||||
const setSortOrder = (order: 'asc' | 'desc') => {
|
||||
setGallerySetting({
|
||||
...gallerySetting,
|
||||
sortOrder: order,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="-mx-2 flex flex-col p-0 text-sm lg:p-0">
|
||||
<div
|
||||
className={
|
||||
'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-2 transition-colors hover:backdrop-blur-3xl lg:py-1'
|
||||
}
|
||||
onClick={() => setSortOrder('desc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-descending-line" />
|
||||
<span>{t('action.sort.newest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'desc' && (
|
||||
<i className="i-mingcute-check-line ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'hover:bg-accent/50 flex cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-2 transition-colors hover:backdrop-blur-3xl lg:py-1'
|
||||
}
|
||||
onClick={() => setSortOrder('asc')}
|
||||
>
|
||||
<i className="i-mingcute-sort-ascending-line" />
|
||||
<span>{t('action.sort.oldest.first')}</span>
|
||||
{gallerySetting.sortOrder === 'asc' && (
|
||||
<i className="i-mingcute-check-line ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
apps/web/src/modules/gallery/panels/ViewPanel.tsx
Normal file
36
apps/web/src/modules/gallery/panels/ViewPanel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ColumnsPanel } from './ColumnsPanel'
|
||||
import { SortPanel } from './SortPanel'
|
||||
|
||||
// 合并的视图面板(排序 + 列数)
|
||||
export const ViewPanel = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="pb-safe lg:pb-safe-2 w-full lg:py-1">
|
||||
<h3 className="mb-3 px-2 text-sm font-medium">
|
||||
{t('action.view.settings')}
|
||||
</h3>
|
||||
|
||||
{/* 排序部分 */}
|
||||
<div className="mb-3 px-2">
|
||||
<h4 className="text-text-secondary mb-3 text-xs font-medium">
|
||||
{t('action.sort.mode')}
|
||||
</h4>
|
||||
<SortPanel />
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="bg-border mx-2 my-3 h-px" />
|
||||
|
||||
{/* 列数部分 */}
|
||||
<div className="px-2">
|
||||
<h4 className="text-text-secondary mb-3 text-xs font-medium">
|
||||
{t('action.columns.setting')}
|
||||
</h4>
|
||||
<ColumnsPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user