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;
}>();
const emit = defineEmits<{
immersive: [value: boolean];
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_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 playing = ref(true);
const waiting = ref(true);
const hideChrome = ref(false);
const stage = ref<HTMLElement | null>(null);
const magazine = ref<{ next: () => void; prev: () => void } | null>(null);
@@ -35,6 +40,12 @@ const visualEffect = computed<EffectId>(() => {
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 loopTimer = 0;
@@ -96,6 +107,11 @@ function togglePlay() {
}
}
function onFab() {
hideChrome.value = false;
if (playing.value) togglePlay();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "ArrowLeft") onUserNav(next);
if (ev.key === "ArrowRight") onUserNav(prev);
@@ -103,21 +119,49 @@ function onKey(ev: KeyboardEvent) {
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;
if (ev.key === "Escape") {
zoom.value = false;
hideChrome.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() {
if (fullscreenElement()) return;
windowInner.value = window.innerWidth;
}
@@ -136,12 +180,16 @@ function armAutoplay() {
onMounted(() => {
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
document.addEventListener("fullscreenchange", onFullscreenChange);
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
armAutoplay();
});
onUnmounted(() => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onFullscreenChange);
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
window.clearTimeout(delayTimer);
stopLoop();
});
@@ -152,25 +200,42 @@ watch(
index.value = 0;
fallbackSlide.value = false;
playing.value = true;
hideChrome.value = false;
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 ref="stage" class="viewer-wrap cinematic" :class="{ immersive }">
<div class="stage-bar page-chrome">
<div class="chrome-nav">
<button class="btn chrome-btn" type="button" @click="onUserNav(prev)">قبلی</button>
<span class="page-pos">{{ images.length ? index + 1 : 0 }} / {{ images.length }}</span>
<button class="btn chrome-btn" type="button" @click="onUserNav(next)">بعدی</button>
</div>
<div class="chrome-tools">
<button class="btn play chrome-btn" type="button" @click="togglePlay">
<span class="lbl-full">{{ playing ? "توقف پخش" : "پخش خودکار" }}</span>
<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>
<button
v-if="immersive"
class="fab-pause"
type="button"
@click="onFab"
>
{{ playing ? "توقف" : "منو" }}
</button>
<div v-if="waiting && playing" class="auto-hint">
<span>تا لحظه‌ای دیگر خودبه‌خود ورق می‌خورد</span>
<i class="hint-bar"></i>
@@ -20,6 +20,7 @@ const mountKey = ref(0);
let book: PageFlip | null = null;
let ignore = false;
let prepared: string[] = [];
let makeGen = 0;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
@@ -87,25 +88,36 @@ function pageBox(first: HTMLImageElement, maxW: number, maxH: number) {
}
async function makeBook() {
const gen = ++makeGen;
const previous = prepared;
prepared = [];
try {
book?.destroy();
} catch {
/* ignore */
}
book = null;
revokePrepared();
mountKey.value += 1;
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 {
const first = await loadImage(props.images[0]);
if (gen !== makeGen) return;
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) {
if (gen !== makeGen) return;
try {
const img = src === props.images[0] ? first : await loadImage(src);
fitted.push(await containDraw(img, pageW, pageH));
@@ -115,7 +127,7 @@ async function makeBook() {
}
await nextTick();
if (!host.value) return;
if (gen !== makeGen || !host.value) return;
book = new PageFlip(host.value, {
width: pageW,
@@ -140,6 +152,7 @@ async function makeBook() {
ignore = false;
});
});
for (const url of previous) URL.revokeObjectURL(url);
} catch {
emit("fallback");
}
+5 -3
View File
@@ -10,6 +10,7 @@ const id = computed(() => String(route.params.id));
const catalog = ref<CatalogPublic | null>(null);
const error = ref("");
const loading = ref(true);
const immersive = ref(false);
const images = computed(() => {
if (!catalog.value) return [];
@@ -30,10 +31,10 @@ onMounted(async () => {
</script>
<template>
<div class="stage-page">
<header class="stage-bar">
<div class="stage-page" :class="{ immersive }">
<header class="stage-bar page-chrome">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn" to="/">ساخت جدید</router-link>
<router-link class="btn chrome-btn" to="/">ساخت جدید</router-link>
</header>
<p v-if="loading" class="viewer-wrap">در حال بارگذاری…</p>
<p v-else-if="error" class="viewer-wrap error">{{ error }}</p>
@@ -42,6 +43,7 @@ onMounted(async () => {
:effect="catalog.effect"
:images="images"
:flip-ms="catalog.flipMs"
@immersive="immersive = $event"
/>
</div>
</template>
+112
View File
@@ -312,6 +312,9 @@ a {
.stage-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background:
radial-gradient(900px 400px at 50% -10%, rgba(180, 83, 9, 0.25), transparent 50%),
var(--stage);
@@ -326,11 +329,46 @@ a {
border-bottom: 1px solid #2a241f;
}
.stage-page > .stage-bar {
flex-shrink: 0;
}
.stage-bar h1 {
flex: 1;
margin: 0;
font-size: 1rem;
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,
@@ -343,6 +381,7 @@ a {
.viewer-wrap {
padding: 16px;
position: relative;
flex: 1;
}
.viewer-wrap.cinematic {
@@ -352,6 +391,79 @@ a {
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 {
position: relative;
max-width: 420px;
+13 -36
View File
@@ -8,27 +8,7 @@
---
## کارهایی که روی لپ‌تاپ شماست
### ۱) بستهٔ ارسال را بسازید
در 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` روی ویندوز را می‌توانید پاک کنید.
کد از Gitea کلون می‌شود: `http://get.feep.ir/Mahdi/digitalcatalog.git`
---
@@ -40,19 +20,17 @@ SSH:
ssh -p 3031 montime@185.50.38.53
```
### ۳) پوشه و باز کردن فایل‌ها
### ۱) کلون
```bash
sudo mkdir -p /var/www/ktg
sudo chown montime:montime /var/www/ktg
cd /var/www/ktg
tar -xvf /tmp/catalog-deploy.tar
rm /tmp/catalog-deploy.tar
git clone http://get.feep.ir/Mahdi/digitalcatalog.git /var/www/ktg
```
اگر پوشه از قبل چیزی داشت و آپدیت است، `storage` و `data` را پاک نکنید.
اگر Gitea خصوصی است، همان یوزر/توکن را بزنید.
### ۴) پورت آزاد باشد
### ۲) پورت آزاد باشد
```bash
pm2 list
@@ -61,7 +39,7 @@ ss -tlnp | grep -E '3000|3001|3010|3020' || netstat -tlnp | grep -E '3000|3001|3
۳۰۲۰ باید خالی باشد. اگر اشغال بود بگویید تا پورت عوض شود؛ خودتان پورت بقیه را عوض نکنید.
### ۵) فایل `.env` سرور
### ۳) فایل `.env` سرور
```bash
cd /var/www/ktg
@@ -82,7 +60,7 @@ DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
دیتابیس Postgres و `DATABASE_URL` سوپرادمین را اینجا نگذارید.
### ۶) نصب، بیلد، پوشه‌ها
### ۴) نصب، بیلد، پوشه‌ها
Node ۲۰ یا جدیدتر.
@@ -93,7 +71,7 @@ npm run build
mkdir -p storage data
```
### ۷) PM2 — فقط این پروسهٔ جدید
### ۵) PM2 — فقط این پروسهٔ جدید
پروسه‌های `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`
### ۸) Nginx — فقط سایت جدید
### ۶) Nginx — فقط سایت جدید
```bash
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` را ویرایش نکنید؛ بگویید خروجی را بفرستید.
### ۹) گواهی SSL
### ۷) گواهی SSL
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 اضافه می‌کند، نه به سایت‌های دیگر.
### ۱۰) تست نهایی
### ۸) تست نهایی
1. `https://ktg.montzar.ir` صفحهٔ ساخت فارسی
2. یک PDF/تصویر کوچک بسازید
@@ -139,12 +117,11 @@ Certbot خودش HTTPS را به همین vhost اضافه می‌کند، نه
## آپدیت بعدی کد
روی ویندوز دوباره مرحلهٔ ۱ و ۲. روی سرور:
روی ویندوز `git push`. روی سرور:
```bash
cd /var/www/ktg
# اگر tar روی فایل‌های قبلی unpacked می‌شود:
tar -xvf /tmp/catalog-deploy.tar
git pull
npm ci
npm run build
pm2 restart ktg-catalog
+48 -14
View File
@@ -1,6 +1,6 @@
# کاتالوگ دیجیتال (Digital Catalog)
سند مرجع پروژه — ۱۸ اوت ۲۰۲۶
سند مرجع پروژه — ۱۸ اوت ۲۰۲۶، به‌روز تا ۱۹ اوت ۲۰۲۶
قبل از پیاده‌سازی نوشته شده است. اگر رفتار محصول عوض شد، همین فایل باید به‌روز شود.
---
@@ -25,10 +25,12 @@
| ثبت‌نام / ورود | ندارد. هر بازدیدکننده دامنه می‌تواند بسازد |
| گالری عمومی | ندارد |
| خروجی ساخت | لینک مشاهده + تصویر QR + لینک حذف |
| ویرایش بعدی | ندارد (صفحات، PDF، افکت بعد از ساخت عوض نمی‌شود) |
| ویرایش بعدی | ندارد (صفحات، PDF، افکت و زمان ورق بعد از ساخت عوض نمی‌شود) |
| انقضای خودکار | ندارد |
| زبان رابط | فارسی، راست‌به‌چپ |
| نسخه اول | ساده؛ بدون حساب کاربری، بدون پنل ادمین، بدون SEO کاتالوگ |
| دامنهٔ زنده | `https://ktg.montzar.ir` |
| دیتابیس نسخه اول | SQLite روی دیسک همین پروژه؛ به Postgres مونتایم/سوپرادمین وصل نمی‌شود |
---
@@ -76,7 +78,7 @@
- یک PDF **یا** چند تصویر (نه هر دو با هم در یک کاتالوگ)
- ترتیب تصاویر = ترتیب انتخاب / ترتیب نام فایل؛ امکان جابه‌جایی قبل از ساخت
- انتخاب یکی از افکت‌های ورق‌زدن
- تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود
- تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود. روی مجله، اسلاید، محو و کارت اعمال می‌شود؛ اسکرول عمودی فقط فاصلهٔ پخش خودکار را از همین زمان می‌گیرد
- عنوان اختیاری (اگر خالی باشد: «کاتالوگ»)
### مشاهده
@@ -87,7 +89,7 @@
- بندانگشتی صفحات
- زوم و تمام‌صفحه
- دسکتاپ: فقط افکت `magazine` دو صفحه روبه‌رو نشان می‌دهد؛ بقیه همیشه یک صفحه
- موبایل: همیشه یک صفحه؛ اگر ورق سه‌بعدی روی دستگاه ضعیف بود، همان کاتالوگ با اسلاید افقی دیده می‌شود (تنظیم ذخیره‌شده عوض نمی‌شود)
- موبایل: همیشه یک صفحه؛ نوار عنوان و دکمه‌ها جمع می‌شوند و هنگام پخش خودکار مخفی می‌شوند (دکمهٔ کوچک توقف می‌ماند). اگر ورق سه‌بعدی روی دستگاه ضعیف بود، همان کاتالوگ با اسلاید افقی دیده می‌شود (تنظیم ذخیره‌شده عوض نمی‌شود)
### اشتراک
@@ -97,9 +99,11 @@
### حذف
- حذف فقط با توکن مخفی
- بعد از حذف، لینک و QR دیگر کار نمی‌کنند
- فایل صفحات از دیسک پاک می‌شوند
- لینک حذف فقط روی صفحهٔ نتیجه نشان داده می‌شود؛ باید همان لحظه کپی و ذخیره شود
- بعداً فقط وقتی به کار می‌آید که سازنده بخواهد همین کاتالوگ را پاک کند (باز کردن لینک در مرورگر و تأیید)
- در برنامه ذخیره یا بازیابی نمی‌شود؛ اگر گم شود راهی نیست
- لینک مشاهده برای اشتراک است؛ لینک حذف را نباید پخش کرد
- بعد از حذف، لینک مشاهده و QR دیگر کار نمی‌کنند و فایل صفحات از دیسک پاک می‌شوند
---
@@ -264,29 +268,53 @@ storage/catalogs/{id}/
سه صفحه اصلی، فارسی و RTL:
1. **ساخت:** ناحیه آپلود، لیست صفحات (مرتب‌سازی)، انتخاب افکت، دکمه ساخت
2. **نتیجه:** لینک، QR، هشدار «لینک حذف را ذخیره کن»، دکمه حذف
1. **ساخت:** ناحیه آپلود، لودینگ هنگام خواندن فایل و ساخت، لیست صفحات، انتخاب افکت، اسلایدر زمان ورق‌خوردن، دکمه ساخت
2. **نتیجه:** لینک مشاهده، QR، هشدار «لینک حذف را ذخیره کن»، فیلد لینک حذف، دکمه حذف
3. **نمایش:** استیج کاتالوگ، کنترل‌ها، بندانگشتی
ظاهر نسخه اول تمیز و ساده باشد، نه داشبورد شلوغ.
---
## ۱۴. اجرا و پیکربندی
## ۱۴. اجرا، دامنه و استقرار
یک سرور 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
# توسعه
PORT=3000
API_PORT=3010
PUBLIC_BASE_URL=https://ktg.montzar.ir
PUBLIC_BASE_URL=http://localhost:3000
STORAGE_DIR=./storage
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)
- [ ] با PDF چندصفحه‌ای در مرورگر (نیاز به بررسی دستی)
- [x] با PDF چندصفحه‌ای در مرورگر (بررسی دستی روی دامنهٔ زنده)
- [x] پنج افکت در UI پیاده شده‌اند (بررسی ظاهری باقی است)
- [x] لینک `/c/:id` از API متادیتا می‌گیرد
- [x] QR همان لینک را کد می‌کند
@@ -314,6 +342,7 @@ DATABASE_PATH=./data/catalogs.sqlite
- [x] گالری یا لیست عمومی وجود نداشته باشد
- [ ] روی موبایل بررسی دستی
- [x] سقف تعداد صفحه پیام خطا بدهد
- [x] روی `https://ktg.montzar.ir` زنده است؛ ساخت کاتالوگ و لینک `/c/:id` کار می‌کند
---
@@ -326,7 +355,7 @@ DATABASE_PATH=./data/catalogs.sqlite
- انقضای اختیاری
- رمز روی کاتالوگ
- هاست چند سروره / S3
- حساب کاربری برای تیم
- حساب کاربری، پروفایل و «کاتالوگ‌های من» — جدا از سوپرادمین/مونتایم؛ دیتابیس و کوکی مال `ktg.montzar.ir`
---
@@ -340,3 +369,8 @@ DATABASE_PATH=./data/catalogs.sqlite
| ۲۰۲۶-۰۸-۱۸ | بدون گالری؛ فقط لینک و QR |
| ۲۰۲۶-۰۸-۱۸ | بعد از ساخت: لینک + QR + لینک حذف؛ بدون ویرایش و بدون انقضا |
| ۲۰۲۶-۰۸-۱۸ | استک: Vue 3 + Hono + SQLite + pdf.js + StPageFlip |
| ۲۰۲۶-۰۸-۱۸ | زمان ورق‌خوردن هنگام ساخت (۴۰۰–۵۰۰۰ms)؛ روی مجله/اسلاید/محو/کارت |
| ۲۰۲۶-۰۸-۱۸ | لودینگ هنگام خواندن فایل، آماده‌سازی صفحات و ساخت کاتالوگ |
| ۲۰۲۶-۰۸-۱۹ | استقرار روی هاست مشترک؛ دامنه `ktg.montzar.ir`؛ پورت ۳۰۲۰؛ SQLite جدا |
| ۲۰۲۶-۰۸-۱۹ | مخزن Gitea: `http://get.feep.ir/Mahdi/digitalcatalog.git` |
| ۲۰۲۶-۰۸-۱۹ | لاگین بعدی اگر آمد به Nest/Postgres بقیه وصل نمی‌شود |