Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 14:37:49 +03:30
co-authored by Cursor
commit a8374e7d6c
54 changed files with 8462 additions and 0 deletions
@@ -0,0 +1,366 @@
# 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 |