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:
+3
-1
@@ -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`);
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: "چوبی با حاشیه پهن" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -12,6 +12,7 @@ export const messages = {
|
||||
tooLarge: "حجم یا تعداد صفحه بیش از حد مجاز است",
|
||||
badType: "نوع فایل پشتیبانی نمیشود",
|
||||
badEffect: "افکت نامعتبر است",
|
||||
badFrame: "قالب نامعتبر است",
|
||||
notFound: "کاتالوگ پیدا نشد",
|
||||
forbiddenDelete: "اجازه حذف ندارید",
|
||||
rateLimited: "تعداد ساخت از این آدرس بیش از حد مجاز است",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user