refactor: update photo data handling and types

- Changed the PhotoManifest type to PhotoManifestItem across various components and services for consistency.
- Removed the views property from the PhotoInfo interface to streamline photo data.
- Updated rules in color.mdc and project.mdc to adjust glob patterns and application behavior.
- Enhanced the feed-sitemap plugin to utilize the new PhotoManifestItem type for better data management.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-26 00:48:04 +08:00
parent e99f5b8ae5
commit ae4f15e3ea
13 changed files with 415 additions and 101 deletions

View File

@@ -1,6 +1,6 @@
---
description:
globs:
globs: apps/**/*
alwaysApply: false
---
# UIKit Colors for Tailwind CSS

View File

@@ -1,7 +1,7 @@
---
description:
globs:
alwaysApply: true
globs: **/**
alwaysApply: false
---
# Project

View File

@@ -1,5 +1,5 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { photoLoader } from '@afilmory/data'
import type { PhotoManifest } from '@afilmory/data/types'
import siteConfig from '@config'
import { DOMParser } from 'linkedom'
import type { NextRequest } from 'next/server'
@@ -66,7 +66,7 @@ export const handler = async (
const createAndInsertOpenGraphMeta = (
document: OnlyHTMLDocument,
photo: PhotoManifest,
photo: PhotoManifestItem,
request: NextRequest,
) => {
// Open Graph meta tags

View File

@@ -1,5 +1,6 @@
import './PhotoViewer.css'
import type { PhotoManifestItem } from '@afilmory/builder'
import type { PickedExif } from '@afilmory/data'
import { isNil } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
@@ -20,7 +21,6 @@ import {
} from '~/icons'
import { getImageFormat } from '~/lib/image-utils'
import { Spring } from '~/lib/spring'
import type { PhotoManifest } from '~/types/photo'
import { MotionButtonBase } from '../button'
import { formatExifData, Row } from './formatExifData'
@@ -28,7 +28,7 @@ import { HistogramChart } from './HistogramChart'
import { RawExifViewer } from './RawExifViewer'
export const ExifPanel: FC<{
currentPhoto: PhotoManifest
currentPhoto: PhotoManifestItem
exifData: PickedExif | null
onClose?: () => void

View File

@@ -0,0 +1,376 @@
import { photoLoader } from '@afilmory/data'
import { useMemo, useState } from 'react'
import { Button } from '~/components/ui/button'
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
// JSON 语法高亮组件
const JsonHighlight = ({ data }: { data: any }) => {
const jsonString = JSON.stringify(data, null, 2)
const highlightJson = (str: string) => {
return str.replaceAll(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = 'text-zinc-500'
if (match.startsWith('"')) {
if (match.endsWith(':')) {
cls = 'text-blue-400' // 键名
} else {
cls = 'text-emerald-400' // 字符串值
}
} else if (/true|false/.test(match)) {
cls = 'text-purple-400' // 布尔值
} else if (/null/.test(match)) {
cls = 'text-red-400' // null
} else {
cls = 'text-orange-400' // 数字
}
return `<span class="${cls}">${match}</span>`
},
)
}
return (
<pre className="text-sm leading-6 text-zinc-300">
<code
dangerouslySetInnerHTML={{
__html: highlightJson(jsonString),
}}
/>
</pre>
)
}
// 统计卡片组件
const StatCard = ({
label,
value,
icon,
}: {
label: string
value: string | number
icon: string
}) => (
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/80">
<div className="absolute inset-0 bg-gradient-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10" />
<div className="relative">
<div className="flex items-center justify-between">
<div className="text-3xl">{icon}</div>
<div className="text-right">
<div className="text-2xl font-bold text-zinc-100">{value}</div>
<div className="text-sm text-zinc-400">{label}</div>
</div>
</div>
</div>
</div>
)
// 统计信息组件
const ManifestStats = ({ data }: { data: any[] }) => {
const stats = useMemo(() => {
const totalPhotos = data.length
const totalSize = data.reduce((sum, photo) => sum + (photo.size || 0), 0)
const totalViews = data.reduce((sum, photo) => sum + (photo.views || 0), 0)
const uniqueTags = new Set()
data.forEach((photo) => {
photo.tags?.forEach((tag: string) => uniqueTags.add(tag))
})
const cameras = new Set()
data.forEach((photo) => {
if (photo.exif?.Make && photo.exif?.Model) {
cameras.add(`${photo.exif.Make} ${photo.exif.Model}`)
}
})
return {
totalPhotos,
totalSize: (totalSize / (1024 * 1024 * 1024)).toFixed(2), // GB
totalViews,
uniqueTags: uniqueTags.size,
uniqueCameras: cameras.size,
}
}, [data])
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
<StatCard label="Photos" value={stats.totalPhotos} icon="📸" />
<StatCard label="Storage" value={`${stats.totalSize} GB`} icon="💾" />
<StatCard
label="Views"
value={stats.totalViews.toLocaleString()}
icon="👁️"
/>
<StatCard label="Tags" value={stats.uniqueTags} icon="🏷️" />
<StatCard label="Cameras" value={stats.uniqueCameras} icon="📷" />
</div>
)
}
// 照片卡片组件
const PhotoCard = ({ photo, index }: { photo: any; index: number }) => (
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/50">
<div className="absolute inset-0 bg-gradient-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10 opacity-0 transition-opacity group-hover:opacity-100" />
<div className="relative p-6">
<div className="flex items-start gap-4">
{/* 缩略图 */}
<div className="flex-shrink-0">
{photo.thumbnailUrl ? (
<div className="relative overflow-hidden rounded-lg">
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="h-16 w-16 object-cover transition-transform group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-zinc-800 text-zinc-600">
<span className="text-xl">📷</span>
</div>
)}
</div>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="inline-flex h-6 w-8 items-center justify-center rounded bg-zinc-800 font-mono text-xs text-zinc-400">
{index + 1}
</span>
<h3 className="truncate font-medium text-zinc-100">
{photo.title}
</h3>
</div>
{/* 元数据网格 */}
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:grid-cols-3">
<div className="flex items-center gap-2">
<span className="text-zinc-500">📐</span>
<span className="text-zinc-300">
{photo.width} × {photo.height}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📦</span>
<span className="text-zinc-300">
{(photo.size / (1024 * 1024)).toFixed(1)} MB
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">📷</span>
<span className="truncate text-zinc-300">
{photo.exif?.Make} {photo.exif?.Model}
</span>
</div>
</div>
{/* 标签 */}
{photo.tags && photo.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => (
<span
key={tagIndex}
className="inline-flex items-center rounded-full bg-blue-500/10 px-2.5 py-1 text-xs font-medium text-blue-400 ring-1 ring-blue-500/20"
>
{tag}
</span>
))}
{photo.tags.length > 3 && (
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-400">
+{photo.tags.length - 3} more
</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
export const Component = () => {
const [searchTerm, setSearchTerm] = useState('')
const [viewMode, setViewMode] = useState<'stats' | 'raw'>('stats')
const photos = photoLoader.getPhotos()
const manifestData = {
version: 'v2',
data: photos,
}
// 搜索过滤
const filteredPhotos = useMemo(() => {
if (!searchTerm) return photos
const term = searchTerm.toLowerCase()
return photos.filter(
(photo) =>
photo.title?.toLowerCase().includes(term) ||
photo.description?.toLowerCase().includes(term) ||
photo.tags?.some((tag) => tag.toLowerCase().includes(term)) ||
photo.exif?.Make?.toLowerCase().includes(term) ||
photo.exif?.Model?.toLowerCase().includes(term),
)
}, [photos, searchTerm])
const handleExport = () => {
const dataStr = JSON.stringify(manifestData, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'photos-manifest.json'
document.body.append(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
return (
<div className="min-h-screen bg-black">
{/* 背景渐变 */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-900 via-black to-zinc-900" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-900/20 via-transparent to-transparent" />
{/* Header */}
<div className="relative">
<div className="sticky top-0 z-50 border-b border-zinc-800/50 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-7xl px-6">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-zinc-100">
Afilmory Manifest
</h1>
</div>
<div className="flex items-center rounded-lg bg-zinc-900/50 p-1">
<Button
variant={viewMode === 'stats' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('stats')}
className="h-8 text-xs"
>
Overview
</Button>
<Button
variant={viewMode === 'raw' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('raw')}
className="h-8 text-xs"
>
Raw Data
</Button>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder="Search photos, tags, cameras..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64 rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-2 text-sm text-zinc-100 placeholder-zinc-500 backdrop-blur-sm transition-colors focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<div className="absolute top-1/2 right-3 -translate-y-1/2 text-zinc-500">
🔍
</div>
</div>
<Button
variant="primary"
size="sm"
onClick={handleExport}
className="h-9 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400"
>
Export JSON
</Button>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="relative mx-auto max-w-7xl px-6 py-8">
{viewMode === 'stats' ? (
<div className="space-y-8">
{/* 统计信息 */}
<div>
<h2 className="mb-6 text-lg font-medium text-zinc-300">
Overview
</h2>
<ManifestStats data={filteredPhotos} />
</div>
{/* 照片列表 */}
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-medium text-zinc-300">
Photos ({filteredPhotos.length.toLocaleString()})
</h2>
{searchTerm && (
<div className="text-sm text-zinc-400">
Filtered from {photos.length.toLocaleString()} total
</div>
)}
</div>
<ScrollArea rootClassName="h-[600px]">
<div className="space-y-4 pr-4">
{filteredPhotos.map((photo, index) => (
<PhotoCard key={photo.id} photo={photo} index={index} />
))}
</div>
</ScrollArea>
</div>
</div>
) : (
/* 原始 JSON 数据视图 */
<div>
<div className="mb-6">
<h2 className="text-lg font-medium text-zinc-300">
Raw Manifest Data
</h2>
<p className="mt-1 text-sm text-zinc-500">
Complete JSON manifest in structured format
</p>
</div>
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm">
<div className="border-b border-zinc-800 bg-zinc-900/50 px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-yellow-500" />
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
<span className="font-mono text-sm text-zinc-400">
photos-manifest.json
</span>
</div>
</div>
<ScrollArea rootClassName="h-[700px]">
<div className="p-6">
<JsonHighlight
data={
searchTerm
? { version: 'v2', data: filteredPhotos }
: manifestData
}
/>
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1 +1 @@
export { type PhotoManifest } from '@afilmory/data'
export type { PhotoManifestItem as PhotoManifest } from '@afilmory/builder'

View File

@@ -191,7 +191,6 @@ export async function executePhotoProcessingPipeline(
title: photoInfo.title,
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,
views: photoInfo.views,
tags: photoInfo.tags,
originalUrl: defaultBuilder
.getStorageManager()

View File

@@ -99,7 +99,6 @@ export function extractPhotoInfo(
return {
title,
dateTaken,
views,
tags,
description: '', // 可以从 EXIF 或其他元数据中获取
}

View File

@@ -32,7 +32,6 @@ export interface ToneAnalysis {
export interface PhotoInfo {
title: string
dateTaken: string
views: number
tags: string[]
description: string
}
@@ -43,16 +42,11 @@ export interface ImageMetadata {
format: string
}
export interface PhotoManifestItem {
export interface PhotoManifestItem extends PhotoInfo {
id: string
title: string
description: string
dateTaken: string
views: number
tags: string[]
originalUrl: string
thumbnailUrl: string | null
blurhash: string | null
thumbnailUrl: string
blurhash: string
width: number
height: number
aspectRatio: number

View File

@@ -1,16 +1,17 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import PhotosManifest from './photos-manifest.json'
import type { PhotoManifest } from './types'
class PhotoLoader {
private photos: PhotoManifest[] = []
private photoMap: Record<string, PhotoManifest> = {}
private photos: PhotoManifestItem[] = []
private photoMap: Record<string, PhotoManifestItem> = {}
constructor() {
this.getAllTags = this.getAllTags.bind(this)
this.getPhotos = this.getPhotos.bind(this)
this.getPhoto = this.getPhoto.bind(this)
this.photos = PhotosManifest.data as unknown as PhotoManifest[]
this.photos = PhotosManifest.data as unknown as PhotoManifestItem[]
this.photos.forEach((photo) => {
this.photoMap[photo.id] = photo
@@ -35,5 +36,4 @@ class PhotoLoader {
}
export const photoLoader = new PhotoLoader()
export type { PhotoManifest } from './types'
export type { PickedExif, ToneAnalysis } from '@afilmory/builder'

View File

@@ -1,23 +0,0 @@
import type { PickedExif, ToneAnalysis } from '@afilmory/builder'
export interface PhotoManifest {
id: string
title: string
description: string
views: number
tags: string[]
originalUrl: string
thumbnailUrl: string
blurhash: string
width: number
height: number
aspectRatio: number
s3Key: string
lastModified: string
size: number
exif: PickedExif
toneAnalysis: ToneAnalysis | null // 影调分析结果
isLivePhoto?: boolean
livePhotoVideoUrl?: string
livePhotoVideoS3Key?: string
}

View File

@@ -2,30 +2,11 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { PhotoManifestItem } from '@afilmory/builder'
import type { Plugin } from 'vite'
import type { SiteConfig } from '../../site.config'
interface PhotoData {
id: string
title: string
description: string
dateTaken: string
views: number
tags: string[]
originalUrl: string
thumbnailUrl: string
blurhash: string
width: number
height: number
aspectRatio: number
s3Key: string
lastModified: string
size: number
exif?: any
isLivePhoto: boolean
}
const __dirname = fileURLToPath(new URL('.', import.meta.url))
export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
@@ -39,9 +20,9 @@ export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
__dirname,
'../../packages/data/src/photos-manifest.json',
)
const photosData: PhotoData[] = (JSON.parse(
readFileSync(manifestPath, 'utf-8'),).data
)
const photosData: PhotoManifestItem[] = JSON.parse(
readFileSync(manifestPath, 'utf-8'),
).data
// Sort photos by date taken (newest first)
const sortedPhotos = photosData.sort(
@@ -78,7 +59,10 @@ export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
}
}
function generateRSSFeed(photos: PhotoData[], config: SiteConfig): string {
function generateRSSFeed(
photos: PhotoManifestItem[],
config: SiteConfig,
): string {
const now = new Date().toUTCString()
const latestPhoto = photos[0]
const lastBuildDate = latestPhoto
@@ -154,7 +138,7 @@ ${rssItems}
</rss>`
}
function generateExifTags(exif: any, photo: PhotoData): string {
function generateExifTags(exif: any, photo: PhotoManifestItem): string {
if (!exif) {
return ''
}
@@ -193,16 +177,16 @@ function generateExifTags(exif: any, photo: PhotoData): string {
// === 图像属性 (basic) ===
// Image Dimensions (图片宽度, 高度)
// Image Dimensions (图片宽度高度)
tags.push(
` <exif:imageWidth>${photo.width}</exif:imageWidth>`,
` <exif:imageHeight>${photo.height}</exif:imageHeight>`,
)
// Date Taken (拍摄时间) - 转换为ISO 8601格式
// Date Taken (拍摄时间) - 转换为 ISO 8601 格式
if (exif.Photo?.DateTimeOriginal) {
try {
// 尝试解析EXIF日期格式 (YYYY:MM:DD HH:mm:ss)
// 尝试解析 EXIF 日期格式 (YYYY:MM:DD HH:mm:ss)
const exifDate = exif.Photo.DateTimeOriginal.replaceAll(':', '-').replace(
/-(\d{2}:\d{2}:\d{2})/,
' $1',
@@ -210,7 +194,7 @@ function generateExifTags(exif: any, photo: PhotoData): string {
const isoDate = new Date(exifDate).toISOString()
tags.push(` <exif:dateTaken>${isoDate}</exif:dateTaken>`)
} catch {
// 如果解析失败使用photo.dateTaken
// 如果解析失败,使用 photo.dateTaken
const isoDate = new Date(photo.dateTaken).toISOString()
tags.push(` <exif:dateTaken>${isoDate}</exif:dateTaken>`)
}
@@ -249,7 +233,7 @@ function generateExifTags(exif: any, photo: PhotoData): string {
)
}
// Focal Length in 35mm equivalent (等效35mm焦距)
// Focal Length in 35mm equivalent (等效 35mm 焦距)
if (exif.Photo?.FocalLengthIn35mmFilm) {
tags.push(
` <exif:focalLength35mm>${exif.Photo.FocalLengthIn35mmFilm}mm</exif:focalLength35mm>`,
@@ -419,10 +403,13 @@ function convertDMSToDD(dms: number[], ref: string): number | null {
dd = dd * -1
}
return Math.round(dd * 1000000) / 1000000 // 保留6位小数
return Math.round(dd * 1000000) / 1000000 // 保留 6 位小数
}
function generateSitemap(photos: PhotoData[], config: SiteConfig): string {
function generateSitemap(
photos: PhotoManifestItem[],
config: SiteConfig,
): string {
const now = new Date().toISOString()
// Main page

View File

@@ -2,35 +2,17 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { workdir } from '../packages/builder/src/path.js'
interface PhotoManifest {
id: string
title: string
description: string
dateTaken: string
views: number
tags: string[]
originalUrl: string
thumbnailUrl: string
blurhash: string
width: number
height: number
aspectRatio: number
s3Key: string
lastModified: string
size: number
exif: any
}
import type { PhotoManifestItem } from '../packages/builder/src/types/photo.js'
class BuildTimePhotoLoader {
private photos: PhotoManifest[] = []
private photoMap: Record<string, PhotoManifest> = {}
private photos: PhotoManifestItem[] = []
private photoMap: Record<string, PhotoManifestItem> = {}
constructor() {
try {
const manifestPath = join(workdir, 'src/data/photos-manifest.json')
const manifestContent = readFileSync(manifestPath, 'utf-8')
this.photos = JSON.parse(manifestContent).data as PhotoManifest[]
this.photos = JSON.parse(manifestContent).data as PhotoManifestItem[]
this.photos.forEach((photo) => {
this.photoMap[photo.id] = photo