Add display frames and improve create-page page management.

Store phone/tablet/photo-frame shells with catalogs, append pages from extra images or PDFs, and reorder drafts with drag-and-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 14:20:16 +03:30
co-authored by Cursor
parent 16cd3197ef
commit c236b0f621
23 changed files with 1069 additions and 134 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
import type { CatalogPublic, CreateCatalogResponse, EffectId } from "@shared/types";
import type { CatalogPublic, CreateCatalogResponse, EffectId, FrameId } from "@shared/types";
async function readError(res: Response): Promise<string> {
try {
@@ -14,10 +14,12 @@ export async function createCatalog(input: {
effect: EffectId;
pages: Blob[];
flipMs: number;
frame: FrameId;
}): Promise<CreateCatalogResponse> {
const data = new FormData();
data.set("title", input.title);
data.set("effect", input.effect);
data.set("frame", input.frame);
data.set("flipMs", String(input.flipMs));
input.pages.forEach((page, i) => {
data.append("pages", page, `page-${String(i + 1).padStart(3, "0")}.jpg`);
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { FrameId } from "@shared/types";
const props = defineProps<{
frame: FrameId;
}>();
</script>
<template>
<div v-if="props.frame === 'none'" class="shell-plain">
<slot />
</div>
<div v-else class="device-shell" :class="props.frame">
<div class="device-bezel">
<i v-if="props.frame === 'phone'" class="device-btn volume"></i>
<i v-if="props.frame === 'phone'" class="device-btn mute"></i>
<i v-if="props.frame === 'phone'" class="device-btn power"></i>
<i v-if="props.frame === 'tablet'" class="device-cam"></i>
<div class="device-screen">
<i v-if="props.frame === 'phone'" class="device-island">
<span class="lens"></span>
</i>
<slot />
<i v-if="props.frame === 'phone'" class="device-bar"></i>
</div>
</div>
</div>
</template>
<style scoped>
.shell-plain {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
+55 -35
View File
@@ -1,10 +1,11 @@
<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 type { EffectId, FrameId } from "@shared/types";
import { DEFAULT_FLIP_MS, DEFAULT_FRAME } from "@shared/types";
import CssPageEffect from "./effects/CssPageEffect.vue";
import MagazineEffect from "./effects/MagazineEffect.vue";
import ScrollEffect from "./effects/ScrollEffect.vue";
import CatalogShell from "./CatalogShell.vue";
const AUTO_DELAY_MS = 3000;
const AUTO_PAUSE_AFTER_FLIP_MS = 2200;
@@ -13,6 +14,7 @@ const props = defineProps<{
effect: EffectId;
images: string[];
flipMs?: number;
frame?: FrameId;
}>();
const emit = defineEmits<{
@@ -20,6 +22,8 @@ const emit = defineEmits<{
}>();
const flipMs = computed(() => props.flipMs ?? DEFAULT_FLIP_MS);
const frame = computed(() => props.frame ?? DEFAULT_FRAME);
const framed = computed(() => frame.value !== "none");
const autoIntervalMs = computed(() => flipMs.value + AUTO_PAUSE_AFTER_FLIP_MS);
const index = ref(0);
@@ -33,15 +37,20 @@ 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 compactUi = computed(() => windowInner.value < 900);
const singlePage = computed(() => compactUi.value || framed.value);
const visualEffect = computed<EffectId>(() => {
if (fallbackSlide.value && props.effect === "magazine") return "slide";
return props.effect;
});
const chromeHidden = computed(() => compactUi.value && hideChrome.value);
const immersive = computed(
() => singlePage.value && (hideChrome.value || (playing.value && !waiting.value)),
() =>
!framed.value &&
compactUi.value &&
(hideChrome.value || (playing.value && !waiting.value)),
);
watch(immersive, (value) => emit("immersive", value), { immediate: true });
@@ -134,7 +143,7 @@ function fullscreenElement() {
}
async function toggleFullscreen() {
if (singlePage.value) {
if (compactUi.value) {
hideChrome.value = true;
waiting.value = false;
return;
@@ -211,7 +220,11 @@ watch(
</script>
<template>
<div ref="stage" class="viewer-wrap cinematic" :class="{ immersive }">
<div
ref="stage"
class="viewer-wrap cinematic"
:class="{ immersive, framed, 'chrome-hidden': chromeHidden }"
>
<div class="stage-bar page-chrome">
<div class="chrome-nav">
<button class="btn chrome-btn" type="button" @click="onUserNav(prev)">قبلی</button>
@@ -242,36 +255,43 @@ watch(
<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"
:immersive="immersive"
@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"
:immersive="immersive"
@next="onUserNav(next)"
@prev="onUserNav(prev)"
/>
<CatalogShell :frame="frame">
<div class="catalog-stage">
<ScrollEffect
v-if="visualEffect === 'scroll'"
:images="images"
:index="index"
:contained="framed"
@update:index="onUserNav(() => go($event))"
/>
<MagazineEffect
v-else-if="visualEffect === 'magazine'"
ref="magazine"
:images="images"
:index="index"
:single-page="singlePage"
:flip-ms="flipMs"
:immersive="immersive"
:contained="framed"
@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"
:immersive="immersive"
:contained="framed"
@next="onUserNav(next)"
@prev="onUserNav(prev)"
/>
</div>
</CatalogShell>
<div v-if="visualEffect !== 'scroll'" class="thumbs">
<div v-if="visualEffect !== 'scroll' && !framed" class="thumbs">
<img
v-for="(src, i) in images"
:key="src"
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { FrameId } from "@shared/types";
import { FRAME_OPTIONS } from "../constants";
defineProps<{
modelValue: FrameId;
}>();
const emit = defineEmits<{
"update:modelValue": [value: FrameId];
}>();
</script>
<template>
<div class="effects frames">
<label v-for="opt in FRAME_OPTIONS" :key="opt.id" class="effect">
<input
type="radio"
name="frame"
:value="opt.id"
:checked="modelValue === opt.id"
@change="emit('update:modelValue', opt.id)"
/>
<span class="frame-mini" :class="opt.id" aria-hidden="true"></span>
<strong>{{ opt.title }}</strong>
<small>{{ opt.hint }}</small>
</label>
</div>
</template>
+62 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
export type DraftPage = {
key: string;
preview: string;
@@ -10,19 +12,70 @@ defineProps<{
}>();
const emit = defineEmits<{
move: [index: number, dir: -1 | 1];
reorder: [from: number, to: number];
remove: [index: number];
}>();
const dragFrom = ref<number | null>(null);
const dragOver = ref<number | null>(null);
function onDragStart(index: number, ev: DragEvent) {
dragFrom.value = index;
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(index));
}
}
function onDragOver(index: number, ev: DragEvent) {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
dragOver.value = index;
}
function onDrop(index: number, ev: DragEvent) {
ev.preventDefault();
const from = dragFrom.value;
dragFrom.value = null;
dragOver.value = null;
if (from === null || from === index) return;
emit("reorder", from, index);
}
function onDragEnd() {
dragFrom.value = null;
dragOver.value = null;
}
</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 class="pages-grid" role="list">
<article
v-for="(page, index) in pages"
:key="page.key"
class="page-tile"
:class="{ dragging: dragFrom === index, 'drop-target': dragOver === index && dragFrom !== index }"
role="listitem"
draggable="true"
:aria-label="`صفحه ${index + 1}`"
@dragstart="onDragStart(index, $event)"
@dragover="onDragOver(index, $event)"
@drop="onDrop(index, $event)"
@dragend="onDragEnd"
@dragleave="dragOver === index && (dragOver = null)"
>
<img :src="page.preview" alt="" draggable="false" />
<span class="page-num">{{ index + 1 }}</span>
<button
class="page-delete"
type="button"
title="حذف"
aria-label="حذف صفحه"
@click.stop="emit('remove', index)"
@pointerdown.stop
>
×
</button>
</article>
</div>
</template>
@@ -8,6 +8,7 @@ const props = defineProps<{
zoom: boolean;
flipMs: number;
immersive?: boolean;
contained?: boolean;
}>();
const emit = defineEmits<{
@@ -55,7 +56,7 @@ function onPointerUp(ev: PointerEvent) {
<template>
<div
class="fx-stage"
:class="[mode, { 'zoom-in': zoom, fill: immersive }]"
:class="[mode, { 'zoom-in': zoom, fill: immersive || contained, contained }]"
:style="{ '--flip-ms': `${flipMs}ms` }"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
@@ -93,6 +94,27 @@ function onPointerUp(ev: PointerEvent) {
max-height: 100dvh;
}
.fx-stage.contained,
.fx-stage.contained.fill {
width: 100%;
height: 100%;
max-height: 100%;
min-height: 0;
margin: 0;
overflow: hidden;
}
.fx-stage.contained .page-img,
.fx-stage.contained.fill .page-img {
max-width: none;
max-height: none;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
box-shadow: none;
}
.fx-stage.card {
perspective: 2600px;
}
@@ -8,6 +8,7 @@ const props = defineProps<{
singlePage: boolean;
flipMs: number;
immersive?: boolean;
contained?: boolean;
}>();
const emit = defineEmits<{
@@ -24,9 +25,10 @@ let ignore = false;
let prepared: string[] = [];
let makeGen = 0;
let fitTimer = 0;
let resizeObs: ResizeObserver | null = null;
function updateFitScale() {
if (!props.immersive || !host.value || !outer.value) {
if ((!props.immersive && !props.contained) || !host.value || !outer.value) {
fitScale.value = 1;
return;
}
@@ -38,13 +40,14 @@ function updateFitScale() {
fitScale.value = 1;
return;
}
const pad = 16;
const s = Math.min((maxW - pad) / w, (maxH - pad) / h);
if (!Number.isFinite(s) || s <= 0) {
fitScale.value = 1;
return;
}
fitScale.value = Math.min(s, 8);
const pad = props.contained ? 0 : 16;
const s = Math.min((maxW - pad) / w, (maxH - pad) / h);
if (!Number.isFinite(s) || s <= 0) {
fitScale.value = 1;
return;
}
/* داخل قاب گوشی کمی بزرگ‌تر تا حاشیه بریده شود */
fitScale.value = Math.min(s * (props.contained ? 1.04 : 1), props.contained ? 1.2 : 8);
}
function scheduleFit() {
@@ -131,7 +134,7 @@ async function makeBook() {
/* ignore */
}
book = null;
mountKey.value += 1;
await nextTick();
if (gen !== makeGen) {
for (const url of previous) URL.revokeObjectURL(url);
@@ -142,11 +145,28 @@ async function makeBook() {
return;
}
if (props.contained) {
let attempts = 0;
while (outer.value.clientHeight < 80 && attempts < 20) {
await new Promise((resolve) => window.setTimeout(resolve, 50));
attempts += 1;
if (gen !== makeGen) return;
}
}
mountKey.value += 1;
await nextTick();
if (gen !== makeGen || !host.value || !outer.value) {
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 pad = props.contained ? 4 : 16;
const maxW = Math.max(160, outer.value.clientWidth - pad);
const maxH = Math.max(160, outer.value.clientHeight - pad);
const { pageW, pageH } = pageBox(first, maxW, maxH);
const fitted: string[] = [];
@@ -214,18 +234,27 @@ watch(
);
watch(
() => props.immersive,
() => [props.immersive, props.contained] as const,
() => {
scheduleFit();
},
);
watch(outer, (el, prev) => {
if (prev && resizeObs) resizeObs.unobserve(prev);
if (!el || typeof ResizeObserver === "undefined") return;
resizeObs ??= new ResizeObserver(() => scheduleFit());
resizeObs.observe(el);
});
onMounted(() => {
window.addEventListener("resize", scheduleFit);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", scheduleFit);
resizeObs?.disconnect();
resizeObs = null;
window.clearTimeout(fitTimer);
try {
book?.destroy();
@@ -268,7 +297,7 @@ function onPointerUp(ev: PointerEvent) {
</script>
<template>
<div class="magazine-frame" dir="ltr" :class="{ fill: immersive }">
<div class="magazine-frame" dir="ltr" :class="{ fill: immersive || contained, contained }">
<div class="magazine-glow"></div>
<div
ref="outer"
@@ -314,10 +343,36 @@ function onPointerUp(ev: PointerEvent) {
max-width: none;
height: 100%;
}
.magazine-frame.fill .magazine-outer {
width: 100%;
height: 100%;
}
.magazine-frame.contained,
.magazine-frame.contained.fill {
width: 100%;
height: 100%;
max-height: 100%;
min-height: 0;
margin: 0;
}
.magazine-frame.contained .magazine-outer,
.magazine-frame.contained.fill .magazine-outer {
height: 100% !important;
max-height: 100% !important;
min-height: 0;
}
.magazine-frame.contained .magazine-glow {
display: none;
}
.magazine-frame.contained .magazine-host {
filter: none;
}
.magazine-host {
width: max-content;
height: max-content;
+18 -1
View File
@@ -4,6 +4,7 @@ import { nextTick, watch } from "vue";
const props = defineProps<{
images: string[];
index: number;
contained?: boolean;
}>();
const emit = defineEmits<{
@@ -21,7 +22,7 @@ watch(
</script>
<template>
<div class="scroll-list">
<div class="scroll-list" :class="{ contained }">
<article
v-for="(src, i) in images"
:id="`scroll-page-${i}`"
@@ -58,6 +59,22 @@ watch(
transform: scale(1);
opacity: 1;
}
.scroll-list.contained {
max-width: none;
width: 100%;
height: 100%;
margin: 0;
padding: 8px;
gap: 12px;
overflow-y: auto;
box-sizing: border-box;
}
.scroll-list.contained .scroll-card img {
border-radius: 4px;
box-shadow: none;
}
.scroll-card img {
display: block;
width: 100%;
+10 -1
View File
@@ -1,4 +1,4 @@
import type { EffectId } from "@shared/types";
import type { EffectId, FrameId } from "@shared/types";
export const EFFECT_OPTIONS: { id: EffectId; title: string; hint: string }[] = [
{ id: "magazine", title: "ورق مجله", hint: "سه‌بعدی، شبیه ورق زدن کاغذ" },
@@ -7,3 +7,12 @@ export const EFFECT_OPTIONS: { id: EffectId; title: string; hint: string }[] = [
{ id: "card", title: "کارت", hint: "ورق دوبعدی مثل کارت" },
{ id: "scroll", title: "اسکرول عمودی", hint: "همه صفحات زیر هم" },
];
export const FRAME_OPTIONS: { id: FrameId; title: string; hint: string }[] = [
{ id: "none", title: "بدون قالب", hint: "فقط صفحات، مثل حالا" },
{ id: "phone", title: "آیفون پرو مکس", hint: "شبیه آیفون ۱۸ پرو مکس" },
{ id: "tablet", title: "تبلت", hint: "صفحه داخل تبلت" },
{ id: "frame-modern", title: "قاب مدرن", hint: "قاب عکس ساده و باریک" },
{ id: "frame-kids", title: "قاب کودکانه", hint: "رنگارنگ و گرد" },
{ id: "frame-classic", title: "قاب کلاسیک", hint: "چوبی با حاشیه پهن" },
];
+57 -43
View File
@@ -1,10 +1,11 @@
<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 type { EffectId, FrameId } from "@shared/types";
import { DEFAULT_FLIP_MS, DEFAULT_FRAME, LIMITS, tooManyPagesMessage } from "@shared/types";
import { createCatalog } from "../api";
import EffectSelect from "../components/EffectSelect.vue";
import FrameSelect from "../components/FrameSelect.vue";
import PageList, { type DraftPage } from "../components/PageList.vue";
import { filesToJpegs, isImageFile, isPdfFile } from "../images";
import { renderPdfToJpegs } from "../pdf";
@@ -12,6 +13,7 @@ import { renderPdfToJpegs } from "../pdf";
const router = useRouter();
const title = ref("");
const effect = ref<EffectId>("magazine");
const frame = ref<FrameId>(DEFAULT_FRAME);
const flipMs = ref(DEFAULT_FLIP_MS);
const pages = ref<DraftPage[]>([]);
const busy = ref(false);
@@ -34,25 +36,30 @@ function setProgress(message: string, done = 0, total = 0) {
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}`,
function toDraftPages(blobs: Blob[], keyOffset = 0): DraftPage[] {
const stamp = Date.now();
return blobs.map((blob, i) => ({
key: `${stamp}-${keyOffset + 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;
function appendPages(blobs: Blob[]) {
const nextCount = pages.value.length + blobs.length;
if (nextCount > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(nextCount));
}
pages.value = [...pages.value, ...toDraftPages(blobs, pages.value.length)];
}
function reorder(from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= pages.value.length || to >= pages.value.length) {
return;
}
const copy = [...pages.value];
const [item] = copy.splice(index, 1);
copy.splice(next, 0, item);
const [item] = copy.splice(from, 1);
copy.splice(to, 0, item);
pages.value = copy;
}
@@ -78,44 +85,44 @@ async function ingest(fileList: File[]) {
return;
}
if (pdfs.length > 1) {
error.value = "فقط یک فایل PDF انتخاب کنید.";
error.value = "فقط یک فایل PDF در هر بار انتخاب کنید.";
return;
}
busy.value = true;
setProgress("در حال خواندن فایل…");
try {
let blobs: Blob[];
if (pdfs.length === 1) {
setProgress("در حال خواندن PDF…");
const blobs = await renderPdfToJpegs(pdfs[0], (done, total) => {
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,
);
}),
);
blobs = await filesToJpegs(images, (done, total) => {
setProgress(
done === 0 ? "در حال آماده‌سازی تصاویر…" : `تبدیل تصویر: ${done} از ${total}`,
done,
total,
);
});
}
if (pages.value.length + blobs.length > LIMITS.maxPages) {
throw new Error(tooManyPagesMessage(pages.value.length + blobs.length));
}
appendPages(blobs);
setProgress(`${pages.value.length} صفحه آماده است`);
} catch (err) {
error.value = err instanceof Error ? err.message : "خطا در خواندن فایل";
setProgress("");
setProgress(pages.value.length ? `${pages.value.length} صفحه آماده است` : "");
} finally {
busy.value = false;
if (fileInput.value) fileInput.value.value = "";
}
}
@@ -134,6 +141,7 @@ async function submit() {
const created = await createCatalog({
title: title.value,
effect: effect.value,
frame: frame.value,
flipMs: flipMs.value,
pages: pages.value.map((p) => p.blob),
});
@@ -178,8 +186,9 @@ async function submit() {
@drop.prevent="onDrop"
>
<p>فایل را اینجا رها کنید یا انتخاب کنید</p>
<p v-if="pages.length" class="drop-hint">می‌توانید تصویر یا PDF بیشتری اضافه کنید</p>
<button class="btn" type="button" :disabled="busy" @click="fileInput?.click()">
انتخاب فایل
{{ pages.length ? "افزودن فایل" : "انتخاب فایل" }}
</button>
<input
ref="fileInput"
@@ -190,14 +199,9 @@ async function submit() {
/>
</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 v-if="pages.length" class="field pages-field">
<label>صفحات ({{ pages.length }}) — بکشید تا جابه‌جا کنید</label>
<PageList :pages="pages" @reorder="reorder" @remove="remove" />
</div>
<div class="field">
@@ -215,9 +219,19 @@ async function submit() {
</div>
</div>
<div v-if="pages.length" class="field">
<label>صفحات ({{ pages.length }})</label>
<PageList :pages="pages" @move="move" @remove="remove" />
<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>قالب نمایش</label>
<FrameSelect v-model="frame" />
</div>
<p v-if="progress && !busy" class="ok">{{ progress }}</p>
+2 -1
View File
@@ -31,7 +31,7 @@ onMounted(async () => {
</script>
<template>
<div class="stage-page" :class="{ immersive }">
<div class="stage-page" :class="{ immersive, framed: catalog && catalog.frame !== 'none' }">
<header class="stage-bar page-chrome">
<h1>{{ catalog?.title || "کاتالوگ دیجیتال" }}</h1>
<router-link class="btn chrome-btn" to="/">ساخت جدید</router-link>
@@ -43,6 +43,7 @@ onMounted(async () => {
:effect="catalog.effect"
:images="images"
:flip-ms="catalog.flipMs"
:frame="catalog.frame"
@immersive="immersive = $event"
/>
</div>
+632 -21
View File
@@ -151,6 +151,12 @@ a {
display: none;
}
.drop-hint {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.effects {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
@@ -159,6 +165,10 @@ a {
}
.effect {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
@@ -167,13 +177,151 @@ a {
}
.effect input {
margin-left: 8px;
margin: 0;
}
.effect strong {
display: block;
line-height: 1.35;
}
.effect small {
display: block;
color: var(--muted);
margin-top: 4px;
font-size: 0.85rem;
line-height: 1.45;
}
.frame-mini {
position: relative;
display: block;
width: 52px;
height: 68px;
margin: 0 auto 10px;
border: 3px solid #a8a29e;
background: #f5f5f4;
box-sizing: border-box;
}
.frame-mini.none {
border-radius: 6px;
background: linear-gradient(180deg, #fff 40%, #e7e5e4);
}
.frame-mini.none::after {
content: "";
position: absolute;
inset: 8px;
background: #d6d3d1;
border-radius: 2px;
}
.frame-mini.phone {
width: 38px;
height: 78px;
border-radius: 11px;
border-color: #8a8580;
background: linear-gradient(145deg, #d4cfc8, #9a9590 45%, #6f6a66);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);
}
.frame-mini.phone::before {
content: "";
position: absolute;
top: 9px;
left: 50%;
z-index: 2;
width: 16px;
height: 5px;
transform: translateX(-50%);
background: #0a0a0a;
border-radius: 99px;
}
.frame-mini.phone::after {
content: "";
position: absolute;
inset: 3px;
background: #0a0a0a;
border-radius: 9px;
}
.frame-mini.tablet {
width: 54px;
height: 68px;
border-radius: 6px;
background: linear-gradient(180deg, #e4e4e7, #a1a1aa);
border-color: #71717a;
}
.frame-mini.tablet::before {
content: "";
position: absolute;
top: 4px;
left: 50%;
width: 4px;
height: 4px;
transform: translateX(-50%);
background: #27272a;
border-radius: 50%;
}
.frame-mini.tablet::after {
content: "";
position: absolute;
inset: 10px 6px 12px;
background: #111;
border-radius: 3px;
}
.frame-mini.frame-modern {
border-width: 5px;
border-color: #1c1917;
background: #111;
}
.frame-mini.frame-modern::after {
content: "";
position: absolute;
inset: 5px;
background: #fff;
}
.frame-mini.frame-kids {
border-radius: 14px;
border: none;
background: linear-gradient(135deg, #fb7185, #fbbf24 50%, #34d399);
padding: 5px;
box-shadow: 0 3px 10px rgba(251, 113, 133, 0.35);
}
.frame-mini.frame-kids::after {
content: "";
position: absolute;
inset: 8px;
background: #fff;
border-radius: 8px;
box-shadow: inset 0 0 0 2px #fde68a;
}
.frame-mini.frame-classic {
border: none;
background: linear-gradient(145deg, #a16207, #78350f);
padding: 6px;
box-shadow: inset 0 0 0 2px #fbbf24;
}
.frame-mini.frame-classic::after {
content: "";
position: absolute;
inset: 10px;
background: #f5f0e6;
box-shadow: inset 0 0 0 1px #d6d3d1;
}
.effects.frames .effect {
align-items: center;
text-align: center;
}
.flip-speed {
@@ -192,32 +340,94 @@ a {
white-space: nowrap;
}
.pages {
.pages-field {
margin-top: 4px;
}
.pages-grid {
display: grid;
gap: 8px;
margin-top: 12px;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 12px;
margin-top: 10px;
}
.page-row {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
.page-tile {
position: relative;
aspect-ratio: 3 / 4;
border-radius: 12px;
padding: 8px;
background: #fff;
overflow: hidden;
border: 2px solid var(--line);
background: #f5f5f4;
cursor: grab;
user-select: none;
touch-action: none;
transition:
border-color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
}
.page-row img {
width: 52px;
height: 68px;
.page-tile:active {
cursor: grabbing;
}
.page-tile.dragging {
opacity: 0.45;
transform: scale(0.96);
}
.page-tile.drop-target {
border-color: #b45309;
box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.25);
}
.page-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
background: #eee;
pointer-events: none;
}
.page-row .grow {
flex: 1;
.page-num {
position: absolute;
bottom: 6px;
right: 6px;
z-index: 1;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgba(28, 25, 23, 0.75);
color: #fff;
font-size: 0.75rem;
line-height: 22px;
text-align: center;
pointer-events: none;
}
.page-delete {
position: absolute;
top: 4px;
left: 4px;
z-index: 2;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 50%;
background: #dc2626;
color: #fff;
font-size: 1rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
display: grid;
place-items: center;
}
.page-delete:hover {
background: #b91c1c;
}
.icon-btn,
@@ -430,12 +640,264 @@ a {
height: 100dvh;
}
.stage-page.immersive .magazine-frame,
.stage-page.immersive .fx-stage {
.stage-page.immersive:not(.framed) .magazine-frame,
.stage-page.immersive:not(.framed) .fx-stage {
width: 100%;
height: 100%;
}
.viewer-wrap.chrome-hidden > .page-chrome {
display: none !important;
}
/* ── device shells (frame / phone / tablet) ── */
.device-shell {
position: relative;
margin: 0 auto;
flex-shrink: 0;
display: block;
box-sizing: border-box;
filter: drop-shadow(0 20px 44px rgba(0, 0, 0, 0.55));
}
.device-bezel {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.device-screen {
position: absolute;
overflow: hidden;
background: #000;
z-index: 1;
}
.device-shell.phone {
/* نسبت نزدیک به آیفون پرو مکس (~19.5:9) */
width: min(292px, 86vw);
height: min(632px, 72dvh);
}
.device-shell.tablet {
width: min(520px, 94vw);
height: min(680px, 62dvh);
}
.device-shell.frame-modern {
width: min(420px, 92vw);
height: min(560px, 62dvh);
}
.device-shell.frame-kids {
width: min(400px, 92vw);
height: min(500px, 62dvh);
}
.device-shell.frame-classic {
width: min(440px, 92vw);
height: min(560px, 62dvh);
}
.device-shell.phone .device-bezel {
/* بدنه تیتانیوم طبیعی آیفون پرو مکس */
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28), transparent 8%, transparent 92%, rgba(0, 0, 0, 0.18)),
linear-gradient(180deg, #e8e4df 0%, #c4beb6 18%, #a59e96 50%, #8f887f 82%, #b7b0a8 100%);
border-radius: 48px;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.45),
inset 0 0 0 2.5px #6d6760,
0 0 0 1px #4a4641,
0 18px 40px rgba(0, 0, 0, 0.4);
}
.device-shell.phone .device-screen {
top: 1.1%;
right: 2.4%;
bottom: 1.1%;
left: 2.4%;
border-radius: 42px;
background: #000;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 0 0 1px #1a1816;
}
/* کمی زوم تا حاشیهٔ عکس داخل قاب دیده نشود */
.device-shell.phone .device-screen .catalog-stage {
transform: scale(1.04);
transform-origin: center center;
}
.device-shell.phone .page-img {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
}
.device-shell.phone .device-island {
position: absolute;
top: 2.4%;
left: 50%;
z-index: 4;
display: block;
width: 34%;
height: 3.6%;
min-height: 22px;
max-height: 28px;
transform: translateX(-50%);
background: #000;
border-radius: 99px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.device-shell.phone .device-island .lens {
position: absolute;
top: 50%;
right: 10%;
width: 9px;
height: 9px;
transform: translateY(-50%);
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, #4b5563, #111827 55%, #000 80%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.device-shell.phone .device-bar {
position: absolute;
bottom: 1.6%;
left: 50%;
z-index: 4;
width: 34%;
height: 4px;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.55);
border-radius: 99px;
pointer-events: none;
}
.device-shell.phone .device-btn {
position: absolute;
z-index: 0;
background: linear-gradient(180deg, #d2ccc5, #8a837b);
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
pointer-events: none;
}
.device-shell.phone .device-btn.mute {
top: 14%;
right: -2px;
width: 3px;
height: 3.2%;
min-height: 14px;
}
.device-shell.phone .device-btn.volume {
top: 20%;
right: -2px;
width: 3px;
height: 9%;
min-height: 42px;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.25),
0 28px 0 0 #8a837b;
}
.device-shell.phone .device-btn.power {
top: 24%;
left: -2px;
width: 3px;
height: 8%;
min-height: 48px;
}
.device-shell.tablet .device-bezel {
background: linear-gradient(180deg, #fafafa 0%, #e4e4e7 48%, #a1a1aa 100%);
border-radius: 18px;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.9),
0 0 0 2px #71717a,
0 0 0 4px rgba(0, 0, 0, 0.12);
}
.device-shell.tablet .device-screen {
top: 7%;
right: 2.8%;
bottom: 2.8%;
left: 2.8%;
border-radius: 4px;
}
.device-shell.tablet .device-cam {
position: absolute;
top: 2.8%;
left: 50%;
z-index: 2;
width: 7px;
height: 7px;
transform: translateX(-50%);
background: #27272a;
border-radius: 50%;
box-shadow: inset 0 0 0 1px #52525b;
pointer-events: none;
}
.device-shell.frame-modern .device-bezel {
background: #fafafa;
box-shadow:
0 0 0 4px #e7e5e4,
0 0 0 8px #1c1917;
}
.device-shell.frame-modern .device-screen {
top: 4%;
right: 4%;
bottom: 4%;
left: 4%;
}
.device-shell.frame-kids .device-bezel {
background: linear-gradient(135deg, #fb7185, #fbbf24 45%, #34d399);
border-radius: 28px;
box-shadow: 0 0 0 5px #fff;
}
.device-shell.frame-kids .device-screen {
top: 5%;
right: 5%;
bottom: 5%;
left: 5%;
border-radius: 12px;
box-shadow: inset 0 0 0 3px #fde68a;
}
.device-shell.frame-classic .device-bezel {
background:
radial-gradient(circle at 25% 15%, #fde68a, transparent 42%),
linear-gradient(145deg, #b45309, #78350f 55%, #451a03);
border-radius: 6px;
box-shadow:
inset 0 0 0 4px #fbbf24,
0 0 0 2px #78350f,
0 14px 32px rgba(0, 0, 0, 0.4);
}
.device-shell.frame-classic .device-screen {
top: 7%;
right: 6%;
bottom: 7%;
left: 6%;
box-shadow: inset 0 0 0 2px #d6d3d1;
}
.tap-exit {
position: absolute;
inset: 0;
@@ -486,6 +948,82 @@ a {
.viewer-wrap:not(.immersive) {
padding: 10px 8px 16px;
}
/* قاب آیفون روی موبایل واقعی: تا حد ممکن پر کردن صفحه */
.stage-page.framed {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.stage-page.framed > .page-chrome {
padding: 6px 8px;
}
.viewer-wrap.framed {
padding: 4px 6px 8px;
justify-content: center;
overflow: hidden;
}
.viewer-wrap.framed > .stage-bar {
width: 100%;
flex-shrink: 0;
margin-bottom: 4px;
}
.device-shell.phone {
width: min(96vw, calc((100dvh - 7.2rem) * 9 / 19.5));
height: min(calc(100dvh - 7.2rem), calc(96vw * 19.5 / 9));
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45));
}
.device-shell.phone .device-bezel {
border-radius: clamp(30px, 11vw, 48px);
}
.device-shell.phone .device-screen {
top: 0.9%;
right: 2%;
bottom: 0.9%;
left: 2%;
border-radius: clamp(26px, 9.5vw, 42px);
}
.device-shell.phone .device-btn {
display: none;
}
.device-shell.phone .device-island {
top: 2%;
width: 32%;
min-height: 18px;
max-height: 24px;
}
.device-shell.phone .device-island .lens {
width: 7px;
height: 7px;
}
.device-shell.phone .device-bar {
bottom: 1.2%;
height: 3px;
}
/* تمام‌صفحه موبایل: قاب تقریباً کل صفحه، بدون حذف طرح */
.viewer-wrap.framed.chrome-hidden {
padding: 2px;
}
.viewer-wrap.framed.chrome-hidden .device-shell.phone {
width: min(calc(100vw - 4px), calc((100dvh - 4px) * 9 / 19.5));
height: min(calc(100dvh - 4px), calc((100vw - 4px) * 19.5 / 9));
}
.viewer-wrap.framed.chrome-hidden .device-shell.phone .device-screen .catalog-stage {
transform: scale(1.1);
}
}
.auto-hint {
@@ -585,3 +1123,76 @@ a {
max-height: 92vh;
max-width: 100%;
}
.viewer-wrap.framed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
padding: 12px;
overflow: auto;
}
.viewer-wrap.framed .device-shell {
margin: 0 auto;
z-index: 2;
}
.viewer-wrap.framed.cinematic {
background: transparent;
border-radius: 0;
}
.catalog-stage {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
}
.viewer-wrap.framed .catalog-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.device-screen .catalog-stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.viewer-wrap.framed .magazine-frame,
.viewer-wrap.framed .fx-stage,
.viewer-wrap.framed .scroll-list {
flex: 1;
width: 100% !important;
height: 100% !important;
max-height: 100% !important;
min-height: 0 !important;
margin: 0 !important;
}
.viewer-wrap.framed .magazine-outer {
width: 100% !important;
height: 100% !important;
max-height: 100% !important;
}
.stage-page.framed {
align-items: stretch;
}
.stage-page.framed .viewer-wrap {
justify-content: center;
min-height: 0;
height: auto;
flex: 1;
}
+5 -3
View File
@@ -78,6 +78,7 @@
- یک PDF **یا** چند تصویر (نه هر دو با هم در یک کاتالوگ)
- ترتیب تصاویر = ترتیب انتخاب / ترتیب نام فایل؛ امکان جابه‌جایی قبل از ساخت
- انتخاب یکی از افکت‌های ورق‌زدن
- انتخاب قالب نمایش (بدون قالب، گوشی، تبلت، قاب مدرن/کودکانه/کلاسیک). قالب دور کل کاتالوگ است و صفحات داخل همان صفحه/شیشه ورق می‌خورند. بعد از ساخت عوض نمی‌شود
- تعیین زمان ورق‌خوردن (۴۰۰ تا ۵۰۰۰ میلی‌ثانیه؛ پیش‌فرض ۱ ثانیه). بعد از ساخت عوض نمی‌شود. روی مجله، اسلاید، محو و کارت اعمال می‌شود؛ اسکرول عمودی فقط فاصلهٔ پخش خودکار را از همین زمان می‌گیرد
- عنوان اختیاری (اگر خالی باشد: «کاتالوگ»)
@@ -177,6 +178,7 @@ Nuxt و فریم‌ورک سنگین بک‌اند رد شد: نمایش کات
| `delete_token` | متن، حدود ۲۴ کاراکتر | فقط برای حذف؛ در URL مشاهده نیست |
| `title` | متن | عنوان |
| `effect` | متن | یکی از شناسه‌های بخش ۶ |
| `frame` | متن | قالب پوسته: `none`، `phone`، `tablet`، `frame-modern`، `frame-kids`، `frame-classic` |
| `page_count` | عدد | تعداد صفحات ذخیره‌شده |
| `flip_ms` | عدد | مدت انیمیشن ورق‌خوردن به میلی‌ثانیه (۴۰۰–۵۰۰۰) |
| `created_at` | زمان | |
@@ -201,8 +203,8 @@ storage/catalogs/{id}/
| متد | مسیر | کار |
|---|---|---|
| `POST` | `/catalogs` | ساخت. بدنه: تصاویر JPEG صفحات (نه خود PDF) + `title` + `effect` + `flipMs` |
| `GET` | `/catalogs/:id` | متادیتا برای بیننده (بدون `delete_token`)؛ شامل `flipMs` |
| `POST` | `/catalogs` | ساخت. بدنه: تصاویر JPEG صفحات (نه خود PDF) + `title` + `effect` + `flipMs` + `frame` |
| `GET` | `/catalogs/:id` | متادیتا برای بیننده (بدون `delete_token`)؛ شامل `flipMs` و `frame` |
| `GET` | `/catalogs/:id/pages/:n` | فایل تصویر صفحه |
| `GET` | `/catalogs/:id/qr` | PNG کد QR. عمومی است چون فقط همان لینک مشاهده را کد می‌کند |
| `DELETE` | `/catalogs/:id` | حذف؛ کوئری `token` باید با `delete_token` یکی باشد |
@@ -373,4 +375,4 @@ DATABASE_PATH=/var/www/ktg/data/catalogs.sqlite
| ۲۰۲۶-۰۸-۱۸ | لودینگ هنگام خواندن فایل، آماده‌سازی صفحات و ساخت کاتالوگ |
| ۲۰۲۶-۰۸-۱۹ | استقرار روی هاست مشترک؛ دامنه `ktg.montzar.ir`؛ پورت ۳۰۲۰؛ SQLite جدا |
| ۲۰۲۶-۰۸-۱۹ | مخزن Gitea: `http://get.feep.ir/Mahdi/digitalcatalog.git` |
| ۲۰۲۶-۰۸-۱۹ | لاگین بعدی اگر آمد به Nest/Postgres بقیه وصل نمی‌شود |
| ۲۰۲۶-۰۸-۲۰ | قالب پوسته دور کاتالوگ: گوشی، تبلت، سه قاب عکس؛ SVG/CSS |
+3 -2
View File
@@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"dev": "concurrently -n api,web -c blue,green \"npm run dev:api\" \"npm run dev:web\"",
"dev:api": "tsx --env-file=.env server/index.ts",
"dev:api": "tsx watch --env-file=.env server/index.ts",
"dev:web": "vite",
"build": "vite build",
"start": "tsx --env-file=.env server/index.ts",
"test": "vitest run"
"test": "vitest run",
"migrate": "tsx scripts/migrate-db.ts"
},
"dependencies": {
"@hono/node-server": "^2.1.1",
+6
View File
@@ -0,0 +1,6 @@
import { openDatabase } from "../server/db.ts";
import { loadEnv } from "../server/env.ts";
const env = loadEnv();
await openDatabase(env.databasePath);
console.log("Database migrated:", env.databasePath);
+10 -3
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import type { EffectId } from "../shared/types";
import { DEFAULT_FLIP_MS } from "../shared/types";
import type { EffectId, FrameId } from "../shared/types";
import { DEFAULT_FLIP_MS, DEFAULT_FRAME, isFrameId } from "../shared/types";
import type { DbBundle } from "./db";
import { catalogsTable } from "./schema";
@@ -9,22 +9,26 @@ export type CatalogRow = {
deleteToken: string;
title: string;
effect: EffectId;
frame: FrameId;
pageCount: number;
flipMs: number;
createdAt: string;
};
export type CatalogInsert = Omit<CatalogRow, "createdAt" | "flipMs"> & {
export type CatalogInsert = Omit<CatalogRow, "createdAt" | "flipMs" | "frame"> & {
createdAt?: string;
flipMs?: number;
frame?: FrameId;
};
function toRow(record: typeof catalogsTable.$inferSelect): CatalogRow {
const frameRaw = record.frame ?? DEFAULT_FRAME;
return {
id: record.id,
deleteToken: record.deleteToken,
title: record.title,
effect: record.effect as EffectId,
frame: isFrameId(frameRaw) ? frameRaw : DEFAULT_FRAME,
pageCount: record.pageCount,
flipMs: record.flipMs ?? DEFAULT_FLIP_MS,
createdAt: record.createdAt,
@@ -36,6 +40,7 @@ export function createCatalogRepo(db: DbBundle) {
create(input: CatalogInsert): CatalogRow {
const createdAt = input.createdAt ?? new Date().toISOString();
const flipMs = input.flipMs ?? DEFAULT_FLIP_MS;
const frame = input.frame ?? DEFAULT_FRAME;
db.drizzle.insert(catalogsTable).values({
id: input.id,
deleteToken: input.deleteToken,
@@ -43,6 +48,7 @@ export function createCatalogRepo(db: DbBundle) {
effect: input.effect,
pageCount: input.pageCount,
flipMs,
frame,
createdAt,
}).run();
db.persist();
@@ -51,6 +57,7 @@ export function createCatalogRepo(db: DbBundle) {
deleteToken: input.deleteToken,
title: input.title,
effect: input.effect,
frame,
pageCount: input.pageCount,
flipMs,
createdAt,
+5
View File
@@ -33,6 +33,7 @@ export async function openDatabase(databasePath: string): Promise<DbBundle> {
effect TEXT NOT NULL,
page_count INTEGER NOT NULL,
flip_ms INTEGER NOT NULL DEFAULT 1000,
frame TEXT NOT NULL DEFAULT 'none',
created_at TEXT NOT NULL
)
`);
@@ -42,6 +43,10 @@ export async function openDatabase(databasePath: string): Promise<DbBundle> {
if (!colNames.has("flip_ms")) {
sqlite.run("ALTER TABLE catalogs ADD COLUMN flip_ms INTEGER NOT NULL DEFAULT 1000");
}
if (!colNames.has("frame")) {
sqlite.run("ALTER TABLE catalogs ADD COLUMN frame TEXT NOT NULL DEFAULT 'none'");
console.log("[db] added catalogs.frame column");
}
const persist = () => {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
+1
View File
@@ -12,6 +12,7 @@ export const messages = {
tooLarge: "حجم یا تعداد صفحه بیش از حد مجاز است",
badType: "نوع فایل پشتیبانی نمی‌شود",
badEffect: "افکت نامعتبر است",
badFrame: "قالب نامعتبر است",
notFound: "کاتالوگ پیدا نشد",
forbiddenDelete: "اجازه حذف ندارید",
rateLimited: "تعداد ساخت از این آدرس بیش از حد مجاز است",
+9
View File
@@ -5,6 +5,8 @@ import {
clampFlipMs,
fileTooLargeMessage,
isEffectId,
isFrameId,
DEFAULT_FRAME,
LIMITS,
tooManyPagesMessage,
type CreateCatalogResponse,
@@ -37,6 +39,7 @@ function toPublic(row: {
id: string;
title: string;
effect: CatalogPublic["effect"];
frame: CatalogPublic["frame"];
pageCount: number;
flipMs: number;
createdAt: string;
@@ -45,6 +48,7 @@ function toPublic(row: {
id: row.id,
title: row.title,
effect: row.effect,
frame: row.frame,
pageCount: row.pageCount,
flipMs: row.flipMs,
createdAt: row.createdAt,
@@ -102,6 +106,10 @@ export function catalogRoutes(opts: {
throw new HttpError(400, messages.badEffect);
}
const flipMs = clampFlipMs(body.flipMs);
const frameRaw = typeof body.frame === "string" && body.frame ? body.frame : DEFAULT_FRAME;
if (!isFrameId(frameRaw)) {
throw new HttpError(400, messages.badFrame);
}
const pages = asFileArray(body.pages);
if (pages.length < 1) {
@@ -127,6 +135,7 @@ export function catalogRoutes(opts: {
effect: effectRaw,
pageCount: buffers.length,
flipMs,
frame: frameRaw,
});
const payload: CreateCatalogResponse = {
+1
View File
@@ -7,5 +7,6 @@ export const catalogsTable = sqliteTable("catalogs", {
effect: text("effect").notNull(),
pageCount: integer("page_count").notNull(),
flipMs: integer("flip_ms").notNull(),
frame: text("frame").notNull(),
createdAt: text("created_at").notNull(),
});
+16
View File
@@ -5,10 +5,25 @@ export function isEffectId(value: string): value is EffectId {
return (EFFECTS as readonly string[]).includes(value);
}
export const FRAMES = [
"none",
"phone",
"tablet",
"frame-modern",
"frame-kids",
"frame-classic",
] as const;
export type FrameId = (typeof FRAMES)[number];
export function isFrameId(value: string): value is FrameId {
return (FRAMES as readonly string[]).includes(value);
}
export type CatalogPublic = {
id: string;
title: string;
effect: EffectId;
frame: FrameId;
pageCount: number;
flipMs: number;
createdAt: string;
@@ -35,6 +50,7 @@ export const LIMITS = {
export const DEFAULT_TITLE = "کاتالوگ";
export const DEFAULT_FLIP_MS = 1000;
export const DEFAULT_FRAME: FrameId = "none";
export function clampFlipMs(value: unknown): number {
const n = Number(value);
+1
View File
@@ -31,6 +31,7 @@ describe("catalog repo", () => {
const found = repo.getById("abc12xyz90");
expect(found).toEqual(created);
expect(found?.flipMs).toBe(1000);
expect(found?.frame).toBe("none");
expect(found?.deleteToken).toBe("token-token-token-token-12");
});
+15
View File
@@ -26,6 +26,7 @@ function form(pages: File[], extra: Record<string, string> = {}) {
data.set("title", extra.title ?? "");
data.set("effect", extra.effect ?? "magazine");
if (extra.flipMs) data.set("flipMs", extra.flipMs);
if (extra.frame) data.set("frame", extra.frame);
for (const page of pages) data.append("pages", page);
return data;
}
@@ -56,6 +57,7 @@ describe("catalog HTTP API", () => {
expect(publicBody.title).toBe("نمونه");
expect(publicBody.pageCount).toBe(2);
expect(publicBody.flipMs).toBe(1000);
expect(publicBody.frame).toBe("none");
expect(publicBody.deleteToken).toBeUndefined();
expect(JSON.stringify(publicBody)).not.toContain("delete");
});
@@ -73,6 +75,19 @@ describe("catalog HTTP API", () => {
expect((await pub.json()).flipMs).toBe(1800);
});
it("stores a catalog frame", async () => {
const app = await testApp();
const created = await app.request("/api/catalogs", {
method: "POST",
body: form([jpegFile()], { frame: "phone" }),
headers: { "x-forwarded-for": "10.0.0.21" },
});
expect(created.status).toBe(201);
const { id } = await created.json();
const pub = await app.request(`/api/catalogs/${id}`);
expect((await pub.json()).frame).toBe("phone");
});
it("clamps flip duration to the allowed range", async () => {
const app = await testApp();
const tooFast = await app.request("/api/catalogs", {