Files
afilmory/apps/web/src/modules/gallery/ActionGroup.tsx

402 lines
11 KiB
TypeScript

import { photoLoader } from '@afilmory/data'
import { useAtom } 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 { 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 allTags = photoLoader.getAllTags()
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 TagsPanel = () => {
const { t } = useTranslation()
const [gallerySetting, setGallerySetting] = useAtom(gallerySettingAtom)
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: [],
})
}
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>
{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>
) : (
<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">
{allTags.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 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,
}: {
icon: string
title: string
badge?: number | string
children: React.ReactNode
contentClassName?: string
}) => {
return (
<DropdownMenu>
<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,
}: {
icon: string
title: string
badge?: number | string
children: React.ReactNode
contentClassName?: string
}) => {
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}
>
{children}
</DesktopActionButton>
)
}
export const ActionGroup = () => {
const { t } = useTranslation()
const [gallerySetting] = useAtom(gallerySettingAtom)
const navigate = useNavigate()
return (
<div className="flex items-center justify-center gap-3">
{/* 地图探索按钮 */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/explory')}
className="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={t('action.map.explore')}
>
<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')}
badge={
gallerySetting.selectedTags.length > 0
? gallerySetting.selectedTags.length
: undefined
}
>
<TagsPanel />
</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 />
</ResponsiveActionButton>
</div>
)
}
const panelMap = {
sort: SortPanel,
tags: TagsPanel,
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>
)
}