Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
storage
|
||||
data
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
catalog-deploy.tar
|
||||
@@ -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 نوشته میشود. روی دامنه واقعی آن را به دامنه خودتان عوض کنید.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: "همه صفحات زیر هم" },
|
||||
];
|
||||
Vendored
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 وصل نمیشود.
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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 |
|
||||
Generated
+4369
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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} صفحه مجاز است.`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
testTimeout: 20000,
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user