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,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%;
|
||||
}
|
||||
Reference in New Issue
Block a user