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

13 KiB
Raw Blame History

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)

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
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