mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
24
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user