feat: enhance gallery settings with adjustable column layout

- Replaced the gallery settings atom with atomWithStorage to persist settings across sessions.
- Added a new AdjustColumnsButton component to allow users to dynamically adjust the number of columns in the gallery based on device type.
- Implemented responsive column width calculation in the MasonryRoot component to accommodate the new column settings.
- Updated the ActionGroup component to include the new AdjustColumnsButton for better user interaction.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-05 21:51:15 +08:00
parent 0b6160f6d5
commit 47f54ad11c
4 changed files with 315 additions and 5 deletions

View File

@@ -1,9 +1,11 @@
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export type GallerySortBy = 'date'
export type GallerySortOrder = 'asc' | 'desc'
export const gallerySettingAtom = atom({
export const gallerySettingAtom = atomWithStorage('gallery-settings', {
sortBy: 'date' as GallerySortBy,
sortOrder: 'desc' as GallerySortOrder,
selectedTags: [] as string[],
columns: 'auto' as number | 'auto', // 自定义列数auto 表示自动计算
})

View File

@@ -0,0 +1,178 @@
import { useCallback, useRef, useState } from 'react'
import { clsxm } from '~/lib/cn'
interface SliderProps {
value: number | 'auto'
onChange: (value: number | 'auto') => void
min: number
max: number
step?: number
autoLabel?: string
className?: string
disabled?: boolean
}
export const Slider = ({
value,
onChange,
min,
max,
step = 1,
autoLabel = '自动',
className,
disabled = false,
}: SliderProps) => {
const [isDragging, setIsDragging] = useState(false)
const sliderRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
// 将值转换为位置百分比
const getPositionFromValue = useCallback(
(val: number | 'auto') => {
if (val === 'auto') return 5 // 自动档位置稍微偏右一点
// 数值档从 15% 开始到 100%
return 15 + ((val - min) / (max - min)) * 85
},
[min, max],
)
// 将位置百分比转换为值
const getValueFromPosition = useCallback(
(position: number) => {
if (position <= 12) return 'auto' // 左侧 12% 区域为自动档
const normalizedPosition = (position - 15) / 85 // 从 15% 开始的 85% 区域为数值
const rawValue = min + Math.max(0, normalizedPosition) * (max - min)
return Math.round(Math.max(min, rawValue) / step) * step
},
[min, max, step],
)
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (disabled) return
event.preventDefault()
setIsDragging(true)
const updateValue = (clientX: number) => {
if (!trackRef.current) return
const rect = trackRef.current.getBoundingClientRect()
const position = ((clientX - rect.left) / rect.width) * 100
const clampedPosition = Math.max(0, Math.min(100, position))
const newValue = getValueFromPosition(clampedPosition)
if (newValue !== value) {
onChange(newValue)
}
}
updateValue(event.clientX)
const handlePointerMove = (e: PointerEvent) => {
updateValue(e.clientX)
}
const handlePointerUp = () => {
setIsDragging(false)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[disabled, value, onChange, getValueFromPosition],
)
const position = getPositionFromValue(value)
return (
<div className={clsxm('w-full', className)}>
{/* 标签 */}
<div className="text-text-secondary mb-2 flex justify-between text-xs">
<span>{autoLabel}</span>
<span>{max}</span>
</div>
{/* 滑块轨道 */}
<div
ref={sliderRef}
className={clsxm(
'relative h-6 cursor-pointer',
disabled && 'cursor-not-allowed opacity-50',
)}
onPointerDown={handlePointerDown}
>
{/* 背景轨道 */}
<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"
>
{/* 自动档区域指示 */}
<div className="absolute top-0 left-0 h-full w-[12%] rounded-l-full bg-green-100 dark:bg-green-900/50" />
{/* 激活区域 */}
<div
className={clsxm(
'absolute top-0 h-full rounded-full transition-all duration-150',
value === 'auto' ? 'bg-green-500' : 'bg-blue-500',
)}
style={{
width: `${Math.max(position, 5)}%`,
borderRadius: value === 'auto' ? '9999px 0 0 9999px' : '9999px',
}}
/>
</div>
{/* 滑块把手 */}
<div
className={clsxm(
'absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg transition-all duration-150',
isDragging ? 'scale-110' : 'hover:scale-105',
value === 'auto' ? 'bg-green-500' : 'bg-blue-500',
disabled && 'cursor-not-allowed',
)}
style={{
left: `${position}%`,
}}
/>
{/* 数值刻度 */}
<div className="absolute top-full mt-1 flex w-full text-xs text-gray-400">
<div className="w-[15%] text-left">
<span
className={clsxm(
'transition-colors',
value === 'auto' && 'font-medium text-green-500',
)}
>
</span>
</div>
<div className="flex w-[85%] justify-between">
{Array.from({ length: max - min + 1 }, (_, i) => min + i).map(
(num) => (
<span
key={num}
className={clsxm(
'transition-colors',
value === num && 'font-medium text-blue-500',
)}
>
{num}
</span>
),
)}
</div>
</div>
</div>
{/* 当前值显示 */}
<div className="mt-8 text-center text-sm font-medium text-gray-700 dark:text-gray-300">
{value === 'auto' ? autoLabel : `${value}`}
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { siteConfig } from '@config'
import { useAtom } from 'jotai'
import { useRef, useState } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { Button } from '~/components/ui/button'
@@ -10,7 +11,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu'
import { Slider } from '~/components/ui/slider'
import { photoLoader } from '~/data/photos'
import { useMobile } from '~/hooks/useMobile'
const allTags = photoLoader.getAllTags()
@@ -111,6 +114,8 @@ export const ActionGroup = () => {
</DropdownMenuContent>
</DropdownMenu>
<AdjustColumnsButton />
{/* 排序按钮 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -151,3 +156,96 @@ export const ActionGroup = () => {
</div>
)
}
const AdjustColumnsButton = () => {
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 } // 桌面端适合的列数范围
const dropdownMenuTriggerRef = useRef<HTMLButtonElement>(null)
const [triggerRectPosition, setTriggerRectPosition] = useState<{
top: number
left: number
height: number
width: number
} | null>(null)
const [open, setOpen] = useState(false)
return (
<>
<Button
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setTriggerRectPosition({
top: rect.top,
left: rect.left,
height: rect.height,
width: rect.width,
})
setOpen(true)
}}
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="列数设置"
>
<i className="i-mingcute-grid-line text-base text-gray-600 dark:text-gray-300" />
{gallerySetting.columns !== 'auto' && (
<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 shadow-sm">
{gallerySetting.columns}
</span>
)}
</Button>
{/* 列数控制按钮 */}
<DropdownMenu
open={open}
onOpenChange={(open) => {
if (!open) {
setTriggerRectPosition(null)
}
setOpen(open)
}}
>
<DropdownMenuTrigger
className={'fixed'}
style={
triggerRectPosition
? {
top: triggerRectPosition.top,
left: triggerRectPosition.left,
height: triggerRectPosition.height,
width: triggerRectPosition.width,
}
: undefined
}
ref={dropdownMenuTriggerRef}
/>
<DropdownMenuContent align="center" className="w-80 p-2">
<DropdownMenuLabel className="mb-3"></DropdownMenuLabel>
<div className="px-2">
<Slider
value={gallerySetting.columns}
onChange={setColumns}
min={columnRange.min}
max={columnRange.max}
autoLabel="自动"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -27,9 +27,10 @@ type MasonryItemType = PhotoManifest | MasonryHeaderItem
const FIRST_SCREEN_ITEMS_COUNT = 30
export const MasonryRoot = () => {
const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
const { columns } = useAtomValue(gallerySettingAtom)
const hasAnimatedRef = useRef(false)
const [showFloatingActions, setShowFloatingActions] = useState(false)
const [containerWidth, setContainerWidth] = useState(0)
const photos = usePhotos()
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
@@ -41,6 +42,38 @@ export const MasonryRoot = () => {
}, [])
const isMobile = useMobile()
// 监听容器宽度变化
useEffect(() => {
const updateContainerWidth = () => {
setContainerWidth(window.innerWidth)
}
updateContainerWidth()
window.addEventListener('resize', updateContainerWidth)
return () => {
window.removeEventListener('resize', updateContainerWidth)
}
}, [])
// 动态计算列宽
const columnWidth = useMemo(() => {
if (columns === 'auto') {
return isMobile ? 150 : 300 // 自动模式使用默认列宽
}
// 自定义列数模式:根据容器宽度和列数计算列宽
const availableWidth = containerWidth - (isMobile ? 8 : 32) // 移动端和桌面端的 padding 不同
const gutter = 4 // 列间距
const calculatedWidth = (availableWidth - (columns - 1) * gutter) / columns
// 根据设备类型设置最小和最大列宽
const minWidth = isMobile ? 120 : 200
const maxWidth = isMobile ? 250 : 500
return Math.max(Math.min(calculatedWidth, maxWidth), minWidth)
}, [isMobile, columns, containerWidth])
// 监听滚动,控制浮动组件的显示
useEffect(() => {
if (!scrollElement) return
@@ -88,7 +121,6 @@ export const MasonryRoot = () => {
<div className="p-1 lg:p-0 [&_*]:!select-none">
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
<Masonry<MasonryItemType>
key={`${sortOrder}-${selectedTags.join(',')}`}
items={useMemo(
() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
[photos, isMobile],
@@ -106,7 +138,7 @@ export const MasonryRoot = () => {
[handleAnimationComplete, photoViewer.openViewer, photos],
)}
onRender={handleRender}
columnWidth={isMobile ? 150 : 300}
columnWidth={columnWidth}
columnGutter={4}
rowGutter={4}
itemHeightEstimate={400}