Fix mobile viewer chrome and keep pages visible during autoplay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 18:07:45 +03:30
co-authored by Cursor
parent a8374e7d6c
commit d25841aa16
6 changed files with 281 additions and 78 deletions
+87 -22
View File
@@ -15,6 +15,10 @@ const props = defineProps<{
flipMs?: number; flipMs?: number;
}>(); }>();
const emit = defineEmits<{
immersive: [value: boolean];
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS); const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS);
const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS); const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS);
@@ -24,6 +28,7 @@ const fullscreen = ref(false);
const fallbackSlide = ref(false); const fallbackSlide = ref(false);
const playing = ref(true); const playing = ref(true);
const waiting = ref(true); const waiting = ref(true);
const hideChrome = ref(false);
const stage = ref<HTMLElement | null>(null); const stage = ref<HTMLElement | null>(null);
const magazine = ref<{ next: () => void; prev: () => void } | null>(null); const magazine = ref<{ next: () => void; prev: () => void } | null>(null);
@@ -35,6 +40,12 @@ const visualEffect = computed<EffectId>(() => {
return props.effect; return props.effect;
}); });
const immersive = computed(
() => singlePage.value && (hideChrome.value || (playing.value && !waiting.value)),
);
watch(immersive, (value) => emit("immersive", value), { immediate: true });
let delayTimer = 0; let delayTimer = 0;
let loopTimer = 0; let loopTimer = 0;
@@ -96,6 +107,11 @@ function togglePlay() {
} }
} }
function onFab() {
hideChrome.value = false;
if (playing.value) togglePlay();
}
function onKey(ev: KeyboardEvent) { function onKey(ev: KeyboardEvent) {
if (ev.key === "ArrowLeft") onUserNav(next); if (ev.key === "ArrowLeft") onUserNav(next);
if (ev.key === "ArrowRight") onUserNav(prev); if (ev.key === "ArrowRight") onUserNav(prev);
@@ -103,21 +119,49 @@ function onKey(ev: KeyboardEvent) {
ev.preventDefault(); ev.preventDefault();
togglePlay(); togglePlay();
} }
if (ev.key === "Escape") zoom.value = false; if (ev.key === "Escape") {
} zoom.value = false;
hideChrome.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 fullscreenElement() {
const doc = document as Document & { webkitFullscreenElement?: Element | null };
return document.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
}
async function toggleFullscreen() {
if (singlePage.value) {
hideChrome.value = !hideChrome.value;
return;
}
const el = stage.value as
| (HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void })
| null;
if (!el) return;
try {
if (!fullscreenElement()) {
const req = el.requestFullscreen ?? el.webkitRequestFullscreen;
if (!req) return;
await req.call(el);
} else {
const exit =
document.exitFullscreen ??
(document as Document & { webkitExitFullscreen?: () => Promise<void> | void })
.webkitExitFullscreen;
if (exit) await exit.call(document);
}
} catch {
/* mobile / iframe / permission — ignore */
}
}
function onFullscreenChange() {
fullscreen.value = Boolean(fullscreenElement());
}
function onResize() { function onResize() {
if (fullscreenElement()) return;
windowInner.value = window.innerWidth; windowInner.value = window.innerWidth;
} }
@@ -136,12 +180,16 @@ function armAutoplay() {
onMounted(() => { onMounted(() => {
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
document.addEventListener("fullscreenchange", onFullscreenChange);
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
armAutoplay(); armAutoplay();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onFullscreenChange);
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
window.clearTimeout(delayTimer); window.clearTimeout(delayTimer);
stopLoop(); stopLoop();
}); });
@@ -152,25 +200,42 @@ watch(
index.value = 0; index.value = 0;
fallbackSlide.value = false; fallbackSlide.value = false;
playing.value = true; playing.value = true;
hideChrome.value = false;
armAutoplay(); armAutoplay();
}, },
); );
</script> </script>
<template> <template>
<div ref="stage" class="viewer-wrap cinematic"> <div ref="stage" class="viewer-wrap cinematic" :class="{ immersive }">
<div class="stage-bar"> <div class="stage-bar page-chrome">
<button class="btn" type="button" @click="onUserNav(prev)">قبلی</button> <div class="chrome-nav">
<button class="btn" type="button" @click="onUserNav(next)">بعدی</button> <button class="btn chrome-btn" type="button" @click="onUserNav(prev)">قبلی</button>
<span>{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span> <span class="page-pos">{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn play" type="button" @click="togglePlay"> <button class="btn chrome-btn" type="button" @click="onUserNav(next)">بعدی</button>
{{ playing ? "توقف پخش" : "پخش خودکار" }} </div>
</button> <div class="chrome-tools">
<span style="flex: 1"></span> <button class="btn play chrome-btn" type="button" @click="togglePlay">
<button class="btn" type="button" @click="zoom = !zoom">زوم</button> <span class="lbl-full">{{ playing ? "توقف پخش" : "پخش خودکار" }}</span>
<button class="btn" type="button" @click="toggleFullscreen">تمام‌صفحه</button> <span class="lbl-short">{{ playing ? "توقف" : "پخش" }}</span>
</button>
<button class="btn chrome-btn" type="button" @click="zoom = !zoom">زوم</button>
<button class="btn chrome-btn" type="button" @click="toggleFullscreen">
<span class="lbl-full">تمام‌صفحه</span>
<span class="lbl-short">تمام</span>
</button>
</div>
</div> </div>
<button
v-if="immersive"
class="fab-pause"
type="button"
@click="onFab"
>
{{ playing ? "توقف" : "منو" }}
</button>
<div v-if="waiting && playing" class="auto-hint"> <div v-if="waiting && playing" class="auto-hint">
<span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span> <span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span>
<i class="hint-bar"></i> <i class="hint-bar"></i>
@@ -20,6 +20,7 @@ const mountKey = ref(0);
let book: PageFlip | null = null; let book: PageFlip | null = null;
let ignore = false; let ignore = false;
let prepared: string[] = []; let prepared: string[] = [];
let makeGen = 0;
function loadImage(src: string): Promise<HTMLImageElement> { function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -87,25 +88,36 @@ function pageBox(first: HTMLImageElement, maxW: number, maxH: number) {
} }
async function makeBook() { async function makeBook() {
const gen = ++makeGen;
const previous = prepared;
prepared = [];
try { try {
book?.destroy(); book?.destroy();
} catch { } catch {
/* ignore */ /* ignore */
} }
book = null; book = null;
revokePrepared();
mountKey.value += 1; mountKey.value += 1;
await nextTick(); await nextTick();
if (!host.value || !outer.value || !props.images.length) return; if (gen !== makeGen) {
for (const url of previous) URL.revokeObjectURL(url);
return;
}
if (!host.value || !outer.value || !props.images.length) {
for (const url of previous) URL.revokeObjectURL(url);
return;
}
try { try {
const first = await loadImage(props.images[0]); const first = await loadImage(props.images[0]);
if (gen !== makeGen) return;
const maxW = Math.max(280, outer.value.clientWidth - 16); const maxW = Math.max(280, outer.value.clientWidth - 16);
const maxH = Math.max(320, outer.value.clientHeight - 16); const maxH = Math.max(320, outer.value.clientHeight - 16);
const { pageW, pageH } = pageBox(first, maxW, maxH); const { pageW, pageH } = pageBox(first, maxW, maxH);
const fitted: string[] = []; const fitted: string[] = [];
for (const src of props.images) { for (const src of props.images) {
if (gen !== makeGen) return;
try { try {
const img = src === props.images[0] ? first : await loadImage(src); const img = src === props.images[0] ? first : await loadImage(src);
fitted.push(await containDraw(img, pageW, pageH)); fitted.push(await containDraw(img, pageW, pageH));
@@ -115,7 +127,7 @@ async function makeBook() {
} }
await nextTick(); await nextTick();
if (!host.value) return; if (gen !== makeGen || !host.value) return;
book = new PageFlip(host.value, { book = new PageFlip(host.value, {
width: pageW, width: pageW,
@@ -140,6 +152,7 @@ async function makeBook() {
ignore = false; ignore = false;
}); });
}); });
for (const url of previous) URL.revokeObjectURL(url);
} catch { } catch {
emit("fallback"); emit("fallback");
} }
+5 -3
View File
@@ -10,6 +10,7 @@ const id = computed(() => String(route.params.id));
const catalog = ref<CatalogPublic | null>(null); const catalog = ref<CatalogPublic | null>(null);
const error = ref(""); const error = ref("");
const loading = ref(true); const loading = ref(true);
const immersive = ref(false);
const images = computed(() => { const images = computed(() => {
if (!catalog.value) return []; if (!catalog.value) return [];
@@ -30,10 +31,10 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="stage-page"> <div class="stage-page" :class="{ immersive }">
<header class="stage-bar"> <header class="stage-bar page-chrome">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1> <h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn" to="/">ساخت جدید</router-link> <router-link class="btn chrome-btn" to="/">ساخت جدید</router-link>
</header> </header>
<p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p> <p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p>
<p v-else-if="error" class="viewer-wrap error">{{ error }}</p> <p v-else-if="error" class="viewer-wrap error">{{ error }}</p>
@@ -42,6 +43,7 @@ onMounted(async () => {
:effect="catalog.effect" :effect="catalog.effect"
:images="images" :images="images"
:flip-ms="catalog.flipMs" :flip-ms="catalog.flipMs"
@immersive="immersive = $event"
/> />
</div> </div>
</template> </template>
+112
View File
@@ -312,6 +312,9 @@ a {
.stage-page { .stage-page {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: background:
radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%), radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%),
var(--stage); var(--stage);
@@ -326,11 +329,46 @@ a {
border-bottom: 1px solid #2a241f; border-bottom: 1px solid #2a241f;
} }
.stage-page > .stage-bar {
flex-shrink: 0;
}
.stage-bar h1 { .stage-bar h1 {
flex: 1; flex: 1;
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chrome-btn {
flex-shrink: 0;
white-space: nowrap;
}
.chrome-nav,
.chrome-tools {
display: flex;
align-items: center;
gap: 8px;
}
.chrome-tools {
margin-inline-start: auto;
}
.page-pos {
min-width: 4.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.lbl-short {
display: none;
} }
.stage-bar a, .stage-bar a,
@@ -343,6 +381,7 @@ a {
.viewer-wrap { .viewer-wrap {
padding: 16px; padding: 16px;
position: relative; position: relative;
flex: 1;
} }
.viewer-wrap.cinematic { .viewer-wrap.cinematic {
@@ -352,6 +391,79 @@ a {
border-radius: 16px; border-radius: 16px;
} }
.fab-pause {
position: fixed;
z-index: 20;
bottom: max(16px, env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
border: 0;
border-radius: 999px;
padding: 10px 22px;
background: #b45309;
color: #fff;
font: inherit;
font-weight: 600;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.stage-page.immersive > .page-chrome,
.viewer-wrap.immersive > .page-chrome,
.viewer-wrap.immersive .thumbs,
.viewer-wrap.immersive .auto-hint {
display: none;
}
.stage-page.immersive .viewer-wrap {
padding: 0;
border-radius: 0;
}
@media (max-width: 900px) {
.stage-bar {
gap: 8px;
padding: 8px 10px;
}
.stage-bar h1 {
font-size: 0.88rem;
}
.chrome-btn {
padding: 6px 9px;
font-size: 0.78rem;
}
.lbl-full {
display: none;
}
.lbl-short {
display: inline;
}
.viewer-wrap.cinematic > .stage-bar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 8px;
}
.chrome-nav,
.chrome-tools {
width: 100%;
justify-content: space-between;
}
.chrome-tools {
margin-inline-start: 0;
}
.viewer-wrap {
padding: 10px 8px 16px;
}
}
.auto-hint { .auto-hint {
position: relative; position: relative;
max-width: 420px; max-width: 420px;
+13 -36
View File
@@ -8,27 +8,7 @@
--- ---
## کارهایی که روی لپ‌تاپ شماست کد از Gitea کلون می‌شود: `http://get.feep.ir/Mahdi/digitalcatalog.git`
### ۱) بستهٔ ارسال را بسازید
در PowerShell:
```powershell
cd E:\projeha\DigitalCatalog
if (Test-Path catalog-deploy.tar) { Remove-Item catalog-deploy.tar }
tar --exclude=node_modules --exclude=dist --exclude=storage --exclude=data --exclude=.env --exclude=catalog-deploy.tar -cvf catalog-deploy.tar .
```
### ۲) آپلود روی سرور
رمز SSH همانی است که برای سوپرادمین/مونتایم می‌زنید:
```powershell
scp -P 3031 catalog-deploy.tar montime@185.50.38.53:/tmp/catalog-deploy.tar
```
بعد از آپلود، فایل `catalog-deploy.tar` روی ویندوز را می‌توانید پاک کنید.
--- ---
@@ -40,19 +20,17 @@ SSH:
ssh -p 3031 montime@185.50.38.53 ssh -p 3031 montime@185.50.38.53
``` ```
### ۳) پوشه و باز کردن فایل‌ها ### ۱) کلون
```bash ```bash
sudo mkdir -p /var/www/ktg sudo mkdir -p /var/www/ktg
sudo chown montime:montime /var/www/ktg sudo chown montime:montime /var/www/ktg
cd /var/www/ktg git clone http://get.feep.ir/Mahdi/digitalcatalog.git /var/www/ktg
tar -xvf /tmp/catalog-deploy.tar
rm /tmp/catalog-deploy.tar
``` ```
اگر پوشه از قبل چیزی داشت و آپدیت است، `storage` و `data` را پاک نکنید. اگر Gitea خصوصی است، همان یوزر/توکن را بزنید.
### ۴) پورت آزاد باشد ### ۲) پورت آزاد باشد
```bash ```bash
pm2 list pm2 list
@@ -61,7 +39,7 @@ ss -tlnp | grep -E '3000|3001|3010|3020' || netstat -tlnp | grep -E '3000|3001|3
۳۰۲۰ باید خالی باشد. اگر اشغال بود بگویید تا پورت عوض شود؛ خودتان پورت بقیه را عوض نکنید. ۳۰۲۰ باید خالی باشد. اگر اشغال بود بگویید تا پورت عوض شود؛ خودتان پورت بقیه را عوض نکنید.
### ۵) فایل `.env` سرور ### ۳) فایل `.env` سرور
```bash ```bash
cd /var/www/ktg cd /var/www/ktg
@@ -82,7 +60,7 @@ DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
دیتابیس Postgres و `DATABASE_URL` سوپرادمین را اینجا نگذارید. دیتابیس Postgres و `DATABASE_URL` سوپرادمین را اینجا نگذارید.
### ۶) نصب، بیلد، پوشه‌ها ### ۴) نصب، بیلد، پوشه‌ها
Node ۲۰ یا جدیدتر. Node ۲۰ یا جدیدتر.
@@ -93,7 +71,7 @@ npm run build
mkdir -p storage data mkdir -p storage data
``` ```
### ۷) PM2 — فقط این پروسهٔ جدید ### ۵) PM2 — فقط این پروسهٔ جدید
پروسه‌های `montime` / `superadmin-api` / فوری‌سند را stop نکنید. پروسه‌های `montime` / `superadmin-api` / فوری‌سند را stop نکنید.
@@ -106,7 +84,7 @@ curl -sI http://127.0.0.1:3020
باید `HTTP/1.1 200` بیاید. اگر خطا بود: `pm2 logs ktg-catalog --lines 80` باید `HTTP/1.1 200` بیاید. اگر خطا بود: `pm2 logs ktg-catalog --lines 80`
### ۸) Nginx — فقط سایت جدید ### ۶) Nginx — فقط سایت جدید
```bash ```bash
sudo cp /var/www/ktg/deploy/nginx-ktg.montzar.ir.conf /etc/nginx/sites-available/ktg.montzar.ir sudo cp /var/www/ktg/deploy/nginx-ktg.montzar.ir.conf /etc/nginx/sites-available/ktg.montzar.ir
@@ -116,7 +94,7 @@ sudo nginx -t && sudo systemctl reload nginx
اگر `nginx -t` خطا داد، فایل‌های `adm.montzar.ir` را ویرایش نکنید؛ بگویید خروجی را بفرستید. اگر `nginx -t` خطا داد، فایل‌های `adm.montzar.ir` را ویرایش نکنید؛ بگویید خروجی را بفرستید.
### ۹) گواهی SSL ### ۷) گواهی SSL
DNS این زیردامنه از قبل مثل `adm.montzar.ir` روی همان IP است. اگر مرورگر هنوز به سرور نرسید، در پنل دامنه تأیید کنید `ktg.montzar.ir` رکورد `A` مثل `adm.montzar.ir` دارد. DNS این زیردامنه از قبل مثل `adm.montzar.ir` روی همان IP است. اگر مرورگر هنوز به سرور نرسید، در پنل دامنه تأیید کنید `ktg.montzar.ir` رکورد `A` مثل `adm.montzar.ir` دارد.
@@ -126,7 +104,7 @@ sudo certbot --nginx -d ktg.montzar.ir
Certbot خودش HTTPS را به همین vhost اضافه می‌کند، نه به سایت‌های دیگر. Certbot خودش HTTPS را به همین vhost اضافه می‌کند، نه به سایت‌های دیگر.
### ۱۰) تست نهایی ### ۸) تست نهایی
1. `https://ktg.montzar.ir` صفحهٔ ساخت فارسی 1. `https://ktg.montzar.ir` صفحهٔ ساخت فارسی
2. یک PDF/تصویر کوچک بسازید 2. یک PDF/تصویر کوچک بسازید
@@ -139,12 +117,11 @@ Certbot خودش HTTPS را به همین vhost اضافه می‌کند، نه
## آپدیت بعدی کد ## آپدیت بعدی کد
روی ویندوز دوباره مرحلهٔ ۱ و ۲. روی سرور: روی ویندوز `git push`. روی سرور:
```bash ```bash
cd /var/www/ktg cd /var/www/ktg
# اگر tar روی فایل‌های قبلی unpacked می‌شود: git pull
tar -xvf /tmp/catalog-deploy.tar
npm ci npm ci
npm run build npm run build
pm2 restart ktg-catalog pm2 restart ktg-catalog
+48 -14
View File
@@ -1,6 +1,6 @@
# کاتالوگ دیجیتال (Digital Catalog) # کاتالوگ دیجیتال (Digital Catalog)
سند مرجع پروژه — ۱۸ اوت ۲۰۲۶ سند مرجع پروژه — ۱۸ اوت ۲۰۲۶، به‌روز تا ۱۹ اوت ۲۰۲۶
قبل از پیاده‌سازی نوشته شده است. اگر رفتار محصول عوض شد، همین فایل باید به‌روز شود. قبل از پیاده‌سازی نوشته شده است. اگر رفتار محصول عوض شد، همین فایل باید به‌روز شود.
--- ---
@@ -25,10 +25,12 @@
| ثبت‌نام / ورود | ندارد. هر بازدیدکننده دامنه می‌تواند بسازد | | ثبت‌نام / ورود | ندارد. هر بازدیدکننده دامنه می‌تواند بسازد |
| گالری عمومی | ندارد | | گالری عمومی | ندارد |
| خروجی ساخت | لینک مشاهده + تصویر QR + لینک حذف | | خروجی ساخت | لینک مشاهده + تصویر QR + لینک حذف |
| ویرایش بعدی | ندارد (صفحات، PDF، افکت بعد از ساخت عوض نمی‌شود) | | ویرایش بعدی | ندارد (صفحات، PDF، افکت و زمان ورق بعد از ساخت عوض نمی‌شود) |
| انقضای خودکار | ندارد | | انقضای خودکار | ندارد |
| زبان رابط | فارسی، راست‌به‌چپ | | زبان رابط | فارسی، راست‌به‌چپ |
| نسخه اول | ساده؛ بدون حساب کاربری، بدون پنل ادمین، بدون SEO کاتالوگ | | نسخه اول | ساده؛ بدون حساب کاربری، بدون پنل ادمین، بدون SEO کاتالوگ |
| دامنهٔ زنده | `https://ktg.montzar.ir` |
| دیتابیس نسخه اول | SQLite روی دیسک همین پروژه؛ به Postgres مونتایم/سوپرادمین وصل نمی‌شود |
--- ---
@@ -76,7 +78,7 @@
- یک PDF **یا** چند تصویر (نه هر دو با هم در یک کاتالوگ) - یک PDF **یا** چند تصویر (نه هر دو با هم در یک کاتالوگ)
- ترتیب تصاویر = ترتیب انتخاب / ترتیب نام فایل؛ امکان جابه‌جایی قبل از ساخت - ترتیب تصاویر = ترتیب انتخاب / ترتیب نام فایل؛ امکان جابه‌جایی قبل از ساخت
- انتخاب یکی از افکت‌های ورق‌زدن - انتخاب یکی از افکت‌های ورق‌زدن
- تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود - تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود. روی مجله، اسلاید، محو و کارت اعمال می‌شود؛ اسکرول عمودی فقط فاصلهٔ پخش خودکار را از همین زمان می‌گیرد
- عنوان اختیاری (اگر خالی باشد: «کاتالوگ») - عنوان اختیاری (اگر خالی باشد: «کاتالوگ»)
### مشاهده ### مشاهده
@@ -87,7 +89,7 @@
- بندانگشتی صفحات - بندانگشتی صفحات
- زوم و تمام‌صفحه - زوم و تمام‌صفحه
- دسکتاپ: فقط افکت `magazine` دو صفحه روبه‌رو نشان می‌دهد؛ بقیه همیشه یک صفحه - دسکتاپ: فقط افکت `magazine` دو صفحه روبه‌رو نشان می‌دهد؛ بقیه همیشه یک صفحه
- موبایل: همیشه یک صفحه؛ اگر ورق سه‌بعدی روی دستگاه ضعیف بود، همان کاتالوگ با اسلاید افقی دیده می‌شود (تنظیم ذخیره‌شده عوض نمی‌شود) - موبایل: همیشه یک صفحه؛ نوار عنوان و دکمه‌ها جمع می‌شوند و هنگام پخش خودکار مخفی می‌شوند (دکمهٔ کوچک توقف می‌ماند). اگر ورق سه‌بعدی روی دستگاه ضعیف بود، همان کاتالوگ با اسلاید افقی دیده می‌شود (تنظیم ذخیره‌شده عوض نمی‌شود)
### اشتراک ### اشتراک
@@ -97,9 +99,11 @@
### حذف ### حذف
- حذف فقط با توکن مخفی - لینک حذف فقط روی صفحهٔ نتیجه نشان داده می‌شود؛ باید همان لحظه کپی و ذخیره شود
- بعد از حذف، لینک و QR دیگر کار نمی‌کنند - بعداً فقط وقتی به کار می‌آید که سازنده بخواهد همین کاتالوگ را پاک کند (باز کردن لینک در مرورگر و تأیید)
- فایل صفحات از دیسک پاک می‌شوند - در برنامه ذخیره یا بازیابی نمی‌شود؛ اگر گم شود راهی نیست
- لینک مشاهده برای اشتراک است؛ لینک حذف را نباید پخش کرد
- بعد از حذف، لینک مشاهده و QR دیگر کار نمی‌کنند و فایل صفحات از دیسک پاک می‌شوند
--- ---
@@ -264,29 +268,53 @@ storage/catalogs/{id}/
سه صفحه اصلی، فارسی و RTL: سه صفحه اصلی، فارسی و RTL:
1. **ساخت:** ناحیه آپلود، لیست صفحات (مرتب‌سازی)، انتخاب افکت، دکمه ساخت 1. **ساخت:** ناحیه آپلود، لودینگ هنگام خواندن فایل و ساخت، لیست صفحات، انتخاب افکت، اسلایدر زمان ورق‌خوردن، دکمه ساخت
2. **نتیجه:** لینک، QR، هشدار «لینک حذف را ذخیره کن»، دکمه حذف 2. **نتیجه:** لینک مشاهده، QR، هشدار «لینک حذف را ذخیره کن»، فیلد لینک حذف، دکمه حذف
3. **نمایش:** استیج کاتالوگ، کنترل‌ها، بندانگشتی 3. **نمایش:** استیج کاتالوگ، کنترل‌ها، بندانگشتی
ظاهر نسخه اول تمیز و ساده باشد، نه داشبورد شلوغ. ظاهر نسخه اول تمیز و ساده باشد، نه داشبورد شلوغ.
--- ---
## ۱۴. اجرا و پیکربندی ## ۱۴. اجرا، دامنه و استقرار
یک سرور Node کافی است. SQLite و پوشه `storage/` کنار اپ می‌مانند. یک سرور Node کافی است. SQLite و پوشه `storage/` کنار اپ می‌مانند.
توسعه محلی: `npm run dev` — فرانت `http://127.0.0.1:3000`، API روی `3010` و از طریق `/api` پروکسی می‌شود.
### دامنهٔ زنده (۱۹ اوت ۲۰۲۶)
- آدرس عمومی: `https://ktg.montzar.ir`
- کد: `http://get.feep.ir/Mahdi/digitalcatalog.git` (شاخه `main`)
- مسیر روی سرور: `/var/www/ktg`
- PM2: نام `ktg-catalog`، **یک اینستنس، fork** (نه cluster؛ sql.js با چند پروسه خراب می‌شود)
- پورت داخلی: `3020` — تداخل نکند با مونتایم (`3000`)، فوری‌سند (`3001`)، سوپرادمین (`3010`)
- Nginx: فقط `server_name ktg.montzar.ir`؛ همهٔ مسیرها به `127.0.0.1:3020` پروکسی می‌شوند (`client_max_body_size 100M` فقط در همین vhost)
- راهنمای قدم‌به‌قدم: [`deploy/DEPLOY.md`](../deploy/DEPLOY.md)
این اپ روی همان هاست مشترک مونتایم است (`185.50.38.53`) ولی **جدا** می‌ماند: پوشه، پورت، پروسهٔ PM2، فایل SQLite و دامنهٔ خودش. دیتابیس Postgres و JWT سوپرادمین لمس نمی‌شود. اگر بعداً لاگین/پروفایل آمد، باز هم داخل همین دامنه و دیتابیس همین پروژه است؛ کوکی نباید روی `.montzar.ir` باشد.
متغیرهای محیط: متغیرهای محیط:
```text ```text
# توسعه
PORT=3000 PORT=3000
API_PORT=3010 API_PORT=3010
PUBLIC_BASE_URL=https://ktg.montzar.ir PUBLIC_BASE_URL=http://localhost:3000
STORAGE_DIR=./storage STORAGE_DIR=./storage
DATABASE_PATH=./data/catalogs.sqlite DATABASE_PATH=./data/catalogs.sqlite
# سرور ktg.montzar.ir
NODE_ENV=production
PORT=3020
PUBLIC_BASE_URL=https://ktg.montzar.ir
STORAGE_DIR=/var/www/ktg/storage
DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
``` ```
`PUBLIC_BASE_URL` همان دامنه‌ای است که داخل QR نوشته می‌شود. `PUBLIC_BASE_URL` همان دامنه‌ای است که داخل QR نوشته می‌شود. کاتالوگ‌های ساخته‌شده با `localhost` روی دامنهٔ زنده معتبر نیستند؛ باید روی سرور دوباره ساخته شوند.
آپدیت کد روی سرور: `git pull` سپس `npm ci && npm run build && pm2 restart ktg-catalog`. پوشه‌های `storage` و `data` و فایل `.env` پاک نشوند.
--- ---
@@ -305,7 +333,7 @@ DATABASE_PATH=./data/catalogs.sqlite
نسخه اول وقتی تمام است که: نسخه اول وقتی تمام است که:
- [x] با چند تصویر کاتالوگ ساخته شود (تست API با JPEG) - [x] با چند تصویر کاتالوگ ساخته شود (تست API با JPEG)
- [ ] با PDF چندصفحه‌ای در مرورگر (نیاز به بررسی دستی) - [x] با PDF چندصفحه‌ای در مرورگر (بررسی دستی روی دامنهٔ زنده)
- [x] پنج افکت در UI پیاده شده‌اند (بررسی ظاهری باقی است) - [x] پنج افکت در UI پیاده شده‌اند (بررسی ظاهری باقی است)
- [x] لینک `/c/:id` از API متادیتا می‌گیرد - [x] لینک `/c/:id` از API متادیتا می‌گیرد
- [x] QR همان لینک را کد می‌کند - [x] QR همان لینک را کد می‌کند
@@ -314,6 +342,7 @@ DATABASE_PATH=./data/catalogs.sqlite
- [x] گالری یا لیست عمومی وجود نداشته باشد - [x] گالری یا لیست عمومی وجود نداشته باشد
- [ ] روی موبایل بررسی دستی - [ ] روی موبایل بررسی دستی
- [x] سقف تعداد صفحه پیام خطا بدهد - [x] سقف تعداد صفحه پیام خطا بدهد
- [x] روی `https://ktg.montzar.ir` زنده است؛ ساخت کاتالوگ و لینک `/c/:id` کار می‌کند
--- ---
@@ -326,7 +355,7 @@ DATABASE_PATH=./data/catalogs.sqlite
- انقضای اختیاری - انقضای اختیاری
- رمز روی کاتالوگ - رمز روی کاتالوگ
- هاست چند سروره / S3 - هاست چند سروره / S3
- حساب کاربری برای تیم - حساب کاربری، پروفایل و «کاتالوگ‌های من» — جدا از سوپرادمین/مونتایم؛ دیتابیس و کوکی مال `ktg.montzar.ir`
--- ---
@@ -340,3 +369,8 @@ DATABASE_PATH=./data/catalogs.sqlite
| ۲۰۲۶-۰۸-۱۸ | بدون گالری؛ فقط لینک و QR | | ۲۰۲۶-۰۸-۱۸ | بدون گالری؛ فقط لینک و QR |
| ۲۰۲۶-۰۸-۱۸ | بعد از ساخت: لینک + QR + لینک حذف؛ بدون ویرایش و بدون انقضا | | ۲۰۲۶-۰۸-۱۸ | بعد از ساخت: لینک + QR + لینک حذف؛ بدون ویرایش و بدون انقضا |
| ۲۰۲۶-۰۸-۱۸ | استک: Vue 3 + Hono + SQLite + pdf.js + StPageFlip | | ۲۰۲۶-۰۸-۱۸ | استک: Vue 3 + Hono + SQLite + pdf.js + StPageFlip |
| ۲۰۲۶-۰۸-۱۸ | زمان ورق‌خوردن هنگام ساخت (۴۰۰–۵۰۰۰ms)؛ روی مجله/اسلاید/محو/کارت |
| ۲۰۲۶-۰۸-۱۸ | لودینگ هنگام خواندن فایل، آماده‌سازی صفحات و ساخت کاتالوگ |
| ۲۰۲۶-۰۸-۱۹ | استقرار روی هاست مشترک؛ دامنه `ktg.montzar.ir`؛ پورت ۳۰۲۰؛ SQLite جدا |
| ۲۰۲۶-۰۸-۱۹ | مخزن Gitea: `http://get.feep.ir/Mahdi/digitalcatalog.git` |
| ۲۰۲۶-۰۸-۱۹ | لاگین بعدی اگر آمد به Nest/Postgres بقیه وصل نمی‌شود |