feat: enhance gallery layout and functionality

- Added support for virtualized rendering in ListView using @tanstack/react-virtual for improved performance with large photo sets.
- Integrated new hooks for mobile responsiveness and context management in ListView and PhotoCard components.
- Updated PageHeader and ViewModeSegment for better layout consistency and user experience.
- Increased upload limits in backend configuration to accommodate larger files and requests.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-30 00:23:14 +08:00
parent 26100b26cd
commit e71548b320
13 changed files with 456 additions and 155 deletions

View File

@@ -35,6 +35,7 @@
"@react-hook/window-size": "3.1.1",
"@t3-oss/env-core": "catalog:",
"@tanstack/react-query": "5.90.11",
"@tanstack/react-virtual": "3.13.12",
"@use-gesture/react": "10.3.1",
"@uswriting/exiftool": "1.0.6",
"blurhash": "2.0.5",

View File

@@ -1,5 +1,10 @@
import { useScrollViewElement } from '@afilmory/ui'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useMobile } from '~/hooks/useMobile'
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
import type { PhotoManifest } from '~/types/photo'
interface ListViewProps {
@@ -7,17 +12,84 @@ interface ListViewProps {
}
export const ListView = ({ photos }: ListViewProps) => {
const scrollElement = useScrollViewElement()
const isMobile = useMobile()
// 间距大小(更紧凑,即 0.5rem = 8px
const gap = 8
// 固定卡片高度:移动端使用动态测量(因为图片高度根据宽高比变化),桌面端 176px (h-44)
const cardHeight = isMobile ? 300 : 176 // 移动端给一个初始估算值
const estimateSize = () => cardHeight + gap
const virtualizer = useVirtualizer({
count: photos.length,
getScrollElement: () => scrollElement,
estimateSize,
overscan: 5,
// 移动端需要动态测量,桌面端使用固定高度
measureElement: isMobile
? (element) => {
if (!element) return estimateSize()
const { height } = element.getBoundingClientRect()
return height + gap
}
: undefined,
})
// 计算总高度:减去最后一个项目的间距(因为最后一个项目不应该有间距)
const totalSize = virtualizer.getTotalSize() - gap
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 className="mx-auto max-w-6xl px-4 py-8 lg:px-6">
<div
style={{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const photo = photos[virtualItem.index]
const isLast = virtualItem.index === photos.length - 1
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: isLast ? 0 : `${gap}px`,
}}
>
<PhotoCard photo={photo} />
</div>
)
})}
</div>
</div>
)
}
const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
const { i18n } = useTranslation()
const photos = useContextPhotos()
const photoViewer = usePhotoViewer()
const imageRef = useRef<HTMLImageElement>(null)
const handleClick = () => {
const photoIndex = photos.findIndex((p) => p.id === photo.id)
if (photoIndex !== -1) {
const triggerEl =
imageRef.current?.parentElement instanceof HTMLElement ? imageRef.current.parentElement : imageRef.current
photoViewer.openViewer(photoIndex, triggerEl ?? undefined)
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString(i18n.language, {
@@ -27,72 +99,221 @@ const PhotoCard = ({ photo }: { photo: PhotoManifest }) => {
})
}
// 获取相机信息
const cameraInfo = photo.exif?.Model || photo.exif?.Make
const lensInfo = photo.exif?.LensModel
// 格式化 EXIF 数据
const formatExifData = () => {
if (!photo.exif) {
return {
camera: null,
lens: null,
iso: null,
aperture: null,
shutterSpeed: null,
focalLength: null,
exposureCompensation: null,
}
}
const { exif } = photo
// 相机信息
const camera = exif.Make && exif.Model ? `${exif.Make} ${exif.Model}` : exif.Model || exif.Make || null
// 镜头信息
const lens = exif.LensMake && exif.LensModel ? `${exif.LensMake} ${exif.LensModel}` : exif.LensModel || null
// ISO
const iso = exif.ISO || null
// 光圈
const aperture = exif.FNumber ? `f/${exif.FNumber}` : null
// 快门速度
const exposureTime = exif.ExposureTime
let shutterSpeed: string | null = null
if (exposureTime) {
if (typeof exposureTime === 'number') {
if (exposureTime >= 1) {
shutterSpeed = `${exposureTime}s`
} else {
shutterSpeed = `1/${Math.round(1 / exposureTime)}s`
}
} else {
shutterSpeed = `${exposureTime}s`
}
} else if (exif.ShutterSpeedValue) {
const speed =
typeof exif.ShutterSpeedValue === 'number'
? exif.ShutterSpeedValue
: Number.parseFloat(String(exif.ShutterSpeedValue))
if (speed >= 1) {
shutterSpeed = `${speed}s`
} else {
shutterSpeed = `1/${Math.round(1 / speed)}s`
}
}
// 焦距 (优先使用 35mm 等效焦距)
const focalLength = exif.FocalLengthIn35mmFormat
? `${Number.parseInt(exif.FocalLengthIn35mmFormat)}mm`
: exif.FocalLength
? `${Number.parseInt(exif.FocalLength)}mm`
: null
// 曝光补偿
const exposureCompensation = exif.ExposureCompensation ? `${exif.ExposureCompensation} EV` : null
return {
camera,
lens,
iso,
aperture,
shutterSpeed,
focalLength,
exposureCompensation,
}
}
const exifData = formatExifData()
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">
<div
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}}
className="group flex flex-col gap-2 overflow-hidden border border-white/5 bg-white/5 p-2 backdrop-blur-sm transition-all duration-200 hover:border-white/10 hover:bg-white/8 lg:h-44 lg:flex-row lg:gap-3"
>
{/* 缩略图 - 移动端按宽高比,桌面端固定高度 */}
<div
className="relative w-full shrink-0 cursor-pointer overflow-hidden lg:h-full lg:w-56"
role="button"
tabIndex={0}
style={
// 移动端:根据宽高比计算高度
{
aspectRatio: photo.aspectRatio ? `${photo.aspectRatio}` : undefined,
}
}
>
<img
ref={imageRef}
src={photo.thumbnailUrl || photo.originalUrl}
alt={photo.title || 'Photo'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105 lg:object-cover"
/>
</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>
{/* 标签 */}
{/* Tags 覆盖在图片上 */}
{photo.tags && photo.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-wrap gap-1 p-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">
<span
key={tag}
className="rounded-full bg-white/20 px-1.5 py-0.5 text-[10px] font-medium text-white/90 backdrop-blur-sm"
>
{tag}
</span>
))}
</div>
)}
</div>
{/* 元数据 - 移动端自适应,桌面端固定高度 */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden py-1 lg:h-full">
{/* 标题和基本信息 */}
<div className="min-h-0 flex-1 overflow-y-auto">
<h3 className="mb-1.5 text-sm font-semibold text-white lg:text-base">{photo.title}</h3>
{/* 元数据行 */}
<div className="space-y-1.5 text-[11px] leading-tight text-white/60 lg:text-xs">
{/* 位置 */}
{photo.location && (
<div className="flex items-center gap-1">
<i className="i-lucide-map-pin text-[10px]" />
<span className="truncate">{photo.location.locationName}</span>
</div>
)}
{/* 日期 */}
<div className="flex items-center gap-1">
<i className="i-lucide-calendar text-[10px]" />
<span>{formatDate(new Date(photo.lastModified).getTime())}</span>
</div>
{/* 相机 */}
{exifData.camera && (
<div className="flex items-center gap-1">
<i className="i-lucide-camera text-[10px]" />
<span className="truncate">{exifData.camera}</span>
</div>
)}
{/* 镜头 */}
{exifData.lens && (
<div className="flex items-center gap-1">
<i className="i-lucide-aperture text-[10px]" />
<span className="truncate">{exifData.lens}</span>
</div>
)}
{/* 尺寸 */}
<div className="flex items-center gap-1">
<i className="i-lucide-image text-[10px]" />
<span>
{photo.width} x {photo.height}
</span>
</div>
</div>
{/* 摄影三要素 + 焦距 - 简洁样式 */}
{(exifData.iso || exifData.aperture || exifData.shutterSpeed || exifData.focalLength) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-white/10 pt-2">
{/* ISO */}
{exifData.iso && (
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
<i className="i-lucide-gauge text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">ISO {exifData.iso}</span>
</div>
)}
{/* 光圈 */}
{exifData.aperture && (
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
<i className="i-lucide-circle-dot text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">{exifData.aperture}</span>
</div>
)}
{/* 快门速度 */}
{exifData.shutterSpeed && (
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
<i className="i-lucide-timer text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">{exifData.shutterSpeed}</span>
</div>
)}
{/* 焦距 */}
{exifData.focalLength && (
<div className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 backdrop-blur-md">
<i className="i-lucide-maximize-2 text-[10px] text-white/70" />
<span className="text-[11px] text-white/90">{exifData.focalLength}</span>
</div>
)}
{/* 曝光补偿 - 次要显示 */}
{exifData.exposureCompensation && (
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
<i className="i-lucide-sliders-horizontal text-[10px] text-white/60" />
<span className="text-[11px] text-white/70">{exifData.exposureCompensation}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -66,8 +66,8 @@ export const PageHeaderCenter = ({ dateRange, location, showDateRange }: PageHea
transition={Spring.presets.smooth}
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>}
<span className="text-xs font-semibold text-white lg:text-sm">{formattedDate}</span>
{location && <span className="text-[10px] text-white/60 lg:text-xs">{location}</span>}
</m.div>
)}
</AnimatePresence>

View File

@@ -16,45 +16,39 @@ export const PageHeaderLeft = () => {
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
const hasRss = true
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 className="flex items-center gap-2">
{siteConfig.author.avatar ? (
<AvatarPrimitive.Root>
<AvatarPrimitive.Image
src={siteConfig.author.avatar}
className="size-7 rounded-lg lg:size-8"
alt={siteConfig.author.name}
/>
<AvatarPrimitive.Fallback>
<div className="flex size-7 items-center justify-center rounded-lg bg-white/10 lg:size-8">
<i className="i-mingcute-camera-2-line text-sm text-white/60 lg:text-base" />
</div>
</AvatarPrimitive.Fallback>
</AvatarPrimitive.Root>
) : (
<div className="flex size-7 items-center justify-center rounded-lg bg-white/10 lg:size-8">
<i className="i-mingcute-camera-2-line text-sm text-white/60 lg:text-base" />
</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 className="flex items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-white lg:text-base">{siteConfig.name}</h1>
<span className="text-xs text-white/40 lg:text-sm">{visiblePhotoCount}</span>
</div>
{(githubUrl || twitterUrl || hasRss) && (
<div className="ml-1 flex items-center gap-1 border-l border-white/10 pl-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>
)
}

View File

@@ -1,11 +1,13 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@afilmory/ui'
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, isCommandPaletteOpenAtom } from '~/atoms/app'
import { useMobile } from '~/hooks/useMobile'
import { ResponsiveActionButton } from '../components/ActionButton'
import { ViewPanel } from '../panels/ViewPanel'
import { ActionIconButton } from './utils'
@@ -27,31 +29,117 @@ export const PageHeaderRight = () => {
(gallerySetting.selectedRatings !== null ? 1 : 0)
return (
<div className="flex items-center gap-1.5 lg:gap-2">
<div className="flex items-center gap-1 rounded-lg bg-white/5 lg:gap-1.5">
{/* Action Buttons */}
<ActionIconButton
icon="i-mingcute-search-line"
title={t('action.search.unified.title')}
onClick={() => setCommandPaletteOpen(true)}
badge={filterCount}
/>
{/* Desktop only: Map Link */}
{!isMobile && (
<div className="border-border flex items-center gap-1 rounded-lg border-[0.5px]">
<ActionIconButton
icon="i-mingcute-map-pin-line"
title={t('action.map.explore')}
onClick={() => navigate('/explory')}
icon="i-mingcute-search-line"
title={t('action.search.unified.title')}
onClick={() => setCommandPaletteOpen(true)}
badge={filterCount}
/>
)}
<ResponsiveActionButton
icon="i-mingcute-layout-grid-line"
title={t('action.view.title')}
badge={hasViewCustomization ? '●' : undefined}
>
<ViewPanel />
</ResponsiveActionButton>
{/* Desktop only: Map Link */}
{!isMobile && (
<ActionIconButton
icon="i-mingcute-map-pin-line"
title={t('action.map.explore')}
onClick={() => navigate('/explory')}
/>
)}
{isMobile ? (
<MobileViewButton
icon="i-mingcute-layout-grid-line"
title={t('action.view.title')}
badge={hasViewCustomization ? '●' : undefined}
>
<ViewPanel />
</MobileViewButton>
) : (
<DesktopViewButton
icon="i-mingcute-layout-grid-line"
title={t('action.view.title')}
badge={hasViewCustomization ? '●' : undefined}
>
<ViewPanel />
</DesktopViewButton>
)}
</div>
</div>
)
}
// 紧凑版本的桌面端视图按钮
const DesktopViewButton = ({
icon,
title,
badge,
children,
}: {
icon: string
title: string
badge?: number | string
children: React.ReactNode
}) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:size-8"
title={title}
>
<i className={`${icon} text-sm lg:text-base`} />
{badge && (
<span className="absolute -top-0.5 -right-0.5 flex size-2 items-center justify-center rounded-full bg-blue-500 lg:size-2.5">
<span className="sr-only">{badge}</span>
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">{children}</DropdownMenuContent>
</DropdownMenu>
)
}
// 紧凑版本的移动端视图按钮
const MobileViewButton = ({
icon,
title,
badge,
children,
}: {
icon: string
title: string
badge?: number | string
children: React.ReactNode
}) => {
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
className="relative flex size-7 items-center justify-center rounded text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:size-8"
title={title}
onClick={() => setOpen(!open)}
>
<i className={`${icon} text-sm lg:text-base`} />
{badge && (
<span className="absolute -top-0.5 -right-0.5 flex size-2 items-center justify-center rounded-full bg-blue-500 lg:size-2.5">
<span className="sr-only">{badge}</span>
</span>
)}
</button>
<Drawer.Root open={open} onOpenChange={setOpen}>
<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 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
{children}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</>
)
}

View File

@@ -15,11 +15,11 @@ export const ViewModeSegment = () => {
}
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">
<div className="border-border relative flex h-7 items-center gap-0.5 rounded-lg border-[0.5px] bg-white/5 p-0.5 lg:h-8 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 ${
className={`relative z-10 flex h-full items-center justify-center rounded-lg px-2 text-xs font-medium transition-colors duration-200 lg:px-4 ${
settings.viewMode === 'masonry' ? 'text-white' : 'text-white/60 hover:text-white/80'
}`}
title={t('gallery.view.masonry')}
@@ -27,17 +27,16 @@ export const ViewModeSegment = () => {
{settings.viewMode === 'masonry' && (
<motion.span
layoutId="segment-indicator"
className="absolute inset-0 rounded-full bg-white/15 shadow-sm"
className="absolute inset-0 rounded-md 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 ${
className={`relative z-10 flex h-full items-center justify-center rounded-lg px-2 text-xs font-medium transition-colors duration-200 lg:px-4 ${
settings.viewMode === 'list' ? 'text-white' : 'text-white/60 hover:text-white/80'
}`}
title={t('gallery.view.list')}
@@ -45,12 +44,11 @@ export const ViewModeSegment = () => {
{settings.viewMode === 'list' && (
<motion.span
layoutId="segment-indicator"
className="absolute inset-0 rounded-full bg-white/15 shadow-sm"
className="absolute inset-0 rounded-md 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

@@ -12,10 +12,10 @@ interface PageHeaderProps {
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">
<div className="flex h-12 items-center justify-between gap-2 px-3 lg:h-12 lg:gap-3 lg:px-4">
<PageHeaderLeft />
<PageHeaderCenter dateRange={dateRange} location={location} showDateRange={showDateRange} />
<div className="flex items-center gap-2 lg:gap-3">
<div className="flex items-center gap-1.5 lg:gap-2">
<ViewModeSegment />
<PageHeaderRight />
</div>

View File

@@ -19,17 +19,17 @@ export function resolveSocialUrl(
return `${baseUrl}${normalized}`
}
// 小型社交按钮样式(用于 PageHeaderLeft
// 小型社交按钮样式(用于 PageHeaderRight
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"
className="inline-flex size-6 items-center justify-center rounded text-white/40 transition-colors duration-200 hover:text-white/80 lg:size-7"
title={title}
>
<i className={`${icon} text-base`} />
<i className={`${icon} text-sm lg:text-base`} />
</a>
)
}
@@ -49,13 +49,13 @@ export const ActionIconButton = ({
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'
'relative flex size-7 items-center justify-center rounded text-white/60 transition-all duration-200 hover:bg-white/10 hover:text-white lg:size-8'
const content = (
<>
<i className={`${icon} text-base lg:text-lg`} />
<i className={`${icon} text-sm lg:text-base`} />
{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">
<span className="absolute -top-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full bg-blue-500 text-[9px] font-medium text-white lg:size-4">
{badge}
</span>
)}

View File

@@ -45,7 +45,7 @@ export const PhotosRoot = () => {
showDateRange={showFloatingActions && !!dateRange.formattedRange}
/>
<div className="mt-16 p-1 **:select-none! lg:px-0 lg:pb-0">
<div className="mt-12 p-1 **:select-none! lg:px-0 lg:pb-0">
{viewMode === 'list' ? <ListView photos={photos} /> : <MasonryView photos={photos} onRender={handleRender} />}
</div>

View File

@@ -41,7 +41,7 @@ export const Component = () => {
<PhotosRoot />
</ScrollElementContext>
) : (
<ScrollArea rootClassName={'h-svh w-full'} viewportClassName="size-full" scrollbarClassName="mt-16">
<ScrollArea rootClassName={'h-svh w-full'} viewportClassName="size-full" scrollbarClassName="mt-12">
<PhotosRoot />
</ScrollArea>
)}

View File

@@ -3,10 +3,10 @@ import type { Context } from 'hono'
import type { UploadAssetInput } from './photo-asset.types'
export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
export const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 500 * 1024 * 1024 // 500 MB
export const MAX_UPLOAD_FILES_PER_BATCH = 32
export const MAX_TEXT_FIELDS_PER_REQUEST = 64
export const ABSOLUTE_MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1024 // 1 GB hard cap per file
export const ABSOLUTE_MAX_REQUEST_SIZE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB hard cap per request
export const MAX_UPLOAD_FILES_PER_BATCH = 128
export const MAX_TEXT_FIELDS_PER_REQUEST = 256
const PHOTO_UPLOAD_LIMIT_CONTEXT_KEY = 'photo.upload.limits'
const PHOTO_UPLOAD_INPUT_CONTEXT_KEY = 'photo.upload.inputs'

View File

@@ -8,7 +8,7 @@ This document tracks the current subscription plans, quota knobs, and the design
- Decouple plan defaults from tenant-specific overrides so superadmins can hotfix limits without redeploys.
- Keep room for future self-serve subscriptions while allowing manual override flows during private beta.
## Plan Catalog (2024-xx-xx)
## Plan Catalog (2025-11-30)
| Plan ID | Label | Availability | Monthly Process Limit | Library Items | Upload Size (MB) | Sync Object (MB) | Notes |
|------------|--------------------|-----------------------|-----------------------|---------------|------------------|------------------|-------|
@@ -18,6 +18,17 @@ This document tracks the current subscription plans, quota knobs, and the design
> `Unlimited` == `null` in the DB schema, meaning enforcement is skipped for that quota dimension.
## Global Hard Caps (system guardrails)
These apply to every plan (including `friend`) to protect the service from pathological requests. Plan-specific limits are enforced first, then clipped by these ceilings:
- Max file size: **1 GB** (`ABSOLUTE_MAX_FILE_SIZE_BYTES`)
- Max request payload: **5 GB** (`ABSOLUTE_MAX_REQUEST_SIZE_BYTES`)
- Max files per batch: **128** (`MAX_UPLOAD_FILES_PER_BATCH`)
- Max text fields per request: **256** (`MAX_TEXT_FIELDS_PER_REQUEST`)
> Effectively: `resolvedFileLimit = min(plan.uploadLimit, 1 GB)` and `resolvedBatchLimit = min(resolvedFileLimit * 128, 5 GB)`.
## Design Notes
1. **Plan definitions** live in `billing-plan.constants.ts`. Each entry carries human-friendly metadata for the super-admin dashboard plus a `quotas` object.

24
pnpm-lock.yaml generated
View File

@@ -657,15 +657,15 @@ importers:
'@react-hook/window-size':
specifier: 3.1.1
version: 3.1.1(react@19.2.0)
'@remixicon/react':
specifier: 4.7.0
version: 4.7.0(react@19.2.0)
'@t3-oss/env-core':
specifier: 'catalog:'
version: 0.13.8(typescript@5.9.3)(zod@4.1.13)
'@tanstack/react-query':
specifier: 5.90.11
version: 5.90.11(react@19.2.0)
'@tanstack/react-virtual':
specifier: 3.13.12
version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@use-gesture/react':
specifier: 10.3.1
version: 10.3.1(react@19.2.0)
@@ -1095,9 +1095,6 @@ importers:
'@react-hook/window-size':
specifier: 3.1.1
version: 3.1.1(react@19.2.0)
'@remixicon/react':
specifier: 4.7.0
version: 4.7.0(react@19.2.0)
'@tanstack/react-form':
specifier: 1.26.0
version: 1.26.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1608,7 +1605,7 @@ importers:
version: 0.16.7(synckit@0.11.11)(typescript@5.9.3)
unplugin-dts:
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
vite:
specifier: 7.2.4
version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@@ -4882,11 +4879,6 @@ packages:
resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==}
engines: {node: '>=14.0.0'}
'@remixicon/react@4.7.0':
resolution: {integrity: sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==}
peerDependencies:
react: '>=18.2.0'
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
@@ -8379,7 +8371,6 @@ packages:
glob@13.0.0:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
@@ -16351,10 +16342,6 @@ snapshots:
'@remix-run/router@1.23.0':
optional: true
'@remixicon/react@4.7.0(react@19.2.0)':
dependencies:
react: 19.2.0
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
@@ -24546,7 +24533,7 @@ snapshots:
magic-string-ast: 1.0.3
unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.3)
'@volar/typescript': 2.4.23
@@ -24560,6 +24547,7 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.25.12
rolldown: 1.0.0-beta.51
rollup: 4.53.3
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies: