Files
digitalcatalog/docs/superpowers/plans/2026-08-18-digital-catalog.md
T

367 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Digital Catalog Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Persian RTL web app where anyone can upload a PDF or images, get a shareable flipbook catalog with a public link and QR code, and delete it only with a one-time secret token.
**Architecture:** Vue 3 + Vite client converts PDF pages to JPEG in the browser with pdf.js, then POSTs images to a Hono API. The API stores JPEGs on disk, metadata in SQLite, and a QR PNG. Vite proxies `/api` in development; production Hono serves `dist/` plus the API. Catalogs are unlisted (no listing endpoint).
**Tech Stack:** Vue 3, Vue Router, TypeScript, Vite, pdf.js (`pdfjs-dist`), StPageFlip (`page-flip`), Hono, `@hono/node-server`, Drizzle ORM, better-sqlite3, nanoid, qrcode, sharp, Vitest.
## Global Constraints
- UI language: Persian, `dir="rtl"`
- No auth, no public gallery, no post-create edit, no auto-expiry
- After create: view link + QR + delete link only
- Effects (locked ids): `magazine` | `slide` | `fade` | `card` | `scroll`
- Max input file 25MB, max 80 pages, 10 catalogs/hour/IP (in-memory)
- Stored pages: JPEG, max width 1600px, quality ~85
- Public URL shape: `{PUBLIC_BASE_URL}/c/{id}`
- POST `/api/catalogs` accepts JPEG page images only (never raw PDF)
- `delete_token` never returned from public GET
- No git commits unless the user explicitly asks
- Spec source of truth: `docs/PROJECT.md`
## File Map
```
package.json
tsconfig.json
tsconfig.app.json
tsconfig.node.json
vite.config.ts
vitest.config.ts
.env.example
.gitignore
README.md
server/env.ts
server/app.ts
server/index.ts
server/db.ts
server/schema.ts
server/catalog-repo.ts
server/storage.ts
server/rate-limit.ts
server/qr.ts
server/magic.ts
server/errors.ts
server/routes/catalogs.ts
shared/types.ts
client/index.html
client/src/main.ts
client/src/App.vue
client/src/styles.css
client/src/router.ts
client/src/api.ts
client/src/pdf.ts
client/src/images.ts
client/src/constants.ts
client/src/pages/CreatePage.vue
client/src/pages/ViewPage.vue
client/src/pages/DonePage.vue
client/src/pages/DeletePage.vue
client/src/components/EffectSelect.vue
client/src/components/PageList.vue
client/src/components/CatalogViewer.vue
client/src/components/effects/MagazineEffect.vue
client/src/components/effects/SlideEffect.vue
client/src/components/effects/FadeEffect.vue
client/src/components/effects/CardEffect.vue
client/src/components/effects/ScrollEffect.vue
tests/server/helpers.ts
tests/server/rate-limit.test.ts
tests/server/storage.test.ts
tests/server/catalogs.test.ts
```
## Shared types (all later tasks use these)
```ts
export const EFFECTS = ["magazine", "slide", "fade", "card", "scroll"] as const;
export type EffectId = (typeof EFFECTS)[number];
export type CatalogPublic = {
id: string;
title: string;
effect: EffectId;
pageCount: number;
createdAt: string;
};
export type CreateCatalogResponse = {
id: string;
viewUrl: string;
deleteUrl: string;
qrUrl: string;
doneUrl: string;
};
```
---
### Task 1: Scaffold + shared types + env
**Files:**
- Create: `package.json`, `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json`, `vite.config.ts`, `vitest.config.ts`, `.env.example`, `.gitignore`, `shared/types.ts`, `server/env.ts`
**Interfaces:**
- Consumes: nothing
- Produces: `loadEnv()` returning `{ port, publicBaseUrl, storageDir, databasePath }`
- [ ] **Step 1:** Create `shared/types.ts` with the types above and `server/env.ts` that reads `PORT`, `PUBLIC_BASE_URL`, `STORAGE_DIR`, `DATABASE_PATH` with defaults `3000`, `http://localhost:3000`, `./storage`, `./data/catalogs.sqlite`.
- [ ] **Step 2:** Create Vite config with `root: "client"`, `server.proxy["/api"] = "http://localhost:3001"`, and Vitest including `tests/**/*.test.ts`.
- [ ] **Step 3:** Install dependencies listed in Tech Stack plus `concurrently`, `tsx`, `vue-router`, `drizzle-kit`. Scripts: `dev` (API on 3001 + Vite), `test`, `build`, `start`.
- [ ] **Step 4:** `npx vitest run` should succeed with zero tests or a trivial env test. Do not commit.
---
### Task 2: Rate limiter
**Files:**
- Create: `server/rate-limit.ts`, `tests/server/rate-limit.test.ts`
**Interfaces:**
- Consumes: nothing
- Produces: `createRateLimiter(limit: number, windowMs: number)` with `check(ip: string): boolean` (true = allowed)
- [ ] **Step 1: Write failing tests** in `tests/server/rate-limit.test.ts`:
- allows first 10 hits
- rejects the 11th within the window
- allows again after window elapses (injectable clock)
- [ ] **Step 2:** Run `npx vitest run tests/server/rate-limit.test.ts` — expect FAIL (module missing).
- [ ] **Step 3:** Implement in-memory sliding window in `server/rate-limit.ts`.
- [ ] **Step 4:** Re-run tests — expect PASS.
---
### Task 3: Storage + magic bytes
**Files:**
- Create: `server/storage.ts`, `server/magic.ts`, `tests/server/storage.test.ts`
**Interfaces:**
- Consumes: `storageDir` from env
- Produces:
- `assertJpeg(buffer: Buffer): void` throws if not JPEG
- `saveCatalogPages(id: string, pages: Buffer[]): Promise<void>` writes `page-001.jpg`…
- `saveQr(id: string, png: Buffer): Promise<void>`
- `readPage(id: string, n: number): Promise<Buffer>`
- `readQr(id: string): Promise<Buffer>`
- `deleteCatalogFiles(id: string): Promise<void>`
- [ ] **Step 1:** Write tests using a temp dir: save 2 tiny JPEGs, read page 1, delete, then read throws.
- [ ] **Step 2:** Run tests — expect FAIL.
- [ ] **Step 3:** Implement filesystem helpers. Use sharp to re-encode JPEG max width 1600, quality 85.
- [ ] **Step 4:** Tests PASS.
---
### Task 4: SQLite catalog repository
**Files:**
- Create: `server/schema.ts`, `server/db.ts`, `server/catalog-repo.ts`
**Interfaces:**
- Consumes: `databasePath`
- Produces:
- `createCatalog({ id, deleteToken, title, effect, pageCount }): CatalogRow`
- `getById(id): CatalogRow | null` (includes `deleteToken` internally)
- `deleteById(id): boolean`
```ts
export type CatalogRow = {
id: string;
deleteToken: string;
title: string;
effect: EffectId;
pageCount: number;
createdAt: string;
};
```
- [ ] **Step 1:** Tests: insert, getById, delete, getById returns null. Use temp sqlite file.
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Drizzle schema `catalogs` with columns `id`, `delete_token`, `title`, `effect`, `page_count`, `created_at`. Auto-create table on boot.
- [ ] **Step 4:** Tests PASS.
---
### Task 5: Catalog HTTP API
**Files:**
- Create: `server/app.ts`, `server/routes/catalogs.ts`, `server/qr.ts`, `server/errors.ts`, `server/index.ts`, `tests/server/helpers.ts`, `tests/server/catalogs.test.ts`
**Interfaces:**
- Consumes: repo, storage, rate limiter, `PUBLIC_BASE_URL`
- Produces Hono app with routes under `/api`:
| Method | Path | Behavior |
|---|---|---|
| POST | `/catalogs` | multipart: `title`, `effect`, `pages` (1..80 JPEGs). Rate-limit by `x-forwarded-for` or socket IP. Returns JSON `{ id, viewUrl, deleteUrl, qrUrl, doneUrl }` |
| GET | `/catalogs/:id` | `{ id, title, effect, pageCount, createdAt }` — never `deleteToken` |
| GET | `/catalogs/:id/pages/:n` | JPEG bytes, `Content-Type: image/jpeg` |
| GET | `/catalogs/:id/qr` | PNG bytes |
| DELETE | `/catalogs/:id?token=` | 403 if token wrong, 404 if missing, 204 + delete files if ok |
Error JSON: `{ error: string }` with Persian messages:
- حجم یا تعداد صفحه بیش از حد مجاز است
- نوع فایل پشتیبانی نمی‌شود
- افکت نامعتبر است
- کاتالوگ پیدا نشد
- اجازه حذف ندارید
- تعداد ساخت از این آدرس بیش از حد مجاز است
Limits: each page buffer ≤ 25MB; page count 1–80; `effect` in EFFECTS; title default `کاتالوگ`; `id` nanoid(10); `deleteToken` nanoid(24).
QR encodes `{publicBaseUrl}/c/{id}`.
`viewUrl` = `{publicBaseUrl}/c/{id}`
`deleteUrl` = `{publicBaseUrl}/c/{id}/delete?token={deleteToken}`
`doneUrl` = `{publicBaseUrl}/c/{id}/done?token={deleteToken}`
`qrUrl` = `/api/catalogs/{id}/qr`
- [ ] **Step 1:** Tests with `app.request()`:
1. POST 2 tiny JPEGs + effect magazine → 201, body has id/urls, GET public has no deleteToken
2. GET page 1 is JPEG
3. GET qr is PNG
4. DELETE without token → 403, catalog still GET 200
5. DELETE with token → 204, then GET 404
6. POST 81 pages → 400
7. POST invalid effect → 400
8. POST non-jpeg → 400
9. 11th POST from same IP in window → 429
10. No GET `/api/catalogs` list route (404)
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Implement routes. `server/index.ts` listens 3001 in dev (`PORT_API` default 3001) and in production listens `PORT` serving `../dist` static files.
- [ ] **Step 4:** Tests PASS.
---
### Task 6: Vue shell + router + RTL
**Files:**
- Create: `client/index.html`, `client/src/main.ts`, `client/src/App.vue`, `client/src/styles.css`, `client/src/router.ts`, `client/src/constants.ts`, `client/src/api.ts`
Routes:
- `/` CreatePage
- `/c/:id` ViewPage
- `/c/:id/done` DonePage (requires `token` query; if missing, redirect to view)
- `/c/:id/delete` DeletePage (requires `token`)
`api.ts`:
- `createCatalog({ title, effect, pages: Blob[] }): Promise<CreateCatalogResponse>`
- `getCatalog(id): Promise<CatalogPublic>`
- `pageUrl(id, n): string` → `/api/catalogs/${id}/pages/${n}`
- `qrUrl(id): string`
- `deleteCatalog(id, token): Promise<void>`
- [ ] **Step 1:** App boots, Persian RTL, `/` shows a heading «کاتالوگ دیجیتال».
- [ ] **Step 2:** `npm run dev` starts without error. Do not commit.
---
### Task 7: Client PDF/image pipeline
**Files:**
- Create: `client/src/pdf.ts`, `client/src/images.ts`
**Interfaces:**
- `renderPdfToJpegs(file: File, onProgress?: (done: number, total: number) => void): Promise<Blob[]>`
- reject if > 25MB
- reject if page count > 80
- render scale so width ≤ 1600, JPEG quality 0.85
- `filesToJpegs(files: File[]): Promise<Blob[]>` for jpeg/png/webp/gif, same size/count limits, preserve chosen order
- [ ] **Step 1:** Wire pdfjs worker from `pdfjs-dist`.
- [ ] **Step 2:** Create page can convert a 1-page PDF and 2 images (manual check later with UI).
---
### Task 8: Create page
**Files:**
- Create: `client/src/pages/CreatePage.vue`, `client/src/components/EffectSelect.vue`, `client/src/components/PageList.vue`
Behavior:
- Drag/drop or file picker
- One PDF **or** multiple images, not mixed; if mixed, Persian error
- Reorder pages (up/down)
- Effect radios with Persian labels from spec section 6
- Optional title
- Submit → POST → `router.push(/c/{id}/done?token=...)`
- Show progress while rendering PDF
- Show API errors in Persian
- [ ] **Step 1:** Implement UI, keep it clean (not a busy dashboard).
- [ ] **Step 2:** Manual: drop images, pick effect, submit reaches API.
---
### Task 9: Done + delete pages
**Files:**
- Create: `client/src/pages/DonePage.vue`, `client/src/pages/DeletePage.vue`
Done:
- Copy view link `{origin}/c/{id}`
- Show QR `<img src=qrUrl>`
- Download QR PNG
- Open catalog button
- Warning: «لینک حذف را همین حالا ذخیره کنید. اگر گم شود قابل بازیابی نیست.»
- Link to delete page with token
Delete:
- Confirm button «حذف کاتالوگ»
- On success, message that it is gone; view link no longer works
- [ ] **Step 1:** Implement.
- [ ] **Step 2:** Verify token-less `/done` redirects to view.
---
### Task 10: Viewer + five effects
**Files:**
- Create: viewer components listed in File Map, `client/src/pages/ViewPage.vue`
Behavior from spec:
- Load catalog; 404 Persian if missing
- Thumbnails, prev/next, arrow keys, fullscreen, zoom
- `magazine`: StPageFlip, two-page spread on desktop (`window.innerWidth >= 900`), single page on mobile; if PageFlip throws or device is coarse+narrow, fall back to SlideEffect visually without changing stored effect
- `slide` / `fade` / `card`: one page, CSS transitions
- `scroll`: all pages stacked, vertical scroll
- Click page edge to turn for magazine/slide/card/fade
- [ ] **Step 1:** Implement MagazineEffect with `page-flip`.
- [ ] **Step 2:** Implement the four CSS effects.
- [ ] **Step 3:** Keyboard and thumbnails work.
---
### Task 11: Production serve + README + spec checklist
**Files:**
- Modify: `server/index.ts`, `README.md`, `docs/PROJECT.md` (checkboxes in section 16 stay until verified)
- [ ] **Step 1:** Production: `vite build` then Hono on `PORT` serves `dist` with SPA fallback for `/c/:id` routes, API still at `/api`.
- [ ] **Step 2:** README: how to copy `.env.example`, `npm install`, `npm run dev`, `npm test`, `npm run build` / `npm start`.
- [ ] **Step 3:** Run `npx vitest run` — all pass.
- [ ] **Step 4:** Smoke: create from images, open `/c/:id`, QR file exists, delete with token, GET 404.
---
## Spec coverage (self-review)
| Spec section | Task |
|---|---|
| Unlisted, no auth, link+QR+delete | 5, 8, 9 |
| Five effects | 10 |
| pdf.js in browser, JPEG to server | 5, 7 |
| Limits 25MB / 80 pages / 10 per hour | 5, 7 |
| SQLite + disk layout | 3, 4 |
| Persian RTL | 6–10 |
| Magazine spread desktop / single mobile | 10 |
| No gallery endpoint | 5 test 10 |
| PUBLIC_BASE_URL in QR | 5 |
| Done page requires token | 6, 9 |