This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Important: The user prefers communication in Chinese (中文). When working on this project:
- Keep conversations in Chinese
- Use Context7 MCP for official documentation lookups when encountering complex/uncertain issues
- Never use
anytype in TypeScript code - maintain strict type safety - Do not modify files listed in
.gitignore - Always run
pnpm lint --fixafter making code changes
EXIF Gallery Nuxt is a full-stack photo gallery solution deployable on Cloudflare Workers. It features AI-powered image analysis (OpenAI/Gemini), browser-side image compression (JSQuash), complete EXIF metadata management, and edge-native storage using Cloudflare R2 and D1.
Tech Stack: Nuxt 4 + Vue 3.5 + NuxtHub + Cloudflare (D1/R2) + UnoCSS + shadcn-vue + Pinia + Drizzle ORM
# Development
pnpm dev # Start dev server (localhost:3000)
pnpm dev --remote # Connect to remote Cloudflare resources locally
# Build & Deploy
pnpm build # Production build
pnpm preview # Preview production build
pnpm deploy # Build and deploy to Cloudflare Workers
# Database
pnpm db:generate # Generate Drizzle migrations from schema changes
# Code Quality
pnpm lint --fix # ESLint with @antfu/eslint-config
pnpm typecheck # Vue + TypeScript type checking
# UI Components
pnpm ui add <component> # Add shadcn-vue component
# Logs (post-deployment)
pnpm logs # Production deployment logs
pnpm logs:preview # Preview environment logsThe application follows a Cloudflare-native architecture where all data lives at the edge:
-
Upload Flow (Browser → Server → R2):
- Browser: Image selected → EXIF extracted via
exifr→ Compressed via JSQuash Web Workers (app/workers/encode.worker.ts) - Multiple formats generated: original JPEG + optimized WebP + modern AVIF + thumbnail
- Auto-resize: images with short edge ≥ 2880px are scaled down to 2160px while preserving aspect ratio
- Server API (
server/api/photos/upload.post.ts): Receives base64 blobs → Uploads to R2 via NuxtHub → Stores metadata in D1 - Optional AI analysis: Compressed image sent to OpenAI/Gemini to generate
title,caption,tags,semanticDescription
- Browser: Image selected → EXIF extracted via
-
Storage Layer:
- D1 (SQLite): Photo metadata and relationships (EXIF, tags, associations)
- R2 (S3-compatible): Binary image blobs served via
/photos/[pathname]route with aggressive caching (Cache-Control: public, max-age=31536000, immutable) - Schema:
photos(main),tags(normalized),photo_tags(many-to-many junction table)
-
Query & Display Flow:
- API (
server/api/photos/index.get.ts): Supports pagination (limit/offset), filtering (bytag/camera/lens/hidden), sorting (takenAt/createdAt) - Composable (
app/composables/usePhotos.ts):usePhotosInfinitefor infinite scroll,usePhotofor single item - Pinia store (
app/stores/photos.ts): Client-side cache with infinite scroll state management
- API (
The schema uses a modern normalized tag system (migrated from legacy comma-separated photos.tags field):
photos (1) ←→ (N) photo_tags (N) ←→ (1) tags
photos.id(CUID, 8 chars): Primary key for photo recordstags.name(unique): Canonical tag names withphotoCountdenormalized counterphoto_tags: Junction table with cascade delete on both sides- Indexes:
idx_photos_taken_at,idx_photos_hidden,idx_tags_photo_count,idx_photo_tags_photo_id,idx_photo_tags_tag_id
Multi-provider AI configuration managed client-side via localStorage (app/composables/useAIConfig.ts):
- Supports OpenAI and Gemini with custom base URL overrides (useful for proxy services)
- Uses
ai-sdk'sgenerateObjectwith Zod schema for type-safe structured output - Image compression before AI analysis: reduces token costs and respects API size limits
- Output:
{ title: string, caption: string, tags: string[], semanticDescription: string }
Image compression runs entirely in the browser via Web Workers to avoid server load:
-
Workers (
app/workers/):decode.worker.ts: Decodes uploaded images to raw pixel dataencode.worker.ts: Encodes pixel data to JPEG/WebP/AVIF formats with quality settings
-
Compression Pipeline (
app/utils/compress.ts):- Reads file → Extracts EXIF (before compression destroys metadata) → Spawns workers
- Auto-resize logic:
Math.min(width, height) >= 2880 ? resize to 2160 : keep original - Quality presets: JPEG 0.85, WebP 0.85, AVIF 0.65, Thumbnail 0.7 at 400px
-
Configuration (
app/composables/useUploadConfig.ts):- User-controllable: toggle compression, enable/disable specific formats
- Stored in localStorage, persists across sessions
/(index.vue): Home page with featured photos/grid: Grid view of all photos/p/[...id]: Photo detail page with EXIF overlay and viewer.js lightbox/tag/[...tag],/camera/[...camera],/lens/[...lens]: Filtered views/admin/*: Protected routes withauthmiddleware, requiresNUXT_ADMIN_PASSWORD- Layouts:
default.vue,home.vue,admin.vue
- Session-based auth via
nuxt-auth-utils - Admin login: POST
/api/authwith password matchingNUXT_ADMIN_PASSWORDenv var - Session encryption:
NUXT_SESSION_PASSWORD(min 32 chars) - Protected routes use
definePageMeta({ middleware: 'auth' })orrequireUserSession(event)on API routes
Cloudflare D1 cannot be connected during build time, so migrations are not auto-applied. Two management strategies:
- Local Development: NuxtHub auto-manages migrations, records in
_hub_migrationstable (no.sqlsuffix) - Cloud Deployment: GitHub Actions workflow (
.github/workflows/migrate.yml) runswrangler d1 migrations apply, records with.sqlsuffix
Important: Never manually run wrangler d1 migrations commands during local dev - suffix mismatch will cause duplicate migration tracking.
When modifying schema:
- Edit
server/db/schema.ts - Run
pnpm db:generateto create migration inserver/db/migrations/sqlite/ - Commit migration file
- Push to
mainbranch → GitHub Actions auto-applies to production D1
app/components/ui/: shadcn-vue base components (Button, Dialog, Card, etc.)app/components/inspira/: inspira-ui animated components (3D effects, motions)app/components/ui-pro/: Project-specific extended UI components- Auto-import enabled for all components, composables, and utils
- Use
<script setup lang="ts">with Composition API exclusively
- ESLint:
@antfu/eslint-config(single quotes, no semicolons, strict TypeScript) - Prohibited:
anytype usage (user's explicit rule) - i18n: Use
$t('key')in templates, translation files ini18n/locales/(en.yml, zh.yml) - Type Safety: Runtime validation with Zod, compile-time with TypeScript strict mode
Required:
NUXT_ADMIN_PASSWORD: Admin panel password (default:admin)NUXT_SESSION_PASSWORD: Session encryption key (min 32 chars, no default)
Optional:
NUXT_PUBLIC_TITLE: Application title (default: "Exif Gallery Nuxt")NUXT_PUBLIC_DESCRIPTION: Meta descriptionNUXT_PUBLIC_DISABLE_3D_CARD_DEFAULT: Disable 3D card effects ("true"/"false")
Adding photo metadata fields:
server/db/schema.ts: Add column tophototablepnpm db:generate: Generate migrationserver/api/photos/upload.post.ts: Handle new field in upload logicapp/composables/usePhotos.ts: Update type definitions- UI components displaying photo info
Adding API endpoints:
- Create
server/api/[name]/[method].ts(e.g.,index.get.ts,[id].put.ts) - Use
eventHandler()wrapper - Access DB:
useDB()from NuxtHub - Require auth:
await requireUserSession(event)
Adding AI providers:
app/utils/aiProviders.ts: Define provider schema and defaultsapp/composables/useAIConfig.ts: Provider CRUD logic already implemented- UI: Admin panel already supports custom provider configuration
wrangler.jsonc binds Cloudflare resources:
GitHub Actions secrets required:
CLOUDFLARE_ACCOUNT_ID: From Cloudflare dashboardCLOUDFLARE_API_TOKEN: With D1 edit permissions
{ "d1_databases": [{ "binding": "DB", "database_id": "xxx" }], "r2_buckets": [{ "binding": "BLOB", "bucket_name": "xxx" }] }