Fix mobile viewer chrome and keep pages visible during autoplay.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 بقیه وصل نمیشود |
|
||||
|
||||
Reference in New Issue
Block a user