Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+342
@@ -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 |
|
||||
Reference in New Issue
Block a user