mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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 表示自动计算
|
||||
})
|
||||
|
||||
178
apps/web/src/components/ui/slider.tsx
Normal file
178
apps/web/src/components/ui/slider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user