feat: implement gallery view modes and enhance layout

- Added ListView and MasonryView components for displaying photos in different layouts.
- Introduced a new GalleryViewMode type to manage view settings.
- Updated PhotosRoot to conditionally render ListView or MasonryView based on the selected view mode.
- Created a PageHeader component with a ViewModeSegment for toggling between view modes.
- Removed the deprecated MasonryRoot component and adjusted imports accordingly.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-29 18:13:25 +08:00
parent 0411a1c658
commit 447caaba6a
23 changed files with 656 additions and 316 deletions

View File

@@ -33,7 +33,6 @@
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@react-hook/window-size": "3.1.1",
"@remixicon/react": "4.7.0",
"@t3-oss/env-core": "catalog:",
"@tanstack/react-query": "5.90.11",
"@use-gesture/react": "10.3.1",

View File

@@ -2,6 +2,7 @@ import { atom } from 'jotai'
export type GallerySortBy = 'date'
export type GallerySortOrder = 'asc' | 'desc'
export type GalleryViewMode = 'masonry' | 'list'
export const gallerySettingAtom = atom({
sortBy: 'date' as GallerySortBy,
@@ -13,6 +14,7 @@ export const gallerySettingAtom = atom({
tagFilterMode: 'union' as 'union' | 'intersection', // Tag filtering logic mode
columns: 'auto' as number | 'auto', // 自定义列数auto 表示自动计算
viewMode: 'masonry' as GalleryViewMode, // 视图模式:瀑布流或列表详情
})
export const isExiftoolLoadedAtom = atom(false)

View File

@@ -0,0 +1,98 @@
import { useTranslation } from 'react-i18next'
import type { PhotoManifest } from '~/types/photo'
interface ListViewProps {
photos: PhotoManifest[]
}
export const ListView = ({ photos }: ListViewProps) => {
return (
<div className="mx-auto max-w-6xl space-y-4 px-4 lg:px-6">
{photos.map((photo) => (
<PhotoCard key={photo.id} photo={photo} />
))}
</div>
)
}
const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
const { i18n } = useTranslation()
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString(i18n.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
// 获取相机信息
const cameraInfo = photo.exif?.Model || photo.exif?.Make
const lensInfo = photo.exif?.LensModel
return (
<div className="group flex flex-col gap-4 overflow-hidden rounded-2xl border border-white/5 bg-white/5 p-4 backdrop-blur-sm transition-all duration-200 hover:border-white/10 hover:bg-white/8 lg:flex-row">
{/* 缩略图 */}
<div className="relative h-64 w-full shrink-0 overflow-hidden rounded-lg lg:h-56 lg:w-80">
<img
src={photo.thumbnailUrl || photo.originalUrl}
alt={photo.title || 'Photo'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
{/* 元数据 */}
<div className="flex min-w-0 flex-1 flex-col justify-between py-1">
{/* 标题 */}
<div>
<h3 className="mb-3 text-xl font-semibold text-white lg:text-2xl">{photo.title}</h3>
{/* 元数据行 */}
<div className="space-y-2 text-sm text-white/60">
{/* 位置 */}
{photo.location && (
<div className="flex items-center gap-2">
<i className="i-lucide-map-pin text-base" />
<span>{photo.location.locationName}</span>
</div>
)}
{/* 日期 */}
<div className="flex items-center gap-2">
<i className="i-lucide-calendar text-base" />
<span>{formatDate(new Date(photo.lastModified).getTime())}</span>
</div>
{/* 相机 */}
{cameraInfo && (
<div className="flex items-center gap-2">
<i className="i-lucide-camera text-base" />
<span>{cameraInfo}</span>
</div>
)}
{/* 镜头 */}
{lensInfo && (
<div className="flex items-center gap-2">
<i className="i-lucide-aperture text-base" />
<span>{lensInfo}</span>
</div>
)}
</div>
</div>
{/* 标签 */}
{photo.tags && photo.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{photo.tags.map((tag) => (
<span key={tag} className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white/80">
{tag}
</span>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,300 +0,0 @@
import { useScrollViewElement } from '@afilmory/ui'
import { clsxm, Spring } from '@afilmory/utils'
import { useAtomValue } from 'jotai'
import { AnimatePresence, m } from 'motion/react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { DateRangeIndicator } from '~/components/ui/date-range-indicator'
import { useMobile } from '~/hooks/useMobile'
import { useContextPhotos } from '~/hooks/usePhotoViewer'
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
import type { PhotoManifest } from '~/types/photo'
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'
import { MasonryPhotoItem } from './MasonryPhotoItem'
class MasonryHeaderItem {
static default = new MasonryHeaderItem()
}
type MasonryItemType = PhotoManifest | MasonryHeaderItem
const FIRST_SCREEN_ITEMS_COUNT = 30
const COLUMN_WIDTH_CONFIG = {
auto: {
mobile: 150,
desktop: 250,
maxColumns: 8,
},
min: {
mobile: 120,
desktop: 200,
},
max: {
mobile: 250,
desktop: 500,
},
}
export const MasonryRoot = () => {
const { columns } = useAtomValue(gallerySettingAtom)
const hasAnimatedRef = useRef(false)
const [showFloatingActions, setShowFloatingActions] = useState(false)
const [containerWidth, setContainerWidth] = useState(0)
const photos = useContextPhotos()
const masonryRef = useRef<MasonryRef>(null)
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
const scrollElement = useScrollViewElement()
const handleAnimationComplete = useCallback(() => {
hasAnimatedRef.current = true
}, [])
const isMobile = useMobile()
const [activePanel, setActivePanel] = useState<PanelType | null>(null)
// 监听容器宽度变化
useEffect(() => {
const updateContainerWidth = () => {
setContainerWidth(window.innerWidth)
}
updateContainerWidth()
window.addEventListener('resize', updateContainerWidth)
return () => {
window.removeEventListener('resize', updateContainerWidth)
}
}, [])
// 动态计算列宽
const columnWidth = useMemo(() => {
const { auto, min, max } = COLUMN_WIDTH_CONFIG
const gutter = 4 // 列间距
const availableWidth = containerWidth - (isMobile ? 8 : 32) // 移动端和桌面端的 padding 不同
if (columns === 'auto') {
const autoWidth = isMobile ? auto.mobile : auto.desktop
if (!isMobile) {
const { maxColumns } = auto
// 当屏幕宽度超过一定阈值时,通过计算动态列宽来限制最大列数
const colCount = Math.floor((availableWidth + gutter) / (autoWidth + gutter))
if (colCount > maxColumns) {
return (availableWidth - (maxColumns - 1) * gutter) / maxColumns
}
}
return autoWidth
}
// 自定义列数模式:根据容器宽度和列数计算列宽
const calculatedWidth = (availableWidth - (columns - 1) * gutter) / columns
// 根据设备类型设置最小和最大列宽
const minWidth = isMobile ? min.mobile : min.desktop
const maxWidth = isMobile ? max.mobile : max.desktop
return Math.max(Math.min(calculatedWidth, maxWidth), minWidth)
}, [isMobile, columns, containerWidth])
// 监听滚动,控制浮动组件的显示
useEffect(() => {
if (!scrollElement) return
const handleScroll = () => {
const { scrollTop } = scrollElement
setShowFloatingActions(scrollTop > 500)
}
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
return () => {
scrollElement.removeEventListener('scroll', handleScroll)
}
}, [scrollElement])
return (
<>
{/* 桌面端:左右分布 */}
{!isMobile && (
<>
<DateRangeIndicator
dateRange={dateRange.formattedRange}
location={dateRange.location}
isVisible={showFloatingActions && !!dateRange.formattedRange}
/>
<FloatingActionBar showFloatingActions={showFloatingActions} />
</>
)}
{/* 移动端:垂直堆叠 */}
{isMobile && !!dateRange.formattedRange && (
<div className="fixed top-0 right-0 left-0 z-50">
<DateRangeIndicator
dateRange={dateRange.formattedRange}
location={dateRange.location}
isVisible={showFloatingActions && !!dateRange.formattedRange}
className="relative top-0 left-0"
/>
</div>
)}
<div className="p-1 **:select-none! lg:px-0 lg:pb-0">
{isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
<Masonry<MasonryItemType>
ref={masonryRef}
items={useMemo(() => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]), [photos, isMobile])}
render={useCallback(
(props) => (
<MasonryItem
{...props}
hasAnimated={hasAnimatedRef.current}
onAnimationComplete={handleAnimationComplete}
/>
),
[handleAnimationComplete],
)}
onRender={handleRender}
columnWidth={columnWidth}
columnGutter={4}
rowGutter={4}
itemHeightEstimate={400}
itemKey={useCallback((data, _index) => {
if (data instanceof MasonryHeaderItem) {
return 'header'
}
return (data as PhotoManifest).id
}, [])}
/>
</div>
<ActionPanel
open={!!activePanel}
onOpenChange={(open) => {
if (!open) {
setActivePanel(null)
}
}}
type={activePanel}
/>
</>
)
}
export const MasonryItem = memo(
({
data,
width,
index,
hasAnimated,
onAnimationComplete,
}: {
data: MasonryItemType
width: number
index: number
hasAnimated: boolean
onAnimationComplete: () => void
}) => {
// 为每个 item 生成唯一的 key 用于追踪
const itemKey = useMemo(() => {
if (data instanceof MasonryHeaderItem) {
return 'header'
}
return (data as PhotoManifest).id
}, [data])
// 只对第一屏的 items 做动画,且只在首次加载时
const shouldAnimate = !hasAnimated && index < FIRST_SCREEN_ITEMS_COUNT
// 计算动画延迟
const delay = shouldAnimate ? (data instanceof MasonryHeaderItem ? 0 : Math.min(index * 0.05, 0.3)) : 0
// Framer Motion 动画变体
const itemVariants = {
hidden: {
opacity: 0,
y: 30,
scale: 0.95,
filter: 'blur(4px)',
},
visible: {
opacity: 1,
y: 0,
scale: 1,
filter: 'blur(0px)',
transition: {
...Spring.presets.smooth,
delay,
},
},
}
if (data instanceof MasonryHeaderItem) {
return <MasonryHeaderMasonryItem style={{ width }} key={itemKey} />
} else {
return (
<m.div
key={itemKey}
variants={shouldAnimate ? itemVariants : undefined}
initial={shouldAnimate ? 'hidden' : 'visible'}
animate="visible"
onAnimationComplete={shouldAnimate ? onAnimationComplete : undefined}
>
<MasonryPhotoItem data={data as PhotoManifest} width={width} />
</m.div>
)
}
},
)
const FloatingActionBar = ({ showFloatingActions }: { showFloatingActions: boolean }) => {
const isMobile = useMobile()
const variants = isMobile
? {
initial: {
opacity: 0,
},
animate: { opacity: 1 },
}
: {
initial: {
opacity: 0,
x: 20,
y: 0,
scale: 0.95,
},
animate: { opacity: 1, x: 0, y: 0, scale: 1 },
}
return (
<AnimatePresence>
{showFloatingActions && (
<m.div
variants={variants}
initial="initial"
animate="animate"
exit="initial"
transition={Spring.presets.snappy}
className={clsxm(
'border-material-opaque rounded-xl border bg-black/60 p-3 shadow-2xl backdrop-blur-[70px]',
isMobile
? 'rounded-t-none rounded-br-none -translate-y-px'
: 'fixed top-4 right-4 z-50 lg:top-6 lg:right-6',
)}
>
<ActionGroup />
</m.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,106 @@
import { useAtomValue } from 'jotai'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { useMobile } from '~/hooks/useMobile'
import type { PhotoManifest } from '~/types/photo'
import type { MasonryRef } from './Masonic'
import { Masonry } from './Masonic'
import { MasonryPhotoItem } from './MasonryPhotoItem'
const COLUMN_WIDTH_CONFIG = {
auto: {
mobile: 150,
desktop: 250,
maxColumns: 8,
},
min: {
mobile: 120,
desktop: 200,
},
max: {
mobile: 250,
desktop: 500,
},
}
interface MasonryViewProps {
photos: PhotoManifest[]
onRender?: (startIndex: number, stopIndex: number, items: any[]) => void
}
export const MasonryView = ({ photos, onRender }: MasonryViewProps) => {
const { columns } = useAtomValue(gallerySettingAtom)
const [containerWidth, setContainerWidth] = useState(0)
const masonryRef = useRef<MasonryRef>(null)
const isMobile = useMobile()
// 监听容器宽度变化
useEffect(() => {
const updateContainerWidth = () => {
setContainerWidth(window.innerWidth)
}
updateContainerWidth()
window.addEventListener('resize', updateContainerWidth)
return () => {
window.removeEventListener('resize', updateContainerWidth)
}
}, [])
// 动态计算列宽
const columnWidth = useMemo(() => {
const { auto, min, max } = COLUMN_WIDTH_CONFIG
const gutter = 4 // 列间距
const availableWidth = containerWidth - (isMobile ? 8 : 32) // 移动端和桌面端的 padding 不同
if (columns === 'auto') {
const autoWidth = isMobile ? auto.mobile : auto.desktop
if (!isMobile) {
const { maxColumns } = auto
// 当屏幕宽度超过一定阈值时,通过计算动态列宽来限制最大列数
const colCount = Math.floor((availableWidth + gutter) / (autoWidth + gutter))
if (colCount > maxColumns) {
return (availableWidth - (maxColumns - 1) * gutter) / maxColumns
}
}
return autoWidth
}
// 自定义列数模式:根据容器宽度和列数计算列宽
const calculatedWidth = (availableWidth - (columns - 1) * gutter) / columns
// 根据设备类型设置最小和最大列宽
const minWidth = isMobile ? min.mobile : min.desktop
const maxWidth = isMobile ? max.mobile : max.desktop
return Math.max(Math.min(calculatedWidth, maxWidth), minWidth)
}, [isMobile, columns, containerWidth])
return (
<Masonry<PhotoManifest>
ref={masonryRef}
items={photos}
render={useCallback(
(props) => (
<MasonryItem {...props} />
),
[],
)}
onRender={onRender}
columnWidth={columnWidth}
columnGutter={4}
rowGutter={4}
itemHeightEstimate={400}
itemKey={useCallback((data, _index) => {
return data.id
}, [])}
/>
)
}
const MasonryItem = memo(MasonryPhotoItem)

View File

@@ -0,0 +1,65 @@
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface PageHeaderCenterProps {
dateRange?: string
location?: string
showDateRange?: boolean
}
export const PageHeaderCenter = ({ dateRange, location, showDateRange }: PageHeaderCenterProps) => {
const { t } = useTranslation()
const translateDay = useCallback((day: string | number) => t(`date.day.${day}` as any), [t])
const translateMonth = useCallback((month: string | number) => t(`date.month.${month}` as any), [t])
// 解析日期范围,提取主要的日期信息
const parseMainDate = useCallback(
(range: string) => {
// 匹配跨年日期范围格式 "2022年3月 - 2023年5月"
const crossYearMatch = range.match(/(\d{4})年(\d+)月\s*-\s*(\d{4})年(\d+)月/)
if (crossYearMatch) {
const [, startYear, startMonth, endYear, endMonth] = crossYearMatch
return `${translateMonth(startMonth)} ${startYear} - ${translateMonth(endMonth)} ${endYear}`
}
// 匹配类似 "2022年3月30日 - 5月2日" 的格式
const singleYearDayMatch = range.match(/(\d{4})年(\d+)月(\d+)日?\s*-\s*(\d+)月(\d+)日?/)
if (singleYearDayMatch) {
const [, year, startMonth, startDay, endMonth, endDay] = singleYearDayMatch
return `${translateMonth(startMonth)} ${translateDay(startDay)} - ${translateMonth(endMonth)} ${translateDay(endDay)} ${year}`
}
// 匹配类似 "2022年3月 - 5月" 的格式
const monthRangeMatch = range.match(/(\d{4})年(\d+)月\s*-\s*(\d+)月/)
if (monthRangeMatch) {
const [, year, startMonth, endMonth] = monthRangeMatch
return `${translateMonth(startMonth)} - ${translateMonth(endMonth)} ${year}`
}
// 匹配单个日期
const singleDateMatch = range.match(/(\d{4})年(\d+)月(\d+)日/)
if (singleDateMatch) {
const [, year, month, day] = singleDateMatch
return `${translateMonth(month)} ${translateDay(day)} ${year}`
}
// 默认返回原始字符串
return range
},
[translateDay, translateMonth],
)
const formattedDate = useMemo(() => (dateRange ? parseMainDate(dateRange) : undefined), [dateRange, parseMainDate])
if (!showDateRange || !formattedDate) {
return null
}
return (
<div className="absolute left-1/2 hidden -translate-x-1/2 flex-col items-center lg:flex">
<span className="text-sm font-semibold text-white">{formattedDate}</span>
{location && <span className="text-xs text-white/60">{location}</span>}
</div>
)
}

View File

@@ -0,0 +1,60 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { siteConfig } from '~/config'
import { usePhotos } from '~/hooks/usePhotoViewer'
import { resolveSocialUrl, SocialIconButton } from './utils'
export const PageHeaderLeft = () => {
const visiblePhotoCount = usePhotos().length
const githubUrl =
siteConfig.social && siteConfig.social.github
? resolveSocialUrl(siteConfig.social.github, { baseUrl: 'https://github.com/' })
: undefined
const twitterUrl =
siteConfig.social && siteConfig.social.twitter
? resolveSocialUrl(siteConfig.social.twitter, { baseUrl: 'https://twitter.com/', stripAt: true })
: undefined
const hasRss = siteConfig.social && siteConfig.social.rss
const hasSocialLinks = githubUrl || twitterUrl || hasRss
return (
<div className="flex items-center gap-2 lg:gap-3">
<div className="relative flex items-center gap-2 lg:gap-3">
{siteConfig.author.avatar ? (
<AvatarPrimitive.Root>
<AvatarPrimitive.Image
src={siteConfig.author.avatar}
className="size-8 rounded-lg lg:size-9"
alt={siteConfig.author.name}
/>
<AvatarPrimitive.Fallback>
<div className="flex size-8 items-center justify-center rounded-lg bg-white/10 lg:size-9">
<i className="i-mingcute-camera-2-line text-base text-white/60 lg:text-lg" />
</div>
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-white/10 lg:size-9">
<i className="i-mingcute-camera-2-line text-base text-white/60 lg:text-lg" />
</div>
)}
</div>
<div className="flex flex-col">
<div className="flex items-baseline gap-1.5 lg:gap-2">
<h1 className="truncate text-base font-semibold text-white lg:text-lg">{siteConfig.name}</h1>
<span className="text-xs text-white/40 lg:text-sm">{visiblePhotoCount}</span>
</div>
{hasSocialLinks && (
<div className="flex items-center gap-2">
{githubUrl && <SocialIconButton icon="i-mingcute-github-fill" title="GitHub" href={githubUrl} />}
{twitterUrl && <SocialIconButton icon="i-mingcute-twitter-fill" title="Twitter" href={twitterUrl} />}
{hasRss && <SocialIconButton icon="i-mingcute-rss-2-fill" title="RSS" href="/feed.xml" />}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useAtom, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app'
import { useMobile } from '~/hooks/useMobile'
import { ResponsiveActionButton } from '../components/ActionButton'
import { ViewPanel } from '../panels/ViewPanel'
import { ActionIconButton } from './utils'
export const PageHeaderRight = () => {
const { t } = useTranslation()
const isMobile = useMobile()
const [gallerySetting] = useAtom(gallerySettingAtom)
const setCommandPaletteOpen = useSetAtom(isCommandPaletteOpenAtom)
const navigate = useNavigate()
// 计算视图设置是否有自定义配置
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 gap-1.5 lg:gap-2">
{/* Action Buttons */}
<ActionIconButton
icon="i-mingcute-search-line"
title={t('action.search.unified.title')}
onClick={() => setCommandPaletteOpen(true)}
badge={filterCount}
/>
{/* Desktop only: Map Link */}
{!isMobile && (
<ActionIconButton
icon="i-mingcute-map-pin-line"
title={t('action.map.explore')}
onClick={() => navigate('/explory')}
/>
)}
<ResponsiveActionButton
icon="i-mingcute-layout-grid-line"
title={t('action.view.title')}
badge={hasViewCustomization ? '●' : undefined}
>
<ViewPanel />
</ResponsiveActionButton>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { Spring } from '@afilmory/utils'
import { useAtom } from 'jotai'
import { m as motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import type { GalleryViewMode } from '~/atoms/app'
import { gallerySettingAtom } from '~/atoms/app'
export const ViewModeSegment = () => {
const { t } = useTranslation()
const [settings, setSettings] = useAtom(gallerySettingAtom)
const handleViewModeChange = (mode: GalleryViewMode) => {
setSettings((prev) => ({ ...prev, viewMode: mode }))
}
return (
<div className="relative flex h-9 items-center gap-0.5 rounded-full bg-white/5 p-0.5 lg:h-10 lg:gap-1 lg:p-1">
<button
type="button"
onClick={() => handleViewModeChange('masonry')}
className={`relative z-10 flex h-full items-center gap-1.5 rounded-full px-2.5 text-sm font-medium transition-colors duration-200 lg:px-3 ${
settings.viewMode === 'masonry' ? 'text-white' : 'text-white/60 hover:text-white/80'
}`}
title={t('gallery.view.masonry')}
>
{settings.viewMode === 'masonry' && (
<motion.span
layoutId="segment-indicator"
className="absolute inset-0 rounded-full bg-white/15 shadow-sm"
transition={Spring.presets.snappy}
/>
)}
<i className="i-mingcute-grid-line relative z-10 text-sm lg:text-base" />
<span className="relative z-10 hidden lg:inline">{t('gallery.view.masonry')}</span>
</button>
<button
type="button"
onClick={() => handleViewModeChange('list')}
className={`relative z-10 flex h-full items-center gap-1.5 rounded-full px-2.5 text-sm font-medium transition-colors duration-200 lg:px-3 ${
settings.viewMode === 'list' ? 'text-white' : 'text-white/60 hover:text-white/80'
}`}
title={t('gallery.view.list')}
>
{settings.viewMode === 'list' && (
<motion.span
layoutId="segment-indicator"
className="absolute inset-0 rounded-full bg-white/15 shadow-sm"
transition={Spring.presets.snappy}
/>
)}
<i className="i-mingcute-list-ordered-line relative z-10 text-sm lg:text-base" />
<span className="relative z-10 hidden lg:inline">{t('gallery.view.list')}</span>
</button>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { PageHeaderCenter } from './PageHeaderCenter'
import { PageHeaderLeft } from './PageHeaderLeft'
import { PageHeaderRight } from './PageHeaderRight'
import { ViewModeSegment } from './ViewModeSegment'
interface PageHeaderProps {
dateRange?: string
location?: string
showDateRange?: boolean
}
export const PageHeader = ({ dateRange, location, showDateRange }: PageHeaderProps) => {
return (
<header className="fixed top-0 right-0 left-0 z-100 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-2 px-3 lg:h-16 lg:gap-4 lg:px-6">
<PageHeaderLeft />
<PageHeaderCenter dateRange={dateRange} location={location} showDateRange={showDateRange} />
<div className="flex items-center gap-2 lg:gap-3">
<ViewModeSegment />
<PageHeaderRight />
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,78 @@
export function resolveSocialUrl(
value: string,
{ baseUrl, stripAt }: { baseUrl: string; stripAt?: boolean },
): string | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
const normalized = stripAt ? trimmed.replace(/^@/, '') : trimmed
if (!normalized) {
return undefined
}
return `${baseUrl}${normalized}`
}
// 小型社交按钮样式(用于 PageHeaderLeft
export const SocialIconButton = ({ icon, title, href }: { icon: string; title: string; href: string }) => {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex size-6 items-center justify-center rounded-md text-white/40 transition-colors duration-200 hover:text-white/80"
title={title}
>
<i className={`${icon} text-base`} />
</a>
)
}
// 统一的圆形按钮样式(用于 PageHeaderRight
export const ActionIconButton = ({
icon,
title,
onClick,
badge,
href,
}: {
icon: string
title: string
onClick?: () => void
badge?: number
href?: string
}) => {
const commonClasses =
'relative flex size-9 items-center justify-center rounded-full bg-white/10 text-white/60 transition-all duration-200 hover:bg-white/20 hover:text-white lg:size-10'
const content = (
<>
<i className={`${icon} text-base lg:text-lg`} />
{badge !== undefined && badge > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{badge}
</span>
)}
</>
)
if (href) {
return (
<a href={href} target="_blank" rel="noreferrer" className={commonClasses} title={title}>
{content}
</a>
)
}
return (
<button type="button" onClick={onClick} className={commonClasses} title={title}>
{content}
</button>
)
}

View File

@@ -0,0 +1,63 @@
import { useScrollViewElement } from '@afilmory/ui'
import { useAtomValue } from 'jotai'
import { useEffect, useState } from 'react'
import { gallerySettingAtom } from '~/atoms/app'
import { useContextPhotos } from '~/hooks/usePhotoViewer'
import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange'
import type { PanelType } from './ActionPanel'
import { ActionPanel } from './ActionPanel'
import { ListView } from './ListView'
import { MasonryView } from './MasonryView'
import { PageHeader } from './PageHeader'
export const PhotosRoot = () => {
const { viewMode } = useAtomValue(gallerySettingAtom)
const [showFloatingActions, setShowFloatingActions] = useState(false)
const photos = useContextPhotos()
const { dateRange, handleRender } = useVisiblePhotosDateRange(photos)
const scrollElement = useScrollViewElement()
const [activePanel, setActivePanel] = useState<PanelType | null>(null)
// 监听滚动,控制浮动组件的显示
useEffect(() => {
if (!scrollElement) return
const handleScroll = () => {
const { scrollTop } = scrollElement
setShowFloatingActions(scrollTop > 500)
}
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
return () => {
scrollElement.removeEventListener('scroll', handleScroll)
}
}, [scrollElement])
return (
<>
<PageHeader
dateRange={dateRange.formattedRange}
location={dateRange.location}
showDateRange={showFloatingActions && !!dateRange.formattedRange}
/>
<div className="mt-16 p-1 **:select-none! lg:px-0 lg:pb-0">
{viewMode === 'list' ? <ListView photos={photos} /> : <MasonryView photos={photos} onRender={handleRender} />}
</div>
<ActionPanel
open={!!activePanel}
onOpenChange={(open) => {
if (!open) {
setActivePanel(null)
}
}}
type={activePanel}
/>
</>
)
}

View File

@@ -26,15 +26,15 @@ export const ActionButton = ({
<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"
className="relative h-10 w-10 rounded-full border-0 bg-white/10 text-white/60 transition-all duration-200 hover:bg-white/20 hover:text-white"
title={title}
onClick={onClick}
ref={ref}
{...props}
>
<i className={clsxm(icon, 'text-base text-gray-600 dark:text-gray-300')} />
<i className={clsxm(icon, 'text-lg')} />
{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">
<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">
{badge}
</span>
)}
@@ -101,7 +101,7 @@ export const MobileActionButton = ({
<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" />
<div className="mx-auto mb-4 h-1.5 w-12 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
{children}
</Drawer.Content>
</Drawer.Portal>

View File

@@ -7,7 +7,7 @@ import { gallerySettingAtom } from '~/atoms/app'
import { siteConfig } from '~/config'
import { useMobile } from '~/hooks/useMobile'
import { getFilteredPhotos, usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import { MasonryRoot } from '~/modules/gallery/MasonryRoot'
import { PhotosRoot } from '~/modules/gallery/PhotosRoot'
import { PhotosProvider } from '~/providers/photos-provider'
export const Component = () => {
@@ -38,11 +38,11 @@ export const Component = () => {
{isMobile ? (
<ScrollElementContext value={document.body}>
<MasonryRoot />
<PhotosRoot />
</ScrollElementContext>
) : (
<ScrollArea rootClassName={'h-svh w-full'} viewportClassName="size-full">
<MasonryRoot />
<ScrollArea rootClassName={'h-svh w-full'} viewportClassName="size-full" scrollbarClassName="mt-16">
<PhotosRoot />
</ScrollArea>
)}

View File

@@ -294,11 +294,11 @@ export class StaticWebService extends StaticAssetService {
}
const faviconLinks = [
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
{ rel: 'manifest', href: '/site.webmanifest' },
{ rel: 'shortcut icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/static/web/apple-touch-icon.png' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/static/web/favicon-32x32.png' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/static/web/favicon-16x16.png' },
{ rel: 'manifest', href: '/static/web/site.webmanifest' },
{ rel: 'shortcut icon', href: '/static/web/favicon.ico' },
]
for (const linkAttrs of faviconLinks) {

View File

@@ -35,7 +35,6 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tooltip": "1.2.8",
"@react-hook/window-size": "3.1.1",
"@remixicon/react": "4.7.0",
"@tanstack/react-form": "1.26.0",
"@tanstack/react-query": "5.90.11",
"better-auth": "1.4.2",
@@ -109,4 +108,4 @@
"eslint --fix"
]
}
}
}

View File

@@ -336,6 +336,11 @@
"gallery.built.at": "Built at ",
"gallery.photos_one": "{{count}} photo",
"gallery.photos_other": "{{count}} photos",
"gallery.search": "Search",
"gallery.view.grid": "Grid View",
"gallery.view.list": "List View",
"gallery.view.map": "Map View",
"gallery.view.masonry": "Masonry",
"inspector.tab.comments": "Comments",
"inspector.tab.info": "Info",
"loading.converting": "Converting...",

View File

@@ -310,6 +310,11 @@
"gallery.built.at": "ビルド日時 ",
"gallery.photos_one": "写真{{count}}枚",
"gallery.photos_other": "写真{{count}}枚",
"gallery.search": "検索",
"gallery.view.grid": "グリッド表示",
"gallery.view.list": "リスト表示",
"gallery.view.map": "マップ表示",
"gallery.view.masonry": "ウォーターフォール",
"loading.converting": "変換中...",
"loading.default": "読み込み中",
"loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...",

View File

@@ -310,6 +310,11 @@
"gallery.built.at": "빌드 날짜 ",
"gallery.photos_one": "사진 {{count}}장",
"gallery.photos_other": "사진 {{count}}장",
"gallery.search": "검색",
"gallery.view.grid": "그리드 보기",
"gallery.view.list": "목록 보기",
"gallery.view.map": "지도 보기",
"gallery.view.masonry": "폭포수",
"loading.converting": "변환 중...",
"loading.default": "로딩 중",
"loading.heic.converting": "HEIC/HEIF 이미지 형식 변환 중...",

View File

@@ -333,6 +333,11 @@
"gallery.built.at": "构建于 ",
"gallery.photos_one": "{{count}} 张照片",
"gallery.photos_other": "{{count}} 张照片",
"gallery.search": "搜索",
"gallery.view.grid": "网格视图",
"gallery.view.list": "列表视图",
"gallery.view.map": "地图视图",
"gallery.view.masonry": "瀑布流",
"inspector.tab.comments": "评论",
"inspector.tab.info": "信息",
"loading.converting": "转换中...",

View File

@@ -310,6 +310,11 @@
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",
"gallery.search": "搜尋",
"gallery.view.grid": "網格檢視",
"gallery.view.list": "清單檢視",
"gallery.view.map": "地圖檢視",
"gallery.view.masonry": "瀑布流",
"loading.converting": "轉換中...",
"loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

@@ -309,6 +309,11 @@
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",
"gallery.search": "搜尋",
"gallery.view.grid": "網格檢視",
"gallery.view.list": "清單檢視",
"gallery.view.map": "地圖檢視",
"gallery.view.masonry": "瀑布流",
"loading.converting": "轉換中...",
"loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",

View File

@@ -26,6 +26,7 @@
"migrate:manifest": "tsx scripts/migrate-manifest.ts",
"preinstall": "sh scripts/preinstall.sh",
"prepare": "simple-git-hooks",
"reinstall": "rm -rf **/*/node_modules && rm -rf node_modules && pnpm install",
"update:lastmodified": "tsx scripts/update-lastmodified.ts"
},
"dependencies": {