mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
98
apps/web/src/modules/gallery/ListView.tsx
Normal file
98
apps/web/src/modules/gallery/ListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
106
apps/web/src/modules/gallery/MasonryView.tsx
Normal file
106
apps/web/src/modules/gallery/MasonryView.tsx
Normal 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)
|
||||
65
apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx
Normal file
65
apps/web/src/modules/gallery/PageHeader/PageHeaderCenter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx
Normal file
60
apps/web/src/modules/gallery/PageHeader/PageHeaderLeft.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx
Normal file
57
apps/web/src/modules/gallery/PageHeader/PageHeaderRight.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx
Normal file
57
apps/web/src/modules/gallery/PageHeader/ViewModeSegment.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/web/src/modules/gallery/PageHeader/index.tsx
Normal file
25
apps/web/src/modules/gallery/PageHeader/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
apps/web/src/modules/gallery/PageHeader/utils.tsx
Normal file
78
apps/web/src/modules/gallery/PageHeader/utils.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
apps/web/src/modules/gallery/PhotosRoot.tsx
Normal file
63
apps/web/src/modules/gallery/PhotosRoot.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 画像フォーマットを変換中...",
|
||||
|
||||
@@ -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 이미지 형식 변환 중...",
|
||||
|
||||
@@ -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": "转换中...",
|
||||
|
||||
@@ -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 圖像格式...",
|
||||
|
||||
@@ -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 圖像格式...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user