Add digital catalog with shareable flipbook and ktg.montzar.ir deploy files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 14:37:49 +03:30
co-authored by Cursor
commit a8374e7d6c
54 changed files with 8462 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>کاتالوگ دیجیتال</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>
+49
View File
@@ -0,0 +1,49 @@
import type { CatalogPublic, CreateCatalogResponse, EffectId } from "@shared/types";
async function readError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string };
return body.error || "خطا در ارتباط با سرور";
} catch {
return "خطا در ارتباط با سرور";
}
}
export async function createCatalog(input: {
title: string;
effect: EffectId;
pages: Blob[];
flipMs: number;
}): Promise<CreateCatalogResponse> {
const data = new FormData();
data.set("title", input.title);
data.set("effect", input.effect);
data.set("flipMs", String(input.flipMs));
input.pages.forEach((page, i) => {
data.append("pages", page, `page-${String(i + 1).padStart(3, "0")}.jpg`);
});
const res = await fetch("/api/catalogs", { method: "POST", body: data });
if (!res.ok) throw new Error(await readError(res));
return res.json();
}
export async function getCatalog(id: string): Promise<CatalogPublic> {
const res = await fetch(`/api/catalogs/${id}`);
if (!res.ok) throw new Error(await readError(res));
return res.json();
}
export function pageUrl(id: string, n: number): string {
return `/api/catalogs/${id}/pages/${n}`;
}
export function qrUrl(id: string): string {
return `/api/catalogs/${id}/qr`;
}
export async function deleteCatalog(id: string, token: string): Promise<void> {
const res = await fetch(`/api/catalogs/${id}?token=${encodeURIComponent(token)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await readError(res));
}
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { EffectId } from "@shared/types";
import { DEFAULT_FLIP_MS } from "@shared/types";
import CssPageEffect from "./effects/CssPageEffect.vue";
import MagazineEffect from "./effects/MagazineEffect.vue";
import ScrollEffect from "./effects/ScrollEffect.vue";
const AUTO_DELAY_MS = 3000;
const AUTO_PAUSE_AFTER_FLIP_MS = 2200;
const props = defineProps<{
effect: EffectId;
images: string[];
flipMs?: number;
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS);
const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS);
const index = ref(0);
const zoom = ref(false);
const fullscreen = ref(false);
const fallbackSlide = ref(false);
const playing = ref(true);
const waiting = ref(true);
const stage = ref<HTMLElement | null>(null);
const magazine = ref<{ next: () => void; prev: () => void } | null>(null);
const windowInner = ref(typeof window === "undefined" ? 1200 : window.innerWidth);
const singlePage = computed(() => windowInner.value < 900);
const visualEffect = computed<EffectId>(() => {
if (fallbackSlide.value && props.effect === "magazine") return "slide";
return props.effect;
});
let delayTimer = 0;
let loopTimer = 0;
function stopLoop() {
window.clearInterval(loopTimer);
loopTimer = 0;
}
function startLoop() {
stopLoop();
loopTimer = window.setInterval(() => {
if (playing.value) advance();
}, autoIntervalMs.value);
}
function advance() {
if (!props.images.length) return;
if (index.value >= props.images.length - 1) {
go(0);
return;
}
next();
}
function next() {
if (visualEffect.value === "magazine") {
magazine.value?.next();
return;
}
if (index.value < props.images.length - 1) index.value += 1;
}
function prev() {
if (visualEffect.value === "magazine") {
magazine.value?.prev();
return;
}
if (index.value > 0) index.value -= 1;
}
function go(i: number) {
index.value = i;
}
function onUserNav(action: () => void) {
waiting.value = false;
action();
if (playing.value) startLoop();
}
function togglePlay() {
playing.value = !playing.value;
waiting.value = false;
if (playing.value) {
advance();
startLoop();
} else {
stopLoop();
}
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "ArrowLeft") onUserNav(next);
if (ev.key === "ArrowRight") onUserNav(prev);
if (ev.key === " ") {
ev.preventDefault();
togglePlay();
}
if (ev.key === "Escape") zoom.value = false;
}
async function toggleFullscreen() {
if (!stage.value) return;
if (!document.fullscreenElement) {
await stage.value.requestFullscreen();
fullscreen.value = true;
} else {
await document.exitFullscreen();
fullscreen.value = false;
}
}
function onResize() {
windowInner.value = window.innerWidth;
}
function armAutoplay() {
window.clearTimeout(delayTimer);
stopLoop();
waiting.value = true;
delayTimer = window.setTimeout(() => {
waiting.value = false;
if (!playing.value) return;
advance();
startLoop();
}, AUTO_DELAY_MS);
}
onMounted(() => {
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
armAutoplay();
});
onUnmounted(() => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
window.clearTimeout(delayTimer);
stopLoop();
});
watch(
() => props.images,
() => {
index.value = 0;
fallbackSlide.value = false;
playing.value = true;
armAutoplay();
},
);
</script>
<template>
<div ref="stage" class="viewer-wrap cinematic">
<div class="stage-bar">
<button class="btn" type="button" @click="onUserNav(prev)">قبلی</button>
<button class="btn" type="button" @click="onUserNav(next)">بعدی</button>
<span>{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn play" type="button" @click="togglePlay">
{{ playing ? "توقف پخش" : "پخش خودکار" }}
</button>
<span style="flex: 1"></span>
<button class="btn" type="button" @click="zoom = !zoom">زوم</button>
<button class="btn" type="button" @click="toggleFullscreen">تمام‌صفحه</button>
</div>
<div v-if="waiting && playing" class="auto-hint">
<span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span>
<i class="hint-bar"></i>
</div>
<ScrollEffect
v-if="visualEffect === 'scroll'"
:images="images"
:index="index"
@update:index="onUserNav(() => go($event))"
/>
<MagazineEffect
v-else-if="visualEffect === 'magazine'"
ref="magazine"
:images="images"
:index="index"
:single-page="singlePage"
:flip-ms="flipMs"
@update:index="index = $event"
@fallback="fallbackSlide = true"
/>
<CssPageEffect
v-else
:images="images"
:index="index"
:mode="visualEffect === 'fade' ? 'fade' : visualEffect === 'card' ? 'card' : 'slide'"
:zoom="zoom"
:flip-ms="flipMs"
@next="onUserNav(next)"
@prev="onUserNav(prev)"
/>
<div v-if="visualEffect !== 'scroll'" class="thumbs">
<img
v-for="(src, i) in images"
:key="src"
:src="src"
:class="{ active: i === index }"
alt=""
@click="onUserNav(() => go(i))"
/>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { EffectId } from "@shared/types";
import { EFFECT_OPTIONS } from "../constants";
defineProps<{
modelValue: EffectId;
}>();
const emit = defineEmits<{
"update:modelValue": [value: EffectId];
}>();
</script>
<template>
<div class="effects">
<label v-for="opt in EFFECT_OPTIONS" :key="opt.id" class="effect">
<input
type="radio"
name="effect"
:value="opt.id"
:checked="modelValue === opt.id"
@change="emit('update:modelValue', opt.id)"
/>
<strong>{{ opt.title }}</strong>
<small>{{ opt.hint }}</small>
</label>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
export type DraftPage = {
key: string;
preview: string;
blob: Blob;
};
defineProps<{
pages: DraftPage[];
}>();
const emit = defineEmits<{
move: [index: number, dir: -1 | 1];
remove: [index: number];
}>();
</script>
<template>
<div class="pages">
<div v-for="(page, index) in pages" :key="page.key" class="page-row">
<img :src="page.preview" alt="" />
<div class="grow">صفحه {{ index + 1 }}</div>
<button class="icon-btn" type="button" @click="emit('move', index, -1)">بالا</button>
<button class="icon-btn" type="button" @click="emit('move', index, 1)">پایین</button>
<button class="icon-btn" type="button" @click="emit('remove', index)">حذف</button>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
const props = defineProps<{
images: string[];
index: number;
mode: "slide" | "fade" | "card";
zoom: boolean;
flipMs: number;
}>();
const emit = defineEmits<{
next: [];
prev: [];
}>();
const dir = ref<"next" | "prev">("next");
const current = computed(() => props.images[props.index] ?? "");
const transitionName = computed(() => `fx-${props.mode}-${dir.value}`);
watch(
() => props.index,
(now, was) => {
if (was === undefined) return;
dir.value = now >= was ? "next" : "prev";
},
);
let startX = 0;
let tracking = false;
function onPointerDown(ev: PointerEvent) {
tracking = true;
startX = ev.clientX;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onPointerUp(ev: PointerEvent) {
if (!tracking) return;
tracking = false;
const dx = ev.clientX - startX;
if (Math.abs(dx) < 48) {
const el = ev.currentTarget as HTMLElement;
const x = ev.clientX - el.getBoundingClientRect().left;
if (x < el.clientWidth / 2) emit("next");
else emit("prev");
return;
}
if (dx < 0) emit("next");
else emit("prev");
}
</script>
<template>
<div
class="fx-stage"
:class="[mode, { 'zoom-in': zoom }]"
:style="{ '--flip-ms': `${flipMs}ms` }"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
@pointercancel="tracking = false"
>
<Transition :name="transitionName">
<div :key="`${mode}-${index}`" class="page-wrap">
<img class="page-img" :src="current" alt="" draggable="false" />
</div>
</Transition>
</div>
</template>
<style scoped>
.fx-stage {
position: relative;
width: min(920px, 100%);
margin: 0 auto;
height: min(74vh, 780px);
display: grid;
place-items: center;
perspective: 2200px;
touch-action: pan-y;
overflow: visible;
}
.fx-stage.card {
perspective: 2600px;
}
.page-wrap {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform-style: preserve-3d;
}
.page-img {
max-width: min(100%, 820px);
max-height: 72vh;
width: auto;
height: auto;
object-fit: contain;
background: #fff;
border-radius: 4px;
box-shadow:
0 8px 10px rgba(0, 0, 0, 0.18),
0 28px 60px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.06);
user-select: none;
backface-visibility: hidden;
}
.zoom-in .page-img {
max-height: 90vh;
max-width: 100%;
}
.fx-slide-next-enter-active,
.fx-slide-next-leave-active,
.fx-slide-prev-enter-active,
.fx-slide-prev-leave-active,
.fx-fade-next-enter-active,
.fx-fade-next-leave-active,
.fx-fade-prev-enter-active,
.fx-fade-prev-leave-active,
.fx-card-next-enter-active,
.fx-card-next-leave-active,
.fx-card-prev-enter-active,
.fx-card-prev-leave-active {
position: absolute;
transition:
transform var(--flip-ms, 1s) cubic-bezier(0.22, 1, 0.36, 1),
opacity var(--flip-ms, 1s) cubic-bezier(0.22, 1, 0.36, 1),
filter var(--flip-ms, 1s) ease;
}
.fx-slide-next-enter-from {
transform: translateX(-58%) scale(0.86) rotateY(-22deg);
opacity: 0;
filter: blur(10px);
}
.fx-slide-next-leave-to {
transform: translateX(52%) scale(0.88) rotateY(16deg);
opacity: 0;
filter: blur(8px);
}
.fx-slide-prev-enter-from {
transform: translateX(58%) scale(0.86) rotateY(22deg);
opacity: 0;
filter: blur(10px);
}
.fx-slide-prev-leave-to {
transform: translateX(-52%) scale(0.88) rotateY(-16deg);
opacity: 0;
filter: blur(8px);
}
.fx-fade-next-enter-from,
.fx-fade-prev-enter-from {
opacity: 0;
transform: scale(1.12);
filter: blur(16px) brightness(1.15);
}
.fx-fade-next-leave-to,
.fx-fade-prev-leave-to {
opacity: 0;
transform: scale(0.9);
filter: blur(10px) brightness(0.7);
}
.fx-card-next-enter-from {
transform: rotateY(-95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-next-leave-to {
transform: rotateY(95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-prev-enter-from {
transform: rotateY(95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
.fx-card-prev-leave-to {
transform: rotateY(-95deg) translateZ(80px) scale(0.92);
opacity: 0.15;
}
@media (prefers-reduced-motion: reduce) {
.fx-slide-next-enter-active,
.fx-slide-next-leave-active,
.fx-slide-prev-enter-active,
.fx-slide-prev-leave-active,
.fx-fade-next-enter-active,
.fx-fade-next-leave-active,
.fx-fade-prev-enter-active,
.fx-fade-prev-leave-active,
.fx-card-next-enter-active,
.fx-card-next-leave-active,
.fx-card-prev-enter-active,
.fx-card-prev-leave-active {
transition-duration: 0.01ms;
}
}
</style>
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
import { PageFlip } from "page-flip";
const props = defineProps<{
images: string[];
index: number;
singlePage: boolean;
flipMs: number;
}>();
const emit = defineEmits<{
"update:index": [value: number];
fallback: [];
}>();
const host = ref<HTMLElement | null>(null);
const outer = ref<HTMLElement | null>(null);
const mountKey = ref(0);
let book: PageFlip | null = null;
let ignore = false;
let prepared: string[] = [];
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image"));
img.src = src;
});
}
function revokePrepared() {
for (const url of prepared) URL.revokeObjectURL(url);
prepared = [];
}
function containDraw(
img: HTMLImageElement,
pageW: number,
pageH: number,
): Promise<string> {
const canvas = document.createElement("canvas");
canvas.width = pageW;
canvas.height = pageH;
const ctx = canvas.getContext("2d");
if (!ctx) return Promise.reject(new Error("canvas"));
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, pageW, pageH);
const scale = Math.min(pageW / img.naturalWidth, pageH / img.naturalHeight);
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const x = Math.round((pageW - w) / 2);
const y = Math.round((pageH - h) / 2);
ctx.save();
ctx.translate(pageW, 0);
ctx.scale(-1, 1);
ctx.drawImage(img, x, y, w, h);
ctx.restore();
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("blob"));
return;
}
const url = URL.createObjectURL(blob);
prepared.push(url);
resolve(url);
},
"image/jpeg",
0.92,
);
});
}
function pageBox(first: HTMLImageElement, maxW: number, maxH: number) {
const ratio = first.naturalWidth / Math.max(1, first.naturalHeight);
const pages = props.singlePage ? 1 : 2;
const scale = Math.min(
maxW / (pages * first.naturalWidth),
maxH / first.naturalHeight,
);
const pageW = Math.max(1, Math.floor(first.naturalWidth * scale));
const pageH = Math.max(1, Math.floor(pageW / ratio));
return { pageW, pageH };
}
async function makeBook() {
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
mountKey.value += 1;
await nextTick();
if (!host.value || !outer.value || !props.images.length) return;
try {
const first = await loadImage(props.images[0]);
const maxW = Math.max(280, outer.value.clientWidth - 16);
const maxH = Math.max(320, outer.value.clientHeight - 16);
const { pageW, pageH } = pageBox(first, maxW, maxH);
const fitted: string[] = [];
for (const src of props.images) {
try {
const img = src === props.images[0] ? first : await loadImage(src);
fitted.push(await containDraw(img, pageW, pageH));
} catch {
fitted.push(src);
}
}
await nextTick();
if (!host.value) return;
book = new PageFlip(host.value, {
width: pageW,
height: pageH,
size: "fixed",
autoSize: false,
drawShadow: true,
flippingTime: props.flipMs,
usePortrait: props.singlePage,
maxShadowOpacity: 0.85,
showCover: false,
mobileScrollSupport: false,
useMouseEvents: false,
showPageCorners: true,
startPage: props.index,
});
book.loadFromImages(fitted);
book.on("flip", (e) => {
ignore = true;
emit("update:index", e.data);
queueMicrotask(() => {
ignore = false;
});
});
} catch {
emit("fallback");
}
}
watch(
() => [props.images.join("|"), props.singlePage, props.flipMs] as const,
() => {
void makeBook();
},
{ immediate: true },
);
watch(
() => props.index,
(value) => {
if (!book || ignore) return;
try {
if (book.getCurrentPageIndex() !== value) book.turnToPage(value);
} catch {
emit("fallback");
}
},
);
onBeforeUnmount(() => {
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
});
defineExpose({
next() {
book?.flipNext();
},
prev() {
book?.flipPrev();
},
});
let pointerX = 0;
function onPointerDown(ev: PointerEvent) {
pointerX = ev.clientX;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onPointerUp(ev: PointerEvent) {
if (!book) return;
const dx = ev.clientX - pointerX;
if (Math.abs(dx) > 42) {
if (dx > 0) book.flipNext();
else book.flipPrev();
return;
}
const el = ev.currentTarget as HTMLElement;
const x = ev.clientX - el.getBoundingClientRect().left;
if (x < el.clientWidth / 2) book.flipNext();
else book.flipPrev();
}
</script>
<template>
<div class="magazine-frame" dir="ltr">
<div class="magazine-glow"></div>
<div
ref="outer"
class="flip-stage magazine-outer"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
>
<div :key="mountKey" ref="host" class="magazine-host"></div>
</div>
</div>
</template>
<style scoped>
.magazine-frame {
position: relative;
width: min(980px, 100%);
margin: 0 auto;
direction: ltr;
}
.magazine-glow {
position: absolute;
inset: 12% 18%;
background: radial-gradient(circle, rgba(245, 158, 11, 0.18), transparent 70%);
filter: blur(30px);
pointer-events: none;
}
.magazine-outer {
position: relative;
width: 100%;
height: min(74vh, 780px);
display: flex;
align-items: center;
justify-content: center;
direction: ltr;
}
.magazine-host {
width: max-content;
height: max-content;
max-width: 100%;
transform: scaleX(-1);
filter: drop-shadow(0 28px 50px rgba(0, 0, 0, 0.55));
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { nextTick, watch } from "vue";
const props = defineProps<{
images: string[];
index: number;
}>();
const emit = defineEmits<{
"update:index": [value: number];
}>();
watch(
() => props.index,
async (value) => {
await nextTick();
const el = document.getElementById(`scroll-page-${value}`);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
},
);
</script>
<template>
<div class="scroll-list">
<article
v-for="(src, i) in images"
:id="`scroll-page-${i}`"
:key="src"
class="scroll-card"
:class="{ active: i === index }"
@click="emit('update:index', i)"
>
<img :src="src" :alt="`صفحه ${i + 1}`" />
</article>
</div>
</template>
<style scoped>
.scroll-list {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 820px;
margin: 0 auto;
padding: 8px 12px 48px;
scroll-snap-type: y mandatory;
}
.scroll-card {
scroll-snap-align: center;
transform: scale(0.96);
opacity: 0.55;
transition:
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.55s ease,
box-shadow 0.55s ease;
}
.scroll-card.active {
transform: scale(1);
opacity: 1;
}
.scroll-card img {
display: block;
width: 100%;
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.scroll-card.active img {
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(245, 158, 11, 0.35);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import type { EffectId } from "@shared/types";
export const EFFECT_OPTIONS: { id: EffectId; title: string; hint: string }[] = [
{ id: "magazine", title: "ورق مجله", hint: "سه‌بعدی، شبیه ورق زدن کاغذ" },
{ id: "slide", title: "اسلاید افقی", hint: "صفحه بعدی از کنار می‌آید" },
{ id: "fade", title: "محو شدن", hint: "صفحه آرام عوض می‌شود" },
{ id: "card", title: "کارت", hint: "ورق دوبعدی مثل کارت" },
{ id: "scroll", title: "اسکرول عمودی", hint: "همه صفحات زیر هم" },
];
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
declare module "page-flip" {
export class PageFlip {
constructor(el: HTMLElement, settings: Record<string, unknown>);
loadFromImages(images: string[]): void;
flipNext(): void;
flipPrev(): void;
turnToPage(page: number): void;
getCurrentPageIndex(): number;
getPageCount(): number;
destroy(): void;
on(event: string, cb: (e: { data: number }) => void): void;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { LIMITS, fileTooLargeMessage, tooManyPagesMessage } from "@shared/types";
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export function isImageFile(file: File): boolean {
if (IMAGE_TYPES.has(file.type)) return true;
return /\.(jpe?g|png|webp|gif)$/i.test(file.name);
}
export function isPdfFile(file: File): boolean {
return file.type === "application/pdf" || /\.pdf$/i.test(file.name);
}
function drawToJpeg(source: CanvasImageSource, width: number, height: number): Promise<Blob> {
const scale = Math.min(1, LIMITS.maxPageWidth / width);
const w = Math.max(1, Math.round(width * scale));
const h = Math.max(1, Math.round(height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return Promise.reject(new Error("تبدیل تصویر ناموفق بود"));
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(source, 0, 0, w, h);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error("تبدیل تصویر ناموفق بود"));
else resolve(blob);
},
"image/jpeg",
LIMITS.jpegQuality / 100,
);
});
}
export async function fileToJpeg(file: File): Promise<Blob> {
if (file.size > LIMITS.maxFileBytes) {
throw new Error(fileTooLargeMessage(file.size));
}
const bitmap = await createImageBitmap(file);
try {
return await drawToJpeg(bitmap, bitmap.width, bitmap.height);
} finally {
bitmap.close();
}
}
export async function filesToJpegs(
files: File[],
onProgress?: (done: number, total: number) => void,
): Promise<Blob[]> {
if (files.length > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(files.length));
}
const out: Blob[] = [];
onProgress?.(0, files.length);
for (const file of files) {
out.push(await fileToJpeg(file));
onProgress?.(out.length, files.length);
}
return out;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
createApp(App).use(router).mount("#app");
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import type { EffectId } from "@shared/types";
import { DEFAULT_FLIP_MS, LIMITS, tooManyPagesMessage } from "@shared/types";
import { createCatalog } from "../api";
import EffectSelect from "../components/EffectSelect.vue";
import PageList, { type DraftPage } from "../components/PageList.vue";
import { filesToJpegs, isImageFile, isPdfFile } from "../images";
import { renderPdfToJpegs } from "../pdf";
const router = useRouter();
const title = ref("");
const effect = ref<EffectId>("magazine");
const flipMs = ref(DEFAULT_FLIP_MS);
const pages = ref<DraftPage[]>([]);
const busy = ref(false);
const progress = ref("");
const progressDone = ref(0);
const progressTotal = ref(0);
const error = ref("");
const dragOver = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const canSubmit = computed(() => pages.value.length > 0 && !busy.value);
const progressPct = computed(() => {
if (!progressTotal.value) return 0;
return Math.round((progressDone.value / progressTotal.value) * 100);
});
function setProgress(message: string, done = 0, total = 0) {
progress.value = message;
progressDone.value = done;
progressTotal.value = total;
}
function revokeAll() {
for (const page of pages.value) URL.revokeObjectURL(page.preview);
}
function setPages(blobs: Blob[]) {
revokeAll();
pages.value = blobs.map((blob, i) => ({
key: `${Date.now()}-${i}`,
blob,
preview: URL.createObjectURL(blob),
}));
}
function move(index: number, dir: -1 | 1) {
const next = index + dir;
if (next < 0 || next >= pages.value.length) return;
const copy = [...pages.value];
const [item] = copy.splice(index, 1);
copy.splice(next, 0, item);
pages.value = copy;
}
function remove(index: number) {
const copy = [...pages.value];
const [item] = copy.splice(index, 1);
if (item) URL.revokeObjectURL(item.preview);
pages.value = copy;
}
async function ingest(fileList: File[]) {
if (busy.value) return;
error.value = "";
if (!fileList.length) return;
const pdfs = fileList.filter(isPdfFile);
const images = fileList.filter(isImageFile);
if (pdfs.length && images.length) {
error.value = "یک PDF یا چند تصویر انتخاب کنید، نه هر دو با هم.";
return;
}
if (!pdfs.length && !images.length) {
error.value = "نوع فایل پشتیبانی نمی‌شود";
return;
}
if (pdfs.length > 1) {
error.value = "فقط یک فایل PDF انتخاب کنید.";
return;
}
busy.value = true;
setProgress("در حال خواندن فایل…");
try {
if (pdfs.length === 1) {
setProgress("در حال خواندن PDF…");
const blobs = await renderPdfToJpegs(pdfs[0], (done, total) => {
setProgress(
done === 0 ? "در حال آماده‌سازی صفحات…" : `تبدیل PDF: ${done} از ${total}`,
done,
total,
);
});
setPages(blobs);
} else {
if (images.length > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(images.length));
}
setProgress("در حال آماده‌سازی تصاویر…");
setPages(
await filesToJpegs(images, (done, total) => {
setProgress(
done === 0 ? "در حال آماده‌سازی تصاویر…" : `تبدیل تصویر: ${done} از ${total}`,
done,
total,
);
}),
);
}
setProgress(`${pages.value.length} صفحه آماده است`);
} catch (err) {
error.value = err instanceof Error ? err.message : "خطا در خواندن فایل";
setProgress("");
} finally {
busy.value = false;
}
}
function onDrop(ev: DragEvent) {
dragOver.value = false;
const list = ev.dataTransfer?.files;
if (list) void ingest([...list]);
}
async function submit() {
if (busy.value) return;
error.value = "";
busy.value = true;
setProgress("در حال ساخت کاتالوگ…");
try {
const created = await createCatalog({
title: title.value,
effect: effect.value,
flipMs: flipMs.value,
pages: pages.value.map((p) => p.blob),
});
const token = new URL(created.deleteUrl).searchParams.get("token") ?? "";
await router.push({ path: `/c/${created.id}/done`, query: { token } });
} catch (err) {
error.value = err instanceof Error ? err.message : "ساخت کاتالوگ ناموفق بود";
setProgress(pages.value.length ? `${pages.value.length} صفحه آماده است` : "");
} finally {
busy.value = false;
}
}
</script>
<template>
<main class="page">
<header class="hero">
<h1>کاتالوگ دیجیتال</h1>
<p>PDF یا تصویرها را بدهید؛ لینک و QR کاتالوگ ورق‌زدنی را بگیرید.</p>
</header>
<section class="card create-card">
<div
v-if="busy"
class="loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
>
<span class="spinner" aria-hidden="true"></span>
<p>{{ progress || "لطفاً صبر کنید…" }}</p>
<div v-if="progressTotal" class="progress-track">
<i :style="{ width: `${progressPct}%` }"></i>
</div>
</div>
<div
class="drop"
:class="{ active: dragOver, disabled: busy }"
@dragover.prevent="!busy && (dragOver = true)"
@dragleave="dragOver = false"
@drop.prevent="onDrop"
>
<p>فایل را اینجا رها کنید یا انتخاب کنید</p>
<button class="btn" type="button" :disabled="busy" @click="fileInput?.click()">
انتخاب فایل
</button>
<input
ref="fileInput"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp,image/gif,.pdf,.jpg,.jpeg,.png,.webp,.gif"
multiple
@change="ingest(fileInput?.files ? [...fileInput.files] : [])"
/>
</div>
<div class="field">
<label>عنوان (اختیاری)</label>
<input v-model="title" type="text" placeholder="کاتالوگ" maxlength="80" />
</div>
<div class="field">
<label>افکت ورق‌زدن</label>
<EffectSelect v-model="effect" />
</div>
<div class="field">
<label>زمان ورق‌خوردن ({{ (flipMs / 1000).toFixed(1) }} ثانیه)</label>
<div class="flip-speed">
<span>سریع</span>
<input
v-model.number="flipMs"
type="range"
:min="LIMITS.minFlipMs"
:max="LIMITS.maxFlipMs"
step="100"
/>
<span>آهسته</span>
</div>
</div>
<div v-if="pages.length" class="field">
<label>صفحات ({{ pages.length }})</label>
<PageList :pages="pages" @move="move" @remove="remove" />
</div>
<p v-if="progress && !busy" class="ok">{{ progress }}</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="row-actions">
<button class="btn primary" type="button" :disabled="!canSubmit" @click="submit">
ساخت کاتالوگ
</button>
</div>
</section>
</main>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { deleteCatalog } from "../api";
const route = useRoute();
const id = computed(() => String(route.params.id));
const token = computed(() => String(route.query.token ?? ""));
const busy = ref(false);
const error = ref("");
const gone = ref(false);
async function confirmDelete() {
error.value = "";
busy.value = true;
try {
await deleteCatalog(id.value, token.value);
gone.value = true;
} catch (err) {
error.value = err instanceof Error ? err.message : "حذف ناموفق بود";
} finally {
busy.value = false;
}
}
</script>
<template>
<main class="page">
<header class="hero">
<h1>حذف کاتالوگ</h1>
<p v-if="!gone">این کار قابل بازگشت نیست. لینک و QR دیگر کار نمی‌کنند.</p>
</header>
<section class="card">
<template v-if="gone">
<p class="ok">کاتالوگ حذف شد.</p>
<router-link class="btn" to="/">ساخت کاتالوگ جدید</router-link>
</template>
<template v-else>
<p v-if="!token" class="error">لینک حذف نامعتبر است.</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="row-actions">
<button
class="btn danger"
type="button"
:disabled="!token || busy"
@click="confirmDelete"
>
حذف کاتالوگ
</button>
<router-link class="btn" :to="`/c/${id}`">انصراف</router-link>
</div>
</template>
</section>
</main>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { qrUrl } from "../api";
const route = useRoute();
const router = useRouter();
const id = computed(() => String(route.params.id));
const token = computed(() => String(route.query.token ?? ""));
const copied = ref(false);
const viewLink = computed(() => `${window.location.origin}/c/${id.value}`);
const deleteLink = computed(
() => `${window.location.origin}/c/${id.value}/delete?token=${encodeURIComponent(token.value)}`,
);
onMounted(() => {
if (!token.value) {
void router.replace(`/c/${id.value}`);
}
});
async function copy(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
}
function downloadQr() {
const a = document.createElement("a");
a.href = qrUrl(id.value);
a.download = `catalog-${id.value}-qr.png`;
a.click();
}
</script>
<template>
<main v-if="token" class="page">
<header class="hero">
<h1>کاتالوگ آماده است</h1>
<p>لینک و QR را برای اشتراک بفرستید.</p>
</header>
<section class="card">
<label>لینک مشاهده</label>
<div class="link-box">
<input :value="viewLink" readonly />
<button class="btn" type="button" @click="copy(viewLink)">کپی</button>
</div>
<p v-if="copied" class="ok">کپی شد.</p>
<div class="field">
<img class="qr" :src="qrUrl(id)" alt="QR کاتالوگ" />
</div>
<div class="row-actions">
<button class="btn primary" type="button" @click="downloadQr">دانلود QR</button>
<router-link class="btn" :to="`/c/${id}`">باز کردن کاتالوگ</router-link>
</div>
<p class="warn">
لینک حذف را همین حالا ذخیره کنید. اگر گم شود قابل بازیابی نیست.
</p>
<div class="link-box">
<input :value="deleteLink" readonly />
<button class="btn" type="button" @click="copy(deleteLink)">کپی حذف</button>
</div>
<div class="row-actions">
<router-link class="btn danger" :to="`/c/${id}/delete?token=${encodeURIComponent(token)}`">
حذف کاتالوگ
</router-link>
</div>
</section>
</main>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { getCatalog, pageUrl } from "../api";
import CatalogViewer from "../components/CatalogViewer.vue";
import type { CatalogPublic } from "@shared/types";
const route = useRoute();
const id = computed(() => String(route.params.id));
const catalog = ref<CatalogPublic | null>(null);
const error = ref("");
const loading = ref(true);
const images = computed(() => {
if (!catalog.value) return [];
return Array.from({ length: catalog.value.pageCount }, (_, i) =>
pageUrl(catalog.value!.id, i + 1),
);
});
onMounted(async () => {
try {
catalog.value = await getCatalog(id.value);
} catch (err) {
error.value = err instanceof Error ? err.message : "کاتالوگ پیدا نشد";
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="stage-page">
<header class="stage-bar">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn" to="/">ساخت جدید</router-link>
</header>
<p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p>
<p v-else-if="error" class="viewer-wrap error">{{ error }}</p>
<CatalogViewer
v-else-if="catalog"
:effect="catalog.effect"
:images="images"
:flip-ms="catalog.flipMs"
/>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
import * as pdfjs from "pdfjs-dist";
import pdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { LIMITS, fileTooLargeMessage, tooManyPagesMessage } from "@shared/types";
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker;
function canvasToJpeg(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error("تبدیل تصویر ناموفق بود"));
else resolve(blob);
},
"image/jpeg",
LIMITS.jpegQuality / 100,
);
});
}
export async function renderPdfToJpegs(
file: File,
onProgress?: (done: number, total: number) => void,
): Promise<Blob[]> {
if (file.size > LIMITS.maxFileBytes) {
throw new Error(fileTooLargeMessage(file.size));
}
const data = new Uint8Array(await file.arrayBuffer());
let pdf;
try {
pdf = await pdfjs.getDocument({ data }).promise;
} catch (err) {
const name =
err && typeof err === "object" && "name" in err
? String((err as { name: string }).name)
: "";
if (name === "PasswordException") {
throw new Error("این PDF رمزگذاری شده است و قابل تبدیل نیست.");
}
if (name === "InvalidPDFException") {
throw new Error("این فایل یک PDF معتبر نیست.");
}
throw err;
}
if (pdf.numPages < 1) {
throw new Error("این PDF صفحه‌ای برای نمایش ندارد.");
}
if (pdf.numPages > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(pdf.numPages));
}
const pages: Blob[] = [];
onProgress?.(0, pdf.numPages);
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(LIMITS.maxPageWidth / base.width, 2);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("تبدیل تصویر ناموفق بود");
const renderTask = page.render({
canvasContext: ctx,
canvas,
viewport,
} as Parameters<typeof page.render>[0]);
await renderTask.promise;
pages.push(await canvasToJpeg(canvas));
onProgress?.(i, pdf.numPages);
}
return pages;
}
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from "vue-router";
import CreatePage from "./pages/CreatePage.vue";
import ViewPage from "./pages/ViewPage.vue";
import DonePage from "./pages/DonePage.vue";
import DeletePage from "./pages/DeletePage.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: CreatePage },
{ path: "/c/:id/done", component: DonePage },
{ path: "/c/:id/delete", component: DeletePage },
{ path: "/c/:id", component: ViewPage },
],
});
+451
View File
@@ -0,0 +1,451 @@
:root {
--bg: #f3eee6;
--bg-2: #fffdf9;
--ink: #1c1917;
--muted: #78716c;
--line: #e7e0d6;
--accent: #b45309;
--accent-2: #9a3412;
--danger: #b91c1c;
--stage: #161210;
--shadow: 0 18px 50px rgba(28, 25, 23, 0.12);
font-family: Vazirmatn, Tahoma, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--ink);
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: var(--accent-2);
}
.page {
max-width: 920px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 1.8rem;
}
.hero p {
margin: 0;
color: var(--muted);
}
.card {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: var(--shadow);
padding: 22px;
margin-top: 24px;
}
.create-card {
position: relative;
overflow: hidden;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 24px;
background: rgba(255, 253, 249, 0.92);
text-align: center;
}
.loading-overlay p {
margin: 0;
font-weight: 600;
color: var(--accent-2);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e7e0d6;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress-track {
width: min(280px, 80%);
height: 6px;
border-radius: 99px;
background: #e7e0d6;
overflow: hidden;
}
.progress-track i {
display: block;
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-top-color: #e7e0d6;
border-color: var(--accent);
}
}
.drop {
border: 2px dashed #d6cbb8;
border-radius: 16px;
padding: 36px 16px;
text-align: center;
background: #faf7f2;
transition: border-color 0.15s, background 0.15s;
}
.drop.active {
border-color: var(--accent);
background: #fff7ed;
}
.drop.disabled {
opacity: 0.65;
pointer-events: none;
}
.drop input {
display: none;
}
.effects {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-top: 12px;
}
.effect {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: #fff;
text-align: right;
}
.effect input {
margin-left: 8px;
}
.effect small {
display: block;
color: var(--muted);
margin-top: 4px;
}
.flip-speed {
display: flex;
align-items: center;
gap: 12px;
}
.flip-speed input[type="range"] {
flex: 1;
}
.flip-speed span {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.pages {
display: grid;
gap: 8px;
margin-top: 12px;
}
.page-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px;
background: #fff;
}
.page-row img {
width: 52px;
height: 68px;
object-fit: cover;
border-radius: 6px;
background: #eee;
}
.page-row .grow {
flex: 1;
}
.icon-btn,
.btn {
border: 0;
border-radius: 12px;
padding: 8px 12px;
background: #e7e0d6;
color: var(--ink);
}
.btn.primary {
background: var(--accent);
color: #fff;
padding: 12px 18px;
font-weight: 600;
}
.btn.danger {
background: var(--danger);
color: #fff;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.field {
margin-top: 18px;
}
.field label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.field input[type="text"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
}
.error {
color: var(--danger);
margin-top: 12px;
}
.ok {
color: #166534;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.link-box {
display: flex;
gap: 8px;
margin-top: 8px;
}
.link-box input {
flex: 1;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
}
.qr {
width: 220px;
height: 220px;
border-radius: 16px;
background: #fff;
border: 1px solid var(--line);
}
.warn {
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
border-radius: 12px;
padding: 12px;
margin-top: 16px;
}
.stage-page {
min-height: 100vh;
background:
radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%),
var(--stage);
color: #f5f0ea;
}
.stage-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #2a241f;
}
.stage-bar h1 {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.stage-bar a,
.stage-bar button {
color: #f5f0ea;
background: #2a241f;
text-decoration: none;
}
.viewer-wrap {
padding: 16px;
position: relative;
}
.viewer-wrap.cinematic {
background:
radial-gradient(1200px 500px at 50% 20%, rgba(245, 158, 11, 0.08), transparent 55%),
radial-gradient(circle at 50% 110%, rgba(0, 0, 0, 0.45), transparent 50%);
border-radius: 16px;
}
.auto-hint {
position: relative;
max-width: 420px;
margin: 0 auto 14px;
text-align: center;
color: #fde68a;
font-size: 0.92rem;
overflow: hidden;
border-radius: 999px;
background: rgba(41, 32, 22, 0.75);
padding: 10px 16px 12px;
}
.hint-bar {
display: block;
height: 3px;
margin: 8px 8px 0;
border-radius: 99px;
background: linear-gradient(90deg, #f59e0b, #fde68a);
transform-origin: right center;
animation: hint-fill 3s linear forwards;
}
@keyframes hint-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.stage-bar .btn.play {
background: #b45309;
color: #fff;
}
.thumbs {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 8px 16px 20px;
}
.thumbs img {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 6px;
opacity: 0.55;
cursor: pointer;
border: 2px solid transparent;
}
.thumbs img.active {
opacity: 1;
border-color: #f59e0b;
}
.flip-stage {
width: min(920px, 100%);
margin: 0 auto;
height: min(72vh, 740px);
display: flex;
align-items: center;
justify-content: center;
perspective: 1600px;
}
.flip-stage img.page-img {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
background: #fff;
user-select: none;
}
.scroll-list {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 820px;
margin: 0 auto;
padding: 12px;
}
.scroll-list img {
width: 100%;
background: #fff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
}
.zoom-in img.page-img {
max-height: 92vh;
max-width: 100%;
}