feat: init

This commit is contained in:
Innei
2025-06-05 13:27:09 +08:00
commit f841d2ada2
181 changed files with 27788 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
---
description:
globs: src/core/**/*.ts
alwaysApply: false
---
# 存储抽象层
本模块使用适配器模式提供了统一的存储接口,支持多种存储提供商。目前已实现 S3 存储提供商,后续可以方便地扩展其他存储服务。
## 架构设计
- **`interfaces.ts`**: 定义通用的存储接口和数据结构
- **`providers/`**: 具体的存储提供商实现
- **`s3-provider.ts`**: S3 存储提供商实现
- **`factory.ts`**: 存储提供商工厂,负责创建具体的存储实例
- **`manager.ts`**: 存储管理器,提供统一的存储操作接口
- **`adapters.ts`**: 适配器函数,保持与原有 API 的兼容性
## 使用方式
### 推荐方式:使用 StorageManager
```typescript
import { defaultStorageManager, StorageManager } from '@/core/storage'
// 使用默认存储管理器(基于环境变量配置)
const buffer = await defaultStorageManager.getFile('path/to/image.jpg')
const images = await defaultStorageManager.listImages()
const allFiles = await defaultStorageManager.listAllFiles()
const publicUrl = defaultStorageManager.generatePublicUrl('path/to/image.jpg')
const livePhotos = await defaultStorageManager.detectLivePhotos()
// 或者创建自定义配置的存储管理器
const customManager = new StorageManager({
provider: 's3',
bucket: 'my-bucket',
region: 'us-east-1',
endpoint: 'https://s3.amazonaws.com',
prefix: 'photos/',
customDomain: 'https://cdn.example.com'
})
```
### 兼容性方式:使用原有 API
```typescript
// 这些函数仍然可用,但标记为 deprecated
import {
getImageFromS3,
listImagesFromS3,
listAllFilesFromS3,
detectLivePhotos,
generateS3Url
} from '@/core/s3/operations'
const buffer = await getImageFromS3('path/to/image.jpg')
const images = await listImagesFromS3()
```
### 直接使用存储提供商
```typescript
import { S3StorageProvider } from '@/core/storage'
const s3Provider = new S3StorageProvider({
provider: 's3',
bucket: 'my-bucket',
region: 'us-east-1',
// ... 其他配置
})
const buffer = await s3Provider.getFile('path/to/image.jpg')
```
## 扩展新的存储提供商
1. 实现 `StorageProvider` 接口:
```typescript
import { StorageProvider, StorageObject } from '@/core/storage/interfaces'
export class MyStorageProvider implements StorageProvider {
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
// 实现文件获取逻辑
}
async listImages(): Promise<StorageObject[]> {
// 实现图片列表逻辑
}
// ... 实现其他接口方法
}
```
2. 在工厂类中注册新的提供商:
```typescript
// 在 factory.ts 中添加新的 case
case 'my-storage':
return new MyStorageProvider(config)
```
3. 更新 `StorageConfig` 接口的 `provider` 类型。
## 优势
1. **解耦**: 业务逻辑与具体存储实现分离
2. **可扩展**: 轻松添加新的存储提供商
3. **统一接口**: 所有存储操作使用相同的 API
4. **向后兼容**: 保持原有 API 的兼容性
5. **类型安全**: 完整的 TypeScript 类型支持
6. **配置灵活**: 支持多种配置方式
## 迁移指南
现有代码可以继续使用原有的 S3 函数,无需立即修改。建议在新代码中使用 `StorageManager` API并逐步迁移现有代码。
旧代码:
```typescript
import { getImageFromS3 } from '@/core/s3/operations'
const buffer = await getImageFromS3('key')
```
新代码:
```typescript
import { defaultStorageManager } from '@/core/storage'
const buffer = await defaultStorageManager.getFile('key')
```

191
.cursor/rules/color.mdc Normal file
View File

@@ -0,0 +1,191 @@
---
description:
globs:
alwaysApply: false
---
# UIKit Colors for Tailwind CSS
You should use @https://github.com/Innei/apple-uikit-colors/blob/main/packages/uikit-colors/macos.ts TailwindCSS atom classname.
## System Colors
red
orange
yellow
green
mint
teal
cyan
blue
indigo
purple
pink
brown
gray
## Fill Colors
fill
fill-secondary
fill-tertiary
fill-quaternary
fill-quinary
fill-vibrant
fill-vibrant-secondary
fill-vibrant-tertiary
fill-vibrant-quaternary
fill-vibrant-quinary
## Text Colors
text
text-secondary
text-tertiary
text-quaternary
text-quinary
text-vibrant
text-vibrant-secondary
text-vibrant-tertiary
text-vibrant-quaternary
text-vibrant-quinary
## Material Colors
material-ultra-thick
material-thick
material-medium
material-thin
material-ultra-thin
material-opaque
## Control Colors
control-enabled
control-disabled
## Interface Colors
menu
popover
titlebar
sidebar
selection-focused
selection-focused-fill
selection-unfocused
selection-unfocused-fill
header-view
tooltip
under-window-background
## Applied Colors
All above tailwind atom will match this colors.
```css
@media (prefers-color-scheme: light) {
html {
--color-red: 255 69 58;
--color-orange: 255 149 0;
--color-yellow: 255 204 0;
--color-green: 40 205 65;
--color-mint: 0 199 190;
--color-teal: 89 173 196;
--color-cyan: 85 190 240;
--color-blue: 0 122 255;
--color-indigo: 88 86 214;
--color-purple: 175 82 222;
--color-pink: 255 45 85;
--color-brown: 162 132 94;
--color-gray: 142 142 147;
--color-fill: 0 0 0 / 0.1;
--color-fillSecondary: 0 0 0 / 0.08;
--color-fillTertiary: 0 0 0 / 0.05;
--color-fillQuaternary: 0 0 0 / 0.03;
--color-fillQuinary: 0 0 0 / 0.02;
--color-fillVibrant: 217 217 217;
--color-fillVibrantSecondary: 230 230 230;
--color-fillVibrantTertiary: 242 242 242;
--color-fillVibrantQuaternary: 247 247 247;
--color-fillVibrantQuinary: 251 251 251;
--color-text: 0 0 0 / 0.85;
--color-textSecondary: 0 0 0 / 0.5;
--color-textTertiary: 0 0 0 / 0.25;
--color-textQuaternary: 0 0 0 / 0.1;
--color-textQuinary: 0 0 0 / 0.05;
--color-textVibrant: 76 76 76;
--color-textVibrantSecondary: 128 128 128;
--color-textVibrantTertiary: 191 191 191;
--color-textVibrantQuaternary: 230 230 230;
--color-textVibrantQuinary: 242 242 242;
--color-materialUltraThick: 246 246 246 / 0.84;
--color-materialThick: 246 246 246 / 0.72;
--color-materialMedium: 246 246 246 / 0.6;
--color-materialThin: 246 246 246 / 0.48;
--color-materialUltraThin: 246 246 246 / 0.36;
--color-materialOpaque: 246 246 246;
--color-controlEnabled: 251 251 251;
--color-controlDisabled: 243 243 243;
--color-menu: 40 40 40 / 0.58;
--color-popover: 0 0 0 / 0.28;
--color-titlebar: 234 234 234 / 0.8;
--color-sidebar: 234 234 234 / 0.84;
--color-selectionFocused: 10 130 255 / 0.75;
--color-selectionFocusedFill: 10 130 255;
--color-selectionUnfocused: 0 0 0 / 0.1;
--color-selectionUnfocusedFill: 246 246 246 / 0.84;
--color-headerView: 255 255 255 / 0.8;
--color-tooltip: 246 246 246 / 0.6;
--color-underWindowBackground: 246 246 246 / 0.84;
}
}
@media (prefers-color-scheme: dark) {
html {
--color-red: 255 69 58;
--color-orange: 255 159 10;
--color-yellow: 255 214 10;
--color-green: 50 215 75;
--color-mint: 106 196 220;
--color-teal: 106 196 220;
--color-cyan: 90 200 245;
--color-blue: 10 132 255;
--color-indigo: 94 92 230;
--color-purple: 191 90 242;
--color-pink: 255 55 95;
--color-brown: 172 142 104;
--color-gray: 152 152 157;
--color-fill: 255 255 255 / 0.1;
--color-fillSecondary: 255 255 255 / 0.08;
--color-fillTertiary: 255 255 255 / 0.05;
--color-fillQuaternary: 255 255 255 / 0.03;
--color-fillQuinary: 255 255 255 / 0.02;
--color-fillVibrant: 36 36 36;
--color-fillVibrantSecondary: 20 20 20;
--color-fillVibrantTertiary: 13 13 13;
--color-fillVibrantQuaternary: 9 9 9;
--color-fillVibrantQuinary: 7 7 7;
--color-text: 255 255 255 / 0.85;
--color-textSecondary: 255 255 255 / 0.5;
--color-textTertiary: 255 255 255 / 0.25;
--color-textQuaternary: 255 255 255 / 0.1;
--color-textQuinary: 255 255 255 / 0.05;
--color-textVibrant: 229 229 229;
--color-textVibrantSecondary: 124 124 124;
--color-textVibrantTertiary: 65 65 65;
--color-textVibrantQuaternary: 35 35 35;
--color-textVibrantQuinary: 17 17 17;
--color-materialUltraThick: 40 40 40 / 0.84;
--color-materialThick: 40 40 40 / 0.72;
--color-materialMedium: 40 40 40 / 0.6;
--color-materialThin: 40 40 40 / 0.48;
--color-materialUltraThin: 40 40 40 / 0.36;
--color-materialOpaque: 40 40 40;
--color-controlEnabled: 255 255 255 / 0.2;
--color-controlDisabled: 255 255 255 / 0.1;
--color-menu: 246 246 246 / 0.72;
--color-popover: 246 246 246 / 0.6;
--color-titlebar: 60 60 60 / 0.8;
--color-sidebar: 0 0 0 / 0.45;
--color-selectionFocused: 10 130 255 / 0.75;
--color-selectionFocusedFill: 10 130 255;
--color-selectionUnfocused: 255 255 255 / 0.1;
--color-selectionUnfocusedFill: 40 40 40 / 0.65;
--color-headerView: 30 30 30 / 0.8;
--color-tooltip: 0 0 0 / 0.35;
--color-underWindowBackground: 0 0 0 / 0.45;
}
}
```

7
.env.template Normal file
View File

@@ -0,0 +1,7 @@
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=images
S3_PREFIX=
S3_ENDPOINT=
S3_CUSTOM_DOMAIN=

41
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache pnpm modules
uses: actions/cache@v4
env:
cache-name: cache-pnpm-modules
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: true
- run: pnpm run build

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
public/og-image-*.png
test-svg-font-rendering.png
test-traditional-font-rendering.png
thomas-x2d-xcd-25v-1.jpg
thomas-x2d-xcd-25v-1.webp
config.json
.env
builder.config.json
apps/web/assets-git
apps/web/public/thumbnails
apps/web/src/data/photos-manifest.json
.vercel

5
.prettierrc.mjs Normal file
View File

@@ -0,0 +1,5 @@
import { factory } from '@innei/prettier'
export default factory({
importSort: false,
})

39
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
"editor.formatOnSave": true,
"[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
// If you do not want to autofix some rules on save
// You can put this in your user settings or workspace settings
"eslint.codeActionsOnSave.rules": [
"!unused-imports/no-unused-imports",
"*"
],
// If you want to silent stylistic rules
// You can put this in your user settings or workspace settings
"eslint.rules.customizations": [
{
"rule": "@stylistic/*",
"severity": "off",
"fixable": true
},
{
"rule": "antfu/consistent-list-newline",
"severity": "off"
},
{
"rule": "hyoban/jsx-attribute-spacing",
"severity": "off"
},
{
"rule": "simple-import-sort/*",
"severity": "off"
},
{
"rule": "unused-imports/no-unused-imports",
"severity": "off"
}
]
}

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# Photo Gallery Site
⚠️警告:此项目多数代码都由 Claude 4 生成,请谨慎使用。
一个现代化的照片画廊网站,支持从 S3 存储自动同步照片具有瀑布流布局、EXIF 信息展示、缩略图生成等功能。
Preview: https://gallery.innei.in
## 特点
- 高性能 WebGL 图像渲染器
- HEIC/HEIF 格式支持
- 支持缩略图生成
- 支持 EXIF 信息展示
- 瀑布流布局
- 支持富士胶片模拟信息读取
## 环境配置
创建 `.env` 文件并配置以下环境变量:
```env
# S3 配置
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_BUCKET_NAME=your_bucket_name
S3_PREFIX=photos/
S3_CUSTOM_DOMAIN=your_custom_domain.com
```
## Photo Gallery Builder
基于适配器模式重构的照片库构建器,提供灵活的存储抽象和可配置的构建选项。
### 配置文件
在项目根目录的 `builder.config.ts` 中可以配置构建器的各种选项:
```typescript
export const builderConfig: BuilderConfig = {
storage: {
provider: 's3',
bucket: 'my-bucket',
region: 'us-east-1',
// ... 其他存储配置
},
options: {
defaultConcurrency: 8, // 默认并发数
maxPhotos: 5000, // 最大照片数量限制
enableLivePhotoDetection: true, // 启用 Live Photo 检测
showProgress: true, // 显示进度
showDetailedStats: true, // 显示详细统计
},
logging: {
verbose: true, // 详细日志
level: 'debug', // 日志级别
outputToFile: false, // 是否输出到文件
},
performance: {
worker: {
timeout: 30000, // Worker 超时时间
},
memoryLimit: 512, // 内存限制MB
enableCache: true, // 启用缓存
},
}
```
#### 自定义存储提供商
如果需要使用其他存储服务(如阿里云 OSS可以
1. 实现新的存储提供商类
2. 在配置中指定使用新的提供商
```typescript
const builder = new PhotoGalleryBuilder({
storage: {
provider: 'oss', // 假设已经实现了 OSS 提供商
bucket: 'my-oss-bucket',
// ... OSS 特定配置
},
})
```
## 🚀 使用
### 开发模式
```bash
pnpm dev
```
### 构建照片清单
```bash
# 增量更新(默认)
pnpm run build:manifest
# 全量更新
pnpm run build:manifest -- --force
```
### 构建生产版本
```bash
pnpm run build
```
## License
2025 © Innei, Released under the MIT License.
> [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/)

6
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
public/og-image-*.png
test-svg-font-rendering.png
test-traditional-font-rendering.png
thomas-x2d-xcd-25v-1.jpg
thomas-x2d-xcd-25v-1.webp

399
apps/web/index.html Normal file
View File

@@ -0,0 +1,399 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Photo Gallery</title>
</head>
<body>
<div id="root">
<!-- Splash Screen - 现代黑色调设计 -->
<div
id="splash-screen"
style="
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #0a0a0a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
sans-serif;
overflow: hidden;
"
>
<!-- 几何背景装饰 -->
<div
style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(
circle at 20% 30%,
rgba(64, 64, 64, 0.1) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 70%,
rgba(64, 64, 64, 0.08) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 80%,
rgba(96, 96, 96, 0.05) 0%,
transparent 50%
);
"
></div>
<!-- 网格背景 -->
<div
style="
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.02;
background-image:
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
linear-gradient(
90deg,
rgba(255, 255, 255, 0.1) 1px,
transparent 1px
);
background-size: 30px 30px;
animation: gridMove 20s linear infinite;
"
></div>
<!-- 动态光线效果 -->
<div
style="
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: conic-gradient(
from 0deg,
transparent,
rgba(255, 255, 255, 0.01),
transparent,
rgba(255, 255, 255, 0.02),
transparent
);
animation: rotate 30s linear infinite;
"
></div>
<!-- 主要内容 -->
<div
style="
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 2.5rem;
text-align: center;
"
>
<!-- Logo Container -->
<div
style="
position: relative;
display: flex;
align-items: center;
justify-content: center;
"
>
<!-- Logo 背景光晕 -->
<div
style="
position: absolute;
width: 8rem;
height: 8rem;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 70%
);
border-radius: 50%;
animation: glowPulse 3s ease-in-out infinite;
"
></div>
<!-- Logo -->
<div
style="
width: 4.5rem;
height: 4.5rem;
background: linear-gradient(135deg, #1a1a1a, #2d2d2d);
border: 1px solid #333;
border-radius: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05),
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: logoFloat 4s ease-in-out infinite;
backdrop-filter: blur(10px);
"
>
<!-- 现代化相机图标 -->
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="#e5e5e5"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M3 9a2 2 0 0 1 2-2h.93a2 2 0 0 0 1.664-.89l.812-1.22A2 2 0 0 1 10.07 4h3.86a2 2 0 0 1 1.664.89l.812 1.22A2 2 0 0 0 18.07 7H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9z"
/>
<circle cx="12" cy="13" r="3" />
<circle cx="12" cy="13" r="1" fill="#e5e5e5" />
</svg>
</div>
</div>
<!-- 应用标题 -->
<div style="display: flex; flex-direction: column; gap: 1rem">
<h1
style="
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.025em;
color: #ffffff;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
animation: titleSlide 1s ease-out;
"
>
Photo Gallery
</h1>
<div
style="
width: 3rem;
height: 2px;
background: linear-gradient(
90deg,
transparent,
#666,
transparent
);
margin: 0 auto;
animation: lineGrow 1.5s ease-out both;
"
></div>
<p
style="
color: #a3a3a3;
font-size: 0.95rem;
margin: 0;
font-weight: 400;
letter-spacing: 0.025em;
animation: subtitleFade 1.5s ease-out both;
opacity: 0;
"
>
Capturing moments, creating memories
</p>
</div>
<!-- 现代加载动画 -->
<div
style="
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 1.5rem;
"
>
<!-- 进度条 -->
<div
style="
width: 12rem;
height: 2px;
background: #1a1a1a;
border-radius: 1px;
overflow: hidden;
position: relative;
"
>
<div
style="
width: 30%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
#666,
#999,
#666,
transparent
);
animation: progressMove 2s ease-in-out infinite;
"
></div>
</div>
<!-- 加载文字 -->
<p
style="
color: #666;
font-size: 0.8rem;
margin: 0;
font-weight: 500;
letter-spacing: 0.05em;
animation: loadingText 2s ease-in-out infinite;
"
>
LOADING
</p>
</div>
</div>
<!-- CSS 动画 -->
<style>
@keyframes gridMove {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(30px, 30px);
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes glowPulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
@keyframes logoFloat {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-8px) scale(1.02);
}
}
@keyframes titleSlide {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes lineGrow {
0% {
width: 0;
opacity: 0;
}
100% {
width: 3rem;
opacity: 1;
}
}
@keyframes subtitleFade {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes progressMove {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
@keyframes loadingText {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
/* 响应式设计 */
@media (max-width: 640px) {
#splash-screen h1 {
font-size: 1.875rem;
}
#splash-screen p {
font-size: 0.875rem;
}
#splash-screen [style*='width: 12rem'] {
width: 10rem !important;
}
}
@media (max-width: 480px) {
#splash-screen h1 {
font-size: 1.5rem;
}
#splash-screen [style*='gap: 2.5rem'] {
gap: 2rem !important;
}
}
</style>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

95
apps/web/package.json Normal file
View File

@@ -0,0 +1,95 @@
{
"name": "@photo-gallery/web",
"type": "module",
"version": "0.0.1",
"packageManager": "pnpm@10.11.1",
"repository": {
"type": "git",
"url": "https://github.com/innei/photo-gallery"
},
"scripts": {
"analyze": "analyzer=1 vite build",
"build": "tsx scripts/build.ts",
"build:manifest": "tsx src/core/cli.ts",
"dev": "tsx scripts/dev.ts",
"format": "prettier --write \"src/**/*.ts\" ",
"lint": "eslint --fix",
"serve": "vite preview"
},
"dependencies": {
"@aws-sdk/client-s3": "3.823.0",
"@aws-sdk/s3-request-presigner": "3.823.0",
"@essentials/request-timeout": "1.3.0",
"@headlessui/react": "2.2.4",
"@photo-gallery/webgl-viewer": "workspace:*",
"@radix-ui/react-context-menu": "2.2.15",
"@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-popover": "1.1.14",
"@radix-ui/react-scroll-area": "1.2.9",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-tooltip": "1.2.7",
"@react-hook/window-size": "3.1.1",
"@remixicon/react": "4.6.0",
"@t3-oss/env-core": "0.13.6",
"@tanstack/react-query": "5.80.2",
"@use-gesture/react": "10.3.1",
"@vercel/analytics": "1.5.0",
"blurhash": "2.0.5",
"clsx": "2.1.1",
"consola": "3.4.2",
"dotenv": "16.5.0",
"es-toolkit": "1.38.0",
"exif-reader": "2.0.2",
"foxact": "0.2.45",
"fuji-recipes": "1.0.2",
"heic-convert": "2.1.0",
"heic-to": "1.1.13",
"immer": "10.1.1",
"jotai": "2.12.5",
"masonic": "4.1.0",
"motion": "12.16.0",
"ofetch": "1.4.1",
"react": "19.1.0",
"react-blurhash": "0.3.0",
"react-dom": "19.1.0",
"react-image-gallery": "1.4.0",
"react-intersection-observer": "9.16.0",
"react-remove-scroll": "2.7.1",
"react-router": "7.6.2",
"react-scan": "0.3.4",
"react-zoom-pan-pinch": "3.7.0",
"sharp": "0.34.2",
"sonner": "2.0.5",
"swiper": "11.2.8",
"tailwind-merge": "3.3.0",
"usehooks-ts": "3.1.1",
"zod": "3.25.51",
"zustand": "5.0.5"
},
"devDependencies": {
"@egoist/tailwindcss-icons": "1.9.0",
"@iconify-json/mingcute": "1.2.3",
"@tailwindcss/container-queries": "0.1.1",
"@tailwindcss/postcss": "4.1.8",
"@tailwindcss/typography": "0.5.16",
"@tailwindcss/vite": "4.1.8",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"code-inspector-plugin": "0.20.12",
"daisyui": "5.0.43",
"execa": "9.6.0",
"postcss": "8.5.4",
"postcss-import": "16.1.0",
"postcss-js": "4.0.1",
"react-compiler-runtime": "19.1.0-rc.2",
"simple-git-hooks": "2.13.0",
"tailwind-scrollbar": "4.0.2",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.8",
"tailwindcss-animate": "1.0.7",
"tailwindcss-safe-area": "0.6.0",
"tailwindcss-uikit-colors": "1.0.0-alpha.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "Photo Gallery",
"short_name": "Gallery",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#0a0a0a",
"background_color": "#0a0a0a",
"display": "standalone"
}

16
apps/web/scripts/build.ts Normal file
View File

@@ -0,0 +1,16 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { $ } from 'execa'
import { precheck } from './precheck'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workdir = path.resolve(__dirname, '..')
async function main() {
await precheck()
await $({ cwd: workdir, stdio: 'inherit' })`vite build`
}
main()

18
apps/web/scripts/dev.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { $ } from 'execa'
import { precheck } from './precheck'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workdir = path.resolve(__dirname, '..')
async function main() {
await precheck()
// Get command line arguments excluding node and script name
const args = process.argv.slice(2)
await $({ cwd: workdir, stdio: 'inherit' })`vite ${args}`
}
main()

View File

@@ -0,0 +1,35 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { builderConfig } from '@builder'
import { $ } from 'execa'
import { pullAndLinkRemoteRepo } from './pull-remote'
export const precheck = async () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workdir = path.resolve(__dirname, '..')
// 检查是否存在 public/thumbnails 和 src/data/photos-manifest.json
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
const photosManifestPath = path.resolve(
workdir,
'src',
'data',
'photos-manifest.json',
)
const isExistThumbnails = existsSync(thumbnailsDir)
const isExistPhotosManifest = existsSync(photosManifestPath)
const shouldDoBuildOrClone = !isExistThumbnails || !isExistPhotosManifest
// 检查 builder 配置
if (shouldDoBuildOrClone) {
if (builderConfig.repo.enable) {
await pullAndLinkRemoteRepo()
} else {
await $({ cwd: workdir, stdio: 'inherit' })`pnpm build:manifest`
}
}
}

View File

@@ -0,0 +1,49 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { builderConfig } from '@builder'
import { $ } from 'execa'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workdir = path.resolve(__dirname, '..')
export const pullAndLinkRemoteRepo = async () => {
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
if (!hasExist) {
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${builderConfig.repo.url} assets-git`
} else {
await $({
cwd: path.resolve(workdir, 'assets-git'),
stdio: 'inherit',
})`git pull --rebase`
}
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
if (existsSync(thumbnailsDir)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${path.resolve(workdir, 'assets-git', 'thumbnails')} ${thumbnailsDir}`
// 删除src/data/photos-manifest.json并建立软连接到 assets-git/photos-manifest.json
const photosManifestPath = path.resolve(
workdir,
'src',
'data',
'photos-manifest.json',
)
if (existsSync(photosManifestPath)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${photosManifestPath}`
}
await $({ cwd: workdir, stdio: 'inherit' })`ln -s ${path.resolve(
workdir,
'assets-git',
'photos-manifest.json',
)} ${photosManifestPath}`
}

18
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { inject } from '@vercel/analytics'
import { Outlet } from 'react-router'
import { RootProviders } from './providers/root-providers'
inject()
function App() {
return (
<RootProviders>
<div className="h-svh">
<Outlet />
</div>
</RootProviders>
)
}
export default App

Binary file not shown.

View File

@@ -0,0 +1,9 @@
import { atom } from 'jotai'
export type GallerySortBy = 'date'
export type GallerySortOrder = 'asc' | 'desc'
export const gallerySettingAtom = atom({
sortBy: 'date' as GallerySortBy,
sortOrder: 'desc' as GallerySortOrder,
selectedTags: [] as string[],
})

View File

@@ -0,0 +1,195 @@
import { atom } from 'jotai'
import { useCallback } from 'react'
import { createAtomHooks } from '~/lib/jotai'
// Atom
type ContextMenuState =
| { open: false }
| {
open: true
position: { x: number; y: number }
menuItems: FollowMenuItem[]
// Just for abort callback
// Also can be optimized by using the `atomWithListeners`
abortController: AbortController
}
export const [
contextMenuAtom,
useContextMenuState,
useContextMenuValue,
useSetContextMenu,
] = createAtomHooks(atom<ContextMenuState>({ open: false }))
const useShowWebContextMenu = () => {
const setContextMenu = useSetContextMenu()
const showWebContextMenu = useCallback(
async (
menuItems: Array<FollowMenuItem>,
e: MouseEvent | React.MouseEvent,
) => {
const abortController = new AbortController()
const resolvers = Promise.withResolvers<void>()
setContextMenu({
open: true,
position: { x: e.clientX, y: e.clientY },
menuItems,
abortController,
})
abortController.signal.addEventListener('abort', () => {
resolvers.resolve()
})
return resolvers.promise
},
[setContextMenu],
)
return showWebContextMenu
}
// Menu
export type FollowMenuItem = MenuItemText | MenuItemSeparator
export type MenuItemInput = MenuItemText | MenuItemSeparator | NilValue
function filterNullableMenuItems(items: MenuItemInput[]): FollowMenuItem[] {
return items
.filter(
(item) =>
item !== null && item !== undefined && item !== false && item !== '',
)
.filter((item) => !item.hide)
.map((item) => {
if (item instanceof MenuItemSeparator) {
return MENU_ITEM_SEPARATOR
}
if (item.submenu && item.submenu.length > 0) {
return item.extend({
submenu: filterNullableMenuItems(item.submenu),
})
}
return item
})
}
export enum MenuItemType {
Separator,
Action,
}
export const useShowContextMenu = () => {
const showWebContextMenu = useShowWebContextMenu()
const showContextMenu = useCallback(
async (
inputMenu: Array<MenuItemInput>,
e: MouseEvent | React.MouseEvent,
) => {
const menuItems = filterNullableMenuItems(inputMenu)
e.preventDefault()
e.stopPropagation()
await showWebContextMenu(menuItems, e)
},
[showWebContextMenu],
)
return showContextMenu
}
export class MenuItemSeparator {
readonly type = MenuItemType.Separator
constructor(public hide = false) {}
static default = new MenuItemSeparator()
}
const noop = () => void 0
export type BaseMenuItemTextConfig = {
label: string
click?: () => void
/** only work in web app */
icon?: React.ReactNode
shortcut?: string
disabled?: boolean
checked?: boolean
supportMultipleSelection?: boolean
}
export class BaseMenuItemText {
readonly type = MenuItemType.Action
private __sortedShortcut: string | null = null
constructor(private configs: BaseMenuItemTextConfig) {
this.__sortedShortcut = this.configs.shortcut || null
}
public get label() {
return this.configs.label
}
public get click() {
return this.configs.click?.bind(this.configs) || noop
}
public get onClick() {
return this.click
}
public get icon() {
return this.configs.icon
}
public get shortcut() {
return this.__sortedShortcut
}
public get disabled() {
return this.configs.disabled || false
}
public get checked() {
return this.configs.checked
}
public get supportMultipleSelection() {
return this.configs.supportMultipleSelection
}
}
export type MenuItemTextConfig = BaseMenuItemTextConfig & {
hide?: boolean
submenu?: MenuItemInput[]
}
export class MenuItemText extends BaseMenuItemText {
protected __submenu: FollowMenuItem[]
constructor(protected config: MenuItemTextConfig) {
super(config)
this.__submenu = this.config.submenu
? filterNullableMenuItems(this.config.submenu)
: []
}
public get submenu() {
return this.__submenu
}
public get hide() {
return this.config.hide || false
}
extend(config: Partial<MenuItemTextConfig>) {
return new MenuItemText({
...this.config,
...config,
})
}
}
export const MENU_ITEM_SEPARATOR = MenuItemSeparator.default

View File

@@ -0,0 +1,43 @@
import { atom, useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import { useMemo } from 'react'
import type { Location, NavigateFunction, Params } from 'react-router'
import { createAtomHooks } from '~/lib/jotai'
interface RouteAtom {
params: Readonly<Params<string>>
searchParams: URLSearchParams
location: Location<any>
}
export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
atom<RouteAtom>({
params: {},
searchParams: new URLSearchParams(),
location: {
pathname: '',
search: '',
hash: '',
state: null,
key: '',
},
}),
)
const noop = []
export const useReadonlyRouteSelector = <T>(
selector: (route: RouteAtom) => T,
deps: any[] = noop,
): T =>
useAtomValue(
useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps),
)
// Vite HMR will create new router instance, but RouterProvider always stable
const [, , , , navigate, setNavigate] = createAtomHooks(
atom<{ fn: NavigateFunction | null }>({ fn() {} }),
)
const getStableRouterNavigate = () => navigate().fn
export { getStableRouterNavigate, setNavigate }

View File

@@ -0,0 +1,38 @@
import { atom } from 'jotai'
const { innerWidth: w, innerHeight: h } = window
const sm = w >= 640
const md = w >= 768
const lg = w >= 1024
const xl = w >= 1280
const _2xl = w >= 1536
export const viewportAtom = atom({
/**
* 640px
*/
sm,
/**
* 768px
*/
md,
/**
* 1024px
*/
lg,
/**
* 1280px
*/
xl,
/**
* 1536px
*/
'2xl': _2xl,
h,
w,
})

View File

@@ -0,0 +1,77 @@
import { repository } from '@pkg'
import { useEffect, useRef } from 'react'
import { isRouteErrorResponse, useRouteError } from 'react-router'
import { attachOpenInEditor } from '~/lib/dev'
import { Button } from '../ui/button'
export function ErrorElement() {
const error = useRouteError()
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: JSON.stringify(error)
const stack = error instanceof Error ? error.stack : null
useEffect(() => {
console.error('Error handled by React Router default ErrorBoundary:', error)
}, [error])
const reloadRef = useRef(false)
if (
message.startsWith('Failed to fetch dynamically imported module') &&
window.sessionStorage.getItem('reload') !== '1'
) {
if (reloadRef.current) return null
window.sessionStorage.setItem('reload', '1')
window.location.reload()
reloadRef.current = true
return null
}
return (
<div className="m-auto flex min-h-full max-w-prose flex-col p-8 pt-12 select-text">
<div className="fixed inset-x-0 top-0 h-12" />
<div className="center flex flex-col">
<i className="i-mingcute-bug-fill size-12 text-red-400" />
<h2 className="mt-12 text-2xl">
Sorry, the app has encountered an error
</h2>
</div>
<h3 className="text-xl">{message}</h3>
{import.meta.env.DEV && stack ? (
<div className="mt-4 cursor-text overflow-auto rounded-md bg-red-50 p-4 text-left font-mono text-sm whitespace-pre text-red-600">
{attachOpenInEditor(stack)}
</div>
) : null}
<p className="my-8">
The App has a temporary problem, click the button below to try reloading
the app or another solution?
</p>
<div className="center gap-4">
<Button onClick={() => (window.location.href = '/')}>Reload</Button>
</div>
<p className="mt-8">
Still having this issue? Please give feedback in Github, thanks!
<a
className="text-accent ml-2 cursor-pointer duration-200"
href={`${repository.url}/issues/new?title=${encodeURIComponent(
`Error: ${message}`,
)}&body=${encodeURIComponent(
`### Error\n\n${message}\n\n### Stack\n\n\`\`\`\n${stack}\n\`\`\``,
)}&label=bug`}
target="_blank"
rel="noreferrer"
>
Submit Issue
</a>
</p>
<div className="grow" />
</div>
)
}

View File

@@ -0,0 +1,53 @@
import type { FC, ReactNode } from 'react'
import { createElement, useEffect, useState } from 'react'
import { LoadingCircle } from '../ui/loading'
export const LoadRemixAsyncComponent: FC<{
loader: () => Promise<any>
Header: FC<{ loader: () => any; [key: string]: any }>
}> = ({ loader, Header }) => {
const [loading, setLoading] = useState(true)
const [Component, setComponent] = useState<{ c: () => ReactNode }>({
c: () => null,
})
useEffect(() => {
let isUnmounted = false
setLoading(true)
loader()
.then((module) => {
if (!module.Component) {
return
}
if (isUnmounted) return
const { loader } = module
setComponent({
c: () => (
<>
<Header loader={loader} />
<module.Component />
</>
),
})
})
.finally(() => {
setLoading(false)
})
return () => {
isUnmounted = true
}
}, [Header, loader])
if (loading) {
return (
<div className="center absolute inset-0 h-full">
<LoadingCircle size="large" />
</div>
)
}
return createElement(Component.c)
}

View File

@@ -0,0 +1,25 @@
import { useLocation, useNavigate } from 'react-router'
import { Button } from '../ui/button'
export const NotFound = () => {
const location = useLocation()
const navigate = useNavigate()
return (
<div className="prose center dark:prose-invert m-auto size-full flex-col">
<main className="flex grow flex-col items-center justify-center">
<p className="font-semibold">
You have come to a desert of knowledge where there is nothing.
</p>
<p>
Current path: <code>{location.pathname}</code>
</p>
<p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</p>
</main>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import type { FC, PropsWithChildren } from 'react'
import { Fragment } from 'react'
export const PassiveFragment: FC<PropsWithChildren> = ({
children,
...rest
}) => <Fragment {...rest}>{children}</Fragment>

View File

@@ -0,0 +1,10 @@
import type { JSX } from 'react'
import { cloneElement } from 'react'
export const ProviderComposer: Component<{
contexts: JSX.Element[]
}> = ({ contexts, children }) =>
contexts.reduceRight(
(kids: any, parent: any) => cloneElement(parent, { children: kids }),
children,
)

View File

@@ -0,0 +1,135 @@
// Tremor Button [v0.2.0]
import { Slot } from '@radix-ui/react-slot'
import { m } from 'motion/react'
import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { tv } from 'tailwind-variants'
import { cx, focusRing } from '~/lib/cn'
const buttonVariants = tv({
base: [
'relative inline-flex items-center justify-center whitespace-nowrap rounded-md text-center font-medium transition-all duration-100 ease-in-out',
'disabled:pointer-events-none',
focusRing,
],
variants: {
variant: {
primary: [
'border-transparent',
'text-white dark:text-white',
'bg-accent dark:bg-accent',
'hover:bg-accent/90 dark:hover:bg-accent/90',
'disabled:bg-accent/50 disabled:text-white/70',
'disabled:dark:bg-accent/30 disabled:dark:text-white/50',
],
secondary: [
'border border-gray-200 dark:border-gray-700',
'text-gray-700 dark:text-gray-200',
'bg-gray-50 dark:bg-gray-800',
'hover:bg-gray-100 dark:hover:bg-gray-750',
'disabled:bg-gray-50 disabled:text-gray-400',
'disabled:dark:bg-gray-800 disabled:dark:text-gray-500',
],
light: [
'shadow-none',
'border-transparent',
'text-gray-900 dark:text-gray-50',
'bg-gray-200 dark:bg-gray-900',
'hover:bg-gray-300/70 dark:hover:bg-gray-800/80',
'disabled:bg-gray-100 disabled:text-gray-400',
'disabled:dark:bg-gray-800 disabled:dark:text-gray-600',
],
ghost: [
'shadow-none',
'border-transparent',
'text-gray-900 dark:text-gray-50',
'bg-transparent dark:hover:bg-fill-tertiary',
'disabled:text-gray-400',
'disabled:dark:text-gray-600',
],
destructive: [
'text-white',
'border-transparent',
'bg-red-600 dark:bg-red-700',
'hover:bg-red-700 dark:hover:bg-red-600',
'disabled:bg-red-300 disabled:text-white',
'disabled:dark:bg-red-950 disabled:dark:text-red-400',
],
},
size: {
xs: 'h-6 px-2 text-xs',
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-8 text-base',
xl: 'h-12 px-8 text-base',
},
flat: {
true: 'shadow-none',
false: 'shadow-sm',
},
},
defaultVariants: {
variant: 'primary',
size: 'sm',
flat: false,
},
})
interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
isLoading?: boolean
loadingText?: string
}
const Button = ({
ref: forwardedRef,
asChild,
isLoading = false,
loadingText,
className,
disabled,
variant,
size,
flat,
children,
...props
}: ButtonProps & {
ref?: React.RefObject<HTMLButtonElement>
}) => {
const Component = asChild ? Slot : m.button
return (
// @ts-expect-error
<Component
ref={forwardedRef}
className={cx(buttonVariants({ variant, size, flat }), className)}
disabled={disabled || isLoading}
data-tremor-id="tremor-raw"
whileTap={{ scale: 0.95 }}
{...props}
>
{isLoading ? (
<span className="pointer-events-none inline-flex items-center justify-center gap-1.5">
<i
className={cx(
'shrink-0 animate-spin i-mingcute-loading-3-line',
size === 'xs' || size === 'sm' ? 'size-3' : 'size-4',
)}
aria-hidden="true"
/>
<span className="sr-only">{loadingText ?? 'Loading'}</span>
<span className="inline-block">{loadingText ?? children}</span>
</span>
) : (
children
)}
</Component>
)
}
Button.displayName = 'Button'
export { Button, type ButtonProps, buttonVariants }

View File

@@ -0,0 +1,25 @@
import type { HTMLMotionProps } from 'motion/react'
import { m } from 'motion/react'
export const MotionButtonBase = ({
ref,
children,
...rest
}: HTMLMotionProps<'button'> & {
ref?: React.RefObject<HTMLButtonElement>
}) => {
return (
<m.button
initial={true}
whileFocus={{ scale: 1.02 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
{...rest}
ref={ref}
>
{children}
</m.button>
)
}
MotionButtonBase.displayName = 'MotionButtonBase'

View File

@@ -0,0 +1,2 @@
export * from './Button'
export * from './MotionButton'

View File

@@ -0,0 +1,51 @@
import * as React from 'react'
import { clsxm } from '~/lib/cn'
export interface CheckboxProps {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
className?: string
children?: React.ReactNode
}
export const Checkbox = ({
ref,
checked = false,
onCheckedChange,
disabled = false,
className,
children,
...props
}: CheckboxProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
className={clsxm(
'inline-flex items-center gap-2 text-sm cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
onClick={() => onCheckedChange?.(!checked)}
{...props}
>
<div
className={clsxm(
'flex h-4 w-4 items-center justify-center rounded border transition-colors',
checked
? 'bg-accent border-accent text-white'
: 'border-border bg-background hover:border-accent/50',
)}
>
{checked && <i className="i-mingcute-check-line size-3" />}
</div>
{children}
</button>
)
}
Checkbox.displayName = 'Checkbox'

View File

@@ -0,0 +1,202 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const RootPortal = ContextMenuPrimitive.Portal
const ContextMenuSubTrigger = ({
ref,
className,
inset,
children,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<
typeof ContextMenuPrimitive.SubTrigger
> | null>
}) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={clsxm(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none',
inset && 'pl-8',
'flex items-center justify-center gap-2',
className,
props.disabled && 'cursor-not-allowed opacity-30',
)}
{...props}
>
{children}
<i className="i-mingcute-right-line -mr-1 ml-auto size-3.5" />
</ContextMenuPrimitive.SubTrigger>
)
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {
ref?: React.Ref<React.ElementRef<
typeof ContextMenuPrimitive.SubContent
> | null>
}) => (
<RootPortal>
<ContextMenuPrimitive.SubContent
ref={ref}
className={clsxm(
'bg-material-medium backdrop-blur-[70px] text-text text-body',
'min-w-32 overflow-hidden',
'rounded-[6px] border p-1',
'shadow-context-menu',
'z-[10061]',
className,
)}
{...props}
/>
</RootPortal>
)
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Content> | null>
}) => (
<RootPortal>
<ContextMenuPrimitive.Content
ref={ref}
className={clsxm(
'bg-material-medium backdrop-blur-[70px] text-text shadow-context-menu z-[10060] min-w-32 overflow-hidden rounded-[6px] border border-border p-1',
'motion-scale-in-75 motion-duration-150 text-body lg:animate-none',
className,
)}
{...props}
/>
</RootPortal>
)
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = ({
ref,
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Item> | null>
}) => (
<ContextMenuPrimitive.Item
ref={ref}
className={clsxm(
'cursor-menu focus:bg-accent text-sm focus:text-accent-foreground relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[highlighted]:bg-theme-selection-hover focus-within:outline-transparent',
'h-[28px]',
inset && 'pl-8',
className,
)}
{...props}
/>
)
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = ({
ref,
className,
children,
checked,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
ref?: React.Ref<React.ElementRef<
typeof ContextMenuPrimitive.CheckboxItem
> | null>
}) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={clsxm(
'cursor-checkbox focus:bg-accent text-sm focus:text-accent-foreground relative flex select-none items-center rounded-[5px] px-8 py-1.5 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus-within:outline-transparent',
'h-[28px]',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex items-center justify-center">
<ContextMenuPrimitive.ItemIndicator asChild>
<i className="i-mgc-check-filled size-3" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuLabel = ({
ref,
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Label> | null>
}) => (
<ContextMenuPrimitive.Label
ref={ref}
className={clsxm(
'text-text px-2 py-1.5 font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
)
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = ({
ref,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & {
ref?: React.Ref<React.ElementRef<
typeof ContextMenuPrimitive.Separator
> | null>
}) => (
<ContextMenuPrimitive.Separator
className="mx-2 my-1 h-px backdrop-blur-[70px]"
asChild
ref={ref}
{...props}
>
<div className="bg-border mr-2 h-px" />
</ContextMenuPrimitive.Separator>
)
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
RootPortal as ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
}

View File

@@ -0,0 +1 @@
export * from './context-menu'

View File

@@ -0,0 +1,205 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
return <DropdownMenuPrimitive.Root {...props} />
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = ({
ref,
className,
inset,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.SubTrigger
> | null>
}) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={clsxm(
'cursor-menu focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[state=open]:bg-theme-selection-active data-[state=open]:text-theme-selection-foreground flex select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none',
inset && 'pl-8',
'center gap-2',
className,
props.disabled && 'cursor-not-allowed opacity-30',
)}
{...props}
>
{children}
<i className="i-mingcute-right-line -mr-1 ml-auto size-3.5" />
</DropdownMenuPrimitive.SubTrigger>
)
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Content> | null>
}) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={clsxm(
'bg-material-medium backdrop-blur-[70px] text-text shadow z-[60] min-w-32 overflow-hidden rounded-[6px] border border-border p-1',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = ({
ref,
className,
inset,
icon,
active,
highlightColor = 'accent',
shortcut,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode)
active?: boolean
highlightColor?: 'accent' | 'gray'
shortcut?: string
} & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null>
}) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={clsxm(
'cursor-menu relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus-within:outline-transparent text-sm my-0.5',
highlightColor === 'accent'
? 'data-[highlighted]:bg-accent/10 focus:bg-accent/10 focus:text-accent data-[highlighted]:text-accent'
: 'data-[highlighted]:bg-accent/10 focus:bg-accent/10 focus:text-accent',
'h-[28px]',
inset && 'pl-8',
className,
)}
{...props}
>
{!!icon && (
<span className="mr-1.5 inline-flex size-4 items-center justify-center">
{typeof icon === 'function' ? icon({ isActive: active }) : icon}
</span>
)}
{props.children}
{/* Justify Fill */}
{!!icon && <span className="ml-1.5 size-4" />}
</DropdownMenuPrimitive.Item>
)
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = ({
ref,
className,
children,
checked,
icon,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
icon?: React.ReactNode
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.CheckboxItem
> | null>
}) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={clsxm(
'cursor-menu relative flex select-none items-center rounded-[5px] py-1.5 pl-2 pr-2 text-sm outline-none transition-colors',
'focus:bg-accent/10 focus:text-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
{!!icon && (
<span className="mr-1.5 inline-flex size-4 items-center justify-center">
{icon}
</span>
)}
{children}
<span className="ml-auto flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator className="ml-1 flex items-center justify-center">
<i className="i-mingcute-check-line size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
</DropdownMenuPrimitive.CheckboxItem>
)
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuLabel = ({
ref,
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Label> | null>
}) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={clsxm(
'text-text px-2 py-1 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
)
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = ({
ref,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
ref?: React.Ref<React.ElementRef<
typeof DropdownMenuPrimitive.Separator
> | null>
}) => (
<DropdownMenuPrimitive.Separator
className="bg-border mx-2 my-1 h-px px-2"
ref={ref}
{...props}
/>
)
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
}

View File

@@ -0,0 +1,19 @@
import { clsxm } from '~/lib/cn'
interface LoadingCircleProps {
size: 'small' | 'medium' | 'large'
}
const sizeMap = {
small: 'text-md',
medium: 'text-xl',
large: 'text-3xl',
}
export const LoadingCircle: Component<LoadingCircleProps> = ({
className,
size,
}) => (
<div className={clsxm(sizeMap[size], className)}>
<i className="i-mingcute-loading-3-line animate-spin" />
</div>
)

View File

@@ -0,0 +1,909 @@
import './PhotoViewer.css'
import type { Exif } from 'exif-reader'
import { m } from 'motion/react'
import type { FC } from 'react'
import { Fragment } from 'react'
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
import { useMobile } from '~/hooks/useMobile'
import {
CarbonIsoOutline,
MaterialSymbolsExposure,
MaterialSymbolsShutterSpeed,
StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens,
TablerAperture,
} from '~/icons'
import { getImageFormat } from '~/lib/image-utils'
import type { PhotoManifest } from '~/types/photo'
import { MotionButtonBase } from '../button'
import { EllipsisHorizontalTextWithTooltip } from '../typography'
export const ExifPanel: FC<{
currentPhoto: PhotoManifest
exifData: Exif | null
onClose?: () => void
}> = ({ currentPhoto, exifData, onClose }) => {
const isMobile = useMobile()
const formattedExifData = formatExifData(exifData)
// 使用通用的图片格式提取函数
const imageFormat = getImageFormat(
currentPhoto.originalUrl || currentPhoto.s3Key || '',
)
return (
<m.div
className={`${
isMobile
? 'exif-panel-mobile fixed right-0 bottom-0 left-0 max-h-[60vh] w-full rounded-t-2xl'
: 'w-80 shrink-0'
} bg-material-medium z-10 flex flex-col text-white backdrop-blur-3xl`}
initial={{
opacity: 0,
...(isMobile ? { y: 100 } : { x: 100 }),
}}
animate={{
opacity: 1,
...(isMobile ? { y: 0 } : { x: 0 }),
}}
exit={{
opacity: 0,
...(isMobile ? { y: 100 } : { x: 100 }),
}}
transition={{ duration: 0.3 }}
>
<div className="mb-4 flex shrink-0 items-center justify-between p-4 pb-0">
<h3 className={`${isMobile ? 'text-base' : 'text-lg'} font-semibold`}>
</h3>
{isMobile && onClose && (
<button
type="button"
className="flex size-6 items-center justify-center rounded-full text-white/70 duration-200 hover:bg-white/10 hover:text-white"
onClick={onClose}
>
<i className="i-mingcute-close-line text-sm" />
</button>
)}
</div>
<ScrollArea
rootClassName="flex-1 min-h-0 overflow-auto lg:overflow-hidden"
viewportClassName="px-4 pb-4"
>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}
<div>
<h4 className="mb-2 text-sm font-medium text-white/80"></h4>
<div className="space-y-1 text-sm">
<Row label="文件名" value={currentPhoto.title} ellipsis />
<Row label="格式" value={imageFormat} />
<Row
label="尺寸"
value={`${currentPhoto.width} × ${currentPhoto.height}`}
/>
<Row
label="文件大小"
value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`}
/>
{formattedExifData?.megaPixels && (
<Row
label="像素"
value={`${Math.floor(
Number.parseFloat(formattedExifData.megaPixels),
)} MP`}
/>
)}
{formattedExifData?.colorSpace && (
<Row label="色彩空间" value={formattedExifData.colorSpace} />
)}
{formattedExifData?.dateTime && (
<Row label="拍摄时间" value={formattedExifData.dateTime} />
)}
</div>
{/* 标签信息 - 移到基本信息 section 内 */}
{currentPhoto.tags && currentPhoto.tags.length > 0 && (
<div className="mt-3">
<div className="mb-2 text-sm text-white/80"></div>
<div className="flex flex-wrap gap-1.5">
{currentPhoto.tags.map((tag) => (
<MotionButtonBase
type="button"
onClick={() => {
window.open(
`/?tags=${tag}`,
'_blank',
'noopener,noreferrer',
)
}}
key={tag}
className="bg-material-medium hover:bg-material-thin inline-flex cursor-pointer items-center rounded-full px-2 py-1 text-xs text-white/90 backdrop-blur-sm"
>
{tag}
</MotionButtonBase>
))}
</div>
</div>
)}
</div>
{formattedExifData && (
<Fragment>
{(formattedExifData.camera || formattedExifData.lens) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className="space-y-1 text-sm">
{formattedExifData.camera && (
<Row label="相机" value={formattedExifData.camera} />
)}
{formattedExifData.lens && (
<Row label="镜头" value={formattedExifData.lens} />
)}
{formattedExifData.focalLength && (
<Row
label="实际焦距"
value={`${formattedExifData.focalLength}mm`}
/>
)}
{formattedExifData.focalLength35mm && (
<Row
label="等效焦距"
value={`${formattedExifData.focalLength35mm}mm`}
/>
)}
{formattedExifData.maxAperture && (
<Row
label="最大光圈"
value={`f/${formattedExifData.maxAperture}`}
/>
)}
{formattedExifData.digitalZoom && (
<Row
label="数字变焦"
value={`${formattedExifData.digitalZoom.toFixed(2)}x`}
/>
)}
</div>
</div>
)}
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className={`grid grid-cols-2 gap-3`}>
{formattedExifData.focalLength35mm && (
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-sm text-white/70" />
<span className="text-xs">
{formattedExifData.focalLength35mm}mm
</span>
</div>
)}
{formattedExifData.aperture && (
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
<TablerAperture className="text-sm text-white/70" />
<span className="text-xs">
{formattedExifData.aperture}
</span>
</div>
)}
{formattedExifData.shutterSpeed && (
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
<MaterialSymbolsShutterSpeed className="text-sm text-white/70" />
<span className="text-xs">
{formattedExifData.shutterSpeed}
</span>
</div>
)}
{formattedExifData.iso && (
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
<CarbonIsoOutline className="text-sm text-white/70" />
<span className="text-xs">
ISO {formattedExifData.iso}
</span>
</div>
)}
{formattedExifData.exposureBias && (
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
<MaterialSymbolsExposure className="text-sm text-white/70" />
<span className="text-xs">
{formattedExifData.exposureBias}
</span>
</div>
)}
</div>
</div>
{/* 新增:拍摄模式信息 */}
{(formattedExifData.exposureMode ||
formattedExifData.meteringMode ||
formattedExifData.whiteBalance ||
formattedExifData.lightSource ||
formattedExifData.flash) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className="space-y-1 text-sm">
{formattedExifData.exposureMode && (
<Row
label="曝光模式"
value={formattedExifData.exposureMode}
/>
)}
{formattedExifData.meteringMode && (
<Row
label="测光模式"
value={formattedExifData.meteringMode}
/>
)}
{formattedExifData.whiteBalance && (
<Row
label="白平衡"
value={formattedExifData.whiteBalance}
/>
)}
{formattedExifData.whiteBalanceBias && (
<Row
label="白平衡偏移"
value={`${formattedExifData.whiteBalanceBias} Mired`}
/>
)}
{formattedExifData.wbShiftAB && (
<Row
label="白平衡偏移 (琥珀-蓝)"
value={formattedExifData.wbShiftAB}
/>
)}
{formattedExifData.wbShiftGM && (
<Row
label="白平衡偏移 (绿-洋红)"
value={formattedExifData.wbShiftGM}
/>
)}
{formattedExifData.whiteBalanceFineTune && (
<Row
label="白平衡微调"
value={formattedExifData.whiteBalanceFineTune}
/>
)}
{formattedExifData.wbGRBLevels && (
<Row
label="白平衡 GRB 级别"
value={
Array.isArray(formattedExifData.wbGRBLevels)
? formattedExifData.wbGRBLevels.join(' ')
: formattedExifData.wbGRBLevels
}
/>
)}
{formattedExifData.wbGRBLevelsStandard && (
<Row
label="标准白平衡 GRB"
value={
Array.isArray(formattedExifData.wbGRBLevelsStandard)
? formattedExifData.wbGRBLevelsStandard.join(' ')
: formattedExifData.wbGRBLevelsStandard
}
/>
)}
{formattedExifData.wbGRBLevelsAuto && (
<Row
label="自动白平衡 GRB"
value={
Array.isArray(formattedExifData.wbGRBLevelsAuto)
? formattedExifData.wbGRBLevelsAuto.join(' ')
: formattedExifData.wbGRBLevelsAuto
}
/>
)}
{formattedExifData.flash && (
<Row label="闪光灯" value={formattedExifData.flash} />
)}
{formattedExifData.lightSource && (
<Row label="光源" value={formattedExifData.lightSource} />
)}
</div>
</div>
)}
{formattedExifData.fujiRecipe && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className="space-y-1 text-sm">
{formattedExifData.fujiRecipe.FilmMode && (
<Row
label="胶片模式"
value={formattedExifData.fujiRecipe.FilmMode}
/>
)}
{formattedExifData.fujiRecipe.DynamicRange && (
<Row
label="动态范围"
value={formattedExifData.fujiRecipe.DynamicRange}
/>
)}
{formattedExifData.fujiRecipe.WhiteBalance && (
<Row
label="白平衡"
value={formattedExifData.fujiRecipe.WhiteBalance}
/>
)}
{formattedExifData.fujiRecipe.HighlightTone && (
<Row
label="高光色调"
value={formattedExifData.fujiRecipe.HighlightTone}
/>
)}
{formattedExifData.fujiRecipe.ShadowTone && (
<Row
label="阴影色调"
value={formattedExifData.fujiRecipe.ShadowTone}
/>
)}
{formattedExifData.fujiRecipe.Saturation && (
<Row
label="饱和度"
value={formattedExifData.fujiRecipe.Saturation}
/>
)}
{formattedExifData.fujiRecipe.Sharpness && (
<Row
label="锐度"
value={formattedExifData.fujiRecipe.Sharpness}
/>
)}
{formattedExifData.fujiRecipe.NoiseReduction && (
<Row
label="降噪"
value={formattedExifData.fujiRecipe.NoiseReduction}
/>
)}
{formattedExifData.fujiRecipe.Clarity && (
<Row
label="清晰度"
value={formattedExifData.fujiRecipe.Clarity}
/>
)}
{formattedExifData.fujiRecipe.ColorChromeEffect && (
<Row
label="色彩效果"
value={formattedExifData.fujiRecipe.ColorChromeEffect}
/>
)}
{formattedExifData.fujiRecipe.ColorChromeFxBlue && (
<Row
label="蓝色色彩效果"
value={formattedExifData.fujiRecipe.ColorChromeFxBlue}
/>
)}
{(formattedExifData.fujiRecipe.GrainEffectRoughness ||
formattedExifData.fujiRecipe.GrainEffectSize) && (
<>
{formattedExifData.fujiRecipe.GrainEffectRoughness && (
<Row
label="颗粒效果强度"
value={
formattedExifData.fujiRecipe.GrainEffectRoughness
}
/>
)}
{formattedExifData.fujiRecipe.GrainEffectSize && (
<Row
label="颗粒效果大小"
value={formattedExifData.fujiRecipe.GrainEffectSize}
/>
)}
</>
)}
{(formattedExifData.fujiRecipe.Red ||
formattedExifData.fujiRecipe.Blue) && (
<>
{formattedExifData.fujiRecipe.Red && (
<Row
label="红色调整"
value={formattedExifData.fujiRecipe.Red}
/>
)}
{formattedExifData.fujiRecipe.Blue && (
<Row
label="蓝色调整"
value={formattedExifData.fujiRecipe.Blue}
/>
)}
</>
)}
</div>
</div>
)}
{formattedExifData.gps && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className="space-y-1 text-sm">
<Row label="纬度" value={formattedExifData.gps.latitude} />
<Row label="经度" value={formattedExifData.gps.longitude} />
{formattedExifData.gps.altitude && (
<Row
label="海拔"
value={`${formattedExifData.gps.altitude}m`}
/>
)}
<div className="mt-2 text-right">
<a
href={`https://uri.amap.com/marker?position=${formattedExifData.gps.longitude},${formattedExifData.gps.latitude}&name=拍摄位置`}
target="_blank"
rel="noopener noreferrer"
className="text-blue inline-flex items-center gap-1 text-xs underline transition-colors hover:text-blue-300"
>
<i className="i-mingcute-external-link-line" />
</a>
</div>
</div>
</div>
)}
{/* 新增:技术参数 */}
{(formattedExifData.brightnessValue ||
formattedExifData.shutterSpeedValue ||
formattedExifData.apertureValue ||
formattedExifData.sensingMethod ||
formattedExifData.customRendered ||
formattedExifData.focalPlaneXResolution ||
formattedExifData.focalPlaneYResolution) && (
<div>
<h4 className="my-2 text-sm font-medium text-white/80">
</h4>
<div className="space-y-1 text-sm">
{formattedExifData.brightnessValue && (
<Row
label="亮度值"
value={formattedExifData.brightnessValue}
/>
)}
{formattedExifData.shutterSpeedValue && (
<Row
label="快门速度值"
value={formattedExifData.shutterSpeedValue}
/>
)}
{formattedExifData.apertureValue && (
<Row
label="光圈值"
value={formattedExifData.apertureValue}
/>
)}
{formattedExifData.sensingMethod && (
<Row
label="感光方法"
value={formattedExifData.sensingMethod}
/>
)}
{formattedExifData.customRendered && (
<Row
label="图像处理"
value={formattedExifData.customRendered}
/>
)}
{(formattedExifData.focalPlaneXResolution ||
formattedExifData.focalPlaneYResolution) && (
<Row
label="焦平面分辨率"
value={`${formattedExifData.focalPlaneXResolution || 'N/A'} × ${formattedExifData.focalPlaneYResolution || 'N/A'}${formattedExifData.focalPlaneResolutionUnit ? ` (${formattedExifData.focalPlaneResolutionUnit})` : ''}`}
/>
)}
</div>
</div>
)}
</Fragment>
)}
</div>
</ScrollArea>
</m.div>
)
}
const formatExifData = (exif: Exif | null) => {
if (!exif) return null
const photo = exif.Photo || {}
const image = exif.Image || {}
const gps = exif.GPSInfo || {}
// 等效焦距 (35mm)
const focalLength35mm = photo.FocalLengthIn35mmFilm
? Math.round(photo.FocalLengthIn35mmFilm)
: null
// 实际焦距
const focalLength = photo.FocalLength ? Math.round(photo.FocalLength) : null
// ISO
const iso = photo.ISOSpeedRatings || image.ISOSpeedRatings
// 快门速度
const exposureTime = photo.ExposureTime
const shutterSpeed = exposureTime
? exposureTime >= 1
? `${exposureTime}s`
: `1/${Math.round(1 / exposureTime)}`
: null
// 光圈
const aperture = photo.FNumber ? `f/${photo.FNumber}` : null
// 最大光圈
const maxAperture = photo.MaxApertureValue
? `${Math.round(Math.pow(Math.sqrt(2), photo.MaxApertureValue) * 10) / 10}`
: null
// 相机信息
const camera =
image.Make && image.Model ? `${image.Make} ${image.Model}` : null
// 镜头信息
const lens =
photo.LensModel || photo.LensSpecification || photo.LensMake || null
// 软件信息
const software = image.Software || null
const offsetTimeOriginal = photo.OffsetTimeOriginal || photo.OffsetTime
// 拍摄时间
const dateTime: string | null = (() => {
const originalDateTimeStr =
(photo.DateTimeOriginal as unknown as string) ||
(photo.DateTime as unknown as string)
if (!originalDateTimeStr) return null
const date = new Date(originalDateTimeStr)
if (offsetTimeOriginal) {
// 解析时区偏移,例如 "+08:00" 或 "-05:00"
const offsetMatch = offsetTimeOriginal.match(/([+-])(\d{2}):(\d{2})/)
if (offsetMatch) {
const [, sign, hours, minutes] = offsetMatch
const offsetMinutes =
(Number.parseInt(hours) * 60 + Number.parseInt(minutes)) *
(sign === '+' ? 1 : -1)
// 减去偏移量,将本地时间转换为 UTC 时间
const utcTime = new Date(date.getTime() - offsetMinutes * 60 * 1000)
return formatDateTime(utcTime)
}
return formatDateTime(date)
}
return formatDateTime(date)
})()
// 曝光模式
const exposureModeMap: Record<number, string> = {
0: '自动曝光',
1: '手动曝光',
2: '自动包围曝光',
}
const exposureMode =
photo.ExposureMode !== undefined
? exposureModeMap[photo.ExposureMode] || `未知 (${photo.ExposureMode})`
: null
// 测光模式
const meteringModeMap: Record<number, string> = {
0: '未知',
1: '平均测光',
2: '中央重点测光',
3: '点测光',
4: '多点测光',
5: '评价测光',
6: '局部测光',
}
const meteringMode =
photo.MeteringMode !== undefined
? meteringModeMap[photo.MeteringMode] || `未知 (${photo.MeteringMode})`
: null
// 白平衡
const whiteBalanceMap: Record<number, string> = {
0: '自动白平衡',
1: '手动白平衡',
}
const whiteBalance =
photo.WhiteBalance !== undefined
? whiteBalanceMap[photo.WhiteBalance] || `未知 (${photo.WhiteBalance})`
: null
// 闪光灯
const flashMap: Record<number, string> = {
0: '未闪光',
1: '闪光',
5: '闪光,未检测到回闪',
7: '闪光,检测到回闪',
9: '强制闪光',
13: '强制闪光,未检测到回闪',
15: '强制闪光,检测到回闪',
16: '未闪光,强制关闭',
24: '未闪光,自动模式',
25: '闪光,自动模式',
29: '闪光,自动模式,未检测到回闪',
31: '闪光,自动模式,检测到回闪',
32: '未提供闪光功能',
}
const flash =
photo.Flash !== undefined
? flashMap[photo.Flash] || `未知 (${photo.Flash})`
: null
// 数字变焦
const digitalZoom = photo.DigitalZoomRatio || null
// 曝光补偿
const exposureBias = photo.ExposureBiasValue
? `${photo.ExposureBiasValue > 0 ? '+' : ''}${photo.ExposureBiasValue.toFixed(1)} EV`
: null
// 亮度值
const brightnessValue = photo.BrightnessValue
? `${photo.BrightnessValue.toFixed(1)} EV`
: null
// 快门速度值
const shutterSpeedValue = photo.ShutterSpeedValue
? `${photo.ShutterSpeedValue.toFixed(1)} EV`
: null
// 光圈值
const apertureValue = photo.ApertureValue
? `${photo.ApertureValue.toFixed(1)} EV`
: null
// 光源类型
const lightSourceMap: Record<number, string> = {
0: '自动',
1: '日光',
2: '荧光灯',
3: '钨丝灯',
4: '闪光灯',
9: '晴天',
10: '阴天',
11: '阴影',
12: '日光荧光灯 (D 5700 7100K)',
13: '日白荧光灯 (N 4600 5400K)',
14: '冷白荧光灯 (W 3900 4500K)',
15: '白荧光灯 (WW 3200 3700K)',
17: '标准光源 A',
18: '标准光源 B',
19: '标准光源 C',
20: 'D55',
21: 'D65',
22: 'D75',
23: 'D50',
24: 'ISO 钨丝灯',
255: '其他光源',
}
const lightSource =
photo.LightSource !== undefined
? lightSourceMap[photo.LightSource] || `未知 (${photo.LightSource})`
: null
// 白平衡偏移/微调相关字段
const whiteBalanceBias = (photo as any).WhiteBalanceBias || null
const wbShiftAB = (photo as any).WBShiftAB || null
const wbShiftGM = (photo as any).WBShiftGM || null
const whiteBalanceFineTune = (photo as any).WhiteBalanceFineTune || null
// 富士相机特有的白平衡字段
const wbGRBLevels =
(photo as any).WBGRBLevels || (photo as any)['WB GRB Levels'] || null
const wbGRBLevelsStandard =
(photo as any).WBGRBLevelsStandard ||
(photo as any)['WB GRB Levels Standard'] ||
null
const wbGRBLevelsAuto =
(photo as any).WBGRBLevelsAuto ||
(photo as any)['WB GRB Levels Auto'] ||
null
// 感光方法
const sensingMethodMap: Record<number, string> = {
1: '未定义',
2: '单芯片彩色区域传感器',
3: '双芯片彩色区域传感器',
4: '三芯片彩色区域传感器',
5: '彩色顺序区域传感器',
7: '三线传感器',
8: '彩色顺序线性传感器',
}
const sensingMethod =
photo.SensingMethod !== undefined
? sensingMethodMap[photo.SensingMethod] || `未知 (${photo.SensingMethod})`
: null
// 自定义渲染
const customRenderedMap: Record<number, string> = {
0: '正常处理',
1: '自定义处理',
}
const customRendered =
photo.CustomRendered !== undefined
? customRenderedMap[photo.CustomRendered] ||
`未知 (${photo.CustomRendered})`
: null
// 焦平面分辨率
const focalPlaneXResolution = photo.FocalPlaneXResolution
? Math.round(photo.FocalPlaneXResolution)
: null
const focalPlaneYResolution = photo.FocalPlaneYResolution
? Math.round(photo.FocalPlaneYResolution)
: null
// 焦平面分辨率单位
const focalPlaneResolutionUnitMap: Record<number, string> = {
1: '无单位',
2: '英寸',
3: '厘米',
}
const focalPlaneResolutionUnit =
photo.FocalPlaneResolutionUnit !== undefined
? focalPlaneResolutionUnitMap[photo.FocalPlaneResolutionUnit] ||
`未知 (${photo.FocalPlaneResolutionUnit})`
: null
// 像素信息
const pixelXDimension = photo.PixelXDimension || null
const pixelYDimension = photo.PixelYDimension || null
const totalPixels =
pixelXDimension && pixelYDimension
? pixelXDimension * pixelYDimension
: null
const megaPixels = totalPixels
? `${(totalPixels / 1000000).toFixed(1)}MP`
: null
// 色彩空间
const colorSpaceMap: Record<number, string> = {
1: 'sRGB',
65535: 'Adobe RGB',
}
const colorSpace =
photo.ColorSpace !== undefined
? colorSpaceMap[photo.ColorSpace] || `未知 (${photo.ColorSpace})`
: null
// GPS 信息
let gpsInfo: {
latitude: string | undefined
longitude: string | undefined
altitude: number | null
} | null = null
if (gps.GPSLatitude && gps.GPSLongitude) {
const latitude = convertDMSToDD(gps.GPSLatitude, gps.GPSLatitudeRef || '')
const longitude = convertDMSToDD(
gps.GPSLongitude,
gps.GPSLongitudeRef || '',
)
const altitude = gps.GPSAltitude || null
gpsInfo = {
latitude: latitude?.toFixed(6),
longitude: longitude?.toFixed(6),
altitude: altitude ? Math.round(altitude) : null,
}
}
// 富士相机 Recipe 信息
const fujiRecipe = (exif as any).FujiRecipe || null
return {
focalLength35mm,
focalLength,
iso,
shutterSpeed,
aperture,
maxAperture,
camera,
lens,
software,
dateTime,
exposureMode,
meteringMode,
whiteBalance,
flash,
digitalZoom,
colorSpace,
gps: gpsInfo,
exposureBias,
brightnessValue,
shutterSpeedValue,
apertureValue,
lightSource,
sensingMethod,
customRendered,
focalPlaneXResolution,
focalPlaneYResolution,
focalPlaneResolutionUnit,
megaPixels,
pixelXDimension,
pixelYDimension,
whiteBalanceBias,
wbShiftAB,
wbShiftGM,
whiteBalanceFineTune,
wbGRBLevels,
wbGRBLevelsStandard,
wbGRBLevelsAuto,
fujiRecipe,
}
}
// 将度分秒格式转换为十进制度数
const convertDMSToDD = (dms: number[], ref: string): number | null => {
if (!dms || dms.length !== 3) return null
const [degrees, minutes, seconds] = dms
let dd = degrees + minutes / 60 + seconds / 3600
if (ref === 'S' || ref === 'W') {
dd = dd * -1
}
return dd
}
const Row: FC<{
label: string
value: string | number | null | undefined | number[]
ellipsis?: boolean
}> = ({ label, value, ellipsis }) => {
return (
<div className="flex justify-between gap-4">
<span className="text-text-secondary shrink-0">{label}</span>
{ellipsis ? (
<span className="relative min-w-0 flex-1 shrink">
<span className="absolute inset-0">
<EllipsisHorizontalTextWithTooltip className="text-text min-w-0 text-right">
{Array.isArray(value) ? value.join(' ') : value}
</EllipsisHorizontalTextWithTooltip>
</span>
</span>
) : (
<span className="text-text min-w-0 text-right">
{Array.isArray(value) ? value.join(' ') : value}
</span>
)}
</div>
)
}
const datetimeFormatter = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium',
})
const formatDateTime = (date: Date | null | undefined) => {
if (!date) return ''
return datetimeFormatter.format(date)
}

View File

@@ -0,0 +1,98 @@
import { m } from 'motion/react'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
import { useMobile } from '~/hooks/useMobile'
import { clsxm } from '~/lib/cn'
import type { PhotoManifest } from '~/types/photo'
export const GalleryThumbnail: FC<{
currentIndex: number
photos: PhotoManifest[]
onIndexChange: (index: number) => void
}> = ({ currentIndex, photos, onIndexChange }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const thumbnailRefs = useRef<(HTMLButtonElement | null)[]>([])
const isMobile = useMobile()
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const currentThumbnail = thumbnailRefs.current[currentIndex]
if (scrollContainer && currentThumbnail) {
const containerWidth = scrollContainer.clientWidth
const thumbnailLeft = currentThumbnail.offsetLeft
const thumbnailWidth = currentThumbnail.clientWidth
const scrollLeft = thumbnailLeft - containerWidth / 2 + thumbnailWidth / 2
scrollContainer.scrollTo({
left: scrollLeft,
behavior: 'smooth',
})
}
}, [currentIndex])
// 处理鼠标滚轮事件,映射为横向滚动
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const handleWheel = (e: WheelEvent) => {
// 阻止默认的垂直滚动
e.preventDefault()
// 优先使用触控板的横向滚动 (deltaX)
// 如果没有横向滚动,则将垂直滚动 (deltaY) 转换为横向滚动
const scrollAmount =
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
scrollContainer.scrollLeft += scrollAmount
}
scrollContainer.addEventListener('wheel', handleWheel, { passive: false })
return () => {
scrollContainer.removeEventListener('wheel', handleWheel)
}
}, [])
return (
<m.div
className="bg-material-medium pb-safe z-10 shrink-0 backdrop-blur-3xl"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100 }}
transition={{ duration: 0.3 }}
>
<div className="bg-material-medium backdrop-blur-[70px]">
<div
ref={scrollContainerRef}
className={`gallery-thumbnail-container flex ${isMobile ? 'gap-2' : 'gap-3'} overflow-x-auto ${isMobile ? 'p-3' : 'p-4'} scrollbar-none`}
>
{photos.map((photo, index) => (
<button
type="button"
key={photo.id}
ref={(el) => {
thumbnailRefs.current[index] = el
}}
className={clsxm(
`flex-shrink-0 ${isMobile ? 'w-12 h-12' : 'w-16 h-16'} rounded-lg overflow-hidden ring-2 transition-all contain-intrinsic-size`,
index === currentIndex
? 'ring-accent scale-110'
: 'ring-transparent hover:ring-accent',
)}
onClick={() => onIndexChange(index)}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="h-full w-full object-cover"
/>
</button>
))}
</div>
</div>
</m.div>
)
}

View File

@@ -0,0 +1,316 @@
import { m, useAnimationControls } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { clsxm } from '~/lib/cn'
import { isMobileDevice } from '~/lib/device-viewport'
import type { ImageLoaderManager } from '~/lib/image-loader-manager'
import { isWebCodecsSupported } from '~/lib/video-converter'
import type { LoadingIndicatorRef } from './LoadingIndicator'
interface LivePhotoProps {
/** Live Photo 视频 URL */
videoUrl: string
/** 图片加载管理器实例 */
imageLoaderManager: ImageLoaderManager
/** 加载指示器引用 */
loadingIndicatorRef: React.RefObject<LoadingIndicatorRef | null>
/** 是否是当前图片 */
isCurrentImage: boolean
/** 自定义样式类名 */
className?: string
}
export const LivePhoto = ({
videoUrl,
imageLoaderManager,
loadingIndicatorRef,
isCurrentImage,
className,
}: LivePhotoProps) => {
// Live Photo 相关状态
const [isPlayingLivePhoto, setIsPlayingLivePhoto] = useState(false)
const [livePhotoVideoLoaded, setLivePhotoVideoLoaded] = useState(false)
const [isConvertingVideo, setIsConvertingVideo] = useState(false)
const [conversionMethod, setConversionMethod] = useState<string>('')
const videoRef = useRef<HTMLVideoElement>(null)
const videoAnimateController = useAnimationControls()
// Live Photo hover 相关
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState(false)
useEffect(() => {
if (
!isCurrentImage ||
livePhotoVideoLoaded ||
isConvertingVideo ||
!videoRef.current
) {
return
}
setIsConvertingVideo(true)
const processVideo = async () => {
try {
const videoResult = await imageLoaderManager.processLivePhotoVideo(
videoUrl,
videoRef.current!,
{
onLoadingStateUpdate: (state) => {
loadingIndicatorRef.current?.updateLoadingState(state)
},
},
)
if (videoResult.conversionMethod) {
setConversionMethod(videoResult.conversionMethod)
}
setLivePhotoVideoLoaded(true)
} catch (videoError) {
console.error('Failed to process Live Photo video:', videoError)
} finally {
setIsConvertingVideo(false)
}
}
processVideo()
}, [
isCurrentImage,
livePhotoVideoLoaded,
isConvertingVideo,
videoUrl,
imageLoaderManager,
loadingIndicatorRef,
])
// 清理函数
useEffect(() => {
return () => {
// Clean up timers
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}
}, [])
// 重置状态(当不是当前图片时)
useEffect(() => {
if (!isCurrentImage) {
setIsPlayingLivePhoto(false)
setLivePhotoVideoLoaded(false)
setIsConvertingVideo(false)
setConversionMethod('')
setIsLongPressing(false)
// Clean up timers
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
// Reset video animation
videoAnimateController.set({ opacity: 0 })
}
}, [isCurrentImage, videoAnimateController])
// Live Photo hover 处理
const handleBadgeMouseEnter = useCallback(() => {
if (!livePhotoVideoLoaded || isPlayingLivePhoto || isConvertingVideo) return
hoverTimerRef.current = setTimeout(async () => {
setIsPlayingLivePhoto(true)
// 开始淡入动画
await videoAnimateController.start({
opacity: 1,
transition: { duration: 0.15, ease: 'easeOut' },
})
const video = videoRef.current
if (video) {
video.currentTime = 0
video.play()
}
}, 200) // 200ms hover 延迟
}, [
livePhotoVideoLoaded,
isPlayingLivePhoto,
isConvertingVideo,
videoAnimateController,
])
const handleBadgeMouseLeave = useCallback(async () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (isPlayingLivePhoto) {
const video = videoRef.current
if (video) {
video.pause()
video.currentTime = 0
}
// 开始淡出动画
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}
}, [isPlayingLivePhoto, videoAnimateController])
// 视频播放结束处理
const handleVideoEnded = useCallback(async () => {
// 播放结束时淡出
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}, [videoAnimateController])
// Live Photo 长按处理(移动端)
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (
!livePhotoVideoLoaded ||
isPlayingLivePhoto ||
isConvertingVideo ||
e.touches.length > 1 // 多指触摸不触发长按
)
return
longPressTimerRef.current = setTimeout(async () => {
setIsLongPressing(true)
setIsPlayingLivePhoto(true)
// 开始淡入动画
await videoAnimateController.start({
opacity: 1,
transition: { duration: 0.15, ease: 'easeOut' },
})
const video = videoRef.current
if (video) {
video.currentTime = 0
video.play()
}
}, 500) // 500ms 长按延迟
},
[
livePhotoVideoLoaded,
isPlayingLivePhoto,
isConvertingVideo,
videoAnimateController,
],
)
const handleTouchEnd = useCallback(async () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
if (isLongPressing && isPlayingLivePhoto) {
setIsLongPressing(false)
const video = videoRef.current
if (video) {
video.pause()
video.currentTime = 0
}
// 开始淡出动画
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}
}, [isLongPressing, isPlayingLivePhoto, videoAnimateController])
const handleTouchMove = useCallback(() => {
// 触摸移动时取消长按
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
return (
<>
{/* Live Photo 视频 */}
<m.video
ref={videoRef}
className={clsxm(
'absolute inset-0 z-10 h-full w-full object-contain',
className,
)}
muted
playsInline
onEnded={handleVideoEnded}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
initial={{ opacity: 0 }}
animate={videoAnimateController}
/>
{/* Live Photo 标识 */}
<div
className={clsxm(
'absolute z-50 flex items-center space-x-1 rounded-xl bg-black/50 px-1 py-1 text-xs text-white cursor-pointer transition-all duration-200 hover:bg-black/70',
import.meta.env.DEV ? 'top-16 right-4' : 'top-12 lg:top-4 left-4',
)}
onMouseEnter={handleBadgeMouseEnter}
onMouseLeave={handleBadgeMouseLeave}
title={isMobileDevice ? '长按播放实况照片' : '悬浮播放实况照片'}
>
{isConvertingVideo ? (
<div className="flex items-center gap-1 px-1">
<i className="i-mingcute-loading-line animate-spin" />
<span></span>
</div>
) : (
<>
<i className="i-mingcute-live-photo-line size-4" />
<span className="mr-1"></span>
{conversionMethod && (
<span className="rounded bg-white/20 px-1 text-xs">
{conversionMethod === 'webcodecs' ? 'WebCodecs' : ''}
</span>
)}
</>
)}
</div>
{/* 操作提示 */}
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50">
{isConvertingVideo
? `正在使用 ${isWebCodecsSupported() ? 'WebCodecs' : 'FFmpeg'} 转换视频格式...`
: isMobileDevice
? '长按播放实况照片 / 双击缩放'
: '悬浮实况标识播放 / 双击缩放'}
</div>
</>
)
}

View File

@@ -0,0 +1,114 @@
import { AnimatePresence, m } from 'motion/react'
import { useCallback, useImperativeHandle, useState } from 'react'
import { Spring } from '~/lib/spring'
interface LoadingState {
isVisible: boolean
isConverting: boolean
isHeicFormat: boolean
loadingProgress: number
loadedBytes: number
totalBytes: number
conversionMessage?: string // 视频转换消息
codecInfo?: string // 编码器信息
}
interface LoadingIndicatorRef {
updateLoadingState: (state: Partial<LoadingState>) => void
resetLoadingState: () => void
}
const initialLoadingState: LoadingState = {
isVisible: false,
isConverting: false,
isHeicFormat: false,
loadingProgress: 0,
loadedBytes: 0,
totalBytes: 0,
conversionMessage: undefined,
codecInfo: undefined,
}
export const LoadingIndicator = ({
ref,
..._
}: {
ref?: React.Ref<LoadingIndicatorRef | null>
}) => {
const [loadingState, setLoadingState] =
useState<LoadingState>(initialLoadingState)
useImperativeHandle(
ref,
useCallback(
() => ({
updateLoadingState: (partialState: Partial<LoadingState>) => {
setLoadingState((prev) => ({ ...prev, ...partialState }))
},
resetLoadingState: () => {
setLoadingState(initialLoadingState)
},
}),
[],
),
)
return (
<AnimatePresence>
{loadingState.isVisible && (
<m.div
className="pointer-events-none absolute right-4 bottom-4 z-10 rounded-xl border border-white/10 bg-black/80 px-3 py-2 backdrop-blur-sm"
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={Spring.presets.snappy}
>
<div className="flex items-center gap-3 text-white">
<div className="relative">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
{loadingState.isConverting ? (
<>
<p className="text-xs font-medium text-white tabular-nums">
{loadingState.conversionMessage || '转换中...'}
</p>
{loadingState.codecInfo && (
<p className="text-xs text-white/70 tabular-nums">
{loadingState.codecInfo}
</p>
)}
<span className="text-xs text-white/60 tabular-nums">
{Math.round(loadingState.loadingProgress)}%
</span>
</>
) : (
<>
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-white">
{loadingState.isHeicFormat ? 'HEIC' : '加载中'}
</p>
<span className="text-xs text-white/60 tabular-nums">
{Math.round(loadingState.loadingProgress)}%
</span>
</div>
{loadingState.totalBytes > 0 && (
<p className="text-xs text-white/70 tabular-nums">
{(loadingState.loadedBytes / 1024 / 1024).toFixed(1)}MB /{' '}
{(loadingState.totalBytes / 1024 / 1024).toFixed(1)}MB
</p>
)}
</>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
)
}
LoadingIndicator.displayName = 'LoadingIndicator'
export type { LoadingIndicatorRef, LoadingState }

View File

@@ -0,0 +1,87 @@
.contain-intrinsic-size {
contain-intrinsic-size: 64px;
content-visibility: auto;
}
/* PhotoViewer custom styles */
/* Hide default Swiper navigation buttons since we use custom ones */
.swiper-button-next,
.swiper-button-prev {
display: none !important;
}
/* Ensure Swiper slides don't interfere with zoom/pan gestures */
.swiper-slide {
overflow: visible;
}
/* Custom navigation button hover effects */
.swiper-button-prev-custom,
.swiper-button-next-custom {
transition: all 0.2s ease;
}
/* Ensure proper touch handling */
.swiper {
touch-action: pan-x;
}
.swiper-slide {
touch-action: manipulation;
}
/* Mobile optimizations */
@media (max-width: 768px) {
/* Improve touch targets on mobile */
.swiper-button-prev-custom,
.swiper-button-next-custom {
min-width: 44px;
min-height: 44px;
}
/* Enable horizontal swiping on mobile */
.swiper {
touch-action: pan-x;
}
.swiper-slide {
touch-action: pan-x;
}
/* Ensure double-tap zoom works on mobile */
.react-transform-wrapper {
touch-action: manipulation;
}
.react-transform-component {
touch-action: manipulation;
}
/* Optimize ExifPanel for mobile */
.exif-panel-mobile {
/* Add safe area padding for devices with notches */
padding-bottom: env(safe-area-inset-bottom);
}
/* Improve thumbnail scrolling on mobile */
.gallery-thumbnail-container {
/* Add momentum scrolling for iOS */
-webkit-overflow-scrolling: touch;
/* Hide scrollbar on mobile */
scrollbar-width: none;
-ms-overflow-style: none;
}
.gallery-thumbnail-container::-webkit-scrollbar {
display: none;
}
}
/* Improve accessibility */
@media (prefers-reduced-motion: reduce) {
.swiper-button-prev-custom,
.swiper-button-next-custom {
transition: none;
}
}

View File

@@ -0,0 +1,401 @@
import './PhotoViewer.css'
// Import Swiper styles
import 'swiper/css'
import 'swiper/css/navigation'
import { AnimatePresence, m } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Blurhash } from 'react-blurhash'
import type { Swiper as SwiperType } from 'swiper'
import { Keyboard, Navigation, Virtual } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
import { PassiveFragment } from '~/components/common/PassiveFragmenet'
import { useMobile } from '~/hooks/useMobile'
import { Spring } from '~/lib/spring'
import type { PhotoManifest } from '~/types/photo'
import { ExifPanel } from './ExifPanel'
import { GalleryThumbnail } from './GalleryThumbnail'
import { ProgressiveImage } from './ProgressiveImage'
import { SharePanel } from './SharePanel'
interface PhotoViewerProps {
photos: PhotoManifest[]
currentIndex: number
isOpen: boolean
onClose: () => void
onIndexChange: (index: number) => void
}
export const PhotoViewer = ({
photos,
currentIndex,
isOpen,
onClose,
onIndexChange,
}: PhotoViewerProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const swiperRef = useRef<SwiperType | null>(null)
const [isImageZoomed, setIsImageZoomed] = useState(false)
const [showExifPanel, setShowExifPanel] = useState(false)
const [currentBlobSrc, setCurrentBlobSrc] = useState<string | null>(null)
const isMobile = useMobile()
const currentPhoto = photos[currentIndex]
// 当 PhotoViewer 关闭时重置缩放状态和面板状态
useEffect(() => {
if (!isOpen) {
setIsImageZoomed(false)
setShowExifPanel(false)
setCurrentBlobSrc(null)
}
}, [isOpen])
// 计算图片的适配尺寸
const getImageDisplaySize = () => {
if (!currentPhoto) return { width: 0, height: 0 }
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// 在移动设备上调整最大尺寸
const maxWidth = isMobile ? viewportWidth * 0.95 : viewportWidth * 0.9
const maxHeight = isMobile ? viewportHeight * 0.8 : viewportHeight * 0.9
const imageAspectRatio = currentPhoto.width / currentPhoto.height
const maxAspectRatio = maxWidth / maxHeight
let displayWidth: number
let displayHeight: number
if (imageAspectRatio > maxAspectRatio) {
// 图片更宽,以宽度为准
displayWidth = Math.min(maxWidth, currentPhoto.width)
displayHeight = displayWidth / imageAspectRatio
} else {
// 图片更高,以高度为准
displayHeight = Math.min(maxHeight, currentPhoto.height)
displayWidth = displayHeight * imageAspectRatio
}
return { width: displayWidth, height: displayHeight }
}
// 预加载相邻图片
useEffect(() => {
if (!isOpen) return
const preloadImage = (src: string) => {
const img = new Image()
img.src = src
}
// 预加载前一张和后一张
if (currentIndex > 0) {
preloadImage(photos[currentIndex - 1].originalUrl)
}
if (currentIndex < photos.length - 1) {
preloadImage(photos[currentIndex + 1].originalUrl)
}
}, [isOpen, currentIndex, photos])
const handlePrevious = useCallback(() => {
if (currentIndex > 0) {
onIndexChange(currentIndex - 1)
}
}, [currentIndex, onIndexChange])
const handleNext = useCallback(() => {
if (currentIndex < photos.length - 1) {
onIndexChange(currentIndex + 1)
}
}, [currentIndex, photos.length, onIndexChange])
// 同步 Swiper 的索引
useEffect(() => {
if (swiperRef.current && swiperRef.current.activeIndex !== currentIndex) {
swiperRef.current.slideTo(currentIndex, 300)
}
// 切换图片时重置缩放状态
setIsImageZoomed(false)
}, [currentIndex])
// 当图片缩放状态改变时,控制 Swiper 的触摸行为
useEffect(() => {
if (swiperRef.current) {
if (isImageZoomed) {
// 图片被缩放时,禁用 Swiper 的触摸滑动
swiperRef.current.allowTouchMove = false
} else {
// 图片未缩放时,启用 Swiper 的触摸滑动
swiperRef.current.allowTouchMove = true
}
}
}, [isImageZoomed])
// 处理图片缩放状态变化
const handleZoomChange = useCallback((isZoomed: boolean) => {
setIsImageZoomed(isZoomed)
}, [])
// 处理 blobSrc 变化
const handleBlobSrcChange = useCallback((blobSrc: string | null) => {
setCurrentBlobSrc(blobSrc)
}, [])
// 键盘导航
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowLeft': {
event.preventDefault()
handlePrevious()
break
}
case 'ArrowRight': {
event.preventDefault()
handleNext()
break
}
case 'Escape': {
event.preventDefault()
onClose()
break
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handlePrevious, handleNext, onClose, showExifPanel])
const imageSize = getImageDisplaySize()
if (!currentPhoto) return null
return (
<>
{/* 固定背景层防止透出 */}
{/* 交叉溶解的 Blurhash 背景 */}
<AnimatePresence mode="popLayout">
{isOpen && (
<PassiveFragment>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
className="bg-material-opaque fixed inset-0"
/>
<m.div
key={currentPhoto.blurhash}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
className="fixed inset-0"
>
<Blurhash
hash={currentPhoto.blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
className="size-fill"
/>
</m.div>
</PassiveFragment>
)}
</AnimatePresence>
<AnimatePresence>
{isOpen && (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ touchAction: isMobile ? 'manipulation' : 'none' }}
>
<div
className={`flex size-full ${isMobile ? 'flex-col' : 'flex-row'}`}
>
<div className="z-[1] flex min-h-0 min-w-0 flex-1 flex-col">
<div className="group relative flex min-h-0 min-w-0 flex-1">
{/* 顶部工具栏 */}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className={`pointer-events-none absolute ${isMobile ? 'top-2 right-2 left-2' : 'top-4 right-4 left-4'} z-30 flex items-center justify-between`}
>
{/* 左侧工具按钮 */}
<div className="flex items-center gap-2">
{/* 信息按钮 - 在移动设备上显示 */}
{isMobile && (
<button
type="button"
className={`bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 ${showExifPanel ? 'bg-accent' : ''}`}
onClick={() => setShowExifPanel(!showExifPanel)}
>
<i className="i-mingcute-information-line" />
</button>
)}
</div>
{/* 右侧按钮组 */}
<div className="flex items-center gap-2">
{/* 分享按钮 */}
<SharePanel
photo={currentPhoto}
blobSrc={currentBlobSrc || undefined}
trigger={
<button
type="button"
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
title="分享照片"
>
<i className="i-mingcute-share-2-line" />
</button>
}
/>
{/* 关闭按钮 */}
<button
type="button"
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
onClick={onClose}
>
<i className="i-mingcute-close-line" />
</button>
</div>
</m.div>
{/* Swiper 容器 */}
<Swiper
modules={[Navigation, Keyboard, Virtual]}
spaceBetween={0}
slidesPerView={1}
initialSlide={currentIndex}
virtual
keyboard={{
enabled: true,
onlyInViewport: true,
}}
navigation={{
prevEl: '.swiper-button-prev-custom',
nextEl: '.swiper-button-next-custom',
}}
onSwiper={(swiper) => {
swiperRef.current = swiper
// 初始化时确保触摸滑动是启用的
swiper.allowTouchMove = !isImageZoomed
}}
onSlideChange={(swiper) => {
onIndexChange(swiper.activeIndex)
}}
className="h-full w-full"
style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }}
>
{photos.map((photo, index) => {
const isCurrentImage = index === currentIndex
return (
<SwiperSlide
key={photo.id}
className="flex items-center justify-center"
virtualIndex={index}
>
<m.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="relative flex h-full w-full items-center justify-center"
>
<ProgressiveImage
isCurrentImage={isCurrentImage}
src={photo.originalUrl}
thumbnailSrc={photo.thumbnailUrl}
alt={photo.title}
width={
isCurrentImage ? imageSize.width : undefined
}
height={
isCurrentImage ? imageSize.height : undefined
}
className="h-full w-full object-contain"
enablePan={
isCurrentImage
? !isMobile || isImageZoomed
: true
}
enableZoom={true}
onZoomChange={
isCurrentImage ? handleZoomChange : undefined
}
onBlobSrcChange={
isCurrentImage ? handleBlobSrcChange : undefined
}
// Live Photo props
isLivePhoto={photo.isLivePhoto}
livePhotoVideoUrl={photo.livePhotoVideoUrl}
/>
</m.div>
</SwiperSlide>
)
})}
</Swiper>
{/* 自定义导航按钮 */}
{currentIndex > 0 && (
<button
type="button"
className={`swiper-button-prev-custom absolute ${isMobile ? 'left-2' : 'left-4'} top-1/2 z-20 flex -translate-y-1/2 items-center justify-center ${isMobile ? 'size-8' : 'size-10'} bg-material-medium rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
onClick={handlePrevious}
>
<i
className={`i-mingcute-left-line ${isMobile ? 'text-lg' : 'text-xl'}`}
/>
</button>
)}
{currentIndex < photos.length - 1 && (
<button
type="button"
className={`swiper-button-next-custom absolute ${isMobile ? 'right-2' : 'right-4'} top-1/2 z-20 flex -translate-y-1/2 items-center justify-center ${isMobile ? 'size-8' : 'size-10'} bg-material-medium rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
>
<i
className={`i-mingcute-right-line ${isMobile ? 'text-lg' : 'text-xl'}`}
/>
</button>
)}
</div>
<GalleryThumbnail
currentIndex={currentIndex}
photos={photos}
onIndexChange={onIndexChange}
/>
</div>
{/* ExifPanel - 在桌面端始终显示,在移动端根据状态显示 */}
{(!isMobile || showExifPanel) && (
<ExifPanel
currentPhoto={currentPhoto}
exifData={currentPhoto.exif}
onClose={isMobile ? () => setShowExifPanel(false) : undefined}
/>
)}
</div>
</div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,321 @@
import type { WebGLImageViewerRef } from '@photo-gallery/webgl-viewer'
import { WebGLImageViewer } from '@photo-gallery/webgl-viewer'
import { m, useAnimationControls } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
MenuItemSeparator,
MenuItemText,
useShowContextMenu,
} from '~/atoms/context-menu'
import { clsxm } from '~/lib/cn'
import { canUseWebGL } from '~/lib/feature'
import { ImageLoaderManager } from '~/lib/image-loader-manager'
import { Spring } from '~/lib/spring'
import { LivePhoto } from './LivePhoto'
import type { LoadingIndicatorRef } from './LoadingIndicator'
import { LoadingIndicator } from './LoadingIndicator'
interface ProgressiveImageProps {
src: string
thumbnailSrc?: string
alt: string
width?: number
height?: number
className?: string
onError?: () => void
onProgress?: (progress: number) => void
onZoomChange?: (isZoomed: boolean) => void
onBlobSrcChange?: (blobSrc: string | null) => void
enableZoom?: boolean
enablePan?: boolean
maxZoom?: number
minZoom?: number
isCurrentImage?: boolean
// Live Photo 相关 props
isLivePhoto?: boolean
livePhotoVideoUrl?: string
}
export const ProgressiveImage = ({
src,
thumbnailSrc,
alt,
className,
onError,
onProgress,
onZoomChange,
onBlobSrcChange,
maxZoom = 20,
minZoom = 1,
isCurrentImage = false,
// Live Photo props
isLivePhoto = false,
livePhotoVideoUrl,
}: ProgressiveImageProps) => {
const [blobSrc, setBlobSrc] = useState<string | null>(null)
const [highResLoaded, setHighResLoaded] = useState(false)
const [error, setError] = useState(false)
const thumbnailRef = useRef<HTMLImageElement>(null)
const transformRef = useRef<WebGLImageViewerRef>(null)
const thumbnailAnimateController = useAnimationControls()
const loadingIndicatorRef = useRef<LoadingIndicatorRef>(null)
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
useEffect(() => {
if (highResLoaded || error || !isCurrentImage) return
// Create new image loader manager
const imageLoaderManager = new ImageLoaderManager()
imageLoaderManagerRef.current = imageLoaderManager
function cleanup() {
setHighResLoaded(false)
setBlobSrc(null)
setError(false)
onBlobSrcChange?.(null)
// Reset loading indicator
loadingIndicatorRef.current?.resetLoadingState()
// Reset transform when image changes
if (transformRef.current) {
transformRef.current.resetView()
}
}
const loadImage = async () => {
try {
const result = await imageLoaderManager.loadImage(src, {
onProgress,
onError,
onLoadingStateUpdate: (state) => {
loadingIndicatorRef.current?.updateLoadingState(state)
},
})
setBlobSrc(result.blobSrc)
onBlobSrcChange?.(result.blobSrc)
setHighResLoaded(true)
} catch (loadError) {
console.error('Failed to load image:', loadError)
setError(true)
}
}
cleanup()
loadImage()
return () => {
imageLoaderManager.cleanup()
}
}, [
highResLoaded,
error,
onProgress,
src,
onError,
isCurrentImage,
onBlobSrcChange,
])
const onTransformed = useCallback(
(originalScale: number, relativeScale: number) => {
const isZoomed = Math.abs(relativeScale - 1) > 0.01
onZoomChange?.(isZoomed)
},
[onZoomChange],
)
const handleThumbnailLoad = useCallback(() => {
thumbnailAnimateController.start({
opacity: 1,
})
}, [thumbnailAnimateController])
const showContextMenu = useShowContextMenu()
if (error) {
return (
<div
className={clsxm(
'flex items-center justify-center bg-material-opaque',
className,
)}
>
<div className="text-text-secondary text-center">
<i className="i-mingcute-image-line mb-2 text-4xl" />
<p className="text-sm"></p>
</div>
</div>
)
}
return (
<div className={clsxm('relative overflow-hidden', className)}>
{/* 缩略图 */}
{thumbnailSrc && (
<m.img
ref={thumbnailRef}
initial={{ opacity: 0 }}
exit={{ opacity: 0 }}
src={thumbnailSrc}
key={thumbnailSrc}
alt={alt}
transition={Spring.presets.smooth}
className="absolute inset-0 h-full w-full object-contain"
animate={thumbnailAnimateController}
onLoad={handleThumbnailLoad}
/>
)}
{highResLoaded && blobSrc && (
<WebGLImageViewer
ref={transformRef}
src={blobSrc}
className="absolute inset-0 h-full w-full"
initialScale={1}
minScale={minZoom}
maxScale={maxZoom}
limitToBounds={true}
centerOnInit={true}
smooth={true}
onZoomChange={onTransformed}
debug={import.meta.env.DEV}
onContextMenu={(e) =>
showContextMenu(
[
new MenuItemText({
label: '复制图片',
click: async () => {
const loadingToast = toast.loading('正在复制图片...')
try {
// Create a canvas to convert the image to PNG
const img = new Image()
img.crossOrigin = 'anonymous'
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = blobSrc
})
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx?.drawImage(img, 0, 0)
// Convert to PNG blob
await new Promise<void>((resolve, reject) => {
canvas.toBlob(async (pngBlob) => {
try {
if (pngBlob) {
await navigator.clipboard.write([
new ClipboardItem({
'image/png': pngBlob,
}),
])
resolve()
} else {
reject(
new Error('Failed to convert image to PNG'),
)
}
} catch (error) {
reject(error)
}
}, 'image/png')
})
toast.dismiss(loadingToast)
toast.success('图片已复制到剪贴板')
} catch (error) {
console.error('Failed to copy image:', error)
// Fallback: try to copy the original blob
try {
const blob = await fetch(blobSrc).then((res) =>
res.blob(),
)
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
])
toast.dismiss(loadingToast)
toast.success('图片已复制到剪贴板')
} catch (fallbackError) {
console.error(
'Fallback copy also failed:',
fallbackError,
)
toast.dismiss(loadingToast)
toast.error('复制图片失败,请稍后重试')
}
}
},
}),
MenuItemSeparator.default,
new MenuItemText({
label: '下载图片',
click: () => {
const a = document.createElement('a')
a.href = blobSrc
a.download = alt
a.click()
},
}),
],
e,
)
}
/>
)}
{/* Live Photo 组件 */}
{isLivePhoto && livePhotoVideoUrl && imageLoaderManagerRef.current && (
<LivePhoto
videoUrl={livePhotoVideoUrl}
imageLoaderManager={imageLoaderManagerRef.current}
loadingIndicatorRef={loadingIndicatorRef}
isCurrentImage={isCurrentImage}
/>
)}
{/* 备用图片(当 WebGL 不可用时) */}
{!canUseWebGL && highResLoaded && blobSrc && (
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/20">
<i className="i-mingcute-warning-line mb-2 text-4xl" />
<span className="text-center text-sm text-white">
WebGL
</span>
</div>
)}
{/* 加载指示器 */}
<LoadingIndicator ref={loadingIndicatorRef} />
{/* 操作提示 */}
{!isLivePhoto && (
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50">
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,289 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { AnimatePresence, m } from 'motion/react'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { clsxm } from '~/lib/cn'
import { Spring } from '~/lib/spring'
import type { PhotoManifest } from '~/types/photo'
interface SharePanelProps {
photo: PhotoManifest
trigger: React.ReactNode
blobSrc?: string
}
interface ShareOption {
id: string
label: string
icon: string
action: () => Promise<void> | void
color?: string
bgColor?: string
}
interface SocialShareOption {
id: string
label: string
icon: string
url: string
color: string
bgColor: string
}
// 社交媒体分享选项
const socialOptions: SocialShareOption[] = [
{
id: 'twitter',
label: 'Twitter',
icon: 'i-mingcute-twitter-fill',
url: 'https://twitter.com/intent/tweet?text={text}&url={url}',
color: 'text-white',
bgColor: 'bg-sky-500',
},
{
id: 'facebook',
label: 'Facebook',
icon: 'i-mingcute-facebook-line',
url: 'https://www.facebook.com/sharer/sharer.php?u={url}',
color: 'text-white',
bgColor: 'bg-[#1877F2]',
},
{
id: 'telegram',
label: 'Telegram',
icon: 'i-mingcute-telegram-line',
url: 'https://t.me/share/url?url={url}&text={text}',
color: 'text-white',
bgColor: 'bg-[#0088CC]',
},
{
id: 'weibo',
label: '微博',
icon: 'i-mingcute-weibo-line',
url: 'https://service.weibo.com/share/share.php?url={url}&title={text}',
color: 'text-white',
bgColor: 'bg-[#E6162D]',
},
]
export const SharePanel = ({ photo, trigger, blobSrc }: SharePanelProps) => {
const [isOpen, setIsOpen] = useState(false)
const handleNativeShare = useCallback(async () => {
const shareUrl = window.location.href
const shareTitle = photo.title || '照片分享'
const shareText = `查看这张精美的照片:${shareTitle}`
try {
// 优先使用 blobSrc转换后的图片如果没有则使用 originalUrl
const imageUrl = blobSrc || photo.originalUrl
const response = await fetch(imageUrl)
const blob = await response.blob()
const file = new File([blob], `${photo.title || 'photo'}.jpg`, {
type: blob.type || 'image/jpeg',
})
// 检查是否支持文件分享
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
title: shareTitle,
text: shareText,
url: shareUrl,
files: [file],
})
} else {
// 不支持文件分享,只分享链接
await navigator.share({
title: shareTitle,
text: shareText,
url: shareUrl,
})
}
setIsOpen(false)
} catch {
// 如果分享失败,复制链接
await navigator.clipboard.writeText(shareUrl)
toast.success('链接已复制到剪贴板')
setIsOpen(false)
}
}, [photo.title, blobSrc, photo.originalUrl])
const handleCopyLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(window.location.href)
toast.success('链接已复制到剪贴板')
setIsOpen(false)
} catch {
toast.error('复制失败')
}
}, [])
const handleSocialShare = useCallback(
(url: string) => {
const shareUrl = encodeURIComponent(window.location.href)
const shareTitle = encodeURIComponent(photo.title || '照片分享')
const shareText = encodeURIComponent(
`查看这张精美的照片:${photo.title || '照片分享'}`,
)
const finalUrl = url
.replace('{url}', shareUrl)
.replace('{title}', shareTitle)
.replace('{text}', shareText)
window.open(finalUrl, '_blank', 'width=600,height=400')
setIsOpen(false)
},
[photo.title],
)
// 功能选项
const actionOptions: ShareOption[] = [
...(typeof navigator !== 'undefined' && 'share' in navigator
? [
{
id: 'native-share',
label: '系统分享',
icon: 'i-mingcute-share-2-line',
action: handleNativeShare,
color: 'text-blue-500',
},
]
: []),
{
id: 'copy-link',
label: '复制链接',
icon: 'i-mingcute-link-line',
action: handleCopyLink,
},
]
return (
<DropdownMenuPrimitive.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuPrimitive.Trigger asChild>
{trigger}
</DropdownMenuPrimitive.Trigger>
<AnimatePresence>
{isOpen && (
<DropdownMenuPrimitive.Portal forceMount>
<DropdownMenuPrimitive.Content
align="end"
sideOffset={8}
className="z-[10000] min-w-[280px] will-change-[opacity,transform]"
asChild
>
<m.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={Spring.presets.smooth}
className={clsxm(
'rounded-2xl border border-border/10 p-4',
'bg-material-ultra-thick backdrop-blur-[70px]',
'shadow-2xl shadow-black/20',
'dark:shadow-black/50',
)}
>
{/* 标题区域 */}
<div className="mb-4 text-center">
<h3 className="text-text font-semibold"></h3>
{photo.title && (
<p className="text-text-secondary mt-1 line-clamp-1 text-sm">
{photo.title}
</p>
)}
</div>
{/* 社交媒体分享 - 第一排 */}
<div className="mb-6">
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
</h4>
</div>
<div className="flex justify-center gap-4">
{socialOptions.map((option) => (
<button
key={option.id}
type="button"
className="group flex flex-col items-center gap-2"
onClick={() => handleSocialShare(option.url)}
>
<div
className={clsxm(
'flex size-12 items-center justify-center rounded-full transition-all duration-200',
option.bgColor,
'group-hover:scale-110 group-active:scale-95',
'shadow-lg',
)}
>
<i
className={clsxm(
option.icon,
'size-5',
option.color,
)}
/>
</div>
<span className="text-text-secondary text-xs font-medium">
{option.label}
</span>
</button>
))}
</div>
</div>
{/* 功能选项 - 第二排 */}
<div>
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
</h4>
</div>
<div className="grid grid-cols-2 gap-1">
{actionOptions.map((option) => (
<button
key={option.id}
type="button"
className={clsxm(
'relative flex cursor-pointer select-none items-center rounded-lg px-2 py-2',
'text-sm outline-none transition-all duration-200',
'hover:bg-fill-secondary/80 active:bg-fill-secondary',
'group',
)}
onClick={() => option.action()}
>
<div className="flex items-center gap-2">
<div
className={clsxm(
'flex size-7 items-center justify-center rounded-full',
'bg-fill-tertiary/80 group-hover:bg-fill-tertiary',
'transition-colors duration-200',
)}
>
<i
className={clsxm(
option.icon,
'size-3.5',
option.color || 'text-text-secondary',
)}
/>
</div>
<span className="text-text text-xs font-medium">
{option.label}
</span>
</div>
</button>
))}
</div>
</div>
</m.div>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
)}
</AnimatePresence>
</DropdownMenuPrimitive.Root>
)
}

View File

@@ -0,0 +1,5 @@
export * from './ExifPanel'
export * from './GalleryThumbnail'
export * from './PhotoViewer'
export * from './ProgressiveImage'
export * from './SharePanel'

View File

@@ -0,0 +1,16 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { createPortal } from 'react-dom'
import { useRootPortal } from './provider'
export const RootPortal: FC<
{
to?: HTMLElement
} & PropsWithChildren
> = (props) => {
const to = useRootPortal()
return createPortal(props.children, props.to || to || document.body)
}

View File

@@ -0,0 +1,15 @@
import { createContext, use } from 'react'
export const useRootPortal = () => {
const ctx = use(RootPortalContext)
return ctx.to || document.body
}
const RootPortalContext = createContext<{
to?: HTMLElement | undefined
}>({
to: undefined,
})
export const RootPortalProvider = RootPortalContext

View File

@@ -0,0 +1,180 @@
import * as ScrollAreaBase from '@radix-ui/react-scroll-area'
import clsx from 'clsx'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
import { ScrollElementContext } from './ctx'
const Corner = ({
ref: forwardedRef,
className,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Corner> & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Corner> | null>
}) => (
<ScrollAreaBase.Corner
{...rest}
ref={forwardedRef}
className={clsx('bg-accent', className)}
/>
)
Corner.displayName = 'ScrollArea.Corner'
const Thumb = ({
ref: forwardedRef,
className,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Thumb> & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Thumb> | null>
}) => (
<ScrollAreaBase.Thumb
{...rest}
onClick={(e) => {
e.stopPropagation()
rest.onClick?.(e)
}}
ref={forwardedRef}
className={clsxm(
'relative w-full flex-1 rounded-xl transition-colors duration-150',
'bg-fill-secondary hover:bg-fill',
'active:bg-control-enabled',
'before:absolute before:-left-1/2 before:-top-1/2 before:h-full before:min-h-[44]',
'before:w-full before:min-w-[44] before:-translate-x-full before:-translate-y-full before:content-[""]',
className,
)}
/>
)
Thumb.displayName = 'ScrollArea.Thumb'
const Scrollbar = ({
ref: forwardedRef,
className,
children,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Scrollbar> & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Scrollbar> | null>
}) => {
const { orientation = 'vertical' } = rest
return (
<ScrollAreaBase.Scrollbar
{...rest}
ref={forwardedRef}
className={clsxm(
'flex w-2.5 touch-none select-none p-0.5',
orientation === 'horizontal'
? `h-2.5 w-full flex-col`
: `w-2.5 flex-row`,
'animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className,
)}
>
{children}
<Thumb />
</ScrollAreaBase.Scrollbar>
)
}
Scrollbar.displayName = 'ScrollArea.Scrollbar'
const Viewport = ({
ref: forwardedRef,
className,
focusable = true,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {
mask?: boolean
focusable?: boolean
} & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Viewport> | null>
}) => {
const ref = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement)
return (
<ScrollAreaBase.Viewport
{...rest}
ref={ref}
tabIndex={focusable ? -1 : void 0}
className={clsxm(
'block size-full',
className,
)}
/>
)
}
Viewport.displayName = 'ScrollArea.Viewport'
const Root = ({
ref: forwardedRef,
className,
children,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Root> & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Root> | null>
}) => (
<ScrollAreaBase.Root
{...rest}
scrollHideDelay={0}
ref={forwardedRef}
className={clsxm('overflow-hidden', className)}
>
{children}
<Corner />
</ScrollAreaBase.Root>
)
Root.displayName = 'ScrollArea.Root'
export const ScrollArea = ({
ref,
flex,
children,
rootClassName,
viewportClassName,
scrollbarClassName,
mask = false,
onScroll,
orientation = 'vertical',
asChild = false,
focusable = true,
}: React.PropsWithChildren & {
rootClassName?: string
viewportClassName?: string
scrollbarClassName?: string
flex?: boolean
mask?: boolean
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
orientation?: 'vertical' | 'horizontal'
asChild?: boolean
focusable?: boolean
} & { ref?: React.Ref<HTMLDivElement | null> }) => {
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(
null,
)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
return (
<ScrollElementContext value={viewportRef}>
<Root className={rootClassName}>
<Viewport
ref={setViewportRef}
className={clsxm(
flex ? '[&>div]:!flex [&>div]:!flex-col' : '',
viewportClassName,
)}
mask={mask}
asChild={asChild}
onScroll={onScroll}
focusable={focusable}
>
{children}
</Viewport>
<Scrollbar orientation={orientation} className={scrollbarClassName} />
</Root>
</ScrollElementContext>
)
}

View File

@@ -0,0 +1,5 @@
import { createContext } from 'react'
export const ScrollElementContext = createContext<HTMLElement | null>(
document.documentElement,
)

View File

@@ -0,0 +1,9 @@
import { use } from 'react'
import { ScrollElementContext } from './ctx'
/**
* Get the scroll area element when in radix scroll area
* @returns
*/
export const useScrollViewElement = () => use(ScrollElementContext)

View File

@@ -0,0 +1,9 @@
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => (
<Sonner theme="dark" {...props} />
)
export { Toaster }

View File

@@ -0,0 +1,51 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { m } from 'motion/react'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
import { Spring } from '~/lib/spring'
import { tooltipStyle } from './styles'
const TooltipProvider = TooltipPrimitive.Provider
const TooltipRoot = TooltipPrimitive.Root
const Tooltip: typeof TooltipProvider = ({ children, ...props }) => (
<TooltipProvider {...props}>
<TooltipPrimitive.Tooltip>{children}</TooltipPrimitive.Tooltip>
</TooltipProvider>
)
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
ref?: React.Ref<React.ElementRef<typeof TooltipPrimitive.Content> | null>
}) => (
<TooltipPrimitive.Content
ref={ref}
asChild
sideOffset={sideOffset}
className={clsxm(tooltipStyle.content, className)}
{...props}
>
<m.div
initial={{ opacity: 0.82, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.smooth}
>
{/* https://github.com/radix-ui/primitives/discussions/868 */}
<TooltipPrimitive.Arrow className="z-50 fill-white [clip-path:inset(0_-10px_-10px_-10px)] dark:fill-neutral-950 dark:drop-shadow-[0_0_1px_theme(colors.white/0.5)]" />
{props.children}
</m.div>
</TooltipPrimitive.Content>
)
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipRoot, TooltipTrigger }
export { RootPortal as TooltipPortal } from '../portal'

View File

@@ -0,0 +1,10 @@
export const tooltipStyle = {
content: [
'relative z-[101] bg-white px-2 py-1 text-text dark:bg-neutral-950',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
'rounded-lg text-sm',
'max-w-[75ch] select-text',
'drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top',
'dark:drop-shadow-[0_0_1px_theme(colors.white/0.5)]',
],
}

View File

@@ -0,0 +1,101 @@
import type { PropsWithChildren } from 'react'
import { useEffect, useState } from 'react'
import { clsxm } from '~/lib/cn'
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from '../tooltip'
const isTextOverflowed = (element: HTMLElement, dir: 'h' | 'v') => {
if (dir === 'h') {
return element.offsetWidth < element.scrollWidth
} else {
return element.offsetHeight < element.scrollHeight
}
}
type EllipsisProps = PropsWithChildren<{
width?: string
className?: string
disabled?: boolean
dir?: 'h' | 'v'
}>
export const EllipsisTextWithTooltip = (props: EllipsisProps) => {
const { children, className, width, disabled, dir = 'v' } = props
const [textElRef, setTextElRef] = useState<HTMLSpanElement | null>()
const [isOverflowed, setIsOverflowed] = useState(false)
const judgment = () => {
if (!textElRef) return
setIsOverflowed(isTextOverflowed(textElRef, dir))
}
useEffect(() => {
judgment()
}, [textElRef, children])
useEffect(() => {
if (!textElRef) return
const resizeObserver = new ResizeObserver(() => {
judgment()
})
resizeObserver.observe(textElRef)
return () => {
resizeObserver.disconnect()
}
}, [textElRef])
const Content = (
<span
className={className}
ref={setTextElRef}
style={
width
? {
maxWidth: width,
}
: undefined
}
>
{children}
</span>
)
if (!isOverflowed || disabled) return Content
return (
<Tooltip>
<TooltipTrigger asChild>{Content}</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<span
className="break-all whitespace-pre-line"
onClick={(e) => e.stopPropagation()}
>
{children}
</span>
</TooltipContent>
</TooltipPortal>
</Tooltip>
)
}
/**
* Show ellipses when horizontal text overflows and full text on hover.
*/
export const EllipsisHorizontalTextWithTooltip = (props: EllipsisProps) => {
const { className, ...rest } = props
return (
<EllipsisTextWithTooltip
className={clsxm('block truncate', className)}
{...rest}
dir="h"
/>
)
}

View File

@@ -0,0 +1 @@
export * from './EllipsisWithTooltip'

153
apps/web/src/core/README.md Normal file
View File

@@ -0,0 +1,153 @@
# Photo Gallery Core 架构
这是照片库构建系统的核心模块,采用模块化设计,将不同功能分离到各自的模块中。
## 架构概览
```
src/core/
├── types/ # 类型定义
│ └── photo.ts # 照片相关类型
├── logger/ # 日志系统
│ └── index.ts # 统一日志器
├── s3/ # S3 存储操作
│ ├── client.ts # S3 客户端配置
│ └── operations.ts # S3 操作(上传、下载、列表)
├── image/ # 图像处理
│ ├── processor.ts # 图像预处理和元数据
│ ├── blurhash.ts # Blurhash 生成
│ ├── thumbnail.ts # 缩略图生成
│ └── exif.ts # EXIF 数据提取
├── photo/ # 照片处理
│ ├── info-extractor.ts # 照片信息提取
│ └── processor.ts # 照片处理主逻辑
├── manifest/ # Manifest 管理
│ └── manager.ts # Manifest 读写和管理
├── worker/ # 并发处理
│ └── pool.ts # Worker 池管理
├── builder/ # 主构建器
│ └── index.ts # 构建流程编排
└── index.ts # 模块入口
```
## 模块说明
### 1. 类型定义 (`types/`)
- `PhotoInfo`: 照片基本信息
- `ImageMetadata`: 图像元数据
- `PhotoManifestItem`: Manifest 项目
- `ProcessPhotoResult`: 处理结果
- `ThumbnailResult`: 缩略图生成结果
### 2. 日志系统 (`logger/`)
- 统一的日志管理
- 支持不同模块的标签化日志
- Worker 专用日志器
### 3. S3 存储操作 (`s3/`)
- **client.ts**: S3 客户端配置和连接
- **operations.ts**: 图片下载、列表获取、URL 生成
### 4. 图像处理 (`image/`)
- **processor.ts**: 图像预处理、HEIC 转换、元数据提取
- **blurhash.ts**: Blurhash 生成算法
- **thumbnail.ts**: 缩略图生成和管理
- **exif.ts**: EXIF 数据提取和清理
### 5. 照片处理 (`photo/`)
- **info-extractor.ts**: 从文件名和 EXIF 提取照片信息
- **processor.ts**: 照片处理主流程,整合所有处理步骤
### 6. Manifest 管理 (`manifest/`)
- **manager.ts**: Manifest 文件的读取、保存、更新检测
### 7. 并发处理 (`worker/`)
- **pool.ts**: Worker 池管理,支持并发处理
### 8. 主构建器 (`builder/`)
- **index.ts**: 整个构建流程的编排和协调
## 使用方式
### 基本使用
```typescript
import { buildManifest } from './src/core/index.js'
await buildManifest({
isForceMode: false,
isForceManifest: false,
isForceThumbnails: false,
concurrencyLimit: 10,
})
```
### 单独使用模块
```typescript
import {
getImageFromS3,
generateThumbnailAndBlurhash,
extractExifData
} from './src/core/index.js'
// 下载图片
const buffer = await getImageFromS3('path/to/image.jpg')
// 生成缩略图
const result = await generateThumbnailAndBlurhash(buffer, 'photo-id', 1920, 1080)
// 提取 EXIF
const exif = await extractExifData(buffer)
```
## 特性
### 1. 模块化设计
- 每个功能模块独立,便于测试和维护
- 清晰的依赖关系
- 易于扩展新功能
### 2. 类型安全
- 完整的 TypeScript 类型定义
- 编译时错误检查
### 3. 性能优化
- Worker 池并发处理
- Sharp 实例复用
- 增量更新支持
### 4. 错误处理
- 统一的错误处理机制
- 详细的日志记录
- 优雅的失败处理
### 5. 配置灵活
- 支持多种运行模式
- 可配置的并发数
- 环境变量配置
## 扩展指南
### 添加新的图像处理功能
1.`image/` 目录下创建新模块
2.`index.ts` 中导出新功能
3.`photo/processor.ts` 中集成
### 添加新的存储后端
1.`s3/` 目录下创建新的操作模块
2. 实现相同的接口
3. 在配置中切换
### 自定义日志器
```typescript
import { logger } from './src/core/index.js'
const customLogger = logger.worker(1).withTag('CUSTOM')
customLogger.info('自定义日志')
```
## 性能考虑
- 使用 Worker 池避免过度并发
- Sharp 实例复用减少内存开销
- 增量更新减少不必要的处理
- 缩略图和 Blurhash 缓存复用

View File

@@ -0,0 +1,346 @@
import type { BuilderConfig } from '@builder'
import { builderConfig } from '@builder'
import { logger } from '../logger/index.js'
import {
handleDeletedPhotos,
loadExistingManifest,
saveManifest,
} from '../manifest/manager.js'
import type { PhotoProcessorOptions } from '../photo/processor.js'
import { processPhoto } from '../photo/processor.js'
import { StorageManager } from '../storage/index.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import { ClusterPool } from '../worker/cluster-pool.js'
import { WorkerPool } from '../worker/pool.js'
export interface BuilderOptions {
isForceMode: boolean
isForceManifest: boolean
isForceThumbnails: boolean
concurrencyLimit?: number // 可选,如果未提供则使用配置文件中的默认值
}
export class PhotoGalleryBuilder {
private storageManager: StorageManager
private config: BuilderConfig
constructor(config?: Partial<BuilderConfig>) {
// 合并用户配置和默认配置
this.config = this.mergeConfig(builderConfig, config)
// 创建存储管理器
this.storageManager = new StorageManager(this.config.storage)
// 配置日志级别
this.configureLogging()
}
private mergeConfig(
baseConfig: BuilderConfig,
userConfig?: Partial<BuilderConfig>,
): BuilderConfig {
if (!userConfig) return baseConfig
return {
repo: { ...baseConfig.repo, ...userConfig.repo },
storage: { ...baseConfig.storage, ...userConfig.storage },
options: { ...baseConfig.options, ...userConfig.options },
logging: { ...baseConfig.logging, ...userConfig.logging },
performance: {
...baseConfig.performance,
...userConfig.performance,
worker: {
...baseConfig.performance.worker,
...userConfig.performance?.worker,
},
},
}
}
private configureLogging(): void {
// 这里可以根据配置调整日志设置
// 目前日志配置在 logger 模块中处理
}
/**
* 构建照片清单
* @param options 构建选项
*/
async buildManifest(options: BuilderOptions): Promise<void> {
const startTime = Date.now()
try {
this.logBuildStart()
// 读取现有的 manifest如果存在
const existingManifest = await this.loadExistingManifest(options)
const existingManifestMap = new Map(
existingManifest.map((item) => [item.s3Key, item]),
)
logger.main.info(`现有 manifest 包含 ${existingManifest.length} 张照片`)
// 列出存储中的所有文件
const allObjects = await this.storageManager.listAllFiles()
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
// 检测 Live Photo 配对(如果启用)
const livePhotoMap = await this.detectLivePhotos(allObjects)
if (this.config.options.enableLivePhotoDetection) {
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
}
// 列出存储中的所有图片文件
const imageObjects = await this.storageManager.listImages()
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
// 检查照片数量限制
if (imageObjects.length > this.config.options.maxPhotos) {
logger.main.warn(
`⚠️ 照片数量 (${imageObjects.length}) 超过配置限制 (${this.config.options.maxPhotos})`,
)
}
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
const manifest: PhotoManifestItem[] = []
let processedCount = 0
let skippedCount = 0
let newCount = 0
let deletedCount = 0
if (imageObjects.length > 0) {
// 获取并发限制
const concurrency =
options.concurrencyLimit ?? this.config.options.defaultConcurrency
// 根据配置选择处理模式
const { useClusterMode } = this.config.performance.worker
logger.main.info(
`开始${useClusterMode ? '多进程' : '并发'}处理任务,${useClusterMode ? '进程' : 'Worker'}数:${concurrency}${useClusterMode ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
)
const processorOptions: PhotoProcessorOptions = {
isForceMode: options.isForceMode,
isForceManifest: options.isForceManifest,
isForceThumbnails: options.isForceThumbnails,
}
let results: ProcessPhotoResult[]
if (useClusterMode) {
// 创建 Cluster 池(多进程模式)
const clusterPool = new ClusterPool<ProcessPhotoResult>(
{
concurrency,
totalTasks: imageObjects.length,
workerConcurrency:
this.config.performance.worker.workerConcurrency,
workerEnv: {
FORCE_MODE: processorOptions.isForceMode.toString(),
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
},
},
logger,
)
// 执行多进程并发处理
results = await clusterPool.execute()
} else {
// 创建传统 Worker 池(主线程并发模式)
const workerPool = new WorkerPool<ProcessPhotoResult>(
{
concurrency,
totalTasks: imageObjects.length,
},
logger,
)
// 执行并发处理
results = await workerPool.execute(async (taskIndex, workerId) => {
const obj = imageObjects[taskIndex]
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
const legacyObj = {
Key: obj.key,
Size: obj.size,
LastModified: obj.lastModified,
ETag: obj.etag,
}
// 转换 Live Photo Map
const legacyLivePhotoMap = new Map()
for (const [key, value] of livePhotoMap) {
legacyLivePhotoMap.set(key, {
Key: value.key,
Size: value.size,
LastModified: value.lastModified,
ETag: value.etag,
})
}
return await processPhoto(
legacyObj,
taskIndex,
workerId,
imageObjects.length,
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
logger,
)
})
}
// 统计结果并添加到 manifest
for (const result of results) {
if (result.item) {
manifest.push(result.item)
switch (result.type) {
case 'new': {
newCount++
processedCount++
break
}
case 'processed': {
processedCount++
break
}
case 'skipped': {
skippedCount++
break
}
}
}
}
}
// 检测并处理已删除的图片
if (
!options.isForceMode &&
!options.isForceManifest &&
existingManifest.length > 0
) {
deletedCount = await handleDeletedPhotos(
existingManifest,
s3ImageKeys,
logger.main,
logger.fs,
)
}
// 保存 manifest
await saveManifest(manifest, logger.fs)
// 显示构建结果
if (this.config.options.showDetailedStats) {
this.logBuildResults(
manifest,
{
newCount,
processedCount,
skippedCount,
deletedCount,
},
Date.now() - startTime,
)
}
} catch (error) {
logger.main.error('❌ 构建 manifest 失败:', error)
throw error
}
}
private async loadExistingManifest(
options: BuilderOptions,
): Promise<PhotoManifestItem[]> {
return options.isForceMode || options.isForceManifest
? []
: await loadExistingManifest()
}
private async detectLivePhotos(
allObjects: Awaited<ReturnType<StorageManager['listAllFiles']>>,
): Promise<Map<string, (typeof allObjects)[0]>> {
if (!this.config.options.enableLivePhotoDetection) {
return new Map()
}
return await this.storageManager.detectLivePhotos(allObjects)
}
private logBuildStart(): void {
switch (this.config.storage.provider) {
case 's3': {
const endpoint = this.config.storage.endpoint || '默认 AWS S3'
const customDomain = this.config.storage.customDomain || '未设置'
const { bucket } = this.config.storage
const prefix = this.config.storage.prefix || '无前缀'
logger.main.info('🚀 开始从存储获取照片列表...')
logger.main.info(`🔗 使用端点:${endpoint}`)
logger.main.info(`🌐 自定义域名:${customDomain}`)
logger.main.info(`🪣 存储桶:${bucket}`)
logger.main.info(`📂 前缀:${prefix}`)
break
}
case 'github': {
const { owner, repo, branch, path } = this.config.storage
logger.main.info('🚀 开始从存储获取照片列表...')
logger.main.info(`👤 仓库所有者:${owner}`)
logger.main.info(`🏷️ 仓库名称:${repo}`)
logger.main.info(`🌲 分支:${branch}`)
logger.main.info(`📂 路径:${path}`)
break
}
}
}
private logBuildResults(
manifest: PhotoManifestItem[],
stats: {
newCount: number
processedCount: number
skippedCount: number
deletedCount: number
},
totalDuration: number,
): void {
const durationSeconds = Math.round(totalDuration / 1000)
const durationMinutes = Math.floor(durationSeconds / 60)
const remainingSeconds = durationSeconds % 60
logger.main.success(`🎉 Manifest 构建完成!`)
logger.main.info(`📊 处理统计:`)
logger.main.info(` 📸 总照片数:${manifest.length}`)
logger.main.info(` 🆕 新增照片:${stats.newCount}`)
logger.main.info(` 🔄 处理照片:${stats.processedCount}`)
logger.main.info(` ⏭️ 跳过照片:${stats.skippedCount}`)
logger.main.info(` 🗑️ 删除照片:${stats.deletedCount}`)
logger.main.info(
` ⏱️ 总耗时:${durationMinutes > 0 ? `${durationMinutes}${remainingSeconds}` : `${durationSeconds}`}`,
)
}
/**
* 获取当前使用的存储管理器
*/
getStorageManager(): StorageManager {
return this.storageManager
}
/**
* 获取当前配置
*/
getConfig(): BuilderConfig {
return { ...this.config }
}
}
// 导出默认的构建器实例
export const defaultBuilder = new PhotoGalleryBuilder()

View File

@@ -0,0 +1,5 @@
// 保持向后兼容的函数
// 重新导出新的 Builder 类和相关类型
export type { BuilderOptions } from './builder.js'
export { defaultBuilder, PhotoGalleryBuilder } from './builder.js'

180
apps/web/src/core/cli.ts Normal file
View File

@@ -0,0 +1,180 @@
import cluster from 'node:cluster'
import { existsSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { builderConfig } from '@builder'
import { $ } from 'execa'
import { defaultBuilder } from './builder/index.js'
import { logger } from './logger/index.js'
import { runAsWorker } from './runAsWorker.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function main() {
// 检查是否作为 cluster worker 运行
if (
process.env.CLUSTER_WORKER === 'true' ||
process.argv.includes('--cluster-worker') ||
cluster.isWorker
) {
await runAsWorker()
return
}
// 如果配置了远程仓库,则使用远程仓库
if (builderConfig.repo.enable) {
// 拉取远程仓库
const workdir = path.resolve(__dirname, '..', '..')
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
if (!hasExist) {
await $({
cwd: workdir,
stdio: 'inherit',
})`git clone ${builderConfig.repo.url} assets-git`
} else {
await $({
cwd: path.resolve(workdir, 'assets-git'),
stdio: 'inherit',
})`git pull --rebase`
}
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
if (existsSync(thumbnailsDir)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
}
await $({
cwd: workdir,
stdio: 'inherit',
})`ln -s ${path.resolve(workdir, 'assets-git', 'thumbnails')} ${thumbnailsDir}`
// 删除src/data/photos-manifest.json并建立软连接到 assets-git/photos-manifest.json
const photosManifestPath = path.resolve(
workdir,
'src',
'data',
'photos-manifest.json',
)
if (existsSync(photosManifestPath)) {
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${photosManifestPath}`
}
await $({ cwd: workdir, stdio: 'inherit' })`ln -s ${path.resolve(
workdir,
'assets-git',
'photos-manifest.json',
)} ${photosManifestPath}`
}
process.title = 'photo-gallery-builder-main'
// 解析命令行参数
const args = new Set(process.argv.slice(2))
const isForceMode = args.has('--force')
const isForceManifest = args.has('--force-manifest')
const isForceThumbnails = args.has('--force-thumbnails')
// 显示帮助信息
if (args.has('--help') || args.has('-h')) {
logger.main.info(`
照片库构建工具 (新版本 - 使用适配器模式)
用法tsx src/core/cli.ts [选项]
选项:
--force 强制重新处理所有照片
--force-manifest 强制重新生成 manifest
--force-thumbnails 强制重新生成缩略图
--config 显示当前配置信息
--help, -h 显示帮助信息
示例:
tsx src/core/cli.ts # 增量更新
tsx src/core/cli.ts --force # 全量更新
tsx src/core/cli.ts --force-thumbnails # 强制重新生成缩略图
tsx src/core/cli.ts --config # 显示配置信息
配置:
在 builder.config.ts 中设置 performance.worker.useClusterMode = true
可启用多进程集群模式,发挥多核心优势。
`)
return
}
// 显示配置信息
if (args.has('--config')) {
const config = defaultBuilder.getConfig()
logger.main.info('🔧 当前配置:')
logger.main.info(` 存储提供商:${config.storage.provider}`)
switch (config.storage.provider) {
case 's3': {
logger.main.info(` 存储桶:${config.storage.bucket}`)
logger.main.info(` 区域:${config.storage.region || '未设置'}`)
logger.main.info(` 端点:${config.storage.endpoint || '默认'}`)
logger.main.info(
` 自定义域名:${config.storage.customDomain || '未设置'}`,
)
logger.main.info(` 前缀:${config.storage.prefix || '无'}`)
break
}
case 'github': {
logger.main.info(` 仓库所有者:${config.storage.owner}`)
logger.main.info(` 仓库名称:${config.storage.repo}`)
logger.main.info(` 分支:${config.storage.branch || 'main'}`)
logger.main.info(` 路径:${config.storage.path || '无'}`)
logger.main.info(` 使用原始 URL${config.storage.useRawUrl || '否'}`)
break
}
}
logger.main.info(` 默认并发数:${config.options.defaultConcurrency}`)
logger.main.info(` 最大照片数:${config.options.maxPhotos}`)
logger.main.info(
` Live Photo 检测:${config.options.enableLivePhotoDetection ? '启用' : '禁用'}`,
)
logger.main.info(` Worker 数:${config.performance.worker.workerCount}`)
logger.main.info(` Worker 超时:${config.performance.worker.timeout}ms`)
logger.main.info(
` 集群模式:${config.performance.worker.useClusterMode ? '启用' : '禁用'}`,
)
return
}
// 确定运行模式
let runMode = '增量更新'
if (isForceMode) {
runMode = '全量更新'
} else if (isForceManifest && isForceThumbnails) {
runMode = '强制刷新 manifest 和缩略图'
} else if (isForceManifest) {
runMode = '强制刷新 manifest'
} else if (isForceThumbnails) {
runMode = '强制刷新缩略图'
}
const config = defaultBuilder.getConfig()
const concurrencyLimit = config.performance.worker.workerCount
const finalConcurrency = concurrencyLimit ?? config.options.defaultConcurrency
const processingMode = config.performance.worker.useClusterMode
? '多进程集群'
: '并发线程池'
logger.main.info(`🚀 运行模式:${runMode}`)
logger.main.info(`⚡ 最大并发数:${finalConcurrency}`)
logger.main.info(`🔧 处理模式:${processingMode}`)
logger.main.info(`🏗️ 使用构建器PhotoGalleryBuilder (适配器模式)`)
// 启动构建过程
await defaultBuilder.buildManifest({
isForceMode,
isForceManifest,
isForceThumbnails,
concurrencyLimit,
})
}
// 运行主函数
main().catch((error) => {
logger.main.error('构建失败:', error)
throw error
})

View File

@@ -0,0 +1,15 @@
// 支持的图片格式
export const SUPPORTED_FORMATS = new Set([
'.jpg',
'.jpeg',
'.png',
'.webp',
'.bmp',
'.tiff',
'.heic',
'.heif',
'.hif',
])
// HEIC/HEIF 格式
export const HEIC_FORMATS = new Set(['.heic', '.heif', '.hif'])

View File

@@ -0,0 +1,76 @@
import { encode } from 'blurhash'
import sharp from 'sharp'
import type { Logger } from '../logger/index.js'
// 生成 blurhash基于缩略图数据保持长宽比
export async function generateBlurhash(
thumbnailBuffer: Buffer,
originalWidth: number,
originalHeight: number,
blurhashLogger?: Logger['blurhash'],
): Promise<string | null> {
const log = blurhashLogger
try {
// 计算原始图像的长宽比
const aspectRatio = originalWidth / originalHeight
// 根据长宽比计算合适的 blurhash 尺寸
// 目标是在保持长宽比的同时,获得合适的细节级别
let targetWidth: number
let targetHeight: number
// 基础尺寸,可以根据需要调整
const baseSize = 64
if (aspectRatio >= 1) {
// 横向图片
targetWidth = baseSize
targetHeight = Math.round(baseSize / aspectRatio)
} else {
// 纵向图片
targetHeight = baseSize
targetWidth = Math.round(baseSize * aspectRatio)
}
// 确保最小尺寸,避免过小的尺寸
targetWidth = Math.max(targetWidth, 16)
targetHeight = Math.max(targetHeight, 16)
// 计算 blurhash 的组件数量
// 根据图像尺寸动态调整,但限制在合理范围内
const xComponents = Math.min(Math.max(Math.round(targetWidth / 16), 3), 9)
const yComponents = Math.min(Math.max(Math.round(targetHeight / 16), 3), 9)
log?.debug(
`生成参数:原始 ${originalWidth}x${originalHeight}, 目标 ${targetWidth}x${targetHeight}, 组件 ${xComponents}x${yComponents}`,
)
// 复用缩略图的 Sharp 实例来生成 blurhash
const { data, info } = await sharp(thumbnailBuffer)
.rotate() // 自动根据 EXIF 旋转
.resize(targetWidth, targetHeight, {
fit: 'fill', // 填充整个目标尺寸,保持长宽比
background: { r: 255, g: 255, b: 255, alpha: 0 }, // 透明背景
})
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true })
// 生成 blurhash
const blurhash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
xComponents,
yComponents,
)
log?.success(`生成成功:${blurhash}`)
return blurhash
} catch (error) {
log?.error('生成失败:', error)
return null
}
}

View File

@@ -0,0 +1,144 @@
import type { Exif } from 'exif-reader'
import exifReader from 'exif-reader'
import getRecipe from 'fuji-recipes'
import sharp from 'sharp'
import type { Logger } from '../logger/index.js'
// 清理 EXIF 数据中的空字符和无用信息
function cleanExifData(exifData: any): any {
if (!exifData || typeof exifData !== 'object') {
return exifData
}
if (Array.isArray(exifData)) {
return exifData.map((item) => cleanExifData(item))
}
// 如果是 Date 对象,直接返回
if (exifData instanceof Date) {
return exifData
}
const cleaned: any = {}
// 重要的日期字段,不应该被过度清理
const importantDateFields = new Set([
'DateTimeOriginal',
'DateTime',
'DateTimeDigitized',
'CreateDate',
'ModifyDate',
])
for (const [key, value] of Object.entries(exifData)) {
if (value === null || value === undefined) {
continue
}
if (typeof value === 'string') {
// 对于重要的日期字段,只移除空字符,不进行过度清理
if (importantDateFields.has(key)) {
const cleanedString = value.replaceAll('\0', '')
if (cleanedString.length > 0) {
cleaned[key] = cleanedString
}
} else {
// 对于其他字符串字段,移除空字符并清理空白字符
const cleanedString = value.replaceAll('\0', '').trim()
if (cleanedString.length > 0) {
cleaned[key] = cleanedString
}
}
} else if (value instanceof Date) {
// Date 对象直接保留
cleaned[key] = value
} else if (typeof value === 'object') {
// 递归清理嵌套对象
const cleanedNested = cleanExifData(value)
if (cleanedNested && Object.keys(cleanedNested).length > 0) {
cleaned[key] = cleanedNested
}
} else {
// 其他类型直接保留
cleaned[key] = value
}
}
return cleaned
}
// 提取 EXIF 数据
export async function extractExifData(
imageBuffer: Buffer,
originalBuffer?: Buffer,
exifLogger?: Logger['exif'],
): Promise<Exif | null> {
const log = exifLogger
try {
log?.info('开始提取 EXIF 数据')
// 首先尝试从处理后的图片中提取 EXIF
let metadata = await sharp(imageBuffer).metadata()
// 如果处理后的图片没有 EXIF 数据,且提供了原始 buffer尝试从原始图片提取
if (!metadata.exif && originalBuffer) {
log?.info('处理后的图片缺少 EXIF 数据,尝试从原始图片提取')
try {
metadata = await sharp(originalBuffer).metadata()
} catch (error) {
log?.warn('从原始图片提取 EXIF 失败,可能是不支持的格式:', error)
}
}
if (!metadata.exif) {
log?.warn('未找到 EXIF 数据')
return null
}
let startIndex = 0
for (let i = 0; i < metadata.exif.length; i++) {
if (
metadata.exif.toString('ascii', i, i + 2) === 'II' ||
metadata.exif.toString('ascii', i, i + 2) === 'MM'
) {
startIndex = i
break
}
if (metadata.exif.toString('ascii', i, i + 4) === 'Exif') {
startIndex = i
break
}
}
const exifBuffer = metadata.exif.subarray(startIndex)
// 使用 exif-reader 解析 EXIF 数据
const exifData = exifReader(exifBuffer)
if (exifData.Photo?.MakerNote) {
const recipe = getRecipe(exifData.Photo.MakerNote)
;(exifData as any).FujiRecipe = recipe
log?.info('检测到富士胶片配方信息')
}
delete exifData.Photo?.MakerNote
delete exifData.Photo?.UserComment
delete exifData.Photo?.PrintImageMatching
delete exifData.Image?.PrintImageMatching
if (!exifData) {
log?.warn('EXIF 数据解析失败')
return null
}
// 清理 EXIF 数据中的空字符和无用数据
const cleanedExifData = cleanExifData(exifData)
log?.success('EXIF 数据提取完成')
return cleanedExifData
} catch (error) {
log?.error('提取 EXIF 数据失败:', error)
return null
}
}

View File

@@ -0,0 +1,101 @@
import path from 'node:path'
import heicConvert from 'heic-convert'
import type sharp from 'sharp'
import { HEIC_FORMATS } from '../constants/index.js'
import type { Logger } from '../logger/index.js'
import type { ImageMetadata } from '../types/photo.js'
// 获取图片元数据(复用 Sharp 实例)
export async function getImageMetadataWithSharp(
sharpInstance: sharp.Sharp,
imageLogger?: Logger['image'],
): Promise<ImageMetadata | null> {
const log = imageLogger
try {
const metadata = await sharpInstance.metadata()
if (!metadata.width || !metadata.height || !metadata.format) {
log?.error('图片元数据不完整')
return null
}
let { width } = metadata
let { height } = metadata
// 根据 EXIF Orientation 信息调整宽高
const { orientation } = metadata
if (
orientation === 5 ||
orientation === 6 ||
orientation === 7 ||
orientation === 8
) {
// 对于需要旋转 90°的图片需要交换宽高
;[width, height] = [height, width]
log?.info(
`检测到需要旋转 90°的图片 (orientation: ${orientation}),交换宽高:${width}x${height}`,
)
}
return {
width,
height,
format: metadata.format,
}
} catch (error) {
log?.error('获取图片元数据失败:', error)
return null
}
}
// 转换 HEIC/HEIF 格式到 JPEG
export async function convertHeicToJpeg(
heicBuffer: Buffer,
imageLogger?: Logger['image'],
): Promise<Buffer> {
const log = imageLogger
try {
log?.info(
`开始 HEIC/HEIF → JPEG 转换 (${Math.round(heicBuffer.length / 1024)}KB)`,
)
const startTime = Date.now()
const jpegBuffer = await heicConvert({
buffer: heicBuffer,
format: 'JPEG',
quality: 0.95, // 高质量转换
})
const duration = Date.now() - startTime
const outputSizeKB = Math.round(jpegBuffer.byteLength / 1024)
log?.success(`HEIC/HEIF 转换完成 (${outputSizeKB}KB, ${duration}ms)`)
return Buffer.from(jpegBuffer)
} catch (error) {
log?.error('HEIC/HEIF 转换失败:', error)
throw error
}
}
// 预处理图片 Buffer处理 HEIC/HEIF 格式)
export async function preprocessImageBuffer(
buffer: Buffer,
key: string,
imageLogger?: Logger['image'],
): Promise<Buffer> {
const log = imageLogger
const ext = path.extname(key).toLowerCase()
// 如果是 HEIC/HEIF 格式,先转换为 JPEG
if (HEIC_FORMATS.has(ext)) {
log?.info(`检测到 HEIC/HEIF 格式:${key}`)
return await convertHeicToJpeg(buffer, log)
}
// 其他格式直接返回原始 buffer
return buffer
}

View File

@@ -0,0 +1,121 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import type { Logger } from '../logger/index.js'
import type { ThumbnailResult } from '../types/photo.js'
import { generateBlurhash } from './blurhash.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 检查缩略图是否存在
export async function thumbnailExists(photoId: string): Promise<boolean> {
try {
const thumbnailPath = path.join(
__dirname,
'../../../public/thumbnails',
`${photoId}.webp`,
)
await fs.access(thumbnailPath)
return true
} catch {
return false
}
}
// 生成缩略图和 blurhash复用 Sharp 实例)
export async function generateThumbnailAndBlurhash(
imageBuffer: Buffer,
photoId: string,
originalWidth: number,
originalHeight: number,
forceRegenerate = false,
workerLogger?: {
thumbnail: Logger['thumbnail']
blurhash: Logger['blurhash']
},
): Promise<ThumbnailResult> {
const thumbnailLog = workerLogger?.thumbnail
const blurhashLog = workerLogger?.blurhash
try {
const thumbnailDir = path.join(__dirname, '../../../public/thumbnails')
await fs.mkdir(thumbnailDir, { recursive: true })
const thumbnailPath = path.join(thumbnailDir, `${photoId}.webp`)
const thumbnailUrl = `/thumbnails/${photoId}.webp`
// 如果不是强制模式且缩略图已存在,读取现有文件
if (!forceRegenerate && (await thumbnailExists(photoId))) {
thumbnailLog?.info(`复用现有缩略图:${photoId}`)
try {
const existingBuffer = await fs.readFile(thumbnailPath)
// 基于现有缩略图生成 blurhash
const blurhash = await generateBlurhash(
existingBuffer,
originalWidth,
originalHeight,
blurhashLog,
)
return {
thumbnailUrl,
thumbnailBuffer: existingBuffer,
blurhash,
}
} catch (error) {
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
// 继续执行生成逻辑
}
}
thumbnailLog?.info(`生成缩略图:${photoId}`)
const startTime = Date.now()
// 创建 Sharp 实例,复用于缩略图和 blurhash 生成
const sharpInstance = sharp(imageBuffer).rotate() // 自动根据 EXIF 旋转
// 生成缩略图
const thumbnailBuffer = await sharpInstance
.clone() // 克隆实例用于缩略图生成
.resize(600, null, {
withoutEnlargement: true,
})
.webp({
quality: 100,
})
.toBuffer()
// 保存到文件
await fs.writeFile(thumbnailPath, thumbnailBuffer)
const duration = Date.now() - startTime
const sizeKB = Math.round(thumbnailBuffer.length / 1024)
thumbnailLog?.success(`生成完成:${photoId} (${sizeKB}KB, ${duration}ms)`)
// 基于生成的缩略图生成 blurhash
const blurhash = await generateBlurhash(
thumbnailBuffer,
originalWidth,
originalHeight,
blurhashLog,
)
return {
thumbnailUrl,
thumbnailBuffer,
blurhash,
}
} catch (error) {
thumbnailLog?.error(`生成失败:${photoId}`, error)
return {
thumbnailUrl: null,
thumbnailBuffer: null,
blurhash: null,
}
}
}

View File

@@ -0,0 +1,61 @@
// 主要构建器
export {
type BuilderOptions,
defaultBuilder,
PhotoGalleryBuilder,
} from './builder/index.js'
// 日志系统
export { type Logger, logger, type WorkerLogger } from './logger/index.js'
// 类型定义
export type {
ImageMetadata,
PhotoInfo,
PhotoManifestItem,
ProcessPhotoResult,
ThumbnailResult,
} from './types/photo.js'
// S3 操作
export { s3Client } from './s3/client.js'
export {
generateS3Url,
getImageFromS3,
listImagesFromS3,
} from './s3/operations.js'
// 图像处理
export { generateBlurhash } from './image/blurhash.js'
export { extractExifData } from './image/exif.js'
export {
getImageMetadataWithSharp,
preprocessImageBuffer,
} from './image/processor.js'
export {
generateThumbnailAndBlurhash,
thumbnailExists,
} from './image/thumbnail.js'
// 照片处理
export { extractPhotoInfo } from './photo/info-extractor.js'
export {
type PhotoProcessorOptions,
processPhoto,
type WorkerLoggers,
} from './photo/processor.js'
// Manifest 管理
export {
handleDeletedPhotos,
loadExistingManifest,
needsUpdate,
saveManifest,
} from './manifest/manager.js'
// Worker 池
export {
type TaskFunction,
WorkerPool,
type WorkerPoolOptions,
} from './worker/pool.js'

View File

@@ -0,0 +1,24 @@
import consola from 'consola'
// 创建系统化的日志器
export const logger = {
// 主进程日志
main: consola.withTag('MAIN'),
// S3 操作日志
s3: consola.withTag('S3'),
// 图片处理日志
image: consola.withTag('IMAGE'),
// 缩略图处理日志
thumbnail: consola.withTag('THUMBNAIL'),
// Blurhash 处理日志
blurhash: consola.withTag('BLURHASH'),
// EXIF 处理日志
exif: consola.withTag('EXIF'),
// 文件系统操作日志
fs: consola.withTag('FS'),
// Worker 日志(动态创建)
worker: (id: number) => consola.withTag(`WORKER-${id}`),
}
export type Logger = typeof logger
export type WorkerLogger = ReturnType<typeof logger.worker>

View File

@@ -0,0 +1,99 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { _Object } from '@aws-sdk/client-s3'
import type { Logger } from '../logger/index.js'
import type { PhotoManifestItem } from '../types/photo.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 读取现有的 manifest
export async function loadExistingManifest(): Promise<PhotoManifestItem[]> {
try {
const manifestPath = path.join(
__dirname,
'../../../src/data/photos-manifest.json',
)
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
return JSON.parse(manifestContent) as PhotoManifestItem[]
} catch {
return []
}
}
// 检查照片是否需要更新(基于最后修改时间)
export function needsUpdate(
existingItem: PhotoManifestItem | undefined,
s3Object: _Object,
): boolean {
if (!existingItem) return true
if (!s3Object.LastModified) return true
const existingModified = new Date(existingItem.lastModified)
const s3Modified = s3Object.LastModified
return s3Modified > existingModified
}
// 保存 manifest
export async function saveManifest(
manifest: PhotoManifestItem[],
fsLogger?: Logger['fs'],
): Promise<void> {
const manifestPath = path.join(
__dirname,
'../../../src/data/photos-manifest.json',
)
// 按日期排序(最新的在前)
const sortedManifest = [...manifest].sort(
(a, b) => new Date(b.dateTaken).getTime() - new Date(a.dateTaken).getTime(),
)
await fs.mkdir(path.dirname(manifestPath), { recursive: true })
await fs.writeFile(manifestPath, JSON.stringify(sortedManifest, null, 2))
fsLogger?.info(`📁 Manifest 保存至:${manifestPath}`)
}
// 检测并处理已删除的图片
export async function handleDeletedPhotos(
existingManifest: PhotoManifestItem[],
s3ImageKeys: Set<string>,
mainLogger?: Logger['main'],
fsLogger?: Logger['fs'],
): Promise<number> {
if (existingManifest.length === 0) {
return 0
}
mainLogger?.info('🔍 检查已删除的图片...')
let deletedCount = 0
for (const existingItem of existingManifest) {
// 如果现有 manifest 中的图片在 S3 中不存在了
if (!s3ImageKeys.has(existingItem.s3Key)) {
mainLogger?.info(`🗑️ 检测到已删除的图片:${existingItem.s3Key}`)
deletedCount++
// 删除对应的缩略图文件
try {
const thumbnailPath = path.join(
__dirname,
'../../../public/thumbnails',
`${existingItem.id}.webp`,
)
await fs.unlink(thumbnailPath)
fsLogger?.info(`🗑️ 已删除缩略图:${existingItem.id}.webp`)
} catch (error) {
// 缩略图可能已经不存在,忽略错误
fsLogger?.warn(`删除缩略图失败:${existingItem.id}.webp`, error)
}
}
}
return deletedCount
}

View File

@@ -0,0 +1,117 @@
import path from 'node:path'
import { env } from '@env'
import type { Exif } from 'exif-reader'
import type { Logger } from '../logger/index.js'
import type { PhotoInfo } from '../types/photo.js'
// 从文件名提取照片信息
export function extractPhotoInfo(
key: string,
exifData?: Exif | null,
imageLogger?: Logger['image'],
): PhotoInfo {
const log = imageLogger
log?.debug(`提取照片信息: ${key}`)
const fileName = path.basename(key, path.extname(key))
// 尝试从文件名解析信息,格式示例: "2024-01-15_城市夜景_1250views"
let title = fileName
let dateTaken = new Date().toISOString()
let views = 0
let tags: string[] = []
// 从目录路径中提取 tags
const dirPath = path.dirname(key)
if (dirPath && dirPath !== '.' && dirPath !== '/') {
// 移除前缀(如果有的话)
let relativePath = dirPath
if (env.S3_PREFIX && dirPath.startsWith(env.S3_PREFIX)) {
relativePath = dirPath.slice(env.S3_PREFIX.length)
}
// 清理路径分隔符
relativePath = relativePath.replaceAll(/^\/+|\/+$/g, '')
if (relativePath) {
// 分割路径并过滤空字符串
const pathParts = relativePath
.split('/')
.filter((part) => part.trim() !== '')
tags = pathParts.map((part) => part.trim())
log?.debug(`从路径提取标签:[${tags.join(', ')}]`)
}
}
// 优先使用 EXIF 中的 DateTimeOriginal
if (exifData?.Photo?.DateTimeOriginal) {
try {
const dateTimeOriginal = exifData.Photo.DateTimeOriginal as any
// 如果是 Date 对象,直接使用
if (dateTimeOriginal instanceof Date) {
dateTaken = dateTimeOriginal.toISOString()
log?.debug('使用 EXIF Date 对象作为拍摄时间')
} else if (typeof dateTimeOriginal === 'string') {
// 如果是字符串,按原来的方式处理
// EXIF 日期格式通常是 "YYYY:MM:DD HH:MM:SS"
const formattedDateStr = dateTimeOriginal.replace(
/^(\d{4}):(\d{2}):(\d{2})/,
'$1-$2-$3',
)
dateTaken = new Date(formattedDateStr).toISOString()
log?.debug(`使用 EXIF 字符串作为拍摄时间:${dateTimeOriginal}`)
} else {
log?.warn(
`未知的 DateTimeOriginal 类型:${typeof dateTimeOriginal}`,
dateTimeOriginal,
)
}
} catch (error) {
log?.warn(
`解析 EXIF DateTimeOriginal 失败:${exifData.Photo.DateTimeOriginal}`,
error,
)
}
} else {
// 如果 EXIF 中没有日期,尝试从文件名解析
const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/)
if (dateMatch) {
dateTaken = new Date(dateMatch[1]).toISOString()
log?.debug(`从文件名提取拍摄时间:${dateMatch[1]}`)
}
}
// 如果文件名包含浏览次数
const viewsMatch = fileName.match(/(\d+)views?/i)
if (viewsMatch) {
views = Number.parseInt(viewsMatch[1])
log?.debug(`从文件名提取浏览次数:${views}`)
}
// 从文件名中提取标题(移除日期和浏览次数)
title = fileName
.replaceAll(/\d{4}-\d{2}-\d{2}[_-]?/g, '')
.replaceAll(/[_-]?\d+views?/gi, '')
.replaceAll(/[_-]+/g, ' ')
.trim()
// 如果标题为空,使用文件名
if (!title) {
title = path.basename(key, path.extname(key))
}
log?.debug(`照片信息提取完成:"${title}"`)
return {
title,
dateTaken,
views,
tags,
description: '', // 可以从 EXIF 或其他元数据中获取
}
}

View File

@@ -0,0 +1,248 @@
import path from 'node:path'
import type { _Object } from '@aws-sdk/client-s3'
import type { Exif } from 'exif-reader'
import sharp from 'sharp'
import { HEIC_FORMATS } from '../constants/index.js'
import { extractExifData } from '../image/exif.js'
import {
getImageMetadataWithSharp,
preprocessImageBuffer,
} from '../image/processor.js'
import {
generateThumbnailAndBlurhash,
thumbnailExists,
} from '../image/thumbnail.js'
import type { Logger } from '../logger/index.js'
import { needsUpdate } from '../manifest/manager.js'
import { generateS3Url, getImageFromS3 } from '../s3/operations.js'
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
import { extractPhotoInfo } from './info-extractor.js'
export interface PhotoProcessorOptions {
isForceMode: boolean
isForceManifest: boolean
isForceThumbnails: boolean
}
export interface WorkerLoggers {
image: Logger['image']
s3: Logger['s3']
thumbnail: Logger['thumbnail']
blurhash: Logger['blurhash']
exif: Logger['exif']
}
// 处理单张照片
export async function processPhoto(
obj: _Object,
index: number,
workerId: number,
totalImages: number,
existingManifestMap: Map<string, PhotoManifestItem>,
livePhotoMap: Map<string, _Object>,
options: PhotoProcessorOptions,
logger: Logger,
): Promise<ProcessPhotoResult> {
const key = obj.Key
if (!key) {
logger.image.warn(`跳过没有 Key 的对象`)
return { item: null, type: 'failed' }
}
const photoId = path.basename(key, path.extname(key))
const existingItem = existingManifestMap.get(key)
// 创建 worker 专用的 logger
const workerLoggers: WorkerLoggers = {
image: logger.worker(workerId).withTag('IMAGE'),
s3: logger.worker(workerId).withTag('S3'),
thumbnail: logger.worker(workerId).withTag('THUMBNAIL'),
blurhash: logger.worker(workerId).withTag('BLURHASH'),
exif: logger.worker(workerId).withTag('EXIF'),
}
workerLoggers.image.info(`📸 [${index + 1}/${totalImages}] ${key}`)
// 检查是否需要更新
if (
!options.isForceMode &&
!options.isForceManifest &&
existingItem &&
!needsUpdate(existingItem, obj)
) {
// 检查缩略图是否存在,如果不存在或强制刷新缩略图则需要重新处理
const hasThumbnail = await thumbnailExists(photoId)
if (hasThumbnail && !options.isForceThumbnails) {
workerLoggers.image.info(`⏭️ 跳过处理 (未更新且缩略图存在): ${key}`)
return { item: existingItem, type: 'skipped' }
} else {
if (options.isForceThumbnails) {
workerLoggers.image.info(`🔄 强制重新生成缩略图:${key}`)
} else {
workerLoggers.image.info(
`🔄 重新生成缩略图 (文件未更新但缩略图缺失): ${key}`,
)
}
}
}
// 需要处理的照片(新照片、更新的照片或缺失缩略图的照片)
const isNewPhoto = !existingItem
if (isNewPhoto) {
workerLoggers.image.info(`🆕 新照片:${key}`)
} else {
workerLoggers.image.info(`🔄 更新照片:${key}`)
}
try {
// 获取图片数据
const rawImageBuffer = await getImageFromS3(key, workerLoggers.s3)
if (!rawImageBuffer) return { item: null, type: 'failed' }
// 预处理图片(处理 HEIC/HEIF 格式)
let imageBuffer: Buffer
try {
imageBuffer = await preprocessImageBuffer(
rawImageBuffer,
key,
workerLoggers.image,
)
} catch (error) {
workerLoggers.image.error(`预处理图片失败:${key}`, error)
return { item: null, type: 'failed' }
}
// 创建 Sharp 实例,复用于多个操作
const sharpInstance = sharp(imageBuffer)
// 获取图片元数据(复用 Sharp 实例)
const metadata = await getImageMetadataWithSharp(
sharpInstance,
workerLoggers.image,
)
if (!metadata) return { item: null, type: 'failed' }
// 如果是增量更新且已有 blurhash可以复用
let thumbnailUrl: string | null = null
let thumbnailBuffer: Buffer | null = null
let blurhash: string | null = null
if (
!options.isForceMode &&
!options.isForceThumbnails &&
existingItem?.blurhash &&
(await thumbnailExists(photoId))
) {
// 复用现有的缩略图和 blurhash
blurhash = existingItem.blurhash
workerLoggers.blurhash.info(`复用现有 blurhash: ${photoId}`)
try {
const fs = await import('node:fs/promises')
const thumbnailPath = path.join(
process.cwd(),
'public/thumbnails',
`${photoId}.webp`,
)
thumbnailBuffer = await fs.readFile(thumbnailPath)
thumbnailUrl = `/thumbnails/${photoId}.webp`
workerLoggers.thumbnail.info(`复用现有缩略图:${photoId}`)
} catch (error) {
workerLoggers.thumbnail.warn(
`读取现有缩略图失败,重新生成:${photoId}`,
error,
)
// 继续执行生成逻辑
}
}
// 如果没有复用成功,则生成缩略图和 blurhash
if (!thumbnailUrl || !thumbnailBuffer || !blurhash) {
const result = await generateThumbnailAndBlurhash(
imageBuffer,
photoId,
metadata.width,
metadata.height,
options.isForceMode || options.isForceThumbnails,
{
thumbnail: workerLoggers.thumbnail,
blurhash: workerLoggers.blurhash,
},
)
thumbnailUrl = result.thumbnailUrl
thumbnailBuffer = result.thumbnailBuffer
blurhash = result.blurhash
}
// 如果是增量更新且已有 EXIF 数据,可以复用
let exifData: Exif | null = null
if (
!options.isForceMode &&
!options.isForceManifest &&
existingItem?.exif
) {
exifData = existingItem.exif
workerLoggers.exif.info(`复用现有 EXIF 数据:${photoId}`)
} else {
// 传入原始 buffer 以便在转换后的图片缺少 EXIF 时回退
const ext = path.extname(key).toLowerCase()
const originalBuffer = HEIC_FORMATS.has(ext) ? rawImageBuffer : undefined
exifData = await extractExifData(
imageBuffer,
originalBuffer,
workerLoggers.exif,
)
}
// 提取照片信息(在获取 EXIF 数据之后,以便使用 DateTimeOriginal
const photoInfo = extractPhotoInfo(key, exifData, workerLoggers.image)
const aspectRatio = metadata.width / metadata.height
// 检查是否为 live photo
const livePhotoVideo = livePhotoMap.get(key)
const isLivePhoto = !!livePhotoVideo
let livePhotoVideoUrl: string | undefined
let livePhotoVideoS3Key: string | undefined
if (isLivePhoto && livePhotoVideo?.Key) {
livePhotoVideoS3Key = livePhotoVideo.Key
livePhotoVideoUrl = generateS3Url(livePhotoVideo.Key)
workerLoggers.image.info(
`📱 检测到 Live Photo${key} -> ${livePhotoVideo.Key}`,
)
}
const photoItem: PhotoManifestItem = {
id: photoId,
title: photoInfo.title,
description: photoInfo.description,
dateTaken: photoInfo.dateTaken,
views: photoInfo.views,
tags: photoInfo.tags,
originalUrl: generateS3Url(key),
thumbnailUrl,
blurhash,
width: metadata.width,
height: metadata.height,
aspectRatio,
s3Key: key,
lastModified: obj.LastModified?.toISOString() || new Date().toISOString(),
size: obj.Size || 0,
exif: exifData,
// Live Photo 相关字段
isLivePhoto,
livePhotoVideoUrl,
livePhotoVideoS3Key,
}
workerLoggers.image.success(`✅ 处理完成:${key}`)
return { item: photoItem, type: isNewPhoto ? 'new' : 'processed' }
} catch (error) {
workerLoggers.image.error(`❌ 处理失败:${key}`, error)
return { item: null, type: 'failed' }
}
}

View File

@@ -0,0 +1,291 @@
import process from 'node:process'
import { logger } from './logger'
import type { PhotoManifestItem } from './types/photo'
import type {
BatchTaskMessage,
BatchTaskResult,
TaskMessage,
TaskResult,
} from './worker/cluster-pool'
// Worker 进程处理逻辑
export async function runAsWorker() {
process.title = 'photo-gallery-builder-worker'
const workerId = Number.parseInt(process.env.WORKER_ID || '0')
// 立即注册消息监听器,避免被异步初始化阻塞
let isInitialized = false
let storageManager: any
let imageObjects: any[]
let existingManifestMap: Map<string, PhotoManifestItem>
let livePhotoMap: Map<string, any>
// 初始化函数,只在第一次收到任务时执行
const initializeWorker = async () => {
if (isInitialized) return
// 动态导入所需模块
const [{ StorageManager }, { builderConfig }, { loadExistingManifest }] =
await Promise.all([
import('./storage/index.js'),
import('@builder'),
import('./manifest/manager.js'),
])
// 在 worker 中初始化存储管理器和数据
storageManager = new StorageManager(builderConfig.storage)
// 获取图片列表worker 需要知道要处理什么)
imageObjects = await storageManager.listImages()
// 获取现有 manifest 和 live photo 信息
const existingManifest = await loadExistingManifest()
existingManifestMap = new Map(
existingManifest.map((item: PhotoManifestItem) => [item.s3Key, item]),
)
// 检测 Live Photos
const allObjects = await storageManager.listAllFiles()
livePhotoMap = builderConfig.options.enableLivePhotoDetection
? await storageManager.detectLivePhotos(allObjects)
: new Map()
isInitialized = true
}
const handleTask = async (message: TaskMessage): Promise<void> => {
try {
// 确保 worker 已初始化
await initializeWorker()
// 动态导入 processPhoto放在这里以避免阻塞消息监听
const { processPhoto } = await import('./photo/processor.js')
const { logger: workerLogger } = await import('./logger/index.js')
const { taskIndex } = message
// 根据 taskIndex 获取对应的图片对象
const obj = imageObjects[taskIndex]
if (!obj) {
throw new Error(`Invalid taskIndex: ${taskIndex}`)
}
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
const legacyObj = {
Key: obj.key,
Size: obj.size,
LastModified: obj.lastModified,
ETag: obj.etag,
}
// 转换 Live Photo Map
const legacyLivePhotoMap = new Map()
for (const [key, value] of livePhotoMap) {
legacyLivePhotoMap.set(key, {
Key: value.key,
Size: value.size,
LastModified: value.lastModified,
ETag: value.etag,
})
}
// 处理器选项(这些可以作为环境变量传递或使用默认值)
const processorOptions = {
isForceMode: process.env.FORCE_MODE === 'true',
isForceManifest: process.env.FORCE_MANIFEST === 'true',
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
}
// 处理照片
const result = await processPhoto(
legacyObj,
taskIndex,
workerId,
imageObjects.length,
existingManifestMap,
legacyLivePhotoMap,
processorOptions,
workerLogger,
)
// 发送结果回主进程
const response: TaskResult = {
type: 'result',
taskId: message.taskId,
result,
}
if (process.send) {
process.send(response)
}
} catch (error) {
// 发送错误回主进程
const response: TaskResult = {
type: 'error',
taskId: message.taskId,
error: error instanceof Error ? error.message : String(error),
}
if (process.send) {
process.send(response)
}
}
}
// 批量任务处理函数
const handleBatchTask = async (message: BatchTaskMessage): Promise<void> => {
try {
// 确保已初始化
await initializeWorker()
const results: TaskResult[] = []
const taskPromises: Promise<void>[] = []
// 创建所有任务的并发执行 Promise
for (const task of message.tasks) {
const taskPromise = (async () => {
try {
const obj = imageObjects[task.taskIndex]
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
const legacyObj = {
Key: obj.key,
Size: obj.size,
LastModified: obj.lastModified,
ETag: obj.etag,
}
// 转换 Live Photo Map
const legacyLivePhotoMap = new Map()
for (const [key, value] of livePhotoMap) {
legacyLivePhotoMap.set(key, {
Key: value.key,
Size: value.size,
LastModified: value.lastModified,
ETag: value.etag,
})
}
// 处理照片
const { processPhoto } = await import('./photo/processor.js')
const result = await processPhoto(
legacyObj,
task.taskIndex,
workerId,
imageObjects.length,
existingManifestMap,
legacyLivePhotoMap,
{
isForceMode: process.env.FORCE_MODE === 'true',
isForceManifest: process.env.FORCE_MANIFEST === 'true',
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
},
logger,
)
// 添加成功结果
results.push({
type: 'result',
taskId: task.taskId,
result,
})
} catch (error) {
// 添加错误结果
results.push({
type: 'error',
taskId: task.taskId,
error: error instanceof Error ? error.message : String(error),
})
}
})()
taskPromises.push(taskPromise)
}
// 等待所有任务完成
await Promise.all(taskPromises)
// 发送批量结果回主进程
const response: BatchTaskResult = {
type: 'batch-result',
results,
}
if (process.send) {
process.send(response)
}
} catch (error) {
// 如果批量处理失败,为每个任务发送错误结果
const results: TaskResult[] = message.tasks.map((task) => ({
type: 'error',
taskId: task.taskId,
error: error instanceof Error ? error.message : String(error),
}))
const response: BatchTaskResult = {
type: 'batch-result',
results,
}
if (process.send) {
process.send(response)
}
}
}
// 立即注册消息监听器
process.on(
'message',
async (
message:
| TaskMessage
| BatchTaskMessage
| { type: 'shutdown' }
| { type: 'ping' },
) => {
if (message.type === 'shutdown') {
process.removeAllListeners('message')
return
}
if (message.type === 'ping') {
// 响应主进程的 ping表示 worker 已准备好
if (process.send) {
process.send({ type: 'pong', workerId })
}
return
}
if (message.type === 'batch-task') {
await handleBatchTask(message)
} else if (message.type === 'task') {
await handleTask(message)
}
},
)
// 错误处理
process.on('uncaughtException', (error) => {
console.error('Worker uncaught exception:', error)
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('Worker unhandled rejection:', reason)
process.exit(1)
})
process.on('SIGINT', () => {
process.exit(0)
})
process.on('SIGTERM', () => {
process.exit(0)
})
// 告知主进程 worker 已准备好
if (process.send) {
process.send({ type: 'ready', workerId })
}
}

View File

@@ -0,0 +1,27 @@
import type { S3ClientConfig } from '@aws-sdk/client-s3'
import { S3Client } from '@aws-sdk/client-s3'
import { env } from '@env'
// 创建 S3 客户端
function createS3Client(): S3Client {
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
throw new Error('S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are required')
}
const s3ClientConfig: S3ClientConfig = {
region: env.S3_REGION,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
}
// 如果提供了自定义端点,则使用它
if (env.S3_ENDPOINT) {
s3ClientConfig.endpoint = env.S3_ENDPOINT
}
return new S3Client(s3ClientConfig)
}
export const s3Client = createS3Client()

View File

@@ -0,0 +1,10 @@
// 重新导出适配器中的函数以保持 API 兼容性
// 推荐使用新的 StorageManager API这些函数将在未来版本中被弃用
export {
detectLivePhotos,
generateS3Url,
getImageFromS3,
listAllFilesFromS3,
listImagesFromS3,
} from '../storage/adapters.js'

View File

@@ -0,0 +1,79 @@
import type { _Object } from '@aws-sdk/client-s3'
import type { Logger } from '../logger/index.js'
import type { StorageObject } from './interfaces.js'
import { defaultStorageManager } from './manager.js'
// 将 StorageObject 转换为 _Object 以保持兼容性
function convertStorageObjectToS3Object(storageObject: StorageObject): _Object {
return {
Key: storageObject.key,
Size: storageObject.size,
LastModified: storageObject.lastModified,
ETag: storageObject.etag,
}
}
/**
* 从 S3 获取图片(兼容性函数)
* @deprecated 推荐使用 defaultStorageManager.getFile()
*/
export async function getImageFromS3(
key: string,
s3Logger?: Logger['s3'],
): Promise<Buffer | null> {
return defaultStorageManager.getFile(key, s3Logger)
}
/**
* 列出 S3 中的所有图片文件(兼容性函数)
* @deprecated 推荐使用 defaultStorageManager.listImages()
*/
export async function listImagesFromS3(): Promise<_Object[]> {
const storageObjects = await defaultStorageManager.listImages()
return storageObjects.map((obj) => convertStorageObjectToS3Object(obj))
}
/**
* 列出 S3 中的所有文件(兼容性函数)
* @deprecated 推荐使用 defaultStorageManager.listAllFiles()
*/
export async function listAllFilesFromS3(): Promise<_Object[]> {
const storageObjects = await defaultStorageManager.listAllFiles()
return storageObjects.map((obj) => convertStorageObjectToS3Object(obj))
}
/**
* 检测 live photo 配对(兼容性函数)
* @deprecated 推荐使用 defaultStorageManager.detectLivePhotos()
*/
export function detectLivePhotos(allObjects: _Object[]): Map<string, _Object> {
// 转换 _Object 数组为 StorageObject 数组
const storageObjects: StorageObject[] = allObjects.map((obj) => ({
key: obj.Key || '',
size: obj.Size,
lastModified: obj.LastModified,
etag: obj.ETag,
}))
// 使用存储管理器检测 Live Photos
const livePhotoMap = defaultStorageManager
.getProvider()
.detectLivePhotos(storageObjects)
// 转换回 _Object 格式
const result = new Map<string, _Object>()
for (const [key, storageObject] of livePhotoMap) {
result.set(key, convertStorageObjectToS3Object(storageObject))
}
return result
}
/**
* 生成 S3 公共 URL兼容性函数
* @deprecated 推荐使用 defaultStorageManager.generatePublicUrl()
*/
export function generateS3Url(key: string): string {
return defaultStorageManager.generatePublicUrl(key)
}

View File

@@ -0,0 +1,42 @@
import { env } from '@env'
import type { StorageConfig, StorageProvider } from './interfaces'
import { GitHubStorageProvider } from './providers/github-provider.js'
import { S3StorageProvider } from './providers/s3-provider.js'
export class StorageFactory {
/**
* 根据配置创建存储提供商实例
* @param config 存储配置
* @returns 存储提供商实例
*/
static createProvider(config: StorageConfig): StorageProvider {
switch (config.provider) {
case 's3': {
return new S3StorageProvider(config)
}
case 'github': {
return new GitHubStorageProvider(config)
}
}
}
/**
* 基于环境变量创建默认的存储提供商
* @returns 默认存储提供商实例
*/
static createDefaultProvider(): StorageProvider {
const config: StorageConfig = {
provider: 's3',
bucket: env.S3_BUCKET_NAME,
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
prefix: env.S3_PREFIX,
customDomain: env.S3_CUSTOM_DOMAIN,
}
return this.createProvider(config)
}
}

View File

@@ -0,0 +1,16 @@
// 导出接口
export type {
StorageConfig,
StorageObject,
StorageProvider,
} from './interfaces.js'
// 导出工厂类
export { StorageFactory } from './factory.js'
// 导出管理器
export { defaultStorageManager, StorageManager } from './manager.js'
// 导出具体提供商(如果需要直接使用)
export { GitHubStorageProvider } from './providers/github-provider.js'
export { S3StorageProvider } from './providers/s3-provider.js'

View File

@@ -0,0 +1,69 @@
import type { Logger } from '../logger/index.js'
// 存储对象的通用接口
export interface StorageObject {
key: string
size?: number
lastModified?: Date
etag?: string
}
// 存储提供商的通用接口
export interface StorageProvider {
/**
* 从存储中获取文件
* @param key 文件的键值/路径
* @param logger 可选的日志记录器
* @returns 文件的 Buffer 数据,如果不存在则返回 null
*/
getFile: (key: string, logger?: Logger['s3']) => Promise<Buffer | null>
/**
* 列出存储中的所有图片文件
* @returns 图片文件对象数组
*/
listImages: () => Promise<StorageObject[]>
/**
* 列出存储中的所有文件
* @returns 所有文件对象数组
*/
listAllFiles: () => Promise<StorageObject[]>
/**
* 生成文件的公共访问 URL
* @param key 文件的键值/路径
* @returns 公共访问 URL
*/
generatePublicUrl: (key: string) => string
/**
* 检测 Live Photos 配对
* @param allObjects 所有文件对象
* @returns Live Photo 配对映射 (图片 key -> 视频对象)
*/
detectLivePhotos: (allObjects: StorageObject[]) => Map<string, StorageObject>
}
export type S3Config = {
provider: 's3'
bucket?: string
region?: string
endpoint?: string
accessKeyId?: string
secretAccessKey?: string
prefix?: string
customDomain?: string
}
export type GitHubConfig = {
provider: 'github'
owner: string
repo: string
branch?: string
token?: string
path?: string
useRawUrl?: boolean
}
export type StorageConfig = S3Config | GitHubConfig

View File

@@ -0,0 +1,83 @@
import type { Logger } from '../logger/index.js'
import { StorageFactory } from './factory.js'
import type {
StorageConfig,
StorageObject,
StorageProvider,
} from './interfaces.js'
export class StorageManager {
private provider: StorageProvider
constructor(config?: StorageConfig) {
this.provider = config
? StorageFactory.createProvider(config)
: StorageFactory.createDefaultProvider()
}
/**
* 从存储中获取文件
* @param key 文件的键值/路径
* @param logger 可选的日志记录器
* @returns 文件的 Buffer 数据,如果不存在则返回 null
*/
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
return this.provider.getFile(key, logger)
}
/**
* 列出存储中的所有图片文件
* @returns 图片文件对象数组
*/
async listImages(): Promise<StorageObject[]> {
return this.provider.listImages()
}
/**
* 列出存储中的所有文件
* @returns 所有文件对象数组
*/
async listAllFiles(): Promise<StorageObject[]> {
return this.provider.listAllFiles()
}
/**
* 生成文件的公共访问 URL
* @param key 文件的键值/路径
* @returns 公共访问 URL
*/
generatePublicUrl(key: string): string {
return this.provider.generatePublicUrl(key)
}
/**
* 检测 Live Photos 配对
* @param allObjects 所有文件对象(可选,如果不提供则自动获取)
* @returns Live Photo 配对映射 (图片 key -> 视频对象)
*/
async detectLivePhotos(
allObjects?: StorageObject[],
): Promise<Map<string, StorageObject>> {
const objects = allObjects || (await this.listAllFiles())
return this.provider.detectLivePhotos(objects)
}
/**
* 获取当前使用的存储提供商
* @returns 存储提供商实例
*/
getProvider(): StorageProvider {
return this.provider
}
/**
* 切换存储提供商
* @param config 新的存储配置
*/
switchProvider(config: StorageConfig): void {
this.provider = StorageFactory.createProvider(config)
}
}
// 导出默认的存储管理器实例
export const defaultStorageManager = new StorageManager()

View File

@@ -0,0 +1,157 @@
# 存储提供商
本目录包含各种存储服务的具体实现。
## S3 存储提供商
支持 AWS S3 和兼容 S3 API 的存储服务(如 MinIO、阿里云 OSS 等)。
### 配置示例
```typescript
const s3Config: StorageConfig = {
provider: 's3',
bucket: 'my-bucket',
region: 'us-east-1',
endpoint: 'https://s3.amazonaws.com',
accessKeyId: 'your-access-key',
secretAccessKey: 'your-secret-key',
prefix: 'photos/',
customDomain: 'https://cdn.example.com',
}
```
## GitHub 存储提供商
将照片存储在 GitHub 仓库中,利用 GitHub 的免费存储空间和全球 CDN。
### 特点
- ✅ 免费存储空间GitHub 仓库限制为 1GB
- ✅ 全球 CDN 支持
- ✅ 版本控制
- ✅ 公开访问(通过 raw.githubusercontent.com
- ✅ 支持私有仓库(需要访问令牌)
- ⚠️ GitHub API 有请求频率限制
- ⚠️ 不适合大量文件或频繁更新
### 配置示例
```typescript
const githubConfig: StorageConfig = {
provider: 'github',
github: {
owner: 'your-username', // GitHub 用户名或组织名
repo: 'photo-gallery', // 仓库名称
branch: 'main', // 分支名称(可选,默认 'main'
token: 'ghp_xxxxxxxxxxxx', // GitHub 访问令牌(可选)
path: 'photos', // 照片存储路径(可选)
useRawUrl: true, // 使用 raw.githubusercontent.com默认 true
},
}
```
### 设置步骤
1. **创建 GitHub 仓库**
```bash
# 创建新仓库(或使用现有仓库)
git clone https://github.com/your-username/photo-gallery.git
cd photo-gallery
mkdir photos
```
2. **获取 GitHub 访问令牌**(可选,但推荐)
- 访问 GitHub Settings > Developer settings > Personal access tokens
- 创建新的 Fine-grained personal access token
- 选择你的仓库
- 赋予 "Contents" 权限(读写)
3. **配置环境变量**
```bash
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
```
4. **更新配置文件**
```typescript
// builder.config.ts
export const builderConfig: BuilderConfig = {
...defaultBuilderConfig,
storage: {
provider: 'github',
github: {
owner: 'your-username',
repo: 'photo-gallery',
branch: 'main',
token: process.env.GITHUB_TOKEN,
path: 'photos',
useRawUrl: true,
},
},
}
```
### 使用示例
```typescript
import { GitHubStorageProvider } from '@/core/storage'
const githubProvider = new GitHubStorageProvider({
provider: 'github',
github: {
owner: 'octocat',
repo: 'Hello-World',
branch: 'main',
token: 'your-token',
path: 'images',
},
})
// 获取文件
const buffer = await githubProvider.getFile('sunset.jpg')
// 列出所有图片
const images = await githubProvider.listImages()
// 生成公共 URL
const url = githubProvider.generatePublicUrl('sunset.jpg')
// 结果https://raw.githubusercontent.com/octocat/Hello-World/main/images/sunset.jpg
```
### API 限制
GitHub API 有以下限制:
- **未认证请求**: 60 requests/hour/IP
- **认证请求**: 5,000 requests/hour/token
- **文件大小**: 最大 100MB通过 API
- **仓库大小**: 建议不超过 1GB
### 最佳实践
1. **使用访问令牌**: 提高 API 请求限制
2. **合理组织目录结构**: 便于管理和访问
3. **定期清理**: 删除不需要的文件以节省空间
4. **监控 API 使用**: 避免超出请求限制
5. **考虑文件大小**: 对于大文件,考虑使用其他存储服务
### 错误处理
GitHub 存储提供商会处理以下错误:
- **404 Not Found**: 文件或仓库不存在
- **403 Forbidden**: 权限不足或 API 限制
- **422 Unprocessable Entity**: 请求格式错误
- **500+ Server Error**: GitHub 服务器错误
### 与其他提供商的对比
| 特性 | S3 | GitHub |
|------|----|----|
| 存储空间 | 按需付费 | 1GB 免费 |
| CDN | 额外付费 | 免费全球 CDN |
| API 限制 | 很高 | 有限制 |
| 适用场景 | 生产环境 | 小型项目、演示 |
| 设置复杂度 | 中等 | 简单 |
选择存储提供商时,请根据你的具体需求和预算进行选择。

View File

@@ -0,0 +1,278 @@
import path from 'node:path'
import { SUPPORTED_FORMATS } from '../../constants/index.js'
import type { Logger } from '../../logger/index.js'
import type {
GitHubConfig,
StorageObject,
StorageProvider,
} from '../interfaces.js'
// GitHub API 响应类型
interface GitHubFileContent {
name: string
path: string
sha: string
size: number
url: string
html_url: string
git_url: string
download_url: string | null
type: 'file' | 'dir'
content?: string
encoding?: string
}
interface GitHubDirectoryContent extends GitHubFileContent {
type: 'dir'
}
type GitHubContent = GitHubFileContent | GitHubDirectoryContent
export class GitHubStorageProvider implements StorageProvider {
private config: GitHubConfig
private githubConfig: NonNullable<GitHubConfig>
private baseApiUrl: string
constructor(config: GitHubConfig) {
this.config = config
if (config.provider !== 'github') {
throw new Error('GitHub 配置不能为空')
}
this.githubConfig = {
branch: 'main',
path: '',
useRawUrl: true,
...config,
}
this.baseApiUrl = `https://api.github.com/repos/${this.githubConfig.owner}/${this.githubConfig.repo}`
if (!this.githubConfig.owner || !this.githubConfig.repo) {
throw new Error('GitHub owner 和 repo 配置不能为空')
}
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'PhotoGallery/1.0',
}
if (this.githubConfig.token) {
headers['Authorization'] = `Bearer ${this.githubConfig.token}`
}
return headers
}
private normalizeKey(key: string): string {
// 移除开头的斜杠,确保路径格式正确
return key.replace(/^\/+/, '')
}
private getFullPath(key: string): string {
const normalizedKey = this.normalizeKey(key)
if (this.githubConfig.path) {
return `${this.githubConfig.path}/${normalizedKey}`.replaceAll(
/\/+/g,
'/',
)
}
return normalizedKey
}
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
const log = logger
try {
log?.info(`下载文件:${key}`)
const startTime = Date.now()
const fullPath = this.getFullPath(key)
const url = `${this.baseApiUrl}/contents/${fullPath}?ref=${this.githubConfig.branch}`
const response = await fetch(url, {
headers: this.getAuthHeaders(),
})
if (!response.ok) {
if (response.status === 404) {
log?.warn(`文件不存在:${key}`)
return null
}
throw new Error(
`GitHub API 请求失败:${response.status} ${response.statusText}`,
)
}
const data = (await response.json()) as GitHubFileContent
if (data.type !== 'file') {
log?.error(`路径不是文件:${key}`)
return null
}
let fileBuffer: Buffer
if (data.download_url) {
// 使用 download_url 获取文件内容(推荐方式)
const fileResponse = await fetch(data.download_url)
if (!fileResponse.ok) {
throw new Error(
`下载文件失败:${fileResponse.status} ${fileResponse.statusText}`,
)
}
const arrayBuffer = await fileResponse.arrayBuffer()
fileBuffer = Buffer.from(arrayBuffer)
} else if (data.content && data.encoding === 'base64') {
// 从 API 响应中解码 base64 内容
fileBuffer = Buffer.from(data.content, 'base64')
} else {
throw new Error('无法获取文件内容')
}
const duration = Date.now() - startTime
const sizeKB = Math.round(fileBuffer.length / 1024)
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
return fileBuffer
} catch (error) {
log?.error(`下载失败:${key}`, error)
return null
}
}
async listImages(): Promise<StorageObject[]> {
const allFiles = await this.listAllFiles()
// 过滤出图片文件
return allFiles.filter((file) => {
const ext = path.extname(file.key).toLowerCase()
return SUPPORTED_FORMATS.has(ext)
})
}
async listAllFiles(): Promise<StorageObject[]> {
const files: StorageObject[] = []
const basePath = this.githubConfig.path || ''
await this.listFilesRecursive(basePath, files)
return files
}
private async listFilesRecursive(
dirPath: string,
files: StorageObject[],
): Promise<void> {
try {
const url = `${this.baseApiUrl}/contents/${dirPath}?ref=${this.githubConfig.branch}`
const response = await fetch(url, {
headers: this.getAuthHeaders(),
})
if (!response.ok) {
if (response.status === 404) {
// 目录不存在,返回空数组
return
}
throw new Error(
`GitHub API 请求失败:${response.status} ${response.statusText}`,
)
}
const contents = (await response.json()) as GitHubContent[]
for (const item of contents) {
if (item.type === 'file') {
// 计算相对于配置路径的 key
let key = item.path
if (this.githubConfig.path) {
key = item.path.replace(
new RegExp(`^${this.githubConfig.path}/`),
'',
)
}
files.push({
key,
size: item.size,
// GitHub API 不直接提供最后修改时间,使用当前时间或从其他 API 获取
lastModified: new Date(),
etag: item.sha,
})
} else if (item.type === 'dir') {
// 递归处理子目录
await this.listFilesRecursive(item.path, files)
}
}
} catch (error) {
console.error(`列出目录 ${dirPath} 失败:`, error)
throw error
}
}
generatePublicUrl(key: string): string {
const fullPath = this.getFullPath(key)
if (this.githubConfig.useRawUrl) {
// 使用 raw.githubusercontent.com 获取文件
return `https://raw.githubusercontent.com/${this.githubConfig.owner}/${this.githubConfig.repo}/${this.githubConfig.branch}/${fullPath}`
} else {
// 使用 GitHub 的 blob URL
return `https://github.com/${this.githubConfig.owner}/${this.githubConfig.repo}/blob/${this.githubConfig.branch}/${fullPath}`
}
}
detectLivePhotos(allObjects: StorageObject[]): Map<string, StorageObject> {
const livePhotoMap = new Map<string, StorageObject>()
// 按目录和基础文件名分组所有文件
const fileGroups = new Map<string, StorageObject[]>()
for (const obj of allObjects) {
if (!obj.key) continue
const dir = path.dirname(obj.key)
const basename = path.basename(obj.key, path.extname(obj.key))
const groupKey = `${dir}/${basename}`
if (!fileGroups.has(groupKey)) {
fileGroups.set(groupKey, [])
}
fileGroups.get(groupKey)!.push(obj)
}
// 在每个分组中寻找图片 + 视频配对
for (const files of fileGroups.values()) {
let imageFile: StorageObject | null = null
let videoFile: StorageObject | null = null
for (const file of files) {
if (!file.key) continue
const ext = path.extname(file.key).toLowerCase()
// 检查是否为支持的图片格式
if (SUPPORTED_FORMATS.has(ext)) {
imageFile = file
}
// 检查是否为 .mov 视频文件
else if (ext === '.mov') {
videoFile = file
}
}
// 如果找到配对,记录为 live photo
if (imageFile && videoFile && imageFile.key) {
livePhotoMap.set(imageFile.key, videoFile)
}
}
return livePhotoMap
}
}

View File

@@ -0,0 +1,194 @@
import path from 'node:path'
import type { _Object } from '@aws-sdk/client-s3'
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { SUPPORTED_FORMATS } from '../../constants/index.js'
import type { Logger } from '../../logger/index.js'
import { s3Client } from '../../s3/client.js'
import type {
S3Config,
StorageObject,
StorageProvider,
} from '../interfaces'
// 将 AWS S3 对象转换为通用存储对象
function convertS3ObjectToStorageObject(s3Object: _Object): StorageObject {
return {
key: s3Object.Key || '',
size: s3Object.Size,
lastModified: s3Object.LastModified,
etag: s3Object.ETag,
}
}
export class S3StorageProvider implements StorageProvider {
private config: S3Config
constructor(config: S3Config) {
this.config = config
}
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
const log = logger
try {
log?.info(`下载文件:${key}`)
const startTime = Date.now()
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: key,
})
const response = await s3Client.send(command)
if (!response.Body) {
log?.error(`S3 响应中没有 Body: ${key}`)
return null
}
// 处理不同类型的 Body
if (response.Body instanceof Buffer) {
const duration = Date.now() - startTime
const sizeKB = Math.round(response.Body.length / 1024)
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
return response.Body
}
// 如果是 Readable stream
const chunks: Uint8Array[] = []
const stream = response.Body as NodeJS.ReadableStream
return new Promise((resolve, reject) => {
stream.on('data', (chunk: Uint8Array) => {
chunks.push(chunk)
})
stream.on('end', () => {
const buffer = Buffer.concat(chunks)
const duration = Date.now() - startTime
const sizeKB = Math.round(buffer.length / 1024)
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
resolve(buffer)
})
stream.on('error', (error) => {
log?.error(`下载失败:${key}`, error)
reject(error)
})
})
} catch (error) {
log?.error(`下载失败:${key}`, error)
return null
}
}
async listImages(): Promise<StorageObject[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.config.prefix,
MaxKeys: 1000, // 最多获取 1000 张照片
})
const listResponse = await s3Client.send(listCommand)
const objects = listResponse.Contents || []
// 过滤出图片文件并转换为通用格式
const imageObjects = objects
.filter((obj: _Object) => {
if (!obj.Key) return false
const ext = path.extname(obj.Key).toLowerCase()
return SUPPORTED_FORMATS.has(ext)
})
.map((obj) => convertS3ObjectToStorageObject(obj))
return imageObjects
}
async listAllFiles(): Promise<StorageObject[]> {
const listCommand = new ListObjectsV2Command({
Bucket: this.config.bucket,
Prefix: this.config.prefix,
MaxKeys: 1000,
})
const listResponse = await s3Client.send(listCommand)
const objects = listResponse.Contents || []
return objects.map((obj) => convertS3ObjectToStorageObject(obj))
}
generatePublicUrl(key: string): string {
// 如果设置了自定义域名,直接使用自定义域名
if (this.config.customDomain) {
const customDomain = this.config.customDomain.replace(/\/$/, '') // 移除末尾的斜杠
return `${customDomain}/${this.config.bucket}/${key}`
}
// 如果使用自定义端点,构建相应的 URL
const { endpoint } = this.config
if (!endpoint) {
// 默认 AWS S3 端点
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`
}
// 检查是否是标准 AWS S3 端点
if (endpoint.includes('amazonaws.com')) {
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`
}
// 对于自定义端点(如 MinIO 等)
const baseUrl = endpoint.replace(/\/$/, '') // 移除末尾的斜杠
return `${baseUrl}/${this.config.bucket}/${key}`
}
detectLivePhotos(allObjects: StorageObject[]): Map<string, StorageObject> {
const livePhotoMap = new Map<string, StorageObject>() // image key -> video object
// 按目录和基础文件名分组所有文件
const fileGroups = new Map<string, StorageObject[]>()
for (const obj of allObjects) {
if (!obj.key) continue
const dir = path.dirname(obj.key)
const basename = path.basename(obj.key, path.extname(obj.key))
const groupKey = `${dir}/${basename}`
if (!fileGroups.has(groupKey)) {
fileGroups.set(groupKey, [])
}
fileGroups.get(groupKey)!.push(obj)
}
// 在每个分组中寻找图片 + 视频配对
for (const files of fileGroups.values()) {
let imageFile: StorageObject | null = null
let videoFile: StorageObject | null = null
for (const file of files) {
if (!file.key) continue
const ext = path.extname(file.key).toLowerCase()
// 检查是否为支持的图片格式
if (SUPPORTED_FORMATS.has(ext)) {
imageFile = file
}
// 检查是否为 .mov 视频文件
else if (ext === '.mov') {
videoFile = file
}
}
// 如果找到配对,记录为 live photo
if (imageFile && videoFile && imageFile.key) {
livePhotoMap.set(imageFile.key, videoFile)
}
}
return livePhotoMap
}
}

View File

@@ -0,0 +1,48 @@
import type { Exif } from 'exif-reader'
export interface PhotoInfo {
title: string
dateTaken: string
views: number
tags: string[]
description: string
}
export interface ImageMetadata {
width: number
height: number
format: string
}
export interface PhotoManifestItem {
id: string
title: string
description: string
dateTaken: string
views: number
tags: string[]
originalUrl: string
thumbnailUrl: string | null
blurhash: string | null
width: number
height: number
aspectRatio: number
s3Key: string
lastModified: string
size: number
exif: Exif | null
isLivePhoto?: boolean
livePhotoVideoUrl?: string
livePhotoVideoS3Key?: string
}
export interface ProcessPhotoResult {
item: PhotoManifestItem | null
type: 'processed' | 'skipped' | 'new' | 'failed'
}
export interface ThumbnailResult {
thumbnailUrl: string | null
thumbnailBuffer: Buffer | null
blurhash: string | null
}

View File

@@ -0,0 +1,462 @@
/* eslint-disable unicorn/prefer-event-target */
import type { Worker } from 'node:cluster'
import cluster from 'node:cluster'
import { EventEmitter } from 'node:events'
import process from 'node:process'
import type { Logger } from '../logger/index.js'
export interface ClusterPoolOptions {
concurrency: number
totalTasks: number
workerEnv?: Record<string, string> // 传递给 worker 的环境变量
workerConcurrency?: number // 每个 worker 内部的并发数
}
export interface WorkerReadyMessage {
type: 'ready' | 'pong'
workerId: number
}
export interface TaskMessage {
type: 'task'
taskId: string
taskIndex: number
workerId: number
}
export interface BatchTaskMessage {
type: 'batch-task'
tasks: Array<{
taskId: string
taskIndex: number
}>
workerId: number
}
export interface TaskResult {
type: 'result' | 'error'
taskId: string
result?: any
error?: string
}
export interface BatchTaskResult {
type: 'batch-result'
results: TaskResult[]
}
export interface WorkerStats {
workerId: number
processedTasks: number
isIdle: boolean
isReady: boolean
}
// 基于 Node.js cluster 的 Worker 池管理器
export class ClusterPool<T> extends EventEmitter {
private concurrency: number
private totalTasks: number
private workerEnv: Record<string, string>
private workerConcurrency: number
private logger: Logger
private taskQueue: Array<{ taskIndex: number }> = []
private workers = new Map<number, Worker>()
private workerStats = new Map<number, WorkerStats>()
private pendingTasks = new Map<
string,
{ resolve: (value: T) => void; reject: (error: Error) => void }
>()
private results: T[] = []
private completedTasks = 0
private isShuttingDown = false
private readyWorkers = new Set<number>()
private workerTaskCounts = new Map<number, number>() // 追踪每个 worker 当前正在处理的任务数
constructor(options: ClusterPoolOptions, logger: Logger) {
super()
this.concurrency = options.concurrency
this.totalTasks = options.totalTasks
this.workerEnv = options.workerEnv || {}
this.workerConcurrency = options.workerConcurrency || 5 // 默认每个 worker 同时处理 5 个任务
this.logger = logger
this.results = Array.from({ length: this.totalTasks })
}
async execute(): Promise<T[]> {
this.logger.main.info(
`开始集群模式处理任务,进程数:${this.concurrency},总任务数:${this.totalTasks}`,
)
// 准备任务队列 - 只包含 taskIndex
for (let i = 0; i < this.totalTasks; i++) {
this.taskQueue.push({
taskIndex: i,
})
}
// 启动 worker 进程
await this.startWorkers()
// 等待所有 worker 准备好
await this.waitForWorkersReady()
// 等待所有任务完成
return new Promise((resolve, reject) => {
this.on('allTasksCompleted', () => {
this.logger.main.success(`所有任务完成,开始关闭进程池`)
this.shutdown()
.then(() => {
resolve(this.results)
})
.catch(reject)
})
this.on('error', reject)
// 开始分发任务
this.distributeInitialTasks()
})
}
private async startWorkers(): Promise<void> {
// 设置 cluster 环境变量以启用 worker 模式
cluster.setupPrimary({
exec: process.argv[1], // 使用当前脚本 (CLI) 作为 worker
args: ['--cluster-worker'], // 传递 worker 标识参数
silent: false,
})
// 根据任务数量和每个 worker 的并发能力决定启动多少个 worker
// 需要的 worker 数 = Math.ceil(总任务数 / 每个 worker 并发数)
// 但不能超过 concurrency 限制
const requiredWorkers = Math.ceil(this.totalTasks / this.workerConcurrency)
const workersToStart = Math.min(this.concurrency, requiredWorkers)
this.logger.main.info(
`计算 worker 数量:总任务 ${this.totalTasks},每个 worker 并发 ${this.workerConcurrency},需要 ${requiredWorkers} 个,实际启动 ${workersToStart}`,
)
for (let i = 1; i <= workersToStart; i++) {
await this.createWorker(i)
}
}
private async createWorker(workerId: number): Promise<void> {
return new Promise((resolve, reject) => {
const worker = cluster.fork({
WORKER_ID: workerId.toString(),
CLUSTER_WORKER: 'true',
WORKER_CONCURRENCY: this.workerConcurrency.toString(),
...this.workerEnv, // 传递自定义环境变量
})
this.workers.set(workerId, worker)
this.workerStats.set(workerId, {
workerId,
processedTasks: 0,
isIdle: true,
isReady: false,
})
this.workerTaskCounts.set(workerId, 0) // 初始化任务计数
const workerLogger = this.logger.worker(workerId)
worker.on('online', () => {
workerLogger.start(
`Worker ${workerId} 进程启动 (PID: ${worker.process?.pid})`,
)
resolve()
})
worker.on(
'message',
(message: TaskResult | BatchTaskResult | WorkerReadyMessage) => {
if (message.type === 'ready' || message.type === 'pong') {
this.handleWorkerReady(workerId, message as WorkerReadyMessage)
} else if (message.type === 'batch-result') {
this.handleWorkerBatchResult(workerId, message as BatchTaskResult)
} else {
this.handleWorkerMessage(workerId, message as TaskResult)
}
},
)
worker.on('error', (error) => {
workerLogger.error(`Worker ${workerId} 进程错误:`, error)
this.handleWorkerError(workerId, error)
})
worker.on('exit', (code, signal) => {
if (!this.isShuttingDown) {
workerLogger.error(
`Worker ${workerId} 意外退出 (code: ${code}, signal: ${signal})`,
)
// 重启 worker
setTimeout(() => this.createWorker(workerId), 1000)
} else {
workerLogger.info(`Worker ${workerId} 正常退出`)
}
})
// 设置超时
setTimeout(() => {
if (!worker.isDead()) {
reject(new Error(`Worker ${workerId} 启动超时`))
}
}, 10000)
})
}
private handleWorkerReady(
workerId: number,
_message: WorkerReadyMessage,
): void {
const stats = this.workerStats.get(workerId)
const workerLogger = this.logger.worker(workerId)
if (stats) {
stats.isReady = true
this.readyWorkers.add(workerId)
workerLogger.info(`Worker ${workerId} 已准备就绪`)
this.emit('workerReady', workerId)
}
}
private distributeInitialTasks(): void {
// 为每个 worker 分配初始任务批次
for (const [workerId] of this.workers) {
this.assignBatchTasksToWorker(workerId)
}
}
private assignBatchTasksToWorker(workerId: number): void {
if (this.taskQueue.length === 0) return
const worker = this.workers.get(workerId)
const stats = this.workerStats.get(workerId)
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
if (!worker || !stats || !stats.isReady) return
// 如果当前 worker 的任务数已达到并发限制,则不分配新任务
if (currentTaskCount >= this.workerConcurrency) return
// 计算可以分配的任务数量
const availableSlots = this.workerConcurrency - currentTaskCount
const tasksToAssign = Math.min(availableSlots, this.taskQueue.length)
if (tasksToAssign === 0) return
// 分配一批任务
const tasks: Array<{ taskId: string; taskIndex: number }> = []
for (let i = 0; i < tasksToAssign; i++) {
const task = this.taskQueue.shift()
if (!task) break
const taskId = `${workerId}-${task.taskIndex}-${Date.now()}-${i}`
tasks.push({
taskId,
taskIndex: task.taskIndex,
})
// 设置待处理任务的 Promise
this.pendingTasks.set(taskId, {
resolve: (_value: T) => {
// Promise resolve callback
},
reject: (_error: Error) => {
// Promise reject callback
},
})
}
// 更新 worker 状态
this.workerTaskCounts.set(workerId, currentTaskCount + tasks.length)
stats.isIdle = tasks.length === 0
// 发送批量任务
const message: BatchTaskMessage = {
type: 'batch-task',
tasks,
workerId,
}
worker.send(message)
const workerLogger = this.logger.worker(workerId)
workerLogger.info(
`分配 ${tasks.length} 个任务 (当前处理中:${currentTaskCount + tasks.length}/${this.workerConcurrency})`,
)
}
private handleWorkerBatchResult(
workerId: number,
message: BatchTaskResult,
): void {
const stats = this.workerStats.get(workerId)
const workerLogger = this.logger.worker(workerId)
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
if (!stats) return
let completedInBatch = 0
let successfulInBatch = 0
// 处理批量结果中的每个任务
for (const taskResult of message.results) {
const pendingTask = this.pendingTasks.get(taskResult.taskId)
if (!pendingTask) {
workerLogger.warn(`收到未知任务结果:${taskResult.taskId}`)
continue
}
this.pendingTasks.delete(taskResult.taskId)
completedInBatch++
if (taskResult.type === 'result' && taskResult.result !== undefined) {
// 从 taskId 中提取 taskIndex
const taskIndex = Number.parseInt(taskResult.taskId.split('-')[1])
this.results[taskIndex] = taskResult.result
successfulInBatch++
this.completedTasks++
} else if (taskResult.type === 'error') {
workerLogger.error(
`任务执行失败:${taskResult.taskId}`,
taskResult.error,
)
pendingTask.reject(new Error(taskResult.error))
}
}
// 更新 worker 状态
const newTaskCount = Math.max(0, currentTaskCount - completedInBatch)
this.workerTaskCounts.set(workerId, newTaskCount)
stats.processedTasks += successfulInBatch
stats.isIdle = newTaskCount === 0
workerLogger.info(
`完成批量任务:${successfulInBatch}/${completedInBatch} 成功 (总完成:${this.completedTasks}/${this.totalTasks},当前处理中:${newTaskCount})`,
)
// 检查是否所有任务都已完成
if (this.completedTasks >= this.totalTasks) {
this.emit('allTasksCompleted')
return
}
// 为该 worker 分配下一批任务
this.assignBatchTasksToWorker(workerId)
}
private handleWorkerMessage(workerId: number, message: TaskResult): void {
const stats = this.workerStats.get(workerId)
const workerLogger = this.logger.worker(workerId)
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
if (!stats) return
const pendingTask = this.pendingTasks.get(message.taskId)
if (!pendingTask) {
workerLogger.warn(`收到未知任务结果:${message.taskId}`)
return
}
this.pendingTasks.delete(message.taskId)
// 更新任务计数
const newTaskCount = Math.max(0, currentTaskCount - 1)
this.workerTaskCounts.set(workerId, newTaskCount)
stats.isIdle = newTaskCount === 0
if (message.type === 'result' && message.result !== undefined) {
// 从 taskId 中提取 taskIndex
const taskIndex = Number.parseInt(message.taskId.split('-')[1])
this.results[taskIndex] = message.result
stats.processedTasks++
this.completedTasks++
workerLogger.info(
`完成任务 ${taskIndex + 1}/${this.totalTasks} (已完成:${this.completedTasks},当前处理中:${newTaskCount})`,
)
// 检查是否所有任务都已完成
if (this.completedTasks >= this.totalTasks) {
this.emit('allTasksCompleted')
return
}
} else if (message.type === 'error') {
workerLogger.error(`任务执行失败:${message.taskId}`, message.error)
pendingTask.reject(new Error(message.error))
}
// 为该 worker 分配下一批任务
this.assignBatchTasksToWorker(workerId)
}
private handleWorkerError(workerId: number, error: Error): void {
const stats = this.workerStats.get(workerId)
if (stats) {
stats.isIdle = true
}
this.emit('error', error)
}
private async shutdown(): Promise<void> {
this.isShuttingDown = true
const shutdownPromises: Promise<void>[] = []
for (const [, worker] of this.workers) {
shutdownPromises.push(
new Promise((resolve) => {
const timeout = setTimeout(() => {
worker.kill('SIGKILL')
resolve()
}, 0)
worker.on('exit', () => {
clearTimeout(timeout)
resolve()
})
// 发送关闭信号
worker.send({ type: 'shutdown' })
}),
)
}
await Promise.all(shutdownPromises)
this.workers.clear()
this.workerStats.clear()
}
private async waitForWorkersReady(): Promise<void> {
return new Promise((resolve) => {
const requiredWorkers = Math.ceil(
this.totalTasks / this.workerConcurrency,
)
const expectedWorkers = Math.min(this.concurrency, requiredWorkers)
const checkReady = () => {
if (this.readyWorkers.size >= expectedWorkers) {
this.logger.main.info(
`所有 ${expectedWorkers} 个 worker 进程已准备就绪`,
)
resolve()
}
}
this.on('workerReady', checkReady)
checkReady() // 立即检查一次
})
}
// 获取 worker 统计信息
getWorkerStats(): WorkerStats[] {
return Array.from(this.workerStats.values())
}
}

View File

@@ -0,0 +1,71 @@
import type { Logger } from '../logger/index.js'
export interface WorkerPoolOptions {
concurrency: number
totalTasks: number
}
export type TaskFunction<T> = (
taskIndex: number,
workerId: number,
) => Promise<T>
// Worker 池管理器
export class WorkerPool<T> {
private concurrency: number
private totalTasks: number
private taskIndex = 0
private logger: Logger
constructor(options: WorkerPoolOptions, logger: Logger) {
this.concurrency = options.concurrency
this.totalTasks = options.totalTasks
this.logger = logger
}
async execute(taskFunction: TaskFunction<T>): Promise<T[]> {
const results: T[] = Array.from({ length: this.totalTasks })
this.logger.main.info(
`开始并发处理任务,工作池模式,并发数:${this.concurrency}`,
)
// Worker 函数
const worker = async (workerId: number): Promise<void> => {
const workerLogger = this.logger.worker(workerId)
workerLogger.start(`Worker ${workerId} 启动`)
let processedByWorker = 0
while (this.taskIndex < this.totalTasks) {
const currentIndex = this.taskIndex++
if (currentIndex >= this.totalTasks) break
workerLogger.info(`开始处理任务 ${currentIndex + 1}/${this.totalTasks}`)
const startTime = Date.now()
const result = await taskFunction(currentIndex, workerId)
const duration = Date.now() - startTime
results[currentIndex] = result
processedByWorker++
workerLogger.info(
`完成任务 ${currentIndex + 1}/${this.totalTasks} - ${duration}ms`,
)
}
workerLogger.success(
`Worker ${workerId} 完成,处理了 ${processedByWorker} 个任务`,
)
}
// 启动工作池
const workers = Array.from({ length: this.concurrency }, (_, i) =>
worker(i + 1),
)
await Promise.all(workers)
return results
}
}

View File

@@ -0,0 +1,37 @@
import type { PhotoManifest } from '~/types/photo'
import PhotosManifest from './photos-manifest.json'
class PhotoLoader {
private photos: PhotoManifest[] = []
private photoMap: Record<string, PhotoManifest> = {}
constructor() {
this.getAllTags = this.getAllTags.bind(this)
this.getPhotos = this.getPhotos.bind(this)
this.getPhoto = this.getPhoto.bind(this)
this.photos = PhotosManifest as unknown as PhotoManifest[]
this.photos.forEach((photo) => {
this.photoMap[photo.id] = photo
})
}
getPhotos() {
return this.photos
}
getPhoto(id: string) {
return this.photoMap[id]
}
getAllTags() {
const tagSet = new Set<string>()
this.photos.forEach((photo) => {
photo.tags.forEach((tag) => tagSet.add(tag))
})
return Array.from(tagSet).sort()
}
}
export const photoLoader = new PhotoLoader()

View File

@@ -0,0 +1 @@
export { domMax as default } from 'motion/react'

55
apps/web/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
import type { FC, PropsWithChildren } from 'react'
declare global {
export type Nullable<T> = T | null | undefined
type IsLiteralString<T> = T extends string
? string extends T
? never
: T
: never
type OmitStringType<T> = T extends any[]
? OmitStringType<T[number]>
: IsLiteralString<T>
type NonUndefined<T> = T extends undefined
? never
: T extends object
? { [K in keyof T]: NonUndefined<T[K]> }
: T
type NilValue = null | undefined | false | ''
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
const APP_NAME: string
const BUILT_DATE: string
}
export {}
declare global {
export type Component<P = object> = FC<Prettify<ComponentType & P>>
export type ComponentWithRef<P = object, Ref = object> = FC<
ComponentWithRefType<P, Ref>
>
export type ComponentWithRefType<P = object, Ref = object> = Prettify<
ComponentType<P> & {
ref?: React.Ref<Ref>
}
>
export type ComponentType<P = object> = {
className?: string
} & PropsWithChildren &
P
}
declare module 'react' {
export interface AriaAttributes {
'data-testid'?: string
'data-hide-in-print'?: boolean
}
}

View File

@@ -0,0 +1,4 @@
export * from './useDark'
export * from './usePrevious'
export * from './useRefValue'
export * from './useTitle'

View File

@@ -0,0 +1,73 @@
import { useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import { useCallback, useLayoutEffect } from 'react'
import { useMediaQuery } from 'usehooks-ts'
import { nextFrame } from '~/lib/dom'
import { jotaiStore } from '~/lib/jotai'
const useDarkQuery = () => useMediaQuery('(prefers-color-scheme: dark)')
type ColorMode = 'light' | 'dark' | 'system'
const themeAtom = atomWithStorage(
'color-mode',
'system' as ColorMode,
undefined,
{
getOnInit: true,
},
)
function useDarkWebApp() {
const systemIsDark = useDarkQuery()
const mode = useAtomValue(themeAtom)
return mode === 'dark' || (mode === 'system' && systemIsDark)
}
export const useIsDark = useDarkWebApp
export const useThemeAtomValue = () => useAtomValue(themeAtom)
const useSyncThemeWebApp = () => {
const colorMode = useAtomValue(themeAtom)
const systemIsDark = useDarkQuery()
useLayoutEffect(() => {
const realColorMode: Exclude<ColorMode, 'system'> =
colorMode === 'system' ? (systemIsDark ? 'dark' : 'light') : colorMode
document.documentElement.dataset.theme = realColorMode
disableTransition(['[role=switch]>*'])()
}, [colorMode, systemIsDark])
}
export const useSyncThemeark = useSyncThemeWebApp
export const useSetTheme = () =>
useCallback((colorMode: ColorMode) => {
jotaiStore.set(themeAtom, colorMode)
}, [])
function disableTransition(disableTransitionExclude: string[] = []) {
const css = document.createElement('style')
css.append(
document.createTextNode(
`
*${disableTransitionExclude.map((s) => `:not(${s})`).join('')} {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
)
document.head.append(css)
return () => {
// Force restyle
;(() => window.getComputedStyle(document.body))()
// Wait for next tick before removing
nextFrame(() => {
css.remove()
})
}
}

View File

@@ -0,0 +1,98 @@
import type { CompositionEventHandler } from 'react'
import { useCallback, useEffect, useRef } from 'react'
type InputElementAttributes = React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>
type TextareaElementAttributes = React.DetailedHTMLProps<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>
export const useInputComposition = <E = HTMLInputElement>(
props: Pick<
E extends HTMLInputElement
? InputElementAttributes
: E extends HTMLTextAreaElement
? TextareaElementAttributes
: never,
'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart' | 'onKeyDownCapture'
>,
) => {
const { onKeyDown, onCompositionStart, onCompositionEnd } = props
const isCompositionRef = useRef(false)
const currentInputTargetRef = useRef<E | null>(null)
const handleCompositionStart: CompositionEventHandler<E> = useCallback(
(e) => {
currentInputTargetRef.current = e.target as E
isCompositionRef.current = true
onCompositionStart?.(e as any)
},
[onCompositionStart],
)
const handleCompositionEnd: CompositionEventHandler<E> = useCallback(
(e) => {
currentInputTargetRef.current = null
isCompositionRef.current = false
onCompositionEnd?.(e as any)
},
[onCompositionEnd],
)
const handleKeyDown: React.KeyboardEventHandler<E> = useCallback(
(e: any) => {
// The keydown event stop emit when the composition is being entered
if (isCompositionRef.current) {
e.stopPropagation()
return
}
onKeyDown?.(e)
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
if (!isCompositionRef.current) {
e.currentTarget.blur()
}
}
},
[onKeyDown],
)
// Register a global capture keydown listener to prevent the radix `useEscapeKeydown` from working
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && currentInputTargetRef.current) {
e.stopPropagation()
e.preventDefault()
}
}
document.addEventListener('keydown', handleGlobalKeyDown, { capture: true })
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, {
capture: true,
})
}
}, [])
const ret = {
onCompositionEnd: handleCompositionEnd,
onCompositionStart: handleCompositionStart,
onKeyDown: handleKeyDown,
}
Object.defineProperty(ret, 'isCompositionRef', {
value: isCompositionRef,
enumerable: false,
})
return ret as typeof ret & {
isCompositionRef: typeof isCompositionRef
}
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
export const useIsOnline = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

View File

@@ -0,0 +1,9 @@
import { useEffect, useRef } from 'react'
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>(void 0)
useEffect(() => {
ref.current = value
})
return ref.current
}

View File

@@ -0,0 +1,14 @@
import { useLayoutEffect, useRef } from 'react'
export const useRefValue = <S>(
value: S,
): Readonly<{
current: Readonly<S>
}> => {
const ref = useRef<S>(value)
useLayoutEffect(() => {
ref.current = value
})
return ref
}

View File

@@ -0,0 +1,14 @@
import { useEffect, useRef } from 'react'
const titleTemplate = `%s | ${APP_NAME}`
export const useTitle = (title?: Nullable<string>) => {
const currentTitleRef = useRef(document.title)
useEffect(() => {
if (!title) return
document.title = titleTemplate.replace('%s', title)
return () => {
document.title = currentTitleRef.current
}
}, [title])
}

View File

@@ -0,0 +1,10 @@
import { useViewport } from './useViewport'
export const useMobile = () => {
return useViewport((v) => v.w < 1024 && v.w !== 0)
}
export const isMobile = () => {
const w = window.innerWidth
return w < 1024 && w !== 0
}

Some files were not shown because too many files have changed in this diff Show More