From ae4f15e3ea012dc755fec4a78c0db423cab0e3b4 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 26 Jun 2025 00:48:04 +0800 Subject: [PATCH] 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 --- .cursor/rules/color.mdc | 2 +- .cursor/rules/project.mdc | 4 +- apps/ssr/src/app/[photoId]/prod.ts | 4 +- .../components/ui/photo-viewer/ExifPanel.tsx | 4 +- apps/web/src/pages/(data)/manifest.tsx | 376 ++++++++++++++++++ apps/web/src/types/photo.ts | 2 +- packages/builder/src/photo/image-pipeline.ts | 1 - packages/builder/src/photo/info-extractor.ts | 1 - packages/builder/src/types/photo.ts | 12 +- packages/data/src/index.ts | 10 +- packages/data/src/types.ts | 23 -- plugins/vite/feed-sitemap.ts | 51 +-- scripts/photo-loader.ts | 26 +- 13 files changed, 415 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/pages/(data)/manifest.tsx delete mode 100644 packages/data/src/types.ts diff --git a/.cursor/rules/color.mdc b/.cursor/rules/color.mdc index c7411dec..0e204f29 100644 --- a/.cursor/rules/color.mdc +++ b/.cursor/rules/color.mdc @@ -1,6 +1,6 @@ --- description: -globs: +globs: apps/**/* alwaysApply: false --- # UIKit Colors for Tailwind CSS diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index 11938bc1..a340cef6 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -1,7 +1,7 @@ --- description: -globs: -alwaysApply: true +globs: **/** +alwaysApply: false --- # Project diff --git a/apps/ssr/src/app/[photoId]/prod.ts b/apps/ssr/src/app/[photoId]/prod.ts index 2e3e34a5..93f23426 100644 --- a/apps/ssr/src/app/[photoId]/prod.ts +++ b/apps/ssr/src/app/[photoId]/prod.ts @@ -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 diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index 9b08b0ce..b3b41a2f 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -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 diff --git a/apps/web/src/pages/(data)/manifest.tsx b/apps/web/src/pages/(data)/manifest.tsx new file mode 100644 index 00000000..995cbbdb --- /dev/null +++ b/apps/web/src/pages/(data)/manifest.tsx @@ -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 `${match}` + }, + ) + } + + return ( +
+      
+    
+ ) +} + +// 统计卡片组件 +const StatCard = ({ + label, + value, + icon, +}: { + label: string + value: string | number + icon: string +}) => ( +
+
+
+
+
{icon}
+
+
{value}
+
{label}
+
+
+
+
+) + +// 统计信息组件 +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 ( +
+ + + + + +
+ ) +} + +// 照片卡片组件 +const PhotoCard = ({ photo, index }: { photo: any; index: number }) => ( +
+
+ +
+
+ {/* 缩略图 */} +
+ {photo.thumbnailUrl ? ( +
+ {photo.title} +
+
+ ) : ( +
+ 📷 +
+ )} +
+ + {/* 内容 */} +
+
+ + {index + 1} + +

+ {photo.title} +

+
+ + {/* 元数据网格 */} +
+
+ 📐 + + {photo.width} × {photo.height} + +
+
+ 📦 + + {(photo.size / (1024 * 1024)).toFixed(1)} MB + +
+
+ 📷 + + {photo.exif?.Make} {photo.exif?.Model} + +
+
+ + {/* 标签 */} + {photo.tags && photo.tags.length > 0 && ( +
+ {photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => ( + + {tag} + + ))} + {photo.tags.length > 3 && ( + + +{photo.tags.length - 3} more + + )} +
+ )} +
+
+
+
+) + +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 ( +
+ {/* 背景渐变 */} +
+
+ + {/* Header */} +
+
+
+
+
+
+

+ Afilmory Manifest +

+
+ +
+ + +
+
+ +
+
+ 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" + /> +
+ 🔍 +
+
+ + +
+
+
+
+ + {/* Content */} +
+ {viewMode === 'stats' ? ( +
+ {/* 统计信息 */} +
+

+ Overview +

+ +
+ + {/* 照片列表 */} +
+
+

+ Photos ({filteredPhotos.length.toLocaleString()}) +

+ {searchTerm && ( +
+ Filtered from {photos.length.toLocaleString()} total +
+ )} +
+ + +
+ {filteredPhotos.map((photo, index) => ( + + ))} +
+
+
+
+ ) : ( + /* 原始 JSON 数据视图 */ +
+
+

+ Raw Manifest Data +

+

+ Complete JSON manifest in structured format +

+
+ +
+
+
+
+
+
+
+
+ + photos-manifest.json + +
+
+ + +
+ +
+
+
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/types/photo.ts b/apps/web/src/types/photo.ts index 612888c6..26a35682 100644 --- a/apps/web/src/types/photo.ts +++ b/apps/web/src/types/photo.ts @@ -1 +1 @@ -export { type PhotoManifest } from '@afilmory/data' +export type { PhotoManifestItem as PhotoManifest } from '@afilmory/builder' diff --git a/packages/builder/src/photo/image-pipeline.ts b/packages/builder/src/photo/image-pipeline.ts index 4f3da135..6b0e938b 100644 --- a/packages/builder/src/photo/image-pipeline.ts +++ b/packages/builder/src/photo/image-pipeline.ts @@ -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() diff --git a/packages/builder/src/photo/info-extractor.ts b/packages/builder/src/photo/info-extractor.ts index 99cca301..9fdf0960 100644 --- a/packages/builder/src/photo/info-extractor.ts +++ b/packages/builder/src/photo/info-extractor.ts @@ -99,7 +99,6 @@ export function extractPhotoInfo( return { title, dateTaken, - views, tags, description: '', // 可以从 EXIF 或其他元数据中获取 } diff --git a/packages/builder/src/types/photo.ts b/packages/builder/src/types/photo.ts index 0edbbda7..81686211 100644 --- a/packages/builder/src/types/photo.ts +++ b/packages/builder/src/types/photo.ts @@ -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 diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 37681613..1995d414 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -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 = {} + private photos: PhotoManifestItem[] = [] + private photoMap: Record = {} 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' diff --git a/packages/data/src/types.ts b/packages/data/src/types.ts deleted file mode 100644 index b8b59690..00000000 --- a/packages/data/src/types.ts +++ /dev/null @@ -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 -} diff --git a/plugins/vite/feed-sitemap.ts b/plugins/vite/feed-sitemap.ts index 102d7af0..53c7324a 100644 --- a/plugins/vite/feed-sitemap.ts +++ b/plugins/vite/feed-sitemap.ts @@ -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} ` } -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( ` ${photo.width}`, ` ${photo.height}`, ) - // 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(` ${isoDate}`) } catch { - // 如果解析失败,使用photo.dateTaken + // 如果解析失败,使用 photo.dateTaken const isoDate = new Date(photo.dateTaken).toISOString() tags.push(` ${isoDate}`) } @@ -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.Photo.FocalLengthIn35mmFilm}mm`, @@ -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 diff --git a/scripts/photo-loader.ts b/scripts/photo-loader.ts index db12d1c9..08e430d8 100644 --- a/scripts/photo-loader.ts +++ b/scripts/photo-loader.ts @@ -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 = {} + private photos: PhotoManifestItem[] = [] + private photoMap: Record = {} 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