# 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` writes `page-001.jpg`… - `saveQr(id: string, png: Buffer): Promise` - `readPage(id: string, n: number): Promise` - `readQr(id: string): Promise` - `deleteCatalogFiles(id: string): Promise` - [ ] **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` - `getCatalog(id): Promise` - `pageUrl(id, n): string` → `/api/catalogs/${id}/pages/${n}` - `qrUrl(id): string` - `deleteCatalog(id, token): Promise` - [ ] **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` - reject if > 25MB - reject if page count > 80 - render scale so width ≤ 1600, JPEG quality 0.85 - `filesToJpegs(files: File[]): Promise` 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 `` - 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 |