13 KiB
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/catalogsaccepts JPEG page images only (never raw PDF) delete_tokennever 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.tswith the types above andserver/env.tsthat readsPORT,PUBLIC_BASE_URL,STORAGE_DIR,DATABASE_PATHwith defaults3000,http://localhost:3000,./storage,./data/catalogs.sqlite. -
Step 2: Create Vite config with
root: "client",server.proxy["/api"] = "http://localhost:3001", and Vitest includingtests/**/*.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 runshould 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)withcheck(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:
storageDirfrom env -
Produces:
assertJpeg(buffer: Buffer): voidthrows if not JPEGsaveCatalogPages(id: string, pages: Buffer[]): Promise<void>writespage-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 }): CatalogRowgetById(id): CatalogRow | null(includesdeleteTokeninternally)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
catalogswith columnsid,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():- POST 2 tiny JPEGs + effect magazine → 201, body has id/urls, GET public has no deleteToken
- GET page 1 is JPEG
- GET qr is PNG
- DELETE without token → 403, catalog still GET 200
- DELETE with token → 204, then GET 404
- POST 81 pages → 400
- POST invalid effect → 400
- POST non-jpeg → 400
- 11th POST from same IP in window → 429
- No GET
/api/catalogslist route (404)
- Step 2: Run — FAIL.
- Step 3: Implement routes.
server/index.tslistens 3001 in dev (PORT_APIdefault 3001) and in production listensPORTserving../diststatic 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/:idViewPage/c/:id/doneDonePage (requirestokenquery; if missing, redirect to view)/c/:id/deleteDeletePage (requirestoken)
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 devstarts 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
/doneredirects 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 buildthen Hono onPORTservesdistwith SPA fallback for/c/:idroutes, 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 |