mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat: add agents documentation for frontend and backend architecture
- Created AGENTS.md for the frontend detailing the glassmorphic depth design system, including design principles, color usage, component structure, and interactive elements. - Added AGENTS.md for the backend outlining the architecture using Hono, core technologies, modular structure, and frontend integration. - Enhanced photo asset service to handle video file uploads, including validation and processing of live photo pairs. - Implemented a confirmation modal for photo uploads in the dashboard, allowing users to review selected files before uploading. - Updated ESLint configuration to improve code quality and maintainability across the project. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
364
AGENTS.md
364
AGENTS.md
@@ -55,43 +55,57 @@ pnpm --filter web type-check
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Patterns & Application Structure
|
||||
The project employs a sophisticated, modular architecture that separates concerns across different applications and packages, enabling independent development, deployment, and scaling.
|
||||
|
||||
**Hybrid SPA + SSR Architecture**: The application uses a unique architecture where Next.js serves as both a hosting platform for the Vite-built SPA and a dynamic SEO/OG meta generator:
|
||||
### Core Components
|
||||
|
||||
- **Production**: Next.js serves the pre-built SPA static assets and provides dynamic routes for SEO
|
||||
- **Development**: Both servers run concurrently with the SSR app proxying to the SPA for seamless development
|
||||
The project is divided into four main applications:
|
||||
|
||||
**Key Design Patterns**:
|
||||
1. **`apps/web` - Standalone Frontend SPA**
|
||||
* **Description**: A pure client-side application built with React, Vite, and TypeScript. It can be deployed independently as a static website and is fully functional on its own.
|
||||
* **UI/Design**: Features a modern, interactive, and user-centric UI. It utilizes a "Glassmorphic Depth Design System" for components like modals, toasts, and floating panels, creating a sense of visual hierarchy through layered transparency and subtle color accents. The design is geared towards a rich end-user experience for photo browsing and visualization.
|
||||
* **Server Integration**: It can operate in two modes:
|
||||
* **Standalone**: Functions without a server, using a pre-built `photos-manifest.json` file.
|
||||
* **Server-Connected**: When a global variable like `window.__MANIFEST__` is detected, it unlocks enhanced features. This injection is handled by either `apps/ssr` (from a static file) or `be/apps/core` (from the database).
|
||||
|
||||
- **Adapter Pattern**: Builder system uses adapters for different storage providers (S3, GitHub)
|
||||
- **Factory Pattern**: Photo processing pipeline with configurable workers and storage adapters
|
||||
- **Observer Pattern**: Manifest changes trigger SSR meta tag updates for social sharing
|
||||
- **Singleton Pattern**: PhotoLoader class provides global access to manifest data
|
||||
2. **`apps/ssr` - Next.js Wrapper for SEO & Prerendering**
|
||||
* **Description**: A Next.js application that acts as a transparent proxy for the `apps/web` SPA. Its primary role is to enhance the frontend with server-side capabilities for performance and discoverability, rather than serving as a full-fledged backend. It injects the manifest from a static JSON file.
|
||||
* **Key Features**:
|
||||
* **OG (Open Graph) Rendering**: Dynamically generates social media preview cards for shared links.
|
||||
* **SEO Metadata Injection**: Injects dynamic SEO tags into the HTML for better search engine visibility.
|
||||
* **SSR for Shared Pages**: Server-renders specific pages to provide fast initial load times.
|
||||
|
||||
- **`be/apps/core`**: The complete backend server (Hono) for real-time data. For a detailed breakdown of its architecture, see `be/apps/core/AGENTS.md`.
|
||||
- **`be/apps/dashboard`**: The administration panel for the backend. See `be/apps/dashboard/AGENTS.md` for UI guidelines.
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
This is a pnpm workspace with multiple applications and packages:
|
||||
|
||||
- `apps/web/` - Main frontend React application (Vite + React 19 SPA)
|
||||
- `apps/ssr/` - Next.js 15 application serving as SPA host + dynamic SEO/OG generator
|
||||
- `packages/builder/` - Photo processing and manifest generation tool with adapter pattern
|
||||
- `packages/webgl-viewer/` - High-performance WebGL-based photo viewer component
|
||||
- `packages/data/` - Shared data access layer and PhotoLoader singleton
|
||||
- `packages/components/` - Reusable UI components across apps
|
||||
- `apps/web/` - Main frontend React application (Vite + React 19 SPA).
|
||||
- `apps/ssr/` - Next.js 15 application serving as an SPA host and dynamic SEO/OG generator.
|
||||
- `be/apps/core/` - The complete backend server (Hono) for real-time data.
|
||||
- `be/apps/dashboard/` - The administration panel for the backend.
|
||||
- `packages/builder/` - Photo processing and manifest generation tool.
|
||||
- `packages/webgl-viewer/` - High-performance WebGL-based photo viewer component.
|
||||
- `packages/data/` - Shared data access layer and PhotoLoader singleton.
|
||||
- `packages/components/` - Reusable UI components shared across apps.
|
||||
- `packages/ui/` - Core UI elements and design system components.
|
||||
- `packages/hooks/` - Shared React hooks.
|
||||
- `packages/utils/` - Utility functions.
|
||||
|
||||
### Next.js as SPA Host & SEO Provider
|
||||
|
||||
**Dual Server Architecture**:
|
||||
**Dual Server Architecture (for `apps/ssr`)**:
|
||||
|
||||
- **Development Mode**: `apps/ssr/src/app/[...all]/route.ts` catches all SPA routes and serves index.html with injected manifest data
|
||||
- **Production Mode**: Next.js serves pre-built Vite SPA assets while providing dynamic OG image generation
|
||||
- **Development Mode**: `apps/ssr/src/app/[...all]/route.ts` catches all SPA routes and serves `index.html` with injected manifest data from the static JSON file.
|
||||
- **Production Mode**: Next.js serves pre-built Vite SPA assets while providing dynamic OG image generation.
|
||||
|
||||
**Dynamic SEO Implementation**:
|
||||
|
||||
- `apps/ssr/src/index.html.ts` - Pre-compiled HTML template with manifest data injected as `window.__MANIFEST__`
|
||||
- Dynamic OG images generated per photo via Next.js API routes (`/og/[photoId]/route.ts`)
|
||||
- HTML meta tags dynamically replaced for social media sharing
|
||||
- `apps/ssr/src/index.html.ts` - Pre-compiled HTML template with manifest data injected as `window.__MANIFEST__`.
|
||||
- Dynamic OG images generated per photo via Next.js API routes (`/og/[photoId]/route.ts`).
|
||||
- HTML meta tags dynamically replaced for social media sharing.
|
||||
|
||||
### Configuration Architecture
|
||||
|
||||
@@ -99,9 +113,9 @@ This is a pnpm workspace with multiple applications and packages:
|
||||
|
||||
1. **Builder Config** (`builder.config.ts`) - **Infrastructure/Processing Layer**:
|
||||
|
||||
- Controls photo processing, storage connections, and build performance
|
||||
- Handles remote git repository sync for manifest/thumbnails
|
||||
- Configures multi-process/cluster processing for large photo sets
|
||||
- Controls photo processing, storage connections, and build performance.
|
||||
- Handles remote git repository sync for manifest/thumbnails.
|
||||
- Configures multi-process/cluster processing for large photo sets.
|
||||
|
||||
2. **Site Config** (`site.config.ts` + `config.json`) - **Presentation/Content Layer**:
|
||||
```typescript
|
||||
@@ -113,299 +127,91 @@ This is a pnpm workspace with multiple applications and packages:
|
||||
map: ["maplibre"] // Map provider configuration
|
||||
}
|
||||
```
|
||||
- Controls site branding, author info, social links
|
||||
- Merged with user `config.json` using es-toolkit/compat
|
||||
- Consumed by both SPA and SSR for consistent branding
|
||||
- Controls site branding, author info, social links.
|
||||
- Merged with user `config.json`.
|
||||
- Consumed by both SPA and SSR/Backend for consistent branding.
|
||||
|
||||
### Manifest Generation & Data Flow
|
||||
|
||||
**Builder Pipeline** (`packages/builder/src/cli.ts`):
|
||||
|
||||
1. **Storage Sync**: Downloads photos from S3/GitHub with incremental change detection
|
||||
2. **Format Processing**: HEIC→JPEG, TIFF→web formats, Live Photo detection
|
||||
3. **Multi-threaded Processing**: Configurable worker pools or cluster mode for performance
|
||||
4. **EXIF & Metadata Extraction**: Camera settings, GPS, Fujifilm recipes, tone analysis
|
||||
5. **Thumbnail Generation**: Multiple sizes with blurhash placeholders
|
||||
6. **Manifest Serialization**: Generates `photos-manifest.json` with full metadata
|
||||
7. **Remote Sync**: Pushes updates to git repository if configured
|
||||
1. **Storage Sync**: Downloads photos from S3/GitHub with incremental change detection.
|
||||
2. **Format Processing**: HEIC→JPEG, TIFF→web formats, Live Photo detection.
|
||||
3. **Multi-threaded Processing**: Configurable worker pools or cluster mode for performance.
|
||||
4. **EXIF & Metadata Extraction**: Camera settings, GPS, Fujifilm recipes, tone analysis.
|
||||
5. **Thumbnail Generation**: Multiple sizes with blurhash placeholders.
|
||||
6. **Manifest Serialization**: Generates `photos-manifest.json` with full metadata.
|
||||
7. **Remote Sync**: Pushes updates to a git repository if configured.
|
||||
|
||||
**SPA Data Consumption** (`packages/data/src/index.ts`):
|
||||
|
||||
```typescript
|
||||
class PhotoLoader {
|
||||
constructor() {
|
||||
this.photos = __MANIFEST__.data // Injected via window global
|
||||
this.cameras = __MANIFEST__.cameras
|
||||
this.lenses = __MANIFEST__.lenses
|
||||
this.photos = window.__MANIFEST__.data // Injected via global
|
||||
this.cameras = window.__MANIFEST__.cameras
|
||||
this.lenses = window.__MANIFEST__.lenses
|
||||
// Creates lookup maps and provides data access layer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Data Flow**:
|
||||
**Data Flow Scenarios**:
|
||||
|
||||
1. Builder generates manifest → `photos-manifest.json`
|
||||
2. SSR injects manifest into HTML → `window.__MANIFEST__`
|
||||
3. SPA PhotoLoader singleton consumes global data
|
||||
4. React components access photos via `photoLoader.getPhotos()`
|
||||
1. **Static/SSR Flow**:
|
||||
* Builder generates `photos-manifest.json`.
|
||||
* `apps/ssr` reads the JSON and injects it into the HTML as `window.__MANIFEST__`.
|
||||
* SPA's `PhotoLoader` consumes the global data.
|
||||
2. **Full Backend Flow**:
|
||||
* `be/apps/core` fetches data from the database.
|
||||
* It generates the manifest object in memory.
|
||||
* It injects the manifest into the HTML as `window.__MANIFEST__` before serving the page.
|
||||
* SPA's `PhotoLoader` consumes the global data, unaware of the source.
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS, Jotai (state), TanStack Query
|
||||
- **Backend**: Next.js 15 (SPA host + SEO), Drizzle ORM, PostgreSQL
|
||||
- **SSR Layer**: Next.js 15
|
||||
- **Backend**: Hono, Drizzle ORM, PostgreSQL, tsyringe (for DI)
|
||||
- **Image Processing**: Sharp, exiftool-vendored, HEIC conversion, blurhash generation
|
||||
- **Storage**: S3-compatible (AWS/MinIO), GitHub repository storage
|
||||
- **Build System**: pnpm workspaces, concurrent dev servers, cluster-based processing
|
||||
|
||||
### Development Workflow
|
||||
|
||||
- **Concurrent Development**: `pnpm dev` runs both SPA (Vite) and SSR (Next.js) servers
|
||||
- **Hot Reloading**: SPA changes reflect immediately, SSR provides SEO preview
|
||||
- **Manifest Building**: `pnpm run build:manifest` processes photos and updates data
|
||||
- **Type Safety**: Shared types between builder, SPA, and SSR ensure consistency
|
||||
- **Page Structure**: Keep files under `pages/` as thin routing shells; move reusable UI/logic into `modules/<domain>/**` (e.g., dashboard overview lives in `modules/dashboard/components`).
|
||||
- **Concurrent Development**: `pnpm dev` runs both SPA (Vite) and SSR (Next.js) servers. Use `pnpm --filter @afilmory/core dev` to run the full backend.
|
||||
- **Hot Reloading**: SPA changes reflect immediately.
|
||||
- **Manifest Building**: `pnpm run build:manifest` processes photos and updates the static `photos-manifest.json`.
|
||||
- **Type Safety**: Shared types between builder, SPA, and servers ensure consistency.
|
||||
- **Page Structure**: Keep files under `pages/` as thin routing shells; move reusable UI/logic into `modules/<domain>/**`.
|
||||
|
||||
### Code Quality Rules
|
||||
|
||||
1. Avoid code duplication - extract common types and components
|
||||
2. Keep components focused - use hooks and component composition
|
||||
3. Follow React best practices - proper Context usage, state management
|
||||
4. Use TypeScript strictly - leverage type safety throughout
|
||||
1. Avoid code duplication - extract common types and components.
|
||||
2. Keep components focused - use hooks and component composition.
|
||||
3. Follow React best practices - proper Context usage, state management.
|
||||
4. Use TypeScript strictly - leverage type safety throughout.
|
||||
|
||||
### i18n Guidelines
|
||||
|
||||
- Use flat keys with `.` separation (e.g., `exif.camera.model`)
|
||||
- Support pluralization with `_one` and `_other` suffixes
|
||||
- Modify English first, then other languages (ESLint auto-removes unused keys)
|
||||
- **CRITICAL: Avoid nested key conflicts in flat structure**
|
||||
- Use flat keys with `.` separation (e.g., `exif.camera.model`).
|
||||
- Support pluralization with `_one` and `_other` suffixes.
|
||||
- Modify English first, then other languages (ESLint auto-removes unused keys).
|
||||
- **CRITICAL: Avoid nested key conflicts in flat structure.**
|
||||
- ❌ WRONG: `"action.tag.mode.and": "AND"` + `"action.tag.mode.and.tooltip": "..."`
|
||||
- ✅ CORRECT: `"action.tag.mode.and": "AND"` + `"action.tag.tooltip.and": "..."`
|
||||
- Rule: A key cannot be both a string value AND a parent object
|
||||
- Each key must be completely independent in the flat structure
|
||||
- Rule: A key cannot be both a string value AND a parent object.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- Check README.md and package.json scripts for test commands
|
||||
- Verify builds work with `pnpm build`
|
||||
- Test photo processing with `pnpm run build:manifest`
|
||||
- Validate types with `pnpm --filter web type-check`
|
||||
- Check `README.md` and `package.json` scripts for test commands.
|
||||
- Verify builds work with `pnpm build`.
|
||||
- Test photo processing with `pnpm run build:manifest`.
|
||||
- Validate types with `pnpm --filter web type-check`.
|
||||
|
||||
## Cursor Rules Integration
|
||||
## Design System
|
||||
|
||||
### Code Quality Standards
|
||||
This project contains multiple web applications with distinct design systems. For specific UI and design guidelines, please refer to the `AGENTS.md` file within each application's directory:
|
||||
|
||||
- Avoid code duplication - extract common types and components when used multiple times
|
||||
- Keep components focused - use hooks and component splitting for large logic blocks
|
||||
- Master React philosophy - proper Context usage, component composition, state management to prevent re-renders
|
||||
|
||||
## Glassmorphic Depth Design System
|
||||
|
||||
Follow uses a sophisticated glassmorphic depth design system for elevated UI components (modals, toasts, floating panels, etc.). This design provides visual hierarchy through layered transparency and subtle color accents.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Multi-layer Depth**: Create visual depth through stacked transparent layers
|
||||
- **Subtle Color Accents**: Use brand colors at very low opacity (5-20%) for borders, glows, and backgrounds
|
||||
- **Refined Blur**: Heavy backdrop blur (backdrop-blur-2xl) for frosted glass effect
|
||||
- **Minimal Shadows**: Combine multiple soft shadows with accent colors for depth perception
|
||||
- **Smooth Animations**: Use Spring presets for all transitions
|
||||
|
||||
### Color Usage
|
||||
|
||||
**IMPORTANT**: Tailwind CSS 4 uses `color-mix()` by default for `/opacity` syntax. **Always prefer Tailwind classes over inline styles.**
|
||||
|
||||
#### Tailwind CSS Classes (Preferred)
|
||||
- **Borders**: `border-accent/20` instead of `borderColor: 'color-mix(...)'`
|
||||
- **Backgrounds**: `bg-accent/5`, `bg-accent/[0.03]` (use bracket notation for custom percentages)
|
||||
- **Text Colors**: `text-accent/80`
|
||||
- **Any solid color with opacity**: Use Tailwind `/opacity` syntax
|
||||
|
||||
#### When to Use Inline Styles with `color-mix()`
|
||||
Only use explicit `color-mix(in srgb, var(--color-accent) X%, transparent)` for:
|
||||
1. **Gradients**: `linear-gradient`, `radial-gradient` (Tailwind doesn't support color-mix in gradients)
|
||||
2. **Box Shadows**: Tailwind doesn't support color-mix in shadows yet
|
||||
3. **Complex multi-color blending**: When you need more than simple opacity
|
||||
|
||||
#### Examples
|
||||
|
||||
✅ **CORRECT - Use Tailwind Classes**:
|
||||
```tsx
|
||||
<div className="border-accent/20 bg-accent/5">
|
||||
<div className="bg-accent/[0.03]"> {/* Custom percentage */}
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
❌ **WRONG - Unnecessary inline styles**:
|
||||
```tsx
|
||||
<div
|
||||
style={{
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
✅ **CORRECT - Inline styles for gradients/shadows**:
|
||||
```tsx
|
||||
<div
|
||||
className="border-accent/20"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
boxShadow: '0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
#### Color Usage Summary
|
||||
- **Borders**: `border-accent/20` (Tailwind class)
|
||||
- **Solid Backgrounds**: `bg-accent/5` or `bg-accent/[0.03]` (Tailwind class)
|
||||
- **Gradient Backgrounds**: Inline style with `linear-gradient` + `color-mix()`
|
||||
- **Shadows**: Inline style with `color-mix()`
|
||||
- **Inner Glow**: Inline style with gradient + `color-mix()`
|
||||
|
||||
### Component Structure
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="rounded-2xl border border-accent/20 backdrop-blur-2xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))",
|
||||
boxShadow:
|
||||
"0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
{/* Inner glow layer */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative">{/* Your content here */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
**IMPORTANT**: Prefer CSS-driven hover effects over JavaScript event handlers for better performance and cleaner code.
|
||||
|
||||
#### Radix UI Menu Items (using `data-highlighted`)
|
||||
|
||||
For Radix UI components that support `data-highlighted` attribute:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg transition-all duration-200 data-[highlighted]:text-accent"
|
||||
style={{
|
||||
// Use CSS custom properties for dynamic background on highlight
|
||||
['--highlight-bg' as any]: 'linear-gradient(to right, color-mix(in srgb, var(--color-accent) 8%, transparent), color-mix(in srgb, var(--color-accent) 5%, transparent))'
|
||||
}}
|
||||
onSelect={() => {}}
|
||||
>
|
||||
Menu Item
|
||||
</DropdownMenuItem>
|
||||
|
||||
// Add this CSS for highlighted state (in tailwind.css):
|
||||
// @layer components {
|
||||
// [data-highlighted] { background: var(--highlight-bg); }
|
||||
// }
|
||||
```
|
||||
|
||||
#### Custom Buttons (using CSS classes)
|
||||
|
||||
**✅ PREFERRED - CSS-driven hover**:
|
||||
```tsx
|
||||
<button className="glassmorphic-btn border-accent/20 text-text-secondary ...">
|
||||
Button Text
|
||||
</button>
|
||||
|
||||
// In tailwind.css:
|
||||
// @layer components {
|
||||
// .glassmorphic-btn:hover {
|
||||
// background: linear-gradient(to right,
|
||||
// color-mix(in srgb, var(--color-accent) 8%, transparent),
|
||||
// color-mix(in srgb, var(--color-accent) 5%, transparent)
|
||||
// ) !important;
|
||||
// color: var(--color-accent) !important;
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
**❌ AVOID - JavaScript event handlers** (use only when CSS cannot achieve the effect):
|
||||
```tsx
|
||||
<button
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "..."
|
||||
e.currentTarget.style.color = "var(--color-accent)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent"
|
||||
e.currentTarget.style.color = ""
|
||||
}}
|
||||
>
|
||||
Button Text
|
||||
</button>
|
||||
```
|
||||
|
||||
### Dividers
|
||||
|
||||
Use gradient dividers within glass containers:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="mx-4 h-px"
|
||||
style={{
|
||||
background: "linear-gradient(to right, transparent, color-mix(in srgb, var(--color-accent) 20%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Animation Guidelines
|
||||
|
||||
- Entry animations: `initial={{ y: 8, opacity: 0 }}` → `animate={{ y: 0, opacity: 1 }}`
|
||||
- Use `Spring.presets.snappy` for quick interactions
|
||||
- Use `Spring.presets.smooth` for larger movements
|
||||
- Keep scale animations subtle (1.0 ↔ 1.02)
|
||||
|
||||
### When to Use
|
||||
|
||||
Apply this design system to:
|
||||
|
||||
- Toast notifications
|
||||
- Modal dialogs
|
||||
- Floating panels and popovers
|
||||
- Ambient UI prompts
|
||||
- Contextual menus
|
||||
- Elevated cards with actions
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
### UI/UX Guidelines
|
||||
|
||||
- Follow Glassmorphic Depth Design System
|
||||
- Use Apple UIKit color system via tailwind-uikit-colors package
|
||||
- Prefer semantic color names: `text-primary`, `fill-secondary`, `material-thin`, etc.
|
||||
- Follow system colors: `red`, `blue`, `green`, `mint`, `teal`, `cyan`, `indigo`, `purple`, `pink`, `brown`, `gray`
|
||||
- Use material design principles with opacity-based fills and proper contrast
|
||||
|
||||
### i18n Development Rules
|
||||
|
||||
- Use flat keys with dot notation: `exif.camera.model`
|
||||
- Support pluralization: `_one` and `_other` suffixes
|
||||
- Always modify English (`en.json`) first, then other languages
|
||||
- Avoid key conflicts in flat structure (e.g., `exif.custom.rendered` vs `exif.custom.rendered.custom`)
|
||||
- ESLint automatically removes unused keys from non-English files
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This is a photo gallery application that processes and displays photos from cloud storage
|
||||
- The builder tool handles complex image processing workflows
|
||||
- WebGL viewer provides high-performance photo viewing experience
|
||||
- Map integration shows photo locations from GPS EXIF data
|
||||
- Live Photo support for iOS/Apple device videos
|
||||
- **`apps/web`**: Contains the "Glassmorphic Depth Design System" for the main user-facing photo gallery. See `apps/web/AGENTS.md` for details.
|
||||
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel. See `be/apps/dashboard/AGENTS.md` for details.
|
||||
43
apps/web/AGENTS.md
Normal file
43
apps/web/AGENTS.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# agents.md
|
||||
|
||||
This agent file only for under this folder project use.
|
||||
|
||||
## Glassmorphic Depth Design System
|
||||
|
||||
This application utilizes a sophisticated glassmorphic depth design system for elevated UI components like modals, toasts, and floating panels. This system creates a sense of visual hierarchy through layered transparency and subtle color accents.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Multi-layer Depth**: Create visual depth through stacked transparent layers.
|
||||
- **Subtle Color Accents**: Use brand colors at very low opacity (5-20%) for borders, glows, and backgrounds.
|
||||
- **Refined Blur**: Employ heavy backdrop blur (`backdrop-blur-2xl`) for a frosted glass effect.
|
||||
- **Minimal Shadows**: Combine multiple soft shadows with accent colors for depth perception.
|
||||
- **Smooth Animations**: Use spring-based presets for all transitions to ensure fluid motion.
|
||||
|
||||
### Color Usage
|
||||
|
||||
**IMPORTANT**: Prefer Tailwind CSS classes with opacity modifiers (e.g., `/20`) over inline styles for applying colors.
|
||||
|
||||
- **Borders**: `border-accent/20`
|
||||
- **Backgrounds**: `bg-accent/5`, `bg-accent/[0.03]`
|
||||
- **Text**: `text-accent/80`
|
||||
|
||||
Use inline styles with `color-mix()` only for complex gradients or shadows that Tailwind classes do not support.
|
||||
|
||||
### Component Structure Example
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="rounded-2xl border border-accent/20 backdrop-blur-2xl"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(...)",
|
||||
boxShadow: "0 8px 32px color-mix(...)",
|
||||
}}
|
||||
>
|
||||
{/* Inner glow and content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
Prefer CSS-driven hover effects using `data-highlighted` attributes (for Radix UI components) or custom CSS classes over JavaScript event handlers for better performance.
|
||||
31
be/apps/core/AGENTS.md
Normal file
31
be/apps/core/AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# agents.md
|
||||
|
||||
## `be/apps/core` - Backend Architecture
|
||||
|
||||
This document provides a detailed overview of the backend server's architecture, built with Hono.
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Web Framework**: **Hono** - A fast, lightweight, and flexible web framework for the edge.
|
||||
- **Dependency Injection**: **tsyringe** - A lightweight dependency injection container for TypeScript/JavaScript, ensuring services are decoupled and easily testable.
|
||||
- **ORM**: **Drizzle ORM** - A TypeScript ORM that provides type-safe database queries.
|
||||
- **Database**: **PostgreSQL** - The primary relational database.
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
The backend follows a modular, DI-driven architecture to ensure separation of concerns and maintainability.
|
||||
|
||||
- **Dependency Injection**: At the core, `tsyringe` manages the lifecycle and injection of services, controllers, and repositories. This makes the codebase loosely coupled. The main container is configured in `app.factory.ts`.
|
||||
- **Modular Structure**: The application logic is organized into feature-based modules under `src/modules`. Each module (e.g., `auth`, `photo`, `tenant`) encapsulates its own controllers, services, and data transfer objects (DTOs), making the system easy to extend.
|
||||
- **Request Lifecycle**: A request flows through Hono's middleware, is routed to a specific controller, which then utilizes injected services to perform business logic. Services may interact with the database via Drizzle ORM.
|
||||
|
||||
### Frontend Integration (`static-web` module)
|
||||
|
||||
A key responsibility of `be/apps/core` is to serve the `apps/web` SPA and enrich it with dynamic data.
|
||||
|
||||
- **Serving Static Files**: The `StaticWebModule` is responsible for serving the built assets of the `apps/web` application.
|
||||
- **Dynamic Data Injection**: Before sending the `index.html` file to the client, the `StaticWebService` intercepts the response and injects dynamic data into the HTML. It finds the `<script id="manifest">` and `<script id="config">` tags and populates them with live data from the database.
|
||||
- `window.__CONFIG__`: Injects server-side configuration.
|
||||
- `window.__MANIFEST__`: Injects the complete, real-time photo manifest, making the frontend fully dynamic.
|
||||
|
||||
This approach allows the frontend SPA to remain completely unaware of the data source (static file vs. live backend), as it always consumes the data from the same global variables.
|
||||
@@ -11,7 +11,7 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } f
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { PhotoBuilderService } from 'core/modules/photo/photo.service'
|
||||
import { requireTenantContext } from 'core/modules/tenant/tenant.context'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
@@ -49,6 +49,15 @@ export interface UploadAssetInput {
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mov', 'mp4'])
|
||||
|
||||
type PreparedUploadPlan = {
|
||||
original: UploadAssetInput
|
||||
storageKey: string
|
||||
baseName: string
|
||||
isVideo: boolean
|
||||
isExisting?: boolean
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class PhotoAssetService {
|
||||
@@ -149,6 +158,7 @@ export class PhotoAssetService {
|
||||
const storageManager = this.createStorageManager(builderConfig, storageConfig)
|
||||
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
|
||||
const deletedThumbnailKeys = new Set<string>()
|
||||
const deletedVideoKeys = new Set<string>()
|
||||
|
||||
for (const record of records) {
|
||||
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
|
||||
@@ -160,6 +170,19 @@ export class PhotoAssetService {
|
||||
})
|
||||
}
|
||||
|
||||
for (const videoKey of this.deriveVideoStorageKeys(record.storageKey)) {
|
||||
if (!videoKey || videoKey === record.storageKey || deletedVideoKeys.has(videoKey)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await storageManager.deleteFile(videoKey)
|
||||
deletedVideoKeys.add(videoKey)
|
||||
} catch {
|
||||
// 忽略缺失的 Live Photo 视频文件
|
||||
deletedVideoKeys.add(videoKey)
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailKey = this.resolveThumbnailStorageKey(record, thumbnailRemotePrefix)
|
||||
if (thumbnailKey && !deletedThumbnailKeys.has(thumbnailKey)) {
|
||||
try {
|
||||
@@ -190,14 +213,345 @@ export class PhotoAssetService {
|
||||
this.photoStorageService.registerStorageProviderPlugin(builder, storageConfig)
|
||||
this.photoBuilderService.applyStorageConfig(builder, storageConfig)
|
||||
const storageManager = builder.getStorageManager()
|
||||
const { photoPlans, videoPlans } = this.prepareUploadPlans(inputs, storageConfig)
|
||||
const unmatchedVideoBaseNames = this.validateLivePhotoPairs(photoPlans, videoPlans)
|
||||
|
||||
const {
|
||||
items: existingItemsRaw,
|
||||
keySet: existingPhotoKeySet,
|
||||
baseNameMap: existingBaseNameMap,
|
||||
} = await this.collectExistingPhotoRecords(photoPlans, videoPlans, tenant.tenant.id, storageManager, db)
|
||||
|
||||
const pendingPhotoPlans = photoPlans.filter((plan) => !existingPhotoKeySet.has(plan.storageKey))
|
||||
|
||||
const additionalPhotoPlans = this.createExistingPhotoPlansForVideos(unmatchedVideoBaseNames, existingBaseNameMap)
|
||||
|
||||
const unresolvedVideoFiles = videoPlans
|
||||
.filter((plan) => unmatchedVideoBaseNames.has(plan.baseName) && !existingBaseNameMap.has(plan.baseName))
|
||||
.map((plan) => plan.original.filename)
|
||||
|
||||
if (unresolvedVideoFiles.length > 0) {
|
||||
const filenames = unresolvedVideoFiles.join(', ')
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: `检测到无对应图片的 MOV 文件:${filenames}`,
|
||||
})
|
||||
}
|
||||
|
||||
const allPendingPhotoPlans = [...pendingPhotoPlans, ...additionalPhotoPlans]
|
||||
if (allPendingPhotoPlans.length === 0) {
|
||||
return existingItemsRaw
|
||||
}
|
||||
|
||||
const reprocessedKeys = new Set(allPendingPhotoPlans.map((plan) => plan.storageKey))
|
||||
const existingItems = existingItemsRaw.filter((item) => !reprocessedKeys.has(item.storageKey))
|
||||
|
||||
const activeVideoPlans = this.selectActiveVideoPlans(allPendingPhotoPlans, videoPlans)
|
||||
const existingStorageMap = await this.buildExistingStorageMap(
|
||||
allPendingPhotoPlans,
|
||||
activeVideoPlans,
|
||||
storageManager,
|
||||
)
|
||||
const videoObjectsByBaseName = await this.prepareVideoObjects(activeVideoPlans, storageManager, existingStorageMap)
|
||||
|
||||
const processedItems = await this.processPendingPhotos({
|
||||
pendingPhotoPlans: allPendingPhotoPlans,
|
||||
videoObjectsByBaseName,
|
||||
builder,
|
||||
builderConfig,
|
||||
storageManager,
|
||||
storageConfig,
|
||||
tenantId: tenant.tenant.id,
|
||||
db,
|
||||
existingStorageMap,
|
||||
})
|
||||
|
||||
return [...existingItems, ...processedItems]
|
||||
}
|
||||
|
||||
private prepareUploadPlans(
|
||||
inputs: readonly UploadAssetInput[],
|
||||
storageConfig: StorageConfig,
|
||||
): { photoPlans: PreparedUploadPlan[]; videoPlans: PreparedUploadPlan[] } {
|
||||
const seenStorageKeys = new Set<string>()
|
||||
const plans: PreparedUploadPlan[] = []
|
||||
|
||||
for (const input of inputs) {
|
||||
const storageKey = this.createStorageKey(input, storageConfig)
|
||||
if (seenStorageKeys.has(storageKey)) {
|
||||
continue
|
||||
}
|
||||
seenStorageKeys.add(storageKey)
|
||||
plans.push({
|
||||
original: input,
|
||||
storageKey,
|
||||
baseName: this.normalizeBaseName(storageKey),
|
||||
isVideo: this.isVideoAsset(input),
|
||||
isExisting: false,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
photoPlans: plans.filter((plan) => !plan.isVideo),
|
||||
videoPlans: plans.filter((plan) => plan.isVideo),
|
||||
}
|
||||
}
|
||||
|
||||
private validateLivePhotoPairs(photoPlans: PreparedUploadPlan[], videoPlans: PreparedUploadPlan[]): Set<string> {
|
||||
const unmatchedBaseNames = new Set<string>()
|
||||
|
||||
if (videoPlans.length === 0) {
|
||||
return unmatchedBaseNames
|
||||
}
|
||||
|
||||
const photoBaseNames = new Set(photoPlans.map((plan) => plan.baseName))
|
||||
for (const plan of videoPlans) {
|
||||
if (!photoBaseNames.has(plan.baseName)) {
|
||||
unmatchedBaseNames.add(plan.baseName)
|
||||
}
|
||||
}
|
||||
|
||||
return unmatchedBaseNames
|
||||
}
|
||||
|
||||
private async collectExistingPhotoRecords(
|
||||
photoPlans: PreparedUploadPlan[],
|
||||
videoPlans: PreparedUploadPlan[],
|
||||
tenantId: string,
|
||||
storageManager: StorageManager,
|
||||
db: ReturnType<DbAccessor['get']>,
|
||||
): Promise<{
|
||||
items: PhotoAssetListItem[]
|
||||
keySet: Set<string>
|
||||
baseNameMap: Map<string, typeof photoAssets.$inferSelect>
|
||||
}> {
|
||||
const recordMap = new Map<string, typeof photoAssets.$inferSelect>()
|
||||
const baseNameMap = new Map<string, typeof photoAssets.$inferSelect>()
|
||||
|
||||
const photoStorageKeys = photoPlans.map((plan) => plan.storageKey)
|
||||
if (photoStorageKeys.length > 0) {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(photoAssets)
|
||||
.where(and(eq(photoAssets.tenantId, tenantId), inArray(photoAssets.storageKey, photoStorageKeys)))
|
||||
|
||||
for (const record of records) {
|
||||
recordMap.set(record.storageKey, record)
|
||||
baseNameMap.set(this.normalizeBaseName(record.storageKey), record)
|
||||
}
|
||||
}
|
||||
|
||||
const videoBaseNames = new Set(videoPlans.map((plan) => plan.baseName))
|
||||
for (const baseName of videoBaseNames) {
|
||||
if (baseNameMap.has(baseName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pattern = `${baseName}.%`
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(photoAssets)
|
||||
.where(and(eq(photoAssets.tenantId, tenantId), sql`${photoAssets.storageKey} ILIKE ${pattern}`))
|
||||
.limit(1)
|
||||
|
||||
if (record) {
|
||||
recordMap.set(record.storageKey, record)
|
||||
baseNameMap.set(this.normalizeBaseName(record.storageKey), record)
|
||||
baseNameMap.set(baseName, record)
|
||||
}
|
||||
}
|
||||
|
||||
if (recordMap.size === 0) {
|
||||
return { items: [], keySet: new Set(), baseNameMap }
|
||||
}
|
||||
|
||||
const records = [...recordMap.values()]
|
||||
const items = await Promise.all(
|
||||
records.map(async (record) => {
|
||||
let publicUrl: string | null = null
|
||||
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
|
||||
try {
|
||||
publicUrl = await Promise.resolve(storageManager.generatePublicUrl(record.storageKey))
|
||||
} catch {
|
||||
publicUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
photoId: record.photoId,
|
||||
storageKey: record.storageKey,
|
||||
storageProvider: record.storageProvider,
|
||||
manifest: record.manifest,
|
||||
syncedAt: record.syncedAt,
|
||||
updatedAt: record.updatedAt,
|
||||
createdAt: record.createdAt,
|
||||
publicUrl,
|
||||
size: record.size ?? null,
|
||||
syncStatus: record.syncStatus,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return { items, keySet: new Set(recordMap.keys()), baseNameMap }
|
||||
}
|
||||
|
||||
private createExistingPhotoPlansForVideos(
|
||||
unmatchedVideoBaseNames: Set<string>,
|
||||
existingBaseNameMap: Map<string, typeof photoAssets.$inferSelect>,
|
||||
): PreparedUploadPlan[] {
|
||||
const plans: PreparedUploadPlan[] = []
|
||||
|
||||
for (const baseName of unmatchedVideoBaseNames) {
|
||||
const record = existingBaseNameMap.get(baseName)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
plans.push({
|
||||
original: {
|
||||
filename: path.basename(record.storageKey),
|
||||
buffer: Buffer.alloc(0),
|
||||
contentType: undefined,
|
||||
},
|
||||
storageKey: record.storageKey,
|
||||
baseName,
|
||||
isVideo: false,
|
||||
isExisting: true,
|
||||
})
|
||||
}
|
||||
|
||||
return plans
|
||||
}
|
||||
|
||||
private selectActiveVideoPlans(
|
||||
pendingPhotoPlans: PreparedUploadPlan[],
|
||||
videoPlans: PreparedUploadPlan[],
|
||||
): PreparedUploadPlan[] {
|
||||
if (pendingPhotoPlans.length === 0 || videoPlans.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pendingBaseNames = new Set(pendingPhotoPlans.map((plan) => plan.baseName))
|
||||
const seenVideoBaseNames = new Set<string>()
|
||||
const activePlans: PreparedUploadPlan[] = []
|
||||
|
||||
for (const plan of videoPlans) {
|
||||
if (!pendingBaseNames.has(plan.baseName)) {
|
||||
continue
|
||||
}
|
||||
if (seenVideoBaseNames.has(plan.baseName)) {
|
||||
continue
|
||||
}
|
||||
seenVideoBaseNames.add(plan.baseName)
|
||||
activePlans.push(plan)
|
||||
}
|
||||
|
||||
return activePlans
|
||||
}
|
||||
|
||||
private async buildExistingStorageMap(
|
||||
pendingPhotoPlans: PreparedUploadPlan[],
|
||||
activeVideoPlans: PreparedUploadPlan[],
|
||||
storageManager: StorageManager,
|
||||
): Promise<Map<string, StorageObject>> {
|
||||
const targetKeys = new Set<string>()
|
||||
for (const plan of pendingPhotoPlans) {
|
||||
targetKeys.add(plan.storageKey)
|
||||
}
|
||||
for (const plan of activeVideoPlans) {
|
||||
targetKeys.add(plan.storageKey)
|
||||
}
|
||||
|
||||
if (targetKeys.size === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const map = new Map<string, StorageObject>()
|
||||
const storageObjects = await storageManager.listAllFiles()
|
||||
for (const object of storageObjects) {
|
||||
if (!object.key) {
|
||||
continue
|
||||
}
|
||||
const normalizedKey = this.normalizeKeyPath(object.key)
|
||||
if (targetKeys.has(normalizedKey)) {
|
||||
map.set(normalizedKey, this.normalizeStorageObjectKey(object, normalizedKey))
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
private async prepareVideoObjects(
|
||||
activeVideoPlans: PreparedUploadPlan[],
|
||||
storageManager: StorageManager,
|
||||
existingStorageMap: Map<string, StorageObject>,
|
||||
): Promise<Map<string, StorageObject>> {
|
||||
const result = new Map<string, StorageObject>()
|
||||
|
||||
for (const plan of activeVideoPlans) {
|
||||
let storageObject = existingStorageMap.get(plan.storageKey)
|
||||
if (!storageObject) {
|
||||
storageObject = this.normalizeStorageObjectKey(
|
||||
await storageManager.uploadFile(plan.storageKey, plan.original.buffer, {
|
||||
contentType: plan.original.contentType,
|
||||
}),
|
||||
plan.storageKey,
|
||||
)
|
||||
existingStorageMap.set(plan.storageKey, storageObject)
|
||||
}
|
||||
|
||||
result.set(plan.baseName, storageObject)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async processPendingPhotos(params: {
|
||||
pendingPhotoPlans: PreparedUploadPlan[]
|
||||
videoObjectsByBaseName: Map<string, StorageObject>
|
||||
builder: ReturnType<PhotoBuilderService['createBuilder']>
|
||||
builderConfig: BuilderConfig
|
||||
storageManager: StorageManager
|
||||
storageConfig: StorageConfig
|
||||
tenantId: string
|
||||
db: ReturnType<DbAccessor['get']>
|
||||
existingStorageMap: Map<string, StorageObject>
|
||||
}): Promise<PhotoAssetListItem[]> {
|
||||
const {
|
||||
pendingPhotoPlans,
|
||||
videoObjectsByBaseName,
|
||||
builder,
|
||||
builderConfig,
|
||||
storageManager,
|
||||
storageConfig,
|
||||
tenantId,
|
||||
db,
|
||||
existingStorageMap,
|
||||
} = params
|
||||
|
||||
const results: PhotoAssetListItem[] = []
|
||||
|
||||
for (const input of inputs) {
|
||||
const key = this.createStorageKey(input, storageConfig)
|
||||
const storageObject = await storageManager.uploadFile(key, input.buffer, {
|
||||
contentType: input.contentType,
|
||||
})
|
||||
for (const plan of pendingPhotoPlans) {
|
||||
let storageObject = existingStorageMap.get(plan.storageKey)
|
||||
if (!storageObject) {
|
||||
if (plan.isExisting) {
|
||||
throw new BizException(ErrorCode.IMAGE_PROCESSING_FAILED, {
|
||||
message: `无法在存储中找到现有图片文件 ${plan.storageKey}`,
|
||||
})
|
||||
}
|
||||
|
||||
storageObject = this.normalizeStorageObjectKey(
|
||||
await storageManager.uploadFile(plan.storageKey, plan.original.buffer, {
|
||||
contentType: plan.original.contentType,
|
||||
}),
|
||||
plan.storageKey,
|
||||
)
|
||||
existingStorageMap.set(plan.storageKey, storageObject)
|
||||
}
|
||||
|
||||
const resolvedPhotoKey = storageObject.key ?? plan.storageKey
|
||||
const videoObject = videoObjectsByBaseName.get(plan.baseName)
|
||||
const livePhotoMap = videoObject ? new Map([[resolvedPhotoKey, videoObject]]) : undefined
|
||||
|
||||
const processed = await this.photoBuilderService.processPhotoFromStorageObject(storageObject, {
|
||||
builder,
|
||||
@@ -207,12 +561,13 @@ export class PhotoAssetService {
|
||||
isForceManifest: true,
|
||||
isForceThumbnails: true,
|
||||
},
|
||||
livePhotoMap,
|
||||
})
|
||||
|
||||
const item = processed?.item
|
||||
if (!item) {
|
||||
throw new BizException(ErrorCode.PHOTO_MANIFEST_GENERATION_FAILED, {
|
||||
message: `无法为文件 ${key} 生成照片清单`,
|
||||
message: `无法为文件 ${resolvedPhotoKey} 生成照片清单`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,9 +576,9 @@ export class PhotoAssetService {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const insertPayload: typeof photoAssets.$inferInsert = {
|
||||
tenantId: tenant.tenant.id,
|
||||
tenantId,
|
||||
photoId: item.id,
|
||||
storageKey: key,
|
||||
storageKey: resolvedPhotoKey,
|
||||
storageProvider: storageConfig.provider,
|
||||
size: snapshot.size ?? null,
|
||||
etag: snapshot.etag ?? null,
|
||||
@@ -268,11 +623,11 @@ export class PhotoAssetService {
|
||||
await db
|
||||
.select()
|
||||
.from(photoAssets)
|
||||
.where(and(eq(photoAssets.tenantId, tenant.tenant.id), eq(photoAssets.storageKey, key)))
|
||||
.where(and(eq(photoAssets.tenantId, tenantId), eq(photoAssets.storageKey, resolvedPhotoKey)))
|
||||
.limit(1)
|
||||
)[0]
|
||||
|
||||
const publicUrl = await Promise.resolve(storageManager.generatePublicUrl(key))
|
||||
const publicUrl = await Promise.resolve(storageManager.generatePublicUrl(resolvedPhotoKey))
|
||||
|
||||
results.push({
|
||||
id: saved.id,
|
||||
@@ -459,4 +814,43 @@ export class PhotoAssetService {
|
||||
|
||||
return filtered.join('/')
|
||||
}
|
||||
|
||||
private deriveVideoStorageKeys(storageKey: string): string[] {
|
||||
const ext = path.extname(storageKey)
|
||||
const base = ext ? storageKey.slice(0, -ext.length) : storageKey
|
||||
if (!base) {
|
||||
return []
|
||||
}
|
||||
|
||||
const variants = ['.mov', '.MOV', '.mp4', '.MP4']
|
||||
return variants.map((variant) => `${base}${variant}`)
|
||||
}
|
||||
|
||||
private normalizeStorageObjectKey(object: StorageObject, fallbackKey: string): StorageObject {
|
||||
const normalizedKey = this.normalizeKeyPath(object?.key ?? fallbackKey)
|
||||
if (object?.key === normalizedKey) {
|
||||
return object
|
||||
}
|
||||
|
||||
return {
|
||||
...object,
|
||||
key: normalizedKey,
|
||||
}
|
||||
}
|
||||
|
||||
private isVideoAsset(input: UploadAssetInput): boolean {
|
||||
const contentType = input.contentType?.toLowerCase() ?? ''
|
||||
if (contentType.startsWith('video/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ext = path.extname(input.filename).replace('.', '').toLowerCase()
|
||||
return VIDEO_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
private normalizeBaseName(value: string): string {
|
||||
const ext = path.extname(value)
|
||||
const base = path.basename(value, ext)
|
||||
return base.trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import { Button, Modal } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import type { ChangeEventHandler } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal'
|
||||
|
||||
type PhotoLibraryActionBarProps = {
|
||||
selectionCount: number
|
||||
isUploading: boolean
|
||||
@@ -30,7 +32,16 @@ export function PhotoLibraryActionBar({
|
||||
const handleFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const { files } = event.currentTarget
|
||||
if (!files || files.length === 0) return
|
||||
void onUpload(files)
|
||||
|
||||
const selectedFiles = Array.from(files)
|
||||
|
||||
Modal.present(PhotoUploadConfirmModal, {
|
||||
files: selectedFiles,
|
||||
onConfirm: (confirmedFiles) => {
|
||||
void onUpload(confirmedFiles)
|
||||
},
|
||||
})
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
@@ -38,7 +49,14 @@ export function PhotoLibraryActionBar({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<input ref={fileInputRef} type="file" className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,.heic,.HEIC,.heif,.HEIF,.hif,.HIF,.mov,.MOV"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -48,7 +66,7 @@ export function PhotoLibraryActionBar({
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
|
||||
上传图片
|
||||
上传文件
|
||||
</Button>
|
||||
|
||||
{selectionCount > 0 ? (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { ModalComponent } from '@afilmory/ui'
|
||||
import { Button, LinearDivider } from '@afilmory/ui'
|
||||
import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '未知大小'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const size = bytes / 1024 ** exponent
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[exponent]}`
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
'heic',
|
||||
'heif',
|
||||
'hif',
|
||||
'avif',
|
||||
'raw',
|
||||
'dng',
|
||||
])
|
||||
|
||||
const getFileExtension = (name: string) => {
|
||||
const normalized = name.toLowerCase()
|
||||
const lastDotIndex = normalized.lastIndexOf('.')
|
||||
return lastDotIndex === -1 ? '' : normalized.slice(lastDotIndex + 1)
|
||||
}
|
||||
|
||||
const getBaseName = (name: string) => {
|
||||
const normalized = name.toLowerCase()
|
||||
const lastDotIndex = normalized.lastIndexOf('.')
|
||||
return lastDotIndex === -1 ? normalized : normalized.slice(0, lastDotIndex)
|
||||
}
|
||||
|
||||
const isMovFile = (name: string) => name.toLowerCase().endsWith('.mov')
|
||||
|
||||
type PhotoUploadConfirmModalProps = {
|
||||
files: File[]
|
||||
onConfirm: (files: FileList) => void | Promise<void>
|
||||
}
|
||||
|
||||
export const PhotoUploadConfirmModal: ModalComponent<PhotoUploadConfirmModalProps> = ({
|
||||
files,
|
||||
onConfirm,
|
||||
dismiss,
|
||||
}) => {
|
||||
const fileItems = useMemo(() => files, [files])
|
||||
const totalSize = useMemo(() => fileItems.reduce((sum, file) => sum + file.size, 0), [fileItems])
|
||||
|
||||
const imageBaseNames = useMemo(() => {
|
||||
return new Set(
|
||||
fileItems
|
||||
.filter((file) => IMAGE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||
.map((file) => getBaseName(file.name)),
|
||||
)
|
||||
}, [fileItems])
|
||||
|
||||
const unmatchedMovFiles = useMemo(() => {
|
||||
return fileItems.filter((file) => {
|
||||
if (!isMovFile(file.name)) return false
|
||||
return !imageBaseNames.has(getBaseName(file.name))
|
||||
})
|
||||
}, [fileItems, imageBaseNames])
|
||||
|
||||
const hasUnmatchedMov = unmatchedMovFiles.length > 0
|
||||
const hasMovFile = useMemo(() => fileItems.some((file) => isMovFile(file.name)), [fileItems])
|
||||
|
||||
const createFileList = (fileArray: File[]): FileList => {
|
||||
if (typeof DataTransfer !== 'undefined') {
|
||||
const transfer = new DataTransfer()
|
||||
fileArray.forEach((file) => transfer.items.add(file))
|
||||
return transfer.files
|
||||
}
|
||||
|
||||
const fallback: Record<number, File> & { length: number; item: (index: number) => File | null } = {
|
||||
length: fileArray.length,
|
||||
item: (index: number) => fileArray[index] ?? null,
|
||||
}
|
||||
|
||||
fileArray.forEach((file, index) => {
|
||||
fallback[index] = file
|
||||
})
|
||||
|
||||
return fallback as unknown as FileList
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (hasUnmatchedMov) return
|
||||
const fileList = createFileList(fileItems)
|
||||
void onConfirm(fileList)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[80vh] w-full flex-col gap-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-text text-lg font-semibold">确认上传这些文件?</h2>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
共选择 {fileItems.length} 项,预计占用 {formatBytes(totalSize)}。确认后将立即开始上传。
|
||||
</p>
|
||||
{hasUnmatchedMov ? (
|
||||
<div className="rounded-lg border border-rose-400/40 bg-rose-500/5 px-3 py-2 text-xs text-rose-300">
|
||||
<p>以下 MOV 文件缺少同名的图像文件,请补齐后再尝试上传:</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{unmatchedMovFiles.map((file) => (
|
||||
<li key={`${file.name}-${file.lastModified}`}>{file.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
hasMovFile && (
|
||||
<p className="text-text-tertiary text-xs">已检测到 MOV 文件,将与同名图片一起作为 Live Photo 处理。</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LinearDivider />
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-fill-tertiary/40 bg-background/30 rounded-lg border">
|
||||
<m.ul
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="divide-fill-tertiary/40 max-h-60 divide-y overflow-auto"
|
||||
>
|
||||
{fileItems.map((file) => {
|
||||
const isUnmatchedMov = unmatchedMovFiles.includes(file)
|
||||
return (
|
||||
<li
|
||||
key={`${file.name}-${file.lastModified}`}
|
||||
className={clsxm(
|
||||
'text-text-secondary flex items-center justify-between gap-3 px-4 py-2 text-sm',
|
||||
isUnmatchedMov && 'text-rose-300',
|
||||
)}
|
||||
>
|
||||
<span className="truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{isUnmatchedMov ? <span className="text-[11px]">缺少同名图片</span> : null}
|
||||
<span className="text-text-tertiary text-xs">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</m.ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={dismiss}
|
||||
className="text-text-secondary hover:text-text"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" variant="primary" size="sm" onClick={handleConfirm} disabled={hasUnmatchedMov}>
|
||||
确认上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PhotoUploadConfirmModal.contentClassName = 'w-[min(420px,90vw)] p-6'
|
||||
@@ -5,25 +5,42 @@ import { defineConfig } from 'eslint-config-hyoban'
|
||||
import checkI18nJson from './plugins/eslint/eslint-check-i18n-json.js'
|
||||
import recursiveSort from './plugins/eslint/eslint-recursive-sort.js'
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
formatting: false,
|
||||
lessOpinionated: true,
|
||||
preferESM: false,
|
||||
react: true,
|
||||
tailwindCSS: true,
|
||||
},
|
||||
// In flat config, some large generated folders slipped through. To be extra safe,
|
||||
// put ignores in a top-level config object first, then append the rest.
|
||||
const rootIgnores = globalIgnores([
|
||||
'apps/ssr/src/index.html.ts',
|
||||
'apps/ssr/public/**',
|
||||
'apps/web/public/**',
|
||||
'packages/docs/public/**',
|
||||
])
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
emitDecoratorMetadata: true,
|
||||
experimentalDecorators: true,
|
||||
},
|
||||
const hyobanConfig = await defineConfig(
|
||||
{
|
||||
formatting: false,
|
||||
lessOpinionated: true,
|
||||
preferESM: false,
|
||||
react: true,
|
||||
tailwindCSS: true,
|
||||
},
|
||||
|
||||
settings: {},
|
||||
rules: {
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
emitDecoratorMetadata: true,
|
||||
experimentalDecorators: true,
|
||||
},
|
||||
},
|
||||
|
||||
// TailwindCSS v4 usually has no config file. Silence the plugin's
|
||||
// config resolution warning by explicitly disabling auto-resolution.
|
||||
settings: {
|
||||
tailwindcss: {
|
||||
// ESLint plugin will not attempt to resolve tailwind config
|
||||
// which avoids repeated "Cannot resolve default tailwindcss config path" warnings.
|
||||
config: false,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 0,
|
||||
'unicorn/prefer-math-trunc': 'off',
|
||||
'unicorn/no-static-only-class': 'off',
|
||||
@@ -63,28 +80,48 @@ export default defineConfig(
|
||||
'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// @ts-expect-error
|
||||
{
|
||||
files: ['locales/**/*.json'],
|
||||
plugins: {
|
||||
'recursive-sort': recursiveSort,
|
||||
'check-i18n-json': checkI18nJson,
|
||||
// @ts-expect-error
|
||||
{
|
||||
files: ['locales/**/*.json'],
|
||||
plugins: {
|
||||
'recursive-sort': recursiveSort,
|
||||
'check-i18n-json': checkI18nJson,
|
||||
},
|
||||
rules: {
|
||||
'recursive-sort/recursive-sort': 'error',
|
||||
'check-i18n-json/valid-i18n-keys': 'error',
|
||||
'check-i18n-json/no-extra-keys': 'error',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'recursive-sort/recursive-sort': 'error',
|
||||
'check-i18n-json/valid-i18n-keys': 'error',
|
||||
'check-i18n-json/no-extra-keys': 'error',
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
rules: {
|
||||
'@stylistic/jsx-self-closing-comp': 'error',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
rules: {
|
||||
'@stylistic/jsx-self-closing-comp': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
globalIgnores(['apps/ssr/src/index.html.ts']),
|
||||
// Backend framework isn't React — disable React-specific hooks rule there.
|
||||
{
|
||||
files: ['be/packages/framework/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Redundant but harmless: keep a local ignore in case this block is used standalone somewhere
|
||||
globalIgnores([
|
||||
'apps/ssr/src/index.html.ts',
|
||||
'apps/ssr/public/**',
|
||||
'apps/web/public/**',
|
||||
'packages/docs/public/**',
|
||||
]),
|
||||
)
|
||||
|
||||
export default [
|
||||
// Ensure ignores are applied globally before any other configs
|
||||
rootIgnores,
|
||||
...hyobanConfig,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user