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
+12
View File
@@ -0,0 +1,12 @@
PORT=3000
API_PORT=3010
PUBLIC_BASE_URL=http://localhost:3000
STORAGE_DIR=./storage
DATABASE_PATH=./data/catalogs.sqlite
# Production (server /var/www/ktg) — see deploy/DEPLOY.md
# NODE_ENV=production
# PORT=3020
# PUBLIC_BASE_URL=https://ktg.montzar.ir
# STORAGE_DIR=/var/www/ktg/storage
# DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
storage
data
.env
*.log
.DS_Store
catalog-deploy.tar
+53
View File
@@ -0,0 +1,53 @@
# کاتالوگ دیجیتال
از PDF یا چند تصویر، یک کاتالوگ ورق‌زدنی با لینک و QR می‌سازد. ثبت‌نام ندارد. کاتالوگ‌ها در سایت لیست نمی‌شوند.
جزئیات محصول: [`docs/PROJECT.md`](docs/PROJECT.md)
## اجرا
نیاز به Node.js ۲۰ یا جدیدتر.
```bash
copy .env.example .env
npm install
npm run dev
```
سپس مرورگر را روی [http://localhost:3000](http://localhost:3000) باز کنید.
- فرانت روی پورت ۳۰۰۰ — فقط همین را در مرورگر باز کنید
- API روی پورت ۳۰۱۰ (از طریق `/api` پروکسی می‌شود)
اگر پورت ۳۰۰۰ اشغال باشد Vite جابه‌جا نمی‌شود. همه ترمینال‌های `npm run dev` را ببندید و یکی را دوباره اجرا کنید.
## ساخت برای سرور
```bash
npm run build
npm start
```
`npm start` هم فرانت ساخته‌شده و هم API را روی `PORT` (پیش‌فرض ۳۰۰۰) سرو می‌کند.
استقرار روی سرور مشترک (`ktg.montzar.ir`): راهنمای قدم‌به‌قدم [`deploy/DEPLOY.md`](deploy/DEPLOY.md).
## تست
```bash
npm test
```
## متغیرهای محیط
در `.env`:
```
PORT=3000
API_PORT=3001
PUBLIC_BASE_URL=http://localhost:3000
STORAGE_DIR=./storage
DATABASE_PATH=./data/catalogs.sqlite
```
`PUBLIC_BASE_URL` همان آدرسی است که داخل QR نوشته می‌شود. روی دامنه واقعی آن را به دامنه خودتان عوض کنید.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>کاتالوگ دیجیتال</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>
+49
View File
@@ -0,0 +1,49 @@
import type { CatalogPublic, CreateCatalogResponse, EffectId } from "@shared/types";
async function readError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string };
return body.error || "خطا در ارتباط با سرور";
} catch {
return "خطا در ارتباط با سرور";
}
}
export async function createCatalog(input: {
title: string;
effect: EffectId;
pages: Blob[];
flipMs: number;
}): Promise<CreateCatalogResponse> {
const data = new FormData();
data.set("title", input.title);
data.set("effect", input.effect);
data.set("flipMs", String(input.flipMs));
input.pages.forEach((page, i) => {
data.append("pages", page, `page-${String(i + 1).padStart(3, "0")}.jpg`);
});
const res = await fetch("/api/catalogs", { method: "POST", body: data });
if (!res.ok) throw new Error(await readError(res));
return res.json();
}
export async function getCatalog(id: string): Promise<CatalogPublic> {
const res = await fetch(`/api/catalogs/${id}`);
if (!res.ok) throw new Error(await readError(res));
return res.json();
}
export function pageUrl(id: string, n: number): string {
return `/api/catalogs/${id}/pages/${n}`;
}
export function qrUrl(id: string): string {
return `/api/catalogs/${id}/qr`;
}
export async function deleteCatalog(id: string, token: string): Promise<void> {
const res = await fetch(`/api/catalogs/${id}?token=${encodeURIComponent(token)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await readError(res));
}
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { EffectId } from "@shared/types";
import { DEFAULT_FLIP_MS } from "@shared/types";
import CssPageEffect from "./effects/CssPageEffect.vue";
import MagazineEffect from "./effects/MagazineEffect.vue";
import ScrollEffect from "./effects/ScrollEffect.vue";
const AUTO_DELAY_MS = 3000;
const AUTO_PAUSE_AFTER_FLIP_MS = 2200;
const props = defineProps<{
effect: EffectId;
images: string[];
flipMs?: number;
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS);
const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS);
const index = ref(0);
const zoom = ref(false);
const fullscreen = ref(false);
const fallbackSlide = ref(false);
const playing = ref(true);
const waiting = ref(true);
const stage = ref<HTMLElement | null>(null);
const magazine = ref<{ next: () => void; prev: () => void } | null>(null);
const windowInner = ref(typeof window === "undefined" ? 1200 : window.innerWidth);
const singlePage = computed(() => windowInner.value < 900);
const visualEffect = computed<EffectId>(() => {
if (fallbackSlide.value && props.effect === "magazine") return "slide";
return props.effect;
});
let delayTimer = 0;
let loopTimer = 0;
function stopLoop() {
window.clearInterval(loopTimer);
loopTimer = 0;
}
function startLoop() {
stopLoop();
loopTimer = window.setInterval(() => {
if (playing.value) advance();
}, autoIntervalMs.value);
}
function advance() {
if (!props.images.length) return;
if (index.value >= props.images.length - 1) {
go(0);
return;
}
next();
}
function next() {
if (visualEffect.value === "magazine") {
magazine.value?.next();
return;
}
if (index.value < props.images.length - 1) index.value += 1;
}
function prev() {
if (visualEffect.value === "magazine") {
magazine.value?.prev();
return;
}
if (index.value > 0) index.value -= 1;
}
function go(i: number) {
index.value = i;
}
function onUserNav(action: () => void) {
waiting.value = false;
action();
if (playing.value) startLoop();
}
function togglePlay() {
playing.value = !playing.value;
waiting.value = false;
if (playing.value) {
advance();
startLoop();
} else {
stopLoop();
}
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "ArrowLeft") onUserNav(next);
if (ev.key === "ArrowRight") onUserNav(prev);
if (ev.key === " ") {
ev.preventDefault();
togglePlay();
}
if (ev.key === "Escape") zoom.value = false;
}
async function toggleFullscreen() {
if (!stage.value) return;
if (!document.fullscreenElement) {
await stage.value.requestFullscreen();
fullscreen.value = true;
} else {
await document.exitFullscreen();
fullscreen.value = false;
}
}
function onResize() {
windowInner.value = window.innerWidth;
}
function armAutoplay() {
window.clearTimeout(delayTimer);
stopLoop();
waiting.value = true;
delayTimer = window.setTimeout(() => {
waiting.value = false;
if (!playing.value) return;
advance();
startLoop();
}, AUTO_DELAY_MS);
}
onMounted(() => {
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
armAutoplay();
});
onUnmounted(() => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
window.clearTimeout(delayTimer);
stopLoop();
});
watch(
() => props.images,
() => {
index.value = 0;
fallbackSlide.value = false;
playing.value = true;
armAutoplay();
},
);
</script>
<template>
<div ref="stage" class="viewer-wrap cinematic">
<div class="stage-bar">
<button class="btn" type="button" @click="onUserNav(prev)">قبلی</button>
<button class="btn" type="button" @click="onUserNav(next)">بعدی</button>
<span>{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn play" type="button" @click="togglePlay">
{{ playing ? "توقف پخش" : "پخش خودکار" }}
</button>
<span style="flex: 1"></span>
<button class="btn" type="button" @click="zoom = !zoom">زوم</button>
<button class="btn" type="button" @click="toggleFullscreen">تمام‌صفحه</button>
</div>
<div v-if="waiting && playing" class="auto-hint">
<span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span>
<i class="hint-bar"></i>
</div>
<ScrollEffect
v-if="visualEffect === 'scroll'"
:images="images"
:index="index"
@update:index="onUserNav(() => go($event))"
/>
<MagazineEffect
v-else-if="visualEffect === 'magazine'"
ref="magazine"
:images="images"
:index="index"
:single-page="singlePage"
:flip-ms="flipMs"
@update:index="index = $event"
@fallback="fallbackSlide = true"
/>
<CssPageEffect
v-else
:images="images"
:index="index"
:mode="visualEffect === 'fade' ? 'fade' : visualEffect === 'card' ? 'card' : 'slide'"
:zoom="zoom"
:flip-ms="flipMs"
@next="onUserNav(next)"
@prev="onUserNav(prev)"
/>
<div v-if="visualEffect !== 'scroll'" class="thumbs">
<img
v-for="(src, i) in images"
:key="src"
:src="src"
:class="{ active: i === index }"
alt=""
@click="onUserNav(() => go(i))"
/>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { EffectId } from "@shared/types";
import { EFFECT_OPTIONS } from "../constants";
defineProps<{
modelValue: EffectId;
}>();
const emit = defineEmits<{
"update:modelValue": [value: EffectId];
}>();
</script>
<template>
<div class="effects">
<label v-for="opt in EFFECT_OPTIONS" :key="opt.id" class="effect">
<input
type="radio"
name="effect"
:value="opt.id"
:checked="modelValue === opt.id"
@change="emit('update:modelValue', opt.id)"
/>
<strong>{{ opt.title }}</strong>
<small>{{ opt.hint }}</small>
</label>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
export type DraftPage = {
key: string;
preview: string;
blob: Blob;
};
defineProps<{
pages: DraftPage[];
}>();
const emit = defineEmits<{
move: [index: number, dir: -1 | 1];
remove: [index: number];
}>();
</script>
<template>
<div class="pages">
<div v-for="(page, index) in pages" :key="page.key" class="page-row">
<img :src="page.preview" alt="" />
<div class="grow">صفحه {{ index + 1 }}</div>
<button class="icon-btn" type="button" @click="emit('move', index, -1)">بالا</button>
<button class="icon-btn" type="button" @click="emit('move', index, 1)">پایین</button>
<button class="icon-btn" type="button" @click="emit('remove', index)">حذف</button>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
const props = defineProps<{
images: string[];
index: number;
mode: "slide" | "fade" | "card";
zoom: boolean;
flipMs: number;
}>();
const emit = defineEmits<{
next: [];
prev: [];
}>();
const dir = ref<"next" | "prev">("next");
const current = computed(() => props.images[props.index] ?? "");
const transitionName = computed(() => `fx-${props.mode}-${dir.value}`);
watch(
() => props.index,
(now, was) => {
if (was === undefined) return;
dir.value = now >= was ? "next" : "prev";
},
);
let startX = 0;
let tracking = false;
function onPointerDown(ev: PointerEvent) {
tracking = true;
startX = ev.clientX;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onPointerUp(ev: PointerEvent) {
if (!tracking) return;
tracking = false;
const dx = ev.clientX - startX;
if (Math.abs(dx) < 48) {
const el = ev.currentTarget as HTMLElement;
const x = ev.clientX - el.getBoundingClientRect().left;
if (x < el.clientWidth / 2) emit("next");
else emit("prev");
return;
}
if (dx < 0) emit("next");
else emit("prev");
}
</script>
<template>
<div
class="fx-stage"
:class="[mode, { 'zoom-in': zoom }]"
:style="{ '--flip-ms': `${flipMs}ms` }"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
@pointercancel="tracking = false"
>
<Transition :name="transitionName">
<div :key="`${mode}-${index}`" class="page-wrap">
<img class="page-img" :src="current" alt="" draggable="false" />
</div>
</Transition>
</div>
</template>
<style scoped>
.fx-stage {
position: relative;
width: min(920px, 100%);
margin: 0 auto;
height: min(74vh, 780px);
display: grid;
place-items: center;
perspective: 2200px;
touch-action: pan-y;
overflow: visible;
}
.fx-stage.card {
perspective: 2600px;
}
.page-wrap {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform-style: preserve-3d;
}
.page-img {
max-width: min(100%, 820px);
max-height: 72vh;
width: auto;
height: auto;
object-fit: contain;
background: #fff;
border-radius: 4px;
box-shadow:
0 8px 10px rgba(0, 0, 0, 0.18),
0 28px 60px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.06);
user-select: none;
backface-visibility: hidden;
}
.zoom-in .page-img {
max-height: 90vh;
max-width: 100%;
}
.fx-slide-next-enter-active,
.fx-slide-next-leave-active,
.fx-slide-prev-enter-active,
.fx-slide-prev-leave-active,
.fx-fade-next-enter-active,
.fx-fade-next-leave-active,
.fx-fade-prev-enter-active,
.fx-fade-prev-leave-active,
.fx-card-next-enter-active,
.fx-card-next-leave-active,
.fx-card-prev-enter-active,
.fx-card-prev-leave-active {
position: absolute;
transition:
transform var(--flip-ms, 1s) cubic-bezier(0.22, 1, 0.36, 1),
opacity var(--flip-ms, 1s) cubic-bezier(0.22, 1, 0.36, 1),
filter var(--flip-ms, 1s) ease;
}
.fx-slide-next-enter-from {
transform: translateX(-58%) scale(0.86) rotateY(-22deg);
opacity: 0;
filter: blur(10px);
}
.fx-slide-next-leave-to {
transform: translateX(52%) scale(0.88) rotateY(16deg);
opacity: 0;
filter: blur(8px);
}
.fx-slide-prev-enter-from {
transform: translateX(58%) scale(0.86) rotateY(22deg);
opacity: 0;
filter: blur(10px);
}
.fx-slide-prev-leave-to {
transform: translateX(-52%) scale(0.88) rotateY(-16deg);
opacity: 0;
filter: blur(8px);
}
.fx-fade-next-enter-from,
.fx-fade-prev-enter-from {
opacity: 0;
transform: scale(1.12);
filter: blur(16px) brightness(1.15);
}
.fx-fade-next-leave-to,
.fx-fade-prev-leave-to {
opacity: 0;
transform: scale(0.9);
filter: blur(10px) brightness(0.7);
}
.fx-card-next-enter-from {
transform: rotateY(-95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-next-leave-to {
transform: rotateY(95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-prev-enter-from {
transform: rotateY(95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-prev-leave-to {
transform: rotateY(-95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
@media (prefers-reduced-motion: reduce) {
.fx-slide-next-enter-active,
.fx-slide-next-leave-active,
.fx-slide-prev-enter-active,
.fx-slide-prev-leave-active,
.fx-fade-next-enter-active,
.fx-fade-next-leave-active,
.fx-fade-prev-enter-active,
.fx-fade-prev-leave-active,
.fx-card-next-enter-active,
.fx-card-next-leave-active,
.fx-card-prev-enter-active,
.fx-card-prev-leave-active {
transition-duration: 0.01ms;
}
}
</style>
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
import { PageFlip } from "page-flip";
const props = defineProps<{
images: string[];
index: number;
singlePage: boolean;
flipMs: number;
}>();
const emit = defineEmits<{
"update:index": [value: number];
fallback: [];
}>();
const host = ref<HTMLElement | null>(null);
const outer = ref<HTMLElement | null>(null);
const mountKey = ref(0);
let book: PageFlip | null = null;
let ignore = false;
let prepared: string[] = [];
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image"));
img.src = src;
});
}
function revokePrepared() {
for (const url of prepared) URL.revokeObjectURL(url);
prepared = [];
}
function containDraw(
img: HTMLImageElement,
pageW: number,
pageH: number,
): Promise<string> {
const canvas = document.createElement("canvas");
canvas.width = pageW;
canvas.height = pageH;
const ctx = canvas.getContext("2d");
if (!ctx) return Promise.reject(new Error("canvas"));
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, pageW, pageH);
const scale = Math.min(pageW / img.naturalWidth, pageH / img.naturalHeight);
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const x = Math.round((pageW - w) / 2);
const y = Math.round((pageH - h) / 2);
ctx.save();
ctx.translate(pageW, 0);
ctx.scale(-1, 1);
ctx.drawImage(img, x, y, w, h);
ctx.restore();
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("blob"));
return;
}
const url = URL.createObjectURL(blob);
prepared.push(url);
resolve(url);
},
"image/jpeg",
0.92,
);
});
}
function pageBox(first: HTMLImageElement, maxW: number, maxH: number) {
const ratio = first.naturalWidth / Math.max(1, first.naturalHeight);
const pages = props.singlePage ? 1 : 2;
const scale = Math.min(
maxW / (pages * first.naturalWidth),
maxH / first.naturalHeight,
);
const pageW = Math.max(1, Math.floor(first.naturalWidth * scale));
const pageH = Math.max(1, Math.floor(pageW / ratio));
return { pageW, pageH };
}
async function makeBook() {
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
mountKey.value += 1;
await nextTick();
if (!host.value || !outer.value || !props.images.length) return;
try {
const first = await loadImage(props.images[0]);
const maxW = Math.max(280, outer.value.clientWidth - 16);
const maxH = Math.max(320, outer.value.clientHeight - 16);
const { pageW, pageH } = pageBox(first, maxW, maxH);
const fitted: string[] = [];
for (const src of props.images) {
try {
const img = src === props.images[0] ? first : await loadImage(src);
fitted.push(await containDraw(img, pageW, pageH));
} catch {
fitted.push(src);
}
}
await nextTick();
if (!host.value) return;
book = new PageFlip(host.value, {
width: pageW,
height: pageH,
size: "fixed",
autoSize: false,
drawShadow: true,
flippingTime: props.flipMs,
usePortrait: props.singlePage,
maxShadowOpacity: 0.85,
showCover: false,
mobileScrollSupport: false,
useMouseEvents: false,
showPageCorners: true,
startPage: props.index,
});
book.loadFromImages(fitted);
book.on("flip", (e) => {
ignore = true;
emit("update:index", e.data);
queueMicrotask(() => {
ignore = false;
});
});
} catch {
emit("fallback");
}
}
watch(
() => [props.images.join("|"), props.singlePage, props.flipMs] as const,
() => {
void makeBook();
},
{ immediate: true },
);
watch(
() => props.index,
(value) => {
if (!book || ignore) return;
try {
if (book.getCurrentPageIndex() !== value) book.turnToPage(value);
} catch {
emit("fallback");
}
},
);
onBeforeUnmount(() => {
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
});
defineExpose({
next() {
book?.flipNext();
},
prev() {
book?.flipPrev();
},
});
let pointerX = 0;
function onPointerDown(ev: PointerEvent) {
pointerX = ev.clientX;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onPointerUp(ev: PointerEvent) {
if (!book) return;
const dx = ev.clientX - pointerX;
if (Math.abs(dx) > 42) {
if (dx > 0) book.flipNext();
else book.flipPrev();
return;
}
const el = ev.currentTarget as HTMLElement;
const x = ev.clientX - el.getBoundingClientRect().left;
if (x < el.clientWidth / 2) book.flipNext();
else book.flipPrev();
}
</script>
<template>
<div class="magazine-frame" dir="ltr">
<div class="magazine-glow"></div>
<div
ref="outer"
class="flip-stage magazine-outer"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
>
<div :key="mountKey" ref="host" class="magazine-host"></div>
</div>
</div>
</template>
<style scoped>
.magazine-frame {
position: relative;
width: min(980px, 100%);
margin: 0 auto;
direction: ltr;
}
.magazine-glow {
position: absolute;
inset: 12% 18%;
background: radial-gradient(circle, rgba(245, 158, 11, 0.18), transparent 70%);
filter: blur(30px);
pointer-events: none;
}
.magazine-outer {
position: relative;
width: 100%;
height: min(74vh, 780px);
display: flex;
align-items: center;
justify-content: center;
direction: ltr;
}
.magazine-host {
width: max-content;
height: max-content;
max-width: 100%;
transform: scaleX(-1);
filter: drop-shadow(0 28px 50px rgba(0, 0, 0, 0.55));
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { nextTick, watch } from "vue";
const props = defineProps<{
images: string[];
index: number;
}>();
const emit = defineEmits<{
"update:index": [value: number];
}>();
watch(
() => props.index,
async (value) => {
await nextTick();
const el = document.getElementById(`scroll-page-${value}`);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
},
);
</script>
<template>
<div class="scroll-list">
<article
v-for="(src, i) in images"
:id="`scroll-page-${i}`"
:key="src"
class="scroll-card"
:class="{ active: i === index }"
@click="emit('update:index', i)"
>
<img :src="src" :alt="`صفحه ${i + 1}`" />
</article>
</div>
</template>
<style scoped>
.scroll-list {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 820px;
margin: 0 auto;
padding: 8px 12px 48px;
scroll-snap-type: y mandatory;
}
.scroll-card {
scroll-snap-align: center;
transform: scale(0.96);
opacity: 0.55;
transition:
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.55s ease,
box-shadow 0.55s ease;
}
.scroll-card.active {
transform: scale(1);
opacity: 1;
}
.scroll-card img {
display: block;
width: 100%;
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.scroll-card.active img {
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(245, 158, 11, 0.35);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import type { EffectId } from "@shared/types";
export const EFFECT_OPTIONS: { id: EffectId; title: string; hint: string }[] = [
{ id: "magazine", title: "ورق مجله", hint: "سه‌بعدی، شبیه ورق زدن کاغذ" },
{ id: "slide", title: "اسلاید افقی", hint: "صفحه بعدی از کنار می‌آید" },
{ id: "fade", title: "محو شدن", hint: "صفحه آرام عوض می‌شود" },
{ id: "card", title: "کارت", hint: "ورق دوبعدی مثل کارت" },
{ id: "scroll", title: "اسکرول عمودی", hint: "همه صفحات زیر هم" },
];
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
declare module "page-flip" {
export class PageFlip {
constructor(el: HTMLElement, settings: Record<string, unknown>);
loadFromImages(images: string[]): void;
flipNext(): void;
flipPrev(): void;
turnToPage(page: number): void;
getCurrentPageIndex(): number;
getPageCount(): number;
destroy(): void;
on(event: string, cb: (e: { data: number }) => void): void;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { LIMITS, fileTooLargeMessage, tooManyPagesMessage } from "@shared/types";
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export function isImageFile(file: File): boolean {
if (IMAGE_TYPES.has(file.type)) return true;
return /\.(jpe?g|png|webp|gif)$/i.test(file.name);
}
export function isPdfFile(file: File): boolean {
return file.type === "application/pdf" || /\.pdf$/i.test(file.name);
}
function drawToJpeg(source: CanvasImageSource, width: number, height: number): Promise<Blob> {
const scale = Math.min(1, LIMITS.maxPageWidth / width);
const w = Math.max(1, Math.round(width * scale));
const h = Math.max(1, Math.round(height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return Promise.reject(new Error("تبدیل تصویر ناموفق بود"));
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(source, 0, 0, w, h);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error("تبدیل تصویر ناموفق بود"));
else resolve(blob);
},
"image/jpeg",
LIMITS.jpegQuality / 100,
);
});
}
export async function fileToJpeg(file: File): Promise<Blob> {
if (file.size > LIMITS.maxFileBytes) {
throw new Error(fileTooLargeMessage(file.size));
}
const bitmap = await createImageBitmap(file);
try {
return await drawToJpeg(bitmap, bitmap.width, bitmap.height);
} finally {
bitmap.close();
}
}
export async function filesToJpegs(
files: File[],
onProgress?: (done: number, total: number) => void,
): Promise<Blob[]> {
if (files.length > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(files.length));
}
const out: Blob[] = [];
onProgress?.(0, files.length);
for (const file of files) {
out.push(await fileToJpeg(file));
onProgress?.(out.length, files.length);
}
return out;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
createApp(App).use(router).mount("#app");
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import type { EffectId } from "@shared/types";
import { DEFAULT_FLIP_MS, LIMITS, tooManyPagesMessage } from "@shared/types";
import { createCatalog } from "../api";
import EffectSelect from "../components/EffectSelect.vue";
import PageList, { type DraftPage } from "../components/PageList.vue";
import { filesToJpegs, isImageFile, isPdfFile } from "../images";
import { renderPdfToJpegs } from "../pdf";
const router = useRouter();
const title = ref("");
const effect = ref<EffectId>("magazine");
const flipMs = ref(DEFAULT_FLIP_MS);
const pages = ref<DraftPage[]>([]);
const busy = ref(false);
const progress = ref("");
const progressDone = ref(0);
const progressTotal = ref(0);
const error = ref("");
const dragOver = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const canSubmit = computed(() => pages.value.length > 0 && !busy.value);
const progressPct = computed(() => {
if (!progressTotal.value) return 0;
return Math.round((progressDone.value / progressTotal.value) * 100);
});
function setProgress(message: string, done = 0, total = 0) {
progress.value = message;
progressDone.value = done;
progressTotal.value = total;
}
function revokeAll() {
for (const page of pages.value) URL.revokeObjectURL(page.preview);
}
function setPages(blobs: Blob[]) {
revokeAll();
pages.value = blobs.map((blob, i) => ({
key: `${Date.now()}-${i}`,
blob,
preview: URL.createObjectURL(blob),
}));
}
function move(index: number, dir: -1 | 1) {
const next = index + dir;
if (next < 0 || next >= pages.value.length) return;
const copy = [...pages.value];
const [item] = copy.splice(index, 1);
copy.splice(next, 0, item);
pages.value = copy;
}
function remove(index: number) {
const copy = [...pages.value];
const [item] = copy.splice(index, 1);
if (item) URL.revokeObjectURL(item.preview);
pages.value = copy;
}
async function ingest(fileList: File[]) {
if (busy.value) return;
error.value = "";
if (!fileList.length) return;
const pdfs = fileList.filter(isPdfFile);
const images = fileList.filter(isImageFile);
if (pdfs.length && images.length) {
error.value = "یک PDF یا چند تصویر انتخاب کنید، نه هر دو با هم.";
return;
}
if (!pdfs.length && !images.length) {
error.value = "نوع فایل پشتیبانی نمی‌شود";
return;
}
if (pdfs.length > 1) {
error.value = "فقط یک فایل PDF انتخاب کنید.";
return;
}
busy.value = true;
setProgress("در حال خواندن فایل…");
try {
if (pdfs.length === 1) {
setProgress("در حال خواندن PDF…");
const blobs = await renderPdfToJpegs(pdfs[0], (done, total) => {
setProgress(
done === 0 ? "در حال آماده‌سازی صفحات…" : `تبدیل PDF: ${done} از ${total}`,
done,
total,
);
});
setPages(blobs);
} else {
if (images.length > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(images.length));
}
setProgress("در حال آماده‌سازی تصاویر…");
setPages(
await filesToJpegs(images, (done, total) => {
setProgress(
done === 0 ? "در حال آماده‌سازی تصاویر…" : `تبدیل تصویر: ${done} از ${total}`,
done,
total,
);
}),
);
}
setProgress(`${pages.value.length} صفحه آماده است`);
} catch (err) {
error.value = err instanceof Error ? err.message : "خطا در خواندن فایل";
setProgress("");
} finally {
busy.value = false;
}
}
function onDrop(ev: DragEvent) {
dragOver.value = false;
const list = ev.dataTransfer?.files;
if (list) void ingest([...list]);
}
async function submit() {
if (busy.value) return;
error.value = "";
busy.value = true;
setProgress("در حال ساخت کاتالوگ…");
try {
const created = await createCatalog({
title: title.value,
effect: effect.value,
flipMs: flipMs.value,
pages: pages.value.map((p) => p.blob),
});
const token = new URL(created.deleteUrl).searchParams.get("token") ?? "";
await router.push({ path: `/c/${created.id}/done`, query: { token } });
} catch (err) {
error.value = err instanceof Error ? err.message : "ساخت کاتالوگ ناموفق بود";
setProgress(pages.value.length ? `${pages.value.length} صفحه آماده است` : "");
} finally {
busy.value = false;
}
}
</script>
<template>
<main class="page">
<header class="hero">
<h1>کاتالوگ دیجیتال</h1>
<p>PDF یا تصویرها را بدهید؛ لینک و QR کاتالوگ ورق‌زدنی را بگیرید.</p>
</header>
<section class="card create-card">
<div
v-if="busy"
class="loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
>
<span class="spinner" aria-hidden="true"></span>
<p>{{ progress || "لطفاً صبر کنید…" }}</p>
<div v-if="progressTotal" class="progress-track">
<i :style="{ width: `${progressPct}%` }"></i>
</div>
</div>
<div
class="drop"
:class="{ active: dragOver, disabled: busy }"
@dragover.prevent="!busy && (dragOver = true)"
@dragleave="dragOver = false"
@drop.prevent="onDrop"
>
<p>فایل را اینجا رها کنید یا انتخاب کنید</p>
<button class="btn" type="button" :disabled="busy" @click="fileInput?.click()">
انتخاب فایل
</button>
<input
ref="fileInput"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp,image/gif,.pdf,.jpg,.jpeg,.png,.webp,.gif"
multiple
@change="ingest(fileInput?.files ? [...fileInput.files] : [])"
/>
</div>
<div class="field">
<label>عنوان (اختیاری)</label>
<input v-model="title" type="text" placeholder="کاتالوگ" maxlength="80" />
</div>
<div class="field">
<label>افکت ورق‌زدن</label>
<EffectSelect v-model="effect" />
</div>
<div class="field">
<label>زمان ورق‌خوردن ({{ (flipMs / 1000).toFixed(1) }} ثانیه)</label>
<div class="flip-speed">
<span>سریع</span>
<input
v-model.number="flipMs"
type="range"
:min="LIMITS.minFlipMs"
:max="LIMITS.maxFlipMs"
step="100"
/>
<span>آهسته</span>
</div>
</div>
<div v-if="pages.length" class="field">
<label>صفحات ({{ pages.length }})</label>
<PageList :pages="pages" @move="move" @remove="remove" />
</div>
<p v-if="progress && !busy" class="ok">{{ progress }}</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="row-actions">
<button class="btn primary" type="button" :disabled="!canSubmit" @click="submit">
ساخت کاتالوگ
</button>
</div>
</section>
</main>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { deleteCatalog } from "../api";
const route = useRoute();
const id = computed(() => String(route.params.id));
const token = computed(() => String(route.query.token ?? ""));
const busy = ref(false);
const error = ref("");
const gone = ref(false);
async function confirmDelete() {
error.value = "";
busy.value = true;
try {
await deleteCatalog(id.value, token.value);
gone.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : "حذف ناموفق بود";
} finally {
busy.value = false;
}
}
</script>
<template>
<main class="page">
<header class="hero">
<h1>حذف کاتالوگ</h1>
<p v-if="!gone">این کار قابل بازگشت نیست. لینک و QR دیگر کار نمی‌کنند.</p>
</header>
<section class="card">
<template v-if="gone">
<p class="ok">کاتالوگ حذف شد.</p>
<router-link class="btn" to="/">ساخت کاتالوگ جدید</router-link>
</template>
<template v-else>
<p v-if="!token" class="error">لینک حذف نامعتبر است.</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="row-actions">
<button
class="btn danger"
type="button"
:disabled="!token || busy"
@click="confirmDelete"
>
حذف کاتالوگ
</button>
<router-link class="btn" :to="`/c/${id}`">انصراف</router-link>
</div>
</template>
</section>
</main>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { qrUrl } from "../api";
const route = useRoute();
const router = useRouter();
const id = computed(() => String(route.params.id));
const token = computed(() => String(route.query.token ?? ""));
const copied = ref(false);
const viewLink = computed(() => `${window.location.origin}/c/${id.value}`);
const deleteLink = computed(
() => `${window.location.origin}/c/${id.value}/delete?token=${encodeURIComponent(token.value)}`,
);
onMounted(() => {
if (!token.value) {
void router.replace(`/c/${id.value}`);
}
});
async function copy(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
}
function downloadQr() {
const a = document.createElement("a");
a.href = qrUrl(id.value);
a.download = `catalog-${id.value}-qr.png`;
a.click();
}
</script>
<template>
<main v-if="token" class="page">
<header class="hero">
<h1>کاتالوگ آماده است</h1>
<p>لینک و QR را برای اشتراک بفرستید.</p>
</header>
<section class="card">
<label>لینک مشاهده</label>
<div class="link-box">
<input :value="viewLink" readonly />
<button class="btn" type="button" @click="copy(viewLink)">کپی</button>
</div>
<p v-if="copied" class="ok">کپی شد.</p>
<div class="field">
<img class="qr" :src="qrUrl(id)" alt="QR کاتالوگ" />
</div>
<div class="row-actions">
<button class="btn primary" type="button" @click="downloadQr">دانلود QR</button>
<router-link class="btn" :to="`/c/${id}`">باز کردن کاتالوگ</router-link>
</div>
<p class="warn">
لینک حذف را همین حالا ذخیره کنید. اگر گم شود قابل بازیابی نیست.
</p>
<div class="link-box">
<input :value="deleteLink" readonly />
<button class="btn" type="button" @click="copy(deleteLink)">کپی حذف</button>
</div>
<div class="row-actions">
<router-link class="btn danger" :to="`/c/${id}/delete?token=${encodeURIComponent(token)}`">
حذف کاتالوگ
</router-link>
</div>
</section>
</main>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { getCatalog, pageUrl } from "../api";
import CatalogViewer from "../components/CatalogViewer.vue";
import type { CatalogPublic } from "@shared/types";
const route = useRoute();
const id = computed(() => String(route.params.id));
const catalog = ref<CatalogPublic | null>(null);
const error = ref("");
const loading = ref(true);
const images = computed(() => {
if (!catalog.value) return [];
return Array.from({ length: catalog.value.pageCount }, (_, i) =>
pageUrl(catalog.value!.id, i + 1),
);
});
onMounted(async () => {
try {
catalog.value = await getCatalog(id.value);
} catch (err) {
error.value = err instanceof Error ? err.message : "کاتالوگ پیدا نشد";
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="stage-page">
<header class="stage-bar">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn" to="/">ساخت جدید</router-link>
</header>
<p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p>
<p v-else-if="error" class="viewer-wrap error">{{ error }}</p>
<CatalogViewer
v-else-if="catalog"
:effect="catalog.effect"
:images="images"
:flip-ms="catalog.flipMs"
/>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
import * as pdfjs from "pdfjs-dist";
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { LIMITS, fileTooLargeMessage, tooManyPagesMessage } from "@shared/types";
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker;
function canvasToJpeg(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error("تبدیل تصویر ناموفق بود"));
else resolve(blob);
},
"image/jpeg",
LIMITS.jpegQuality / 100,
);
});
}
export async function renderPdfToJpegs(
file: File,
onProgress?: (done: number, total: number) => void,
): Promise<Blob[]> {
if (file.size > LIMITS.maxFileBytes) {
throw new Error(fileTooLargeMessage(file.size));
}
const data = new Uint8Array(await file.arrayBuffer());
let pdf;
try {
pdf = await pdfjs.getDocument({ data }).promise;
} catch (err) {
const name =
err && typeof err === "object" && "name" in err
? String((err as { name: string }).name)
: "";
if (name === "PasswordException") {
throw new Error("این PDF رمزگذاری شده است و قابل تبدیل نیست.");
}
if (name === "InvalidPDFException") {
throw new Error("این فایل یک PDF معتبر نیست.");
}
throw err;
}
if (pdf.numPages < 1) {
throw new Error("این PDF صفحه‌ای برای نمایش ندارد.");
}
if (pdf.numPages > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(pdf.numPages));
}
const pages: Blob[] = [];
onProgress?.(0, pdf.numPages);
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(LIMITS.maxPageWidth / base.width, 2);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("تبدیل تصویر ناموفق بود");
const renderTask = page.render({
canvasContext: ctx,
canvas,
viewport,
} as Parameters<typeof page.render>[0]);
await renderTask.promise;
pages.push(await canvasToJpeg(canvas));
onProgress?.(i, pdf.numPages);
}
return pages;
}
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from "vue-router";
import CreatePage from "./pages/CreatePage.vue";
import ViewPage from "./pages/ViewPage.vue";
import DonePage from "./pages/DonePage.vue";
import DeletePage from "./pages/DeletePage.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: CreatePage },
{ path: "/c/:id/done", component: DonePage },
{ path: "/c/:id/delete", component: DeletePage },
{ path: "/c/:id", component: ViewPage },
],
});
+451
View File
@@ -0,0 +1,451 @@
:root {
--bg: #f3eee6;
--bg-2: #fffdf9;
--ink: #1c1917;
--muted: #78716c;
--line: #e7e0d6;
--accent: #b45309;
--accent-2: #9a3412;
--danger: #b91c1c;
--stage: #161210;
--shadow: 0 18px 50px rgba(28, 25, 23, 0.12);
font-family: Vazirmatn, Tahoma, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--ink);
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: var(--accent-2);
}
.page {
max-width: 920px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 1.8rem;
}
.hero p {
margin: 0;
color: var(--muted);
}
.card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 22px;
margin-top: 24px;
}
.create-card {
position: relative;
overflow: hidden;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 24px;
background: rgba(255, 253, 249, 0.92);
text-align: center;
}
.loading-overlay p {
margin: 0;
font-weight: 600;
color: var(--accent-2);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e7e0d6;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress-track {
width: min(280px, 80%);
height: 6px;
border-radius: 99px;
background: #e7e0d6;
overflow: hidden;
}
.progress-track i {
display: block;
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-top-color: #e7e0d6;
border-color: var(--accent);
}
}
.drop {
border: 2px dashed #d6cbb8;
border-radius: 16px;
padding: 36px 16px;
text-align: center;
background: #faf7f2;
transition: border-color 0.15s, background 0.15s;
}
.drop.active {
border-color: var(--accent);
background: #fff7ed;
}
.drop.disabled {
opacity: 0.65;
pointer-events: none;
}
.drop input {
display: none;
}
.effects {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-top: 12px;
}
.effect {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: #fff;
text-align: right;
}
.effect input {
margin-left: 8px;
}
.effect small {
display: block;
color: var(--muted);
margin-top: 4px;
}
.flip-speed {
display: flex;
align-items: center;
gap: 12px;
}
.flip-speed input[type="range"] {
flex: 1;
}
.flip-speed span {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.pages {
display: grid;
gap: 8px;
margin-top: 12px;
}
.page-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px;
background: #fff;
}
.page-row img {
width: 52px;
height: 68px;
object-fit: cover;
border-radius: 6px;
background: #eee;
}
.page-row .grow {
flex: 1;
}
.icon-btn,
.btn {
border: 0;
border-radius: 12px;
padding: 8px 12px;
background: #e7e0d6;
color: var(--ink);
}
.btn.primary {
background: var(--accent);
color: #fff;
padding: 12px 18px;
font-weight: 600;
}
.btn.danger {
background: var(--danger);
color: #fff;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.field {
margin-top: 18px;
}
.field label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.field input[type="text"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
}
.error {
color: var(--danger);
margin-top: 12px;
}
.ok {
color: #166534;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.link-box {
display: flex;
gap: 8px;
margin-top: 8px;
}
.link-box input {
flex: 1;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
}
.qr {
width: 220px;
height: 220px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--line);
}
.warn {
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
border-radius: 12px;
padding: 12px;
margin-top: 16px;
}
.stage-page {
min-height: 100vh;
background:
radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%),
var(--stage);
color: #f5f0ea;
}
.stage-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #2a241f;
}
.stage-bar h1 {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.stage-bar a,
.stage-bar button {
color: #f5f0ea;
background: #2a241f;
text-decoration: none;
}
.viewer-wrap {
padding: 16px;
position: relative;
}
.viewer-wrap.cinematic {
background:
radial-gradient(1200px 500px at 50% 20%, rgba(245, 158, 11, 0.08), transparent 55%),
radial-gradient(circle at 50% 110%, rgba(0, 0, 0, 0.45), transparent 50%);
border-radius: 16px;
}
.auto-hint {
position: relative;
max-width: 420px;
margin: 0 auto 14px;
text-align: center;
color: #fde68a;
font-size: 0.92rem;
overflow: hidden;
border-radius: 999px;
background: rgba(41, 32, 22, 0.75);
padding: 10px 16px 12px;
}
.hint-bar {
display: block;
height: 3px;
margin: 8px 8px 0;
border-radius: 99px;
background: linear-gradient(90deg, #f59e0b, #fde68a);
transform-origin: right center;
animation: hint-fill 3s linear forwards;
}
@keyframes hint-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.stage-bar .btn.play {
background: #b45309;
color: #fff;
}
.thumbs {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 8px 16px 20px;
}
.thumbs img {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 6px;
opacity: 0.55;
cursor: pointer;
border: 2px solid transparent;
}
.thumbs img.active {
opacity: 1;
border-color: #f59e0b;
}
.flip-stage {
width: min(920px, 100%);
margin: 0 auto;
height: min(72vh, 740px);
display: flex;
align-items: center;
justify-content: center;
perspective: 1600px;
}
.flip-stage img.page-img {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
background: #fff;
user-select: none;
}
.scroll-list {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 820px;
margin: 0 auto;
padding: 12px;
}
.scroll-list img {
width: 100%;
background: #fff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
}
.zoom-in img.page-img {
max-height: 92vh;
max-width: 100%;
}
+6
View File
@@ -0,0 +1,6 @@
NODE_ENV=production
PORT=3020
API_PORT=3020
PUBLIC_BASE_URL=https://ktg.montzar.ir
STORAGE_DIR=/var/www/ktg/storage
DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
+167
View File
@@ -0,0 +1,167 @@
# استقرار کاتالوگ — `ktg.montzar.ir`
هاست مشترک مونتایم: `185.50.38.53` (SSH پورت `3031`، کاربر `montime`)
دامنه: `https://ktg.montzar.ir`
پورت داخلی: `3020` (۳۰۰۰ مونتایم، ۳۰۰۱ فوری‌سند، ۳۰۱۰ سوپرادمین)
دیتابیس Postgres بقیه پروژه‌ها را لمس نکنید. این اپ SQLite خودش را در `/var/www/ktg/data` دارد.
---
## کارهایی که روی لپ‌تاپ شماست
### ۱) بستهٔ ارسال را بسازید
در PowerShell:
```powershell
cd E:\projeha\DigitalCatalog
if (Test-Path catalog-deploy.tar) { Remove-Item catalog-deploy.tar }
tar --exclude=node_modules --exclude=dist --exclude=storage --exclude=data --exclude=.env --exclude=catalog-deploy.tar -cvf catalog-deploy.tar .
```
### ۲) آپلود روی سرور
رمز SSH همانی است که برای سوپرادمین/مونتایم می‌زنید:
```powershell
scp -P 3031 catalog-deploy.tar montime@185.50.38.53:/tmp/catalog-deploy.tar
```
بعد از آپلود، فایل `catalog-deploy.tar` روی ویندوز را می‌توانید پاک کنید.
---
## کارهایی که روی سرور است
SSH:
```bash
ssh -p 3031 montime@185.50.38.53
```
### ۳) پوشه و باز کردن فایل‌ها
```bash
sudo mkdir -p /var/www/ktg
sudo chown montime:montime /var/www/ktg
cd /var/www/ktg
tar -xvf /tmp/catalog-deploy.tar
rm /tmp/catalog-deploy.tar
```
اگر پوشه از قبل چیزی داشت و آپدیت است، `storage` و `data` را پاک نکنید.
### ۴) پورت آزاد باشد
```bash
pm2 list
ss -tlnp | grep -E '3000|3001|3010|3020' || netstat -tlnp | grep -E '3000|3001|3010|3020'
```
۳۰۲۰ باید خالی باشد. اگر اشغال بود بگویید تا پورت عوض شود؛ خودتان پورت بقیه را عوض نکنید.
### ۵) فایل `.env` سرور
```bash
cd /var/www/ktg
cp deploy/.env.production.example .env
nano .env
```
باید دقیقاً این باشد (مگر پورت عوض شده باشد):
```env
NODE_ENV=production
PORT=3020
API_PORT=3020
PUBLIC_BASE_URL=https://ktg.montzar.ir
STORAGE_DIR=/var/www/ktg/storage
DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
```
دیتابیس Postgres و `DATABASE_URL` سوپرادمین را اینجا نگذارید.
### ۶) نصب، بیلد، پوشه‌ها
Node ۲۰ یا جدیدتر.
```bash
cd /var/www/ktg
npm ci
npm run build
mkdir -p storage data
```
### ۷) PM2 — فقط این پروسهٔ جدید
پروسه‌های `montime` / `superadmin-api` / فوری‌سند را stop نکنید.
```bash
pm2 start /var/www/ktg/deploy/ecosystem.config.cjs
pm2 save
pm2 list
curl -sI http://127.0.0.1:3020
```
باید `HTTP/1.1 200` بیاید. اگر خطا بود: `pm2 logs ktg-catalog --lines 80`
### ۸) Nginx — فقط سایت جدید
```bash
sudo cp /var/www/ktg/deploy/nginx-ktg.montzar.ir.conf /etc/nginx/sites-available/ktg.montzar.ir
sudo ln -sf /etc/nginx/sites-available/ktg.montzar.ir /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
اگر `nginx -t` خطا داد، فایل‌های `adm.montzar.ir` را ویرایش نکنید؛ بگویید خروجی را بفرستید.
### ۹) گواهی SSL
DNS این زیردامنه از قبل مثل `adm.montzar.ir` روی همان IP است. اگر مرورگر هنوز به سرور نرسید، در پنل دامنه تأیید کنید `ktg.montzar.ir` رکورد `A` مثل `adm.montzar.ir` دارد.
```bash
sudo certbot --nginx -d ktg.montzar.ir
```
Certbot خودش HTTPS را به همین vhost اضافه می‌کند، نه به سایت‌های دیگر.
### ۱۰) تست نهایی
1. `https://ktg.montzar.ir` صفحهٔ ساخت فارسی
2. یک PDF/تصویر کوچک بسازید
3. QR و لینک باید با `https://ktg.montzar.ir/c/...` باشد نه localhost
4. لینک مشاهده در تب دیگر باز شود
کاتالوگ‌های لوکال (`127.0.0.1:3000`) روی این دامنه کار نمی‌کنند؛ باید روی سرور دوباره ساخته شوند.
---
## آپدیت بعدی کد
روی ویندوز دوباره مرحلهٔ ۱ و ۲. روی سرور:
```bash
cd /var/www/ktg
# اگر tar روی فایل‌های قبلی unpacked می‌شود:
tar -xvf /tmp/catalog-deploy.tar
npm ci
npm run build
pm2 restart ktg-catalog
```
پوشه‌های `storage` و `data` و فایل `.env` را پاک نکنید.
---
## تداخل نداشتن (یادآوری)
| این پروژه | بقیه — دست نزنید |
|---|---|
| دامنه `ktg.montzar.ir` | `adm.montzar.ir` و دامنهٔ مونتایم |
| پورت ۳۰۲۰ | ۳۰۰۰ / ۳۰۰۱ / ۳۰۱۰ |
| PM2 نام `ktg-catalog` | پروسه‌های موجود |
| `/var/www/ktg` | `/var/www/superadmin` و پوشهٔ مونتایم |
| SQLite داخل `data/` | Postgres |
لاگین و پروفایل اگر بعداً آمد، باز هم داخل همین دامنه و همین پوشه است؛ به دیتابیس Nest وصل نمی‌شود.
+26
View File
@@ -0,0 +1,26 @@
/**
* PM2 — Digital Catalog on the shared montime host.
* Port 3020 avoids clash with montime (:3000), forisanad (:3001), superadmin (:3010).
*
* On server (as montime):
* pm2 start /var/www/ktg/deploy/ecosystem.config.cjs
* pm2 save
*/
module.exports = {
apps: [
{
name: "ktg-catalog",
cwd: "/var/www/ktg",
script: "node_modules/tsx/dist/cli.mjs",
args: "--env-file=.env server/index.ts",
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
PORT: "3020",
},
max_memory_restart: "500M",
time: true,
},
],
};
+28
View File
@@ -0,0 +1,28 @@
# Place under /etc/nginx/sites-available/ktg.montzar.ir
# First deploy: HTTP only. Then: sudo certbot --nginx -d ktg.montzar.ir
#
# Do not copy this into adm.montzar.ir or montime vhosts.
# Do not change client_max_body_size on other sites.
upstream ktg_catalog {
server 127.0.0.1:3020;
keepalive 8;
}
server {
listen 80;
listen [::]:80;
server_name ktg.montzar.ir;
client_max_body_size 100M;
location / {
proxy_pass http://ktg_catalog;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}
+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 |
+4369
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "digital-catalog",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "concurrently -n api,web -c blue,green \"npm run dev:api\" \"npm run dev:web\"",
"dev:api": "tsx --env-file=.env server/index.ts",
"dev:web": "vite",
"build": "vite build",
"start": "tsx --env-file=.env server/index.ts",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
"drizzle-orm": "^0.45.2",
"hono": "^4.13.3",
"nanoid": "^5.1.16",
"page-flip": "^2.0.7",
"pdfjs-dist": "^4.10.38",
"qrcode": "^1.5.4",
"sql.js": "^1.14.1",
"tsx": "^4.19.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/qrcode": "^1.5.6",
"@types/sql.js": "^1.4.11",
"@vitejs/plugin-vue": "^5.2.1",
"concurrently": "^9.1.2",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^2.1.8",
"vue-tsc": "^2.2.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from "hono";
import { serveStatic } from "@hono/node-server/serve-static";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { LIMITS } from "../shared/types";
import { createCatalogRepo } from "./catalog-repo";
import { openDatabase } from "./db";
import type { Env } from "./env";
import { HttpError } from "./errors";
import { createRateLimiter } from "./rate-limit";
import { catalogRoutes } from "./routes/catalogs";
export async function createApp(env: Env, options?: { now?: () => number }) {
const db = await openDatabase(env.databasePath);
const repo = createCatalogRepo(db);
const limiter = createRateLimiter(
LIMITS.maxCreatesPerHour,
LIMITS.rateWindowMs,
options?.now,
);
const app = new Hono();
app.onError((err, c) => {
if (err instanceof HttpError) {
return c.json({ error: err.message }, err.status as 400);
}
console.error(err);
return c.json({ error: "خطای داخلی سرور" }, 500);
});
app.route("/api", catalogRoutes({ env, repo, limiter }));
if (env.isProduction) {
const dist = "./dist";
app.use("/*", serveStatic({ root: dist }));
app.get("*", async (c) => {
const html = await readFile(path.join("dist", "index.html"), "utf8");
return c.html(html);
});
}
return app;
}
+79
View File
@@ -0,0 +1,79 @@
import { eq } from "drizzle-orm";
import type { EffectId } from "../shared/types";
import { DEFAULT_FLIP_MS } from "../shared/types";
import type { DbBundle } from "./db";
import { catalogsTable } from "./schema";
export type CatalogRow = {
id: string;
deleteToken: string;
title: string;
effect: EffectId;
pageCount: number;
flipMs: number;
createdAt: string;
};
export type CatalogInsert = Omit<CatalogRow, "createdAt" | "flipMs"> & {
createdAt?: string;
flipMs?: number;
};
function toRow(record: typeof catalogsTable.$inferSelect): CatalogRow {
return {
id: record.id,
deleteToken: record.deleteToken,
title: record.title,
effect: record.effect as EffectId,
pageCount: record.pageCount,
flipMs: record.flipMs ?? DEFAULT_FLIP_MS,
createdAt: record.createdAt,
};
}
export function createCatalogRepo(db: DbBundle) {
return {
create(input: CatalogInsert): CatalogRow {
const createdAt = input.createdAt ?? new Date().toISOString();
const flipMs = input.flipMs ?? DEFAULT_FLIP_MS;
db.drizzle.insert(catalogsTable).values({
id: input.id,
deleteToken: input.deleteToken,
title: input.title,
effect: input.effect,
pageCount: input.pageCount,
flipMs,
createdAt,
}).run();
db.persist();
return {
id: input.id,
deleteToken: input.deleteToken,
title: input.title,
effect: input.effect,
pageCount: input.pageCount,
flipMs,
createdAt,
};
},
getById(id: string): CatalogRow | null {
const row = db.drizzle
.select()
.from(catalogsTable)
.where(eq(catalogsTable.id, id))
.get();
return row ? toRow(row) : null;
},
deleteById(id: string): boolean {
const existing = this.getById(id);
if (!existing) return false;
db.drizzle.delete(catalogsTable).where(eq(catalogsTable.id, id)).run();
db.persist();
return true;
},
};
}
export type CatalogRepo = ReturnType<typeof createCatalogRepo>;
+58
View File
@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import initSqlJs, { type Database as SqlJsDatabase } from "sql.js";
import { drizzle, type SQLJsDatabase } from "drizzle-orm/sql-js";
import { catalogsTable } from "./schema";
const require = createRequire(import.meta.url);
export type DbBundle = {
sqlite: SqlJsDatabase;
drizzle: SQLJsDatabase;
persist: () => void;
};
export async function openDatabase(databasePath: string): Promise<DbBundle> {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const sqlJsDist = path.dirname(require.resolve("sql.js/dist/sql-wasm.js"));
const SQL = await initSqlJs({
locateFile: (file) => path.join(sqlJsDist, file),
});
const sqlite = fs.existsSync(databasePath)
? new SQL.Database(fs.readFileSync(databasePath))
: new SQL.Database();
sqlite.run(`
CREATE TABLE IF NOT EXISTS catalogs (
id TEXT PRIMARY KEY,
delete_token TEXT NOT NULL,
title TEXT NOT NULL,
effect TEXT NOT NULL,
page_count INTEGER NOT NULL,
flip_ms INTEGER NOT NULL DEFAULT 1000,
created_at TEXT NOT NULL
)
`);
const info = sqlite.exec("PRAGMA table_info(catalogs)");
const colNames = new Set((info[0]?.values ?? []).map((row) => String(row[1])));
if (!colNames.has("flip_ms")) {
sqlite.run("ALTER TABLE catalogs ADD COLUMN flip_ms INTEGER NOT NULL DEFAULT 1000");
}
const persist = () => {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
fs.writeFileSync(databasePath, Buffer.from(sqlite.export()));
};
persist();
return {
sqlite,
drizzle: drizzle(sqlite, { schema: { catalogsTable } }),
persist,
};
}
+30
View File
@@ -0,0 +1,30 @@
import path from "node:path";
export type Env = {
port: number;
apiPort: number;
publicBaseUrl: string;
storageDir: string;
databasePath: string;
isProduction: boolean;
};
export function loadEnv(overrides: Partial<NodeJS.ProcessEnv> = {}): Env {
const read = (key: string, fallback: string) =>
overrides[key] ?? process.env[key] ?? fallback;
const isProduction =
(overrides.NODE_ENV ?? process.env.NODE_ENV) === "production";
return {
port: Number(read("PORT", "3000")),
apiPort: Number(read("API_PORT", "3010")),
publicBaseUrl: read("PUBLIC_BASE_URL", "http://localhost:3000").replace(
/\/$/,
"",
),
storageDir: path.resolve(read("STORAGE_DIR", "./storage")),
databasePath: path.resolve(read("DATABASE_PATH", "./data/catalogs.sqlite")),
isProduction,
};
}
+19
View File
@@ -0,0 +1,19 @@
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "HttpError";
}
}
export const messages = {
tooLarge: "حجم یا تعداد صفحه بیش از حد مجاز است",
badType: "نوع فایل پشتیبانی نمی‌شود",
badEffect: "افکت نامعتبر است",
notFound: "کاتالوگ پیدا نشد",
forbiddenDelete: "اجازه حذف ندارید",
rateLimited: "تعداد ساخت از این آدرس بیش از حد مجاز است",
tokenRequired: "لینک حذف نامعتبر است",
} as const;
+30
View File
@@ -0,0 +1,30 @@
import { serve } from "@hono/node-server";
import { createApp } from "./app";
import { loadEnv } from "./env";
const env = loadEnv();
const lifecycle = process.env.npm_lifecycle_event ?? "";
const serveUi = env.isProduction || lifecycle === "start";
const app = await createApp({ ...env, isProduction: serveUi });
const port = serveUi ? env.port : env.apiPort;
try {
serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => {
console.log(
`Listening on http://127.0.0.1:${port}${serveUi ? " (app + api)" : " (api)"}`,
);
});
} catch (err) {
console.error(err);
process.exit(1);
}
process.on("uncaughtException", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(
`Port ${port} is already in use. Stop the other npm run dev / node process, then start again.`,
);
process.exit(1);
}
throw err;
});
+19
View File
@@ -0,0 +1,19 @@
/** JPEG SOI: FF D8 FF */
export function isJpeg(buffer: Buffer): boolean {
return (
buffer.length >= 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
);
}
export function isPng(buffer: Buffer): boolean {
return (
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
);
}
+10
View File
@@ -0,0 +1,10 @@
import QRCode from "qrcode";
export async function makeQrPng(text: string): Promise<Buffer> {
return QRCode.toBuffer(text, {
type: "png",
width: 512,
margin: 1,
errorCorrectionLevel: "M",
});
}
+25
View File
@@ -0,0 +1,25 @@
export type RateLimiter = {
check: (ip: string) => boolean;
};
export function createRateLimiter(
limit: number,
windowMs: number,
now: () => number = () => Date.now(),
): RateLimiter {
const hits = new Map<string, number[]>();
return {
check(ip: string) {
const t = now();
const recent = (hits.get(ip) ?? []).filter((ts) => t - ts < windowMs);
if (recent.length >= limit) {
hits.set(ip, recent);
return false;
}
recent.push(t);
hits.set(ip, recent);
return true;
},
};
}
+198
View File
@@ -0,0 +1,198 @@
import { Hono } from "hono";
import { nanoid } from "nanoid";
import {
DEFAULT_TITLE,
clampFlipMs,
fileTooLargeMessage,
isEffectId,
LIMITS,
tooManyPagesMessage,
type CreateCatalogResponse,
type CatalogPublic,
} from "../../shared/types";
import type { CatalogRepo } from "../catalog-repo";
import type { Env } from "../env";
import { HttpError, messages } from "../errors";
import { isJpeg } from "../magic";
import { makeQrPng } from "../qr";
import type { RateLimiter } from "../rate-limit";
import {
deleteCatalogFiles,
readPage,
readQr,
saveCatalogPages,
saveQr,
StorageError,
} from "../storage";
function clientIp(c: { req: { header: (name: string) => string | undefined } }): string {
return (
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
c.req.header("x-real-ip") ||
"127.0.0.1"
);
}
function toPublic(row: {
id: string;
title: string;
effect: CatalogPublic["effect"];
pageCount: number;
flipMs: number;
createdAt: string;
}): CatalogPublic {
return {
id: row.id,
title: row.title,
effect: row.effect,
pageCount: row.pageCount,
flipMs: row.flipMs,
createdAt: row.createdAt,
};
}
async function readPageBuffers(pages: Blob[]): Promise<Buffer[]> {
const buffers: Buffer[] = [];
for (const file of pages) {
if (file.size > LIMITS.maxFileBytes) {
throw new HttpError(400, fileTooLargeMessage(file.size));
}
const buf = Buffer.from(await file.arrayBuffer());
if (!isJpeg(buf)) {
throw new HttpError(400, messages.badType);
}
buffers.push(buf);
}
return buffers;
}
function isBlobLike(value: unknown): value is Blob {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Blob).arrayBuffer === "function" &&
typeof (value as Blob).size === "number"
);
}
function asFileArray(value: unknown): Blob[] {
if (!value) return [];
const list = Array.isArray(value) ? value : [value];
return list.filter(isBlobLike);
}
export function catalogRoutes(opts: {
env: Env;
repo: CatalogRepo;
limiter: RateLimiter;
}) {
const app = new Hono();
app.post("/catalogs", async (c) => {
const ip = clientIp(c);
if (!opts.limiter.check(ip)) {
throw new HttpError(429, messages.rateLimited);
}
const body = await c.req.parseBody({ all: true });
const titleRaw = typeof body.title === "string" ? body.title.trim() : "";
const title = titleRaw || DEFAULT_TITLE;
const effectRaw = typeof body.effect === "string" ? body.effect : "";
if (!isEffectId(effectRaw)) {
throw new HttpError(400, messages.badEffect);
}
const flipMs = clampFlipMs(body.flipMs);
const pages = asFileArray(body.pages);
if (pages.length < 1) {
throw new HttpError(400, "هیچ صفحه‌ای دریافت نشد.");
}
if (pages.length > LIMITS.maxPages) {
throw new HttpError(400, tooManyPagesMessage(pages.length));
}
const buffers = await readPageBuffers(pages);
const id = nanoid(10);
const deleteToken = nanoid(24);
await saveCatalogPages(opts.env.storageDir, id, buffers);
const viewUrl = `${opts.env.publicBaseUrl}/c/${id}`;
const qrPng = await makeQrPng(viewUrl);
await saveQr(opts.env.storageDir, id, qrPng);
opts.repo.create({
id,
deleteToken,
title,
effect: effectRaw,
pageCount: buffers.length,
flipMs,
});
const payload: CreateCatalogResponse = {
id,
viewUrl,
deleteUrl: `${opts.env.publicBaseUrl}/c/${id}/delete?token=${deleteToken}`,
qrUrl: `/api/catalogs/${id}/qr`,
doneUrl: `${opts.env.publicBaseUrl}/c/${id}/done?token=${deleteToken}`,
};
return c.json(payload, 201);
});
app.get("/catalogs/:id", (c) => {
const row = opts.repo.getById(c.req.param("id"));
if (!row) throw new HttpError(404, messages.notFound);
return c.json(toPublic(row));
});
app.get("/catalogs/:id/pages/:n", async (c) => {
const row = opts.repo.getById(c.req.param("id"));
if (!row) throw new HttpError(404, messages.notFound);
const n = Number(c.req.param("n"));
if (!Number.isInteger(n) || n < 1 || n > row.pageCount) {
throw new HttpError(404, messages.notFound);
}
try {
const buf = await readPage(opts.env.storageDir, row.id, n);
return new Response(buf, {
headers: {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (err) {
if (err instanceof StorageError) throw new HttpError(404, messages.notFound);
throw err;
}
});
app.get("/catalogs/:id/qr", async (c) => {
const row = opts.repo.getById(c.req.param("id"));
if (!row) throw new HttpError(404, messages.notFound);
try {
const buf = await readQr(opts.env.storageDir, row.id);
return new Response(buf, {
headers: { "Content-Type": "image/png" },
});
} catch (err) {
if (err instanceof StorageError) throw new HttpError(404, messages.notFound);
throw err;
}
});
app.delete("/catalogs/:id", async (c) => {
const token = c.req.query("token") ?? "";
if (!token) throw new HttpError(403, messages.forbiddenDelete);
const row = opts.repo.getById(c.req.param("id"));
if (!row) throw new HttpError(404, messages.notFound);
if (row.deleteToken !== token) {
throw new HttpError(403, messages.forbiddenDelete);
}
opts.repo.deleteById(row.id);
await deleteCatalogFiles(opts.env.storageDir, row.id);
return c.body(null, 204);
});
return app;
}
+11
View File
@@ -0,0 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const catalogsTable = sqliteTable("catalogs", {
id: text("id").primaryKey(),
deleteToken: text("delete_token").notNull(),
title: text("title").notNull(),
effect: text("effect").notNull(),
pageCount: integer("page_count").notNull(),
flipMs: integer("flip_ms").notNull(),
createdAt: text("created_at").notNull(),
});
+75
View File
@@ -0,0 +1,75 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isJpeg } from "./magic";
export class StorageError extends Error {
constructor(message: string) {
super(message);
this.name = "StorageError";
}
}
export function pageFileName(n: number): string {
return `page-${String(n).padStart(3, "0")}.jpg`;
}
export function catalogDir(storageDir: string, id: string): string {
return path.join(storageDir, "catalogs", id);
}
export async function saveCatalogPages(
storageDir: string,
id: string,
pages: Buffer[],
): Promise<void> {
const dir = catalogDir(storageDir, id);
await fs.mkdir(dir, { recursive: true });
let i = 1;
for (const page of pages) {
if (!isJpeg(page)) {
await fs.rm(dir, { recursive: true, force: true });
throw new StorageError("نوع فایل پشتیبانی نمی‌شود");
}
await fs.writeFile(path.join(dir, pageFileName(i)), page);
i += 1;
}
}
export async function saveQr(
storageDir: string,
id: string,
png: Buffer,
): Promise<void> {
const dir = catalogDir(storageDir, id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "qr.png"), png);
}
export async function readPage(
storageDir: string,
id: string,
n: number,
): Promise<Buffer> {
const file = path.join(catalogDir(storageDir, id), pageFileName(n));
try {
return await fs.readFile(file);
} catch {
throw new StorageError("کاتالوگ پیدا نشد");
}
}
export async function readQr(storageDir: string, id: string): Promise<Buffer> {
const file = path.join(catalogDir(storageDir, id), "qr.png");
try {
return await fs.readFile(file);
} catch {
throw new StorageError("کاتالوگ پیدا نشد");
}
}
export async function deleteCatalogFiles(
storageDir: string,
id: string,
): Promise<void> {
await fs.rm(catalogDir(storageDir, id), { recursive: true, force: true });
}
+53
View File
@@ -0,0 +1,53 @@
export const EFFECTS = ["magazine", "slide", "fade", "card", "scroll"] as const;
export type EffectId = (typeof EFFECTS)[number];
export function isEffectId(value: string): value is EffectId {
return (EFFECTS as readonly string[]).includes(value);
}
export type CatalogPublic = {
id: string;
title: string;
effect: EffectId;
pageCount: number;
flipMs: number;
createdAt: string;
};
export type CreateCatalogResponse = {
id: string;
viewUrl: string;
deleteUrl: string;
qrUrl: string;
doneUrl: string;
};
export const LIMITS = {
maxFileBytes: 100 * 1024 * 1024,
maxPages: 200,
maxCreatesPerHour: 10,
maxPageWidth: 1600,
jpegQuality: 85,
rateWindowMs: 60 * 60 * 1000,
minFlipMs: 400,
maxFlipMs: 5000,
} as const;
export const DEFAULT_TITLE = "کاتالوگ";
export const DEFAULT_FLIP_MS = 1000;
export function clampFlipMs(value: unknown): number {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_FLIP_MS;
return Math.min(LIMITS.maxFlipMs, Math.max(LIMITS.minFlipMs, Math.round(n)));
}
export function fileTooLargeMessage(sizeBytes: number): string {
const used = (sizeBytes / (1024 * 1024)).toFixed(1);
const max = LIMITS.maxFileBytes / (1024 * 1024);
return `حجم فایل ${used} مگابایت است؛ حداکثر ${max} مگابایت مجاز است.`;
}
export function tooManyPagesMessage(count: number): string {
return `این فایل ${count} صفحه دارد؛ حداکثر ${LIMITS.maxPages} صفحه مجاز است.`;
}
+49
View File
@@ -0,0 +1,49 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { openDatabase } from "../../server/db";
import { createCatalogRepo } from "../../server/catalog-repo";
const temps: string[] = [];
async function tempDb() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-db-"));
temps.push(dir);
const db = await openDatabase(path.join(dir, "catalogs.sqlite"));
return createCatalogRepo(db);
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("catalog repo", () => {
it("inserts and reads a catalog", async () => {
const repo = await tempDb();
const created = repo.create({
id: "abc12xyz90",
deleteToken: "token-token-token-token-12",
title: "کاتالوگ",
effect: "magazine",
pageCount: 2,
});
const found = repo.getById("abc12xyz90");
expect(found).toEqual(created);
expect(found?.flipMs).toBe(1000);
expect(found?.deleteToken).toBe("token-token-token-token-12");
});
it("returns null after delete", async () => {
const repo = await tempDb();
repo.create({
id: "id1",
deleteToken: "tok",
title: "کاتالوگ",
effect: "slide",
pageCount: 1,
});
expect(repo.deleteById("id1")).toBe(true);
expect(repo.getById("id1")).toBeNull();
});
});
+225
View File
@@ -0,0 +1,225 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../server/app";
import { loadEnv } from "../../server/env";
import { LIMITS } from "../../shared/types";
import { jpegFile, TINY_PNG, TINY_JPEG } from "./helpers";
const temps: string[] = [];
async function testApp(now?: () => number) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-app-"));
temps.push(dir);
const env = loadEnv({
PUBLIC_BASE_URL: "http://localhost:3000",
STORAGE_DIR: path.join(dir, "storage"),
DATABASE_PATH: path.join(dir, "data", "catalogs.sqlite"),
NODE_ENV: "test",
});
return createApp(env, { now });
}
function form(pages: File[], extra: Record<string, string> = {}) {
const data = new FormData();
data.set("title", extra.title ?? "");
data.set("effect", extra.effect ?? "magazine");
if (extra.flipMs) data.set("flipMs", extra.flipMs);
for (const page of pages) data.append("pages", page);
return data;
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("catalog HTTP API", () => {
it("creates a catalog and hides delete token on public GET", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile("a.jpg"), jpegFile("b.jpg")], { title: "نمونه" }),
headers: { "x-forwarded-for": "10.0.0.1" },
});
expect(created.status).toBe(201);
const body = await created.json();
expect(body.id).toMatch(/^[A-Za-z0-9_-]{10}$/);
expect(body.viewUrl).toBe(`http://localhost:3000/c/${body.id}`);
expect(body.deleteUrl).toContain("token=");
expect(body.doneUrl).toContain("/done?token=");
expect(body.qrUrl).toBe(`/api/catalogs/${body.id}/qr`);
const pub = await app.request(`/api/catalogs/${body.id}`);
expect(pub.status).toBe(200);
const publicBody = await pub.json();
expect(publicBody.title).toBe("نمونه");
expect(publicBody.pageCount).toBe(2);
expect(publicBody.flipMs).toBe(1000);
expect(publicBody.deleteToken).toBeUndefined();
expect(JSON.stringify(publicBody)).not.toContain("delete");
});
it("stores a custom flip duration", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "1800" }),
headers: { "x-forwarded-for": "10.0.0.12" },
});
expect(created.status).toBe(201);
const { id } = await created.json();
const pub = await app.request(`/api/catalogs/${id}`);
expect((await pub.json()).flipMs).toBe(1800);
});
it("clamps flip duration to the allowed range", async () => {
const app = await testApp();
const tooFast = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "50" }),
headers: { "x-forwarded-for": "10.0.0.13" },
});
const tooSlow = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { flipMs: "9000" }),
headers: { "x-forwarded-for": "10.0.0.14" },
});
const fastId = (await tooFast.json()).id;
const slowId = (await tooSlow.json()).id;
expect((await (await app.request(`/api/catalogs/${fastId}`)).json()).flipMs).toBe(
LIMITS.minFlipMs,
);
expect((await (await app.request(`/api/catalogs/${slowId}`)).json()).flipMs).toBe(
LIMITS.maxFlipMs,
);
});
it("serves jpeg pages and png qr", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.2" },
});
const { id } = await created.json();
const page = await app.request(`/api/catalogs/${id}/pages/1`);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("image/jpeg");
const qr = await app.request(`/api/catalogs/${id}/qr`);
expect(qr.status).toBe(200);
expect(qr.headers.get("content-type")).toBe("image/png");
const qrBytes = Buffer.from(await qr.arrayBuffer());
expect(qrBytes[0]).toBe(0x89);
expect(qrBytes[1]).toBe(0x50);
});
it("rejects delete without token and keeps the catalog", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.3" },
});
const { id } = await created.json();
const denied = await app.request(`/api/catalogs/${id}`, { method: "DELETE" });
expect(denied.status).toBe(403);
expect((await app.request(`/api/catalogs/${id}`)).status).toBe(200);
});
it("deletes with token then returns 404", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.4" },
});
const body = await created.json();
const token = new URL(body.deleteUrl).searchParams.get("token");
const del = await app.request(`/api/catalogs/${body.id}?token=${token}`, {
method: "DELETE",
});
expect(del.status).toBe(204);
expect((await app.request(`/api/catalogs/${body.id}`)).status).toBe(404);
});
it("rejects more pages than the limit", async () => {
const app = await testApp();
const pages = Array.from({ length: LIMITS.maxPages + 1 }, (_, i) =>
jpegFile(`${i}.jpg`),
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: form(pages),
headers: { "x-forwarded-for": "10.0.0.5" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("صفحه");
});
it("accepts a jpeg Blob the way the browser sends pages", async () => {
const app = await testApp();
const data = new FormData();
data.set("title", "");
data.set("effect", "slide");
data.append(
"pages",
new Blob([TINY_JPEG], { type: "image/jpeg" }),
"page-001.jpg",
);
const res = await app.request("/api/catalogs", {
method: "POST",
body: data,
headers: { "x-forwarded-for": "10.0.0.11" },
});
expect(res.status).toBe(201);
});
it("rejects an invalid effect", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { effect: "explode" }),
headers: { "x-forwarded-for": "10.0.0.6" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("افکت نامعتبر است");
});
it("rejects non-jpeg pages", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([new File([TINY_PNG], "x.png", { type: "image/png" })]),
headers: { "x-forwarded-for": "10.0.0.7" },
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("نوع فایل پشتیبانی نمی‌شود");
});
it("rate-limits the 11th create from one IP", async () => {
const app = await testApp(() => 1_000);
for (let i = 0; i < 10; i++) {
const res = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(res.status).toBe(201);
}
const blocked = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()]),
headers: { "x-forwarded-for": "10.0.0.8" },
});
expect(blocked.status).toBe(429);
});
it("has no public catalog list", async () => {
const app = await testApp();
const res = await app.request("/api/catalogs");
expect(res.status).toBe(404);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Minimal JPEG (SOI + EOI) used only for API/storage tests. */
export const TINY_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
export const TINY_PNG = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
export function jpegFile(name = "page.jpg"): File {
return new File([TINY_JPEG], name, { type: "image/jpeg" });
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { createRateLimiter } from "../../server/rate-limit";
describe("createRateLimiter", () => {
it("allows the first 10 hits from an IP", () => {
let now = 1_000;
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
for (let i = 0; i < 10; i++) {
expect(limiter.check("1.1.1.1")).toBe(true);
}
});
it("rejects the 11th hit within the window", () => {
let now = 1_000;
const limiter = createRateLimiter(10, 60 * 60 * 1000, () => now);
for (let i = 0; i < 10; i++) {
limiter.check("1.1.1.1");
}
expect(limiter.check("1.1.1.1")).toBe(false);
});
it("allows another hit after the window elapses", () => {
let now = 1_000;
const windowMs = 60 * 60 * 1000;
const limiter = createRateLimiter(10, windowMs, () => now);
for (let i = 0; i < 10; i++) {
limiter.check("1.1.1.1");
}
now += windowMs + 1;
expect(limiter.check("1.1.1.1")).toBe(true);
});
it("tracks IPs independently", () => {
const limiter = createRateLimiter(1, 60_000, () => 1_000);
expect(limiter.check("a")).toBe(true);
expect(limiter.check("a")).toBe(false);
expect(limiter.check("b")).toBe(true);
});
});
+48
View File
@@ -0,0 +1,48 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
deleteCatalogFiles,
readPage,
saveCatalogPages,
StorageError,
} from "../../server/storage";
import { TINY_JPEG, TINY_PNG } from "./helpers";
const temps: string[] = [];
async function tempDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dc-storage-"));
temps.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("saveCatalogPages", () => {
it("writes numbered jpegs and can read them back", async () => {
const dir = await tempDir();
await saveCatalogPages(dir, "abc", [TINY_JPEG, TINY_JPEG]);
const page1 = await readPage(dir, "abc", 1);
expect(page1.equals(TINY_JPEG)).toBe(true);
const page2 = await readPage(dir, "abc", 2);
expect(page2.equals(TINY_JPEG)).toBe(true);
});
it("rejects non-jpeg pages", async () => {
const dir = await tempDir();
await expect(saveCatalogPages(dir, "abc", [TINY_PNG])).rejects.toBeInstanceOf(
StorageError,
);
});
it("deletes files so later reads fail", async () => {
const dir = await tempDir();
await saveCatalogPages(dir, "abc", [TINY_JPEG]);
await deleteCatalogFiles(dir, "abc");
await expect(readPage(dir, "abc", 1)).rejects.toBeInstanceOf(StorageError);
});
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
}
},
"include": ["client/src/**/*.ts", "client/src/**/*.vue", "shared/**/*.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"server/**/*.ts",
"shared/**/*.ts",
"tests/**/*.ts"
]
}
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
root: "client",
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./client/src", import.meta.url)),
"@shared": fileURLToPath(new URL("./shared", import.meta.url)),
"page-flip": fileURLToPath(
new URL("./node_modules/page-flip/dist/js/page-flip.module.js", import.meta.url),
),
},
},
server: {
host: "127.0.0.1",
port: 3000,
strictPort: true,
proxy: {
"/api": "http://127.0.0.1:3010",
},
},
build: {
outDir: "../dist",
emptyOutDir: true,
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
testTimeout: 20000,
fileParallelism: false,
},
});