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:
Innei
2025-11-02 20:35:21 +08:00
parent 71b925bd36
commit 6cf5fd7c51
7 changed files with 836 additions and 329 deletions

364
AGENTS.md
View File

@@ -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
View 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
View 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.

View File

@@ -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()
}
}

View File

@@ -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 ? (

View File

@@ -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'

View File

@@ -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,
]