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
+342
View File
@@ -0,0 +1,342 @@
# کاتالوگ دیجیتال (Digital Catalog)
سند مرجع پروژه — ۱۸ اوت ۲۰۲۶
قبل از پیاده‌سازی نوشته شده است. اگر رفتار محصول عوض شد، همین فایل باید به‌روز شود.
---
## ۱. محصول چیست
کاربر یک PDF چندصفحه‌ای یا چند تصویر می‌دهد. سیستم یک **کاتالوگ دیجیتال** می‌سازد: صفحات با افکت ورق‌زدن نمایش داده می‌شوند، یک **لینک مشاهده** و یک **QR** تحویل می‌شود.
هدف دو کار با هم است:
1. ساخت و دیدن کاتالوگ همان لحظه
2. اشتراک‌گذاری همان کاتالوگ با لینک و QR، بدون ثبت‌نام
کاتالوگ‌ها در سایت **لیست نمی‌شوند**. فقط کسی که لینک یا QR را دارد می‌تواند ببیند.
---
## ۲. تصمیم‌های قفل‌شده
| موضوع | تصمیم |
|---|---|
| ثبت‌نام / ورود | ندارد. هر بازدیدکننده دامنه می‌تواند بسازد |
| گالری عمومی | ندارد |
| خروجی ساخت | لینک مشاهده + تصویر QR + لینک حذف |
| ویرایش بعدی | ندارد (صفحات، PDF، افکت بعد از ساخت عوض نمی‌شود) |
| انقضای خودکار | ندارد |
| زبان رابط | فارسی، راست‌به‌چپ |
| نسخه اول | ساده؛ بدون حساب کاربری، بدون پنل ادمین، بدون SEO کاتالوگ |
---
## ۳. چه چیزهایی نسخه اول نیست
این‌ها عمداً بیرون هستند تا پروژه ساده بماند:
- حساب کاربری، نقش، پنل مدیریت
- گالری، جستجو، نقشه سایت از کاتالوگ‌ها
- ویرایش کاتالوگ بعد از ساخت
- نقطه کلیک‌پذیر روی صفحه، فهرست مطالب، ویدیو داخل صفحه
- موسیقی پس‌زمینه، پخش خودکار اجباری
- اپ دسکتاپ / موبایل بومی
- پرداخت، چندزبانه بودن رابط، تم‌های زیاد
- Three.js و شبیه‌سازی فیزیک کاغذ از صفر
اگر فایل اشتباه آپلود شد: کاتالوگ را با لینک حذف پاک می‌کند و یکی جدید می‌سازد.
---
## ۴. مسیر کاربر
```text
ورود به دامنه
→ صفحه ساخت (آپلود PDF یا چند تصویر + انتخاب افکت)
→ پردازش و ذخیره
→ صفحه نتیجه:
• لینک مشاهده (کپی)
• QR (نمایش + دانلود PNG)
• باز کردن کاتالوگ
• لینک حذف (یک‌بار نشان داده می‌شود)
→ بیننده با لینک یا اسکن QR کاتالوگ را ورق می‌زند
```
لینک حذف را باید همان لحظه ذخیره کند. اگر گم شود، راه بازیابی نیست (چون حساب کاربری نداریم). این محدودیت باید روی صفحه نتیجه نوشته شود.
---
## ۵. امکانات نسخه اول
### ساخت
- Drag & drop یا انتخاب فایل
- هنگام خواندن PDF/تصویر، آماده‌سازی صفحات و ساخت کاتالوگ، لودینگ با متن پیشرفت نشان داده می‌شود
- یک PDF **یا** چند تصویر (نه هر دو با هم در یک کاتالوگ)
- ترتیب تصاویر = ترتیب انتخاب / ترتیب نام فایل؛ امکان جابه‌جایی قبل از ساخت
- انتخاب یکی از افکت‌های ورق‌زدن
- تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود
- عنوان اختیاری (اگر خالی باشد: «کاتالوگ»)
### مشاهده
- نمایش صفحات با افکت انتخاب‌شده (اسلاید، محو و کارت با انتقال ورودی/خروجی واقعی)
- ۳ ثانیه پس از باز شدن، پخش خودکار شروع می‌شود؛ فاصلهٔ بین ورق‌ها برابر زمان ورق‌خوردن ذخیره‌شده به‌علاوهٔ کمی مکث است
- قبلی / بعدی، کلیدهای جهت، کلیک/اسوایپ روی صفحه
- بندانگشتی صفحات
- زوم و تمام‌صفحه
- دسکتاپ: فقط افکت `magazine` دو صفحه روبه‌رو نشان می‌دهد؛ بقیه همیشه یک صفحه
- موبایل: همیشه یک صفحه؛ اگر ورق سه‌بعدی روی دستگاه ضعیف بود، همان کاتالوگ با اسلاید افقی دیده می‌شود (تنظیم ذخیره‌شده عوض نمی‌شود)
### اشتراک
- آدرس ثابت: `{BASE_URL}/c/{id}`
- QR همان آدرس را کد می‌کند
- دانلود QR به صورت PNG
### حذف
- حذف فقط با توکن مخفی
- بعد از حذف، لینک و QR دیگر کار نمی‌کنند
- فایل صفحات از دیسک پاک می‌شوند
---
## ۶. افکت‌های ورق‌زدن
موقع ساخت، **یکی** انتخاب می‌شود و برای همیشه روی همان کاتالوگ می‌ماند.
| شناسه | نام نمایشی | رفتار |
|---|---|---|
| `magazine` | ورق مجله | سه‌بعدی واقعی؛ افکت اصلی |
| `slide` | اسلاید افقی | صفحه بعدی از بغل می‌آید |
| `fade` | محو شدن | صفحه فعلی محو، بعدی ظاهر می‌شود |
| `card` | کارت | ورق دوبعدی مثل کارت |
| `scroll` | اسکرول عمودی | صفحات زیر هم؛ مناسب موبایل |
پیاده‌سازی: برای `magazine` از کتابخانه **StPageFlip** استفاده می‌شود. بقیه با Vue + CSS/transition. فیزیک کاغذ از صفر نوشته نمی‌شود.
---
## ۷. معماری
یک اپلیکیشن با دو بخش در یک مخزن:
- **فرانت‌اند:** Vue 3 — ساخت، نتیجه، نمایش کاتالوگ
- **بک‌اند:** API سبک Node — آپلود، ذخیره، سرو کردن صفحات، QR، حذف
کاتالوگ‌ها روی سرور ذخیره می‌شوند تا لینک برای همه باز شود. تبدیل PDF به تصویر **در مرورگر با pdf.js** انجام می‌شود. سرور PDF خام نمی‌گیرد؛ فقط JPEG صفحات را ذخیره می‌کند. به ابزار native مثل Poppler نیاز نیست.
```text
مرورگر سرور
────── ────
انتخاب PDF/تصویر
pdf.js → تصویر هر صفحه
آپلود تصاویر + افکت + عنوان → ذخیره فایل‌ها روی دیسک
→ ردیف در SQLite
→ ساخت QR
صفحه نتیجه ← لینک/QR/حذف
بیننده /c/:id → متادیتا + آدرس تصاویر
نمایش با افکت انتخاب‌شده
```
---
## ۸. استک قفل‌شده
| لایه | انتخاب | دلیل |
|---|---|---|
| UI | Vue 3 + Vite + TypeScript | کامپوننت و state برای آپلود و نمایش |
| استایل | CSS ساده + RTL | بدون UI kit سنگین |
| PDF در مرورگر | pdf.js | تبدیل صفحه به تصویر بدون سرور خاص |
| ورق مجله | StPageFlip | افکت آماده و قابل‌نگهداری |
| QR | بسته `qrcode` در سرور | PNG قابل دانلود |
| API | Hono روی Node | سبک، TypeScript |
| داده | SQLite از طریق sql.js + Drizzle | بدون کامپایل native (روی ویندوز بدون Visual Studio هم نصب می‌شود) |
| فایل صفحات | پوشه محلی دیسک | ساده برای یک سرور |
| شناسه | nanoid | کوتاه و غیرقابل حدس نسبی |
HTML/CSS خالی رد شد: آپلود، تبدیل PDF، چند افکت، موبایل و صفحه نتیجه خیلی زود شلوغ می‌شود.
Nuxt و فریم‌ورک سنگین بک‌اند رد شد: نمایش کاتالوگ تقریباً همه در کلاینت است؛ یک API کوچک کافی است.
---
## ۹. مدل داده
جدول `catalogs`:
| فیلد | نوع | توضیح |
|---|---|---|
| `id` | متن، حدود ۱۰ کاراکتر | شناسه عمومی در لینک |
| `delete_token` | متن، حدود ۲۴ کاراکتر | فقط برای حذف؛ در URL مشاهده نیست |
| `title` | متن | عنوان |
| `effect` | متن | یکی از شناسه‌های بخش ۶ |
| `page_count` | عدد | تعداد صفحات ذخیره‌شده |
| `flip_ms` | عدد | مدت انیمیشن ورق‌خوردن به میلی‌ثانیه (۴۰۰–۵۰۰۰) |
| `created_at` | زمان | |
فایل‌ها:
```text
storage/catalogs/{id}/
page-001.jpg
page-002.jpg
...
qr.png
```
صفحات با عرض حداکثر ۱۶۰۰ پیکسل، JPEG با کیفیت حدود ۸۵ ذخیره می‌شوند.
---
## ۱۰. API
پایه: `/api`
| متد | مسیر | کار |
|---|---|---|
| `POST` | `/catalogs` | ساخت. بدنه: تصاویر JPEG صفحات (نه خود PDF) + `title` + `effect` + `flipMs` |
| `GET` | `/catalogs/:id` | متادیتا برای بیننده (بدون `delete_token`)؛ شامل `flipMs` |
| `GET` | `/catalogs/:id/pages/:n` | فایل تصویر صفحه |
| `GET` | `/catalogs/:id/qr` | PNG کد QR. عمومی است چون فقط همان لینک مشاهده را کد می‌کند |
| `DELETE` | `/catalogs/:id` | حذف؛ کوئری `token` باید با `delete_token` یکی باشد |
پاسخ ساخت:
```json
{
"id": "abc12xyz90",
"viewUrl": "https://example.com/c/abc12xyz90",
"deleteUrl": "https://example.com/c/abc12xyz90/delete?token=...",
"qrUrl": "https://example.com/api/catalogs/abc12xyz90/qr"
}
```
`deleteUrl` فقط همین یک‌بار به سازنده داده می‌شود. از `GET` عمومی برنمی‌گردد.
صفحات فرانت:
| مسیر | صفحه |
|---|---|
| `/` | ساخت کاتالوگ |
| `/c/:id` | نمایش کاتالوگ |
| `/c/:id/done?token=...` | صفحه نتیجه (لینک، QR، حذف) — توکن فقط برای نشان دادن دکمه حذف و جلوگیری از دیدن این صفحه توسط بیننده معمولی |
| `/c/:id/delete?token=...` | تأیید حذف |
بیننده `/c/:id` را می‌بیند. صفحه نتیجه بدون توکن باز نشود.
---
## ۱۱. محدودیت‌ها (ضد سوءاستفاده)
چون ثبت‌نام نیست، این سقف‌ها اجباری‌اند:
| مورد | سقف نسخه اول |
|---|---|
| حجم هر فایل ورودی | ۱۰۰ مگابایت |
| تعداد صفحه | حداکثر ۲۰۰ |
| فرمت PDF | `application/pdf` |
| فرمت تصویر | jpeg, png, webp, gif |
| ساخت از یک IP | ۱۰ کاتالوگ در ساعت (شمارنده در حافظه همان پروسه Node؛ یک سرور) |
| عرض تصویر ذخیره‌شده | حداکثر ۱۶۰۰px |
| زمان ورق‌خوردن | ۴۰۰ تا ۵۰۰۰ میلی‌ثانیه |
اگر سقف رد شود، پیام فارسی واضح نشان داده می‌شود و چیزی ذخیره نمی‌شود.
---
## ۱۲. امنیت و حریم
- کاتالوگ **unlisted** است: endpoint لیست وجود ندارد
- `id` تصادفی است؛ نباید پشت سر هم و قابل حدس باشد
- حذف فقط با `delete_token`
- نوع فایل واقعی بررسی شود، نه فقط پسوند
- فایل آپلودی هرگز به عنوان اسکریپت سرو نشود
- برای QR باید `PUBLIC_BASE_URL` در محیط سرور تنظیم شود تا لینک داخل QR درست باشد
این مدل «امنیت از طریق مخفی بودن لینک» است، نه قفل رمز روی کاتالوگ. هر کس لینک را داشته باشد محتوا را می‌بیند.
---
## ۱۳. رابط کاربری (خلاصه)
سه صفحه اصلی، فارسی و RTL:
1. **ساخت:** ناحیه آپلود، لیست صفحات (مرتب‌سازی)، انتخاب افکت، دکمه ساخت
2. **نتیجه:** لینک، QR، هشدار «لینک حذف را ذخیره کن»، دکمه حذف
3. **نمایش:** استیج کاتالوگ، کنترل‌ها، بندانگشتی
ظاهر نسخه اول تمیز و ساده باشد، نه داشبورد شلوغ.
---
## ۱۴. اجرا و پیکربندی
یک سرور Node کافی است. SQLite و پوشه `storage/` کنار اپ می‌مانند.
متغیرهای محیط:
```text
PORT=3000
API_PORT=3010
PUBLIC_BASE_URL=https://ktg.montzar.ir
STORAGE_DIR=./storage
DATABASE_PATH=./data/catalogs.sqlite
```
`PUBLIC_BASE_URL` همان دامنه‌ای است که داخل QR نوشته می‌شود.
---
## ۱۵. گزینه‌هایی که بررسی شد و انتخاب نشد
1. **فقط HTML/CSS** — برای دمو کافی است؛ برای آپلود، PDF، چند افکت و لینک عمومی کافی نیست.
2. **فقط Vue در مرورگر، بدون سرور** — کاتالوگ بعد از بستن تب از بین می‌رود؛ لینک و QR برای دیگری کار نمی‌کند.
3. **Vue + بک‌اند + حساب کاربری / گالری** — برای نسخه اول اضافه است.
انتخاب نهایی: **Vue 3 + API سبک Node + SQLite + فایل روی دیسک.**
---
## ۱۶. معیار تمام شدن نسخه اول
نسخه اول وقتی تمام است که:
- [x] با چند تصویر کاتالوگ ساخته شود (تست API با JPEG)
- [ ] با PDF چندصفحه‌ای در مرورگر (نیاز به بررسی دستی)
- [x] پنج افکت در UI پیاده شده‌اند (بررسی ظاهری باقی است)
- [x] لینک `/c/:id` از API متادیتا می‌گیرد
- [x] QR همان لینک را کد می‌کند
- [x] حذف با توکن کاتالوگ را از بین ببرد
- [x] بدون توکن نشود حذف کرد
- [x] گالری یا لیست عمومی وجود نداشته باشد
- [ ] روی موبایل بررسی دستی
- [x] سقف تعداد صفحه پیام خطا بدهد
---
## ۱۷. کارهای بعدی (عمداً بعد از نسخه اول)
فقط اگر بعداً لازم شد، نه الان:
- ذخیره لینک‌های همین مرورگر در `localStorage`
- عوض کردن افکت بدون آپلود دوباره
- انقضای اختیاری
- رمز روی کاتالوگ
- هاست چند سروره / S3
- حساب کاربری برای تیم
---
## تاریخچه تصمیم
| تاریخ | تصمیم |
|---|---|
| ۲۰۲۶-۰۸-۱۸ | محصول: کاتالوگ دیجیتال از PDF/تصویر با افکت و اشتراک |
| ۲۰۲۶-۰۸-۱۸ | از اول هم مشاهده همان‌جا، هم لینک عمومی + QR |
| ۲۰۲۶-۰۸-۱۸ | بدون ورود؛ هر بازدیدکننده می‌تواند بسازد |
| ۲۰۲۶-۰۸-۱۸ | بدون گالری؛ فقط لینک و QR |
| ۲۰۲۶-۰۸-۱۸ | بعد از ساخت: لینک + QR + لینک حذف؛ بدون ویرایش و بدون انقضا |
| ۲۰۲۶-۰۸-۱۸ | استک: Vue 3 + Hono + SQLite + pdf.js + StPageFlip |
@@ -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 |