# PicImpact Self-deployable photography portfolio website built with Next.js 16 + Hono.js + PostgreSQL. ## Quick Reference ```bash # Development pnpm run dev:server # Start dev server pnpm run dev:turbopack # Dev with Turbopack # Build pnpm run build # Production build pnpm run build:vercel # Vercel deployment build pnpm run build:node # Node.js deployment build # Database pnpm run prisma:generate # Generate Prisma client pnpm run prisma:dev # Run dev migrations pnpm run prisma:deploy # Deploy migrations pnpm run prisma:seed # Seed initial data # Linting pnpm run lint # ESLint check pnpm run lint:fix # Auto-fix lint issues ``` ## Tech Stack - **Framework:** Next.js 16 + React 19 + TypeScript (strict mode) - **Backend API:** Hono.js (routes in `hono/`) - **Database:** PostgreSQL + Prisma ORM (`prisma/schema.prisma`) - **Auth:** better-auth (email/password, TOTP 2FA, WebAuthn Passkeys) - **Styling:** Tailwind CSS v4 + Radix UI + shadcn/ui - **State:** Zustand + SWR - **i18n:** next-intl (zh, en, ja, zh-TW) - **Storage:** AWS S3 / Cloudflare R2 / Open List API ## Project Structure ``` app/ # Next.js app router (default)/ # Public gallery routes admin/ # Admin dashboard api/ # API routes (auth + Hono) login/ sign-up/ # Auth pages components/ # React components (admin/, gallery/, ui/, etc.) hono/ # Hono API route handlers open/ # Public API endpoints storage/ # Storage-specific endpoints server/ # Server-side code auth/ # better-auth config db/query/ # Database read queries db/operate/ # Database write operations lib/ # Utilities (db client, S3, R2, uploads) stores/ # Zustand stores hooks/ # React hooks types/ # TypeScript type definitions messages/ # i18n translation JSON files prisma/ # Schema, migrations, seed style/ # Global CSS ``` ## Code Conventions - **Quotes:** Single quotes (enforced by ESLint) - **Semicolons:** Never (enforced by ESLint) - **`any`:** Warned, avoid where possible - **Path alias:** `~/` maps to project root - **Components:** PascalCase filenames - **Pages/routes:** lowercase (Next.js convention) - **No unused variables/parameters** (TypeScript strict) ## Architecture Notes - **Database queries:** Read operations in `server/db/query/`, write operations in `server/db/operate/`. Prisma singleton in `server/lib/db.ts`. - **Server actions:** Shared Next.js server actions in `server/actions/`. Used by page components to deduplicate data fetching. - **Auth:** better-auth with sessions stored in DB, cookie-based with 7-day duration. Protected routes require auth middleware. - **Images:** Support EXIF extraction, thumbhash generation, Live Photos, WebGL viewer, map clustering via Supercluster. - **Deployment:** Docker (multi-arch), Vercel, or bare Node.js. `script.sh` handles Docker startup (migrate + seed + start). ## API Design Specification ### Routing Structure ``` /api/auth/[...all] → better-auth (login, signup, 2FA, passkeys) /api/v1/* → Protected endpoints (require auth session) /api/public/* → Public endpoints (no auth required) ``` All `/api/v1/*` and `/api/public/*` routes are handled by Hono via `app/api/[[...route]]/route.ts`. ### Response Format (Target Standard) All API responses MUST follow this envelope format: ```typescript // Success response { code: 200, message: 'Success', data?: T // Present for GET requests } // Error response { code: number, // HTTP status code (400, 404, 500, etc.) message: string // Human-readable error description } ``` **Rules:** - GET endpoints: return `{ code: 200, message: 'Success', data: }` - Mutation endpoints (POST/PUT/DELETE): return `{ code: 200, message: 'Success' }` - Errors: throw via helpers in `hono/_lib/errors.ts` (`badRequest`, `notFound`, `conflict`, `serverError`); the global `onError` in `hono/index.ts` shapes the response into the envelope. - Binary endpoints (image blob, file download): return raw binary with appropriate Content-Type header. ### API Helpers (`hono/_lib/`) Use these in every Hono handler instead of writing `c.json(...)` by hand: ```ts import { ok, okEmpty } from '~/hono/_lib/response' import { badRequest, notFound, conflict, serverError } from '~/hono/_lib/errors' app.get('/things', async (c) => { const data = await fetchThings() return ok(c, data) // { code: 200, message: 'Success', data } }) app.put('/things', async (c) => { await updateThing(await c.req.json()) return okEmpty(c) // { code: 200, message: 'Success' } }) app.post('/things', async (c) => { const body = await c.req.json() if (!body.name) throw badRequest('name is required') // ... }) ``` The `sessionMiddleware` in `hono/_lib/context.ts` is mounted on the `/api/v1` chain; handlers can read `c.get('session')` to access the better-auth session (or `null` when unauthenticated). `requireAuth` is exported but **not yet mounted** — opt-in per module is tracked in the API refactor plan. ### URL Naming Convention - **All paths use kebab-case:** `/api/v1/images/update-show` (not `updateShow` or `update-Album`) - **Resource-oriented:** `//` or `///:id` - **No verb prefixes on GET:** `/api/v1/albums` not `/api/v1/albums/get` - **Action verbs only for non-CRUD operations:** `/api/v1/daily/refresh` ### HTTP Method Semantics | Method | Usage | Idempotent | |--------|-------|------------| | GET | Read data, no side effects | Yes | | POST | Create resource or trigger action | No | | PUT | Update existing resource | Yes | | DELETE | Remove resource | Yes | **Rules:** - Never use POST for read-only operations - GET endpoints accept query parameters, not request bodies - PUT endpoints accept JSON body with the fields to update ### Request Body Convention - **All request bodies use camelCase keys:** `{ dailyEnabled, albumValue }` (not `daily_enabled` or `album_value`) - Database column names (snake_case) are mapped in the server layer, not exposed to the API consumer ### Module Reference #### Settings (`/api/v1/settings/`) | Method | Path | Purpose | |--------|------|---------| | GET | `/settings/custom-info` | Fetch display settings (title, favicon, author, etc.) | | PUT | `/settings/custom-info` | Update display settings | | GET | `/settings/s3-info` | Fetch S3 storage config | | PUT | `/settings/s3-info` | Update S3 storage config | | GET | `/settings/r2-info` | Fetch R2 storage config | | PUT | `/settings/r2-info` | Update R2 storage config | | GET | `/settings/open-list-info` | Fetch Open List config | | PUT | `/settings/open-list-info` | Update Open List config | | GET | `/settings/admin-config` | Fetch admin-specific settings | #### Images (`/api/v1/images/`) | Method | Path | Purpose | |--------|------|---------| | POST | `/images` | Add new image | | PUT | `/images` | Update image metadata | | DELETE | `/images/:id` | Delete single image | | DELETE | `/images/batch-delete` | Batch delete images | | PUT | `/images/update-show` | Toggle image visibility | | PUT | `/images/update-album` | Change image album binding | | GET | `/images/camera-lens-list` | List camera/lens models (admin) | #### Albums (`/api/v1/albums/`) | Method | Path | Purpose | |--------|------|---------| | GET | `/albums` | List all albums | | POST | `/albums` | Create album | | PUT | `/albums` | Update album | | DELETE | `/albums/:id` | Delete album | | PUT | `/albums/update-show` | Toggle album visibility | #### Daily Homepage (`/api/v1/daily/`) | Method | Path | Purpose | |--------|------|---------| | GET | `/daily/config` | Fetch daily homepage settings | | PUT | `/daily/config` | Update daily homepage settings | | GET | `/daily/albums` | List albums with daily weights | | PUT | `/daily/albums` | Batch update album weights | | POST | `/daily/refresh` | Trigger manual refresh | #### Files (`/api/v1/file/`) | Method | Path | Purpose | |--------|------|---------| | POST | `/file/presigned-url` | Generate upload presigned URL | | POST | `/file/upload` | Upload file via FormData | | POST | `/file/object-url` | Get public URL for stored object | #### Storage (`/api/v1/storage/`) | Method | Path | Purpose | |--------|------|---------| | GET | `/storage/open-list/info` | Fetch Open List connection info | | GET | `/storage/open-list/storages` | List available Open List storages | #### Public Endpoints (`/api/public/`) | Method | Path | Purpose | |--------|------|---------| | GET | `/public/images/image-by-id` | Fetch image metadata by ID | | GET | `/public/download/:id` | Download image file | | GET | `/public/camera-lens` | List camera/lens models (public, filtered to visible albums) | ### Known Deviations from Target Standard Tracked in `docs/plans/2026-05-19-api-refactor-design.md`. Snapshot: 1. **snake_case data model leak** — `Config.config_key/config_value`, `Album.album_value`, `Image.image_name`, `image_sorting`, `show_on_mainpage`, `Exif.data_time` are exposed in API responses and request bodies. Server-side mapping layer pending. (PR-07, PR-08, PR-09.) 2. **`/api/public/download/:id` dual return type** — Returns binary blob OR `{ url, filename }` JSON depending on direct-download config. To be split into `/download/:id` (binary) and `/download/:id/presigned` (JSON envelope). (PR-01.) 3. ~~`/api/public/images/image-blob` SSRF~~ — **DONE (PR-02):** Endpoint deleted. No in-repo consumers existed. 4. ~~`/api/v1/images/camera-lens-list` & `/api/public/camera-lens-list`~~ — **DONE (PR-04):** Both endpoints consolidated under Hono. Public moved to `GET /api/public/camera-lens` and reuses `fetchClientCameraAndLensList` / `fetchDailyCameraAndLensList`, which filter to `show=0` (visible) images and, for album-scoped requests, `albums.show=0` as well. Admin remains at `GET /api/v1/images/camera-lens-list` and returns the unfiltered set. 5. **Settings PUT body shape** — `/settings/{r2,s3,open-list}-info` PUT accepts `Config[]` array with snake_case `config_key/config_value` instead of a flat camelCase object. (PR-07.) 6. **Per-handler auth & admin layout server check** — `/api/v1/*` auth is enforced only in `proxy.ts` middleware; no defense-in-depth. `app/admin/layout.tsx` has no server-side session check. (PR-10.) 7. **Tasks advisory lock scope** — `kickMetadataTaskRun` holds `withTaskLock` across the entire 10-image batch (~200s worst case). (PR-05.) ## Environment Variables Key variables (see `.env.example` for full list): - `DATABASE_URL` / `DIRECT_URL` — PostgreSQL connections - `BETTER_AUTH_SECRET` — Auth secret key - `BETTER_AUTH_URL` — App base URL - `BETTER_AUTH_PASSKEY_RP_ID` / `BETTER_AUTH_PASSKEY_RP_NAME` — WebAuthn config ## Git Workflow Never commit directly to main. Use feature branches (`feat/`, `fix/`) and PRs.