feat(cms): add VDO Frame Scrubber block with isolated sequence library
No files matched your search
@@ -142,6 +142,33 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
|
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
// 2.96 VDO_FRAME_SCRUBBER
|
||||||
|
{
|
||||||
|
type: 'VDO_FRAME_SCRUBBER',
|
||||||
|
category: 'hero',
|
||||||
|
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
|
||||||
|
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
|
||||||
|
badge: 'Video Frames · 4K',
|
||||||
|
renderPreview: () => (
|
||||||
|
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||||
|
<div className="space-y-1 max-w-[65%] z-10">
|
||||||
|
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
|
||||||
|
</span>
|
||||||
|
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||||
|
FROM VIDEO TO INTERACTIVE
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
|
||||||
|
<span>00:18.0s @ 24FPS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
|
||||||
|
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
// 2.95 INTERACTIVE_360_SHOWCASE
|
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Upload,
|
||||||
|
Film,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
FolderOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
|
||||||
|
export interface FrameSequenceItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
folderPath: string
|
||||||
|
pattern: string
|
||||||
|
padding: number
|
||||||
|
totalFrames: number
|
||||||
|
fps: number
|
||||||
|
durationSeconds: number
|
||||||
|
posterUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelectSequence: (seq: FrameSequenceItem) => void
|
||||||
|
selectedSlug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrameSequenceModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelectSequence,
|
||||||
|
selectedSlug,
|
||||||
|
}: Props) {
|
||||||
|
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Upload Form State
|
||||||
|
const [showUploadForm, setShowUploadForm] = useState(false)
|
||||||
|
const [uploadName, setUploadName] = useState('')
|
||||||
|
const [uploadFps, setUploadFps] = useState('24')
|
||||||
|
const [zipFile, setZipFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const fetchSequences = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setSequences(data.sequences || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load frame sequences', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setUploadError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleUploadZip = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zipFile) {
|
||||||
|
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
setUploadError(null)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', zipFile)
|
||||||
|
if (uploadName) formData.append('name', uploadName)
|
||||||
|
formData.append('fps', uploadFps)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setZipFile(null)
|
||||||
|
setUploadName('')
|
||||||
|
|
||||||
|
if (data.sequence) {
|
||||||
|
onSelectSequence(data.sequence)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (slug: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setSequences((prev) => prev.filter((s) => s.slug !== slug))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete sequence', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
|
||||||
|
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
|
||||||
|
<Film className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
|
||||||
|
แยกจาก Media ปกติ
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(!showUploadForm)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
|
||||||
|
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Upload Form Accordion */}
|
||||||
|
{showUploadForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleUploadZip}
|
||||||
|
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
|
||||||
|
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ชื่อชุดวิดีโอ (Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadName}
|
||||||
|
onChange={(e) => setUploadName(e.target.value)}
|
||||||
|
placeholder="เช่น Chrome Sprocket 18s Spin"
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ความเร็วเฟรมต่อวินาที (FPS)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={uploadFps}
|
||||||
|
onChange={(e) => setUploadFps(e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
|
||||||
|
<option value="30">30 FPS (Standard Video)</option>
|
||||||
|
<option value="60">60 FPS (Ultra Smooth 3D)</option>
|
||||||
|
<option value="12">12 FPS (Stop Motion)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ไฟล์ .ZIP ที่มีรูปเฟรม
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(false)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading || !zipFile}
|
||||||
|
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sequence Cards Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
|
||||||
|
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
|
||||||
|
</div>
|
||||||
|
) : sequences.length === 0 ? (
|
||||||
|
<div className="py-20 text-center space-y-3">
|
||||||
|
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{sequences.map((seq) => {
|
||||||
|
const isSelected = selectedSlug === seq.slug
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seq.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSequence(seq)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
|
||||||
|
: 'border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Poster Thumbnail */}
|
||||||
|
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
|
||||||
|
<img
|
||||||
|
src={seq.posterUrl}
|
||||||
|
alt={seq.name}
|
||||||
|
className="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
|
||||||
|
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
|
||||||
|
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
|
||||||
|
{seq.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] font-mono text-slate-500 truncate">
|
||||||
|
{seq.folderPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
|
||||||
|
<span className="font-mono text-slate-400">
|
||||||
|
{seq.fps} FPS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(seq.slug, e)}
|
||||||
|
title="ลบชุดนี้"
|
||||||
|
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
|
||||||
|
>
|
||||||
|
เลือกชุดนี้
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
updatePayloadValue: (field: string, value: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VdoFrameScrubberForm({
|
||||||
|
payload,
|
||||||
|
updatePayloadValue,
|
||||||
|
}: Props) {
|
||||||
|
const [productsList, setProductsList] = useState<any[]>([])
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
|
||||||
|
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
|
||||||
|
const totalFrames = Number(payload.totalFrames) || 432
|
||||||
|
const fps = Number(payload.fps) || 24
|
||||||
|
const durationSeconds = Number(payload.durationSeconds) || 18
|
||||||
|
|
||||||
|
const eyebrow = (payload.eyebrow as string) || ''
|
||||||
|
const title = (payload.title as string) || ''
|
||||||
|
const subtitle = (payload.subtitle as string) || ''
|
||||||
|
const instruction = (payload.instruction as string) || ''
|
||||||
|
const productName = (payload.productName as string) || ''
|
||||||
|
const productBadge = (payload.productBadge as string) || ''
|
||||||
|
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||||
|
const theme = (payload.theme as string) || 'dark'
|
||||||
|
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||||
|
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||||
|
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||||
|
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const fetchCatalog = async () => {
|
||||||
|
setLoadingProducts(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||||
|
if (isMounted) setProductsList(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load products in VdoFrameScrubberForm', err)
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setLoadingProducts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCatalog()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-seed defaults on brand new block
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
if (!payload.padding) updatePayloadValue('padding', 3)
|
||||||
|
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
|
||||||
|
if (!payload.fps) updatePayloadValue('fps', 24)
|
||||||
|
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
|
||||||
|
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||||
|
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectProduct = (productIdStr: string) => {
|
||||||
|
if (!productIdStr) {
|
||||||
|
updatePayloadValue('productId', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pid = Number(productIdStr)
|
||||||
|
updatePayloadValue('productId', pid)
|
||||||
|
|
||||||
|
const prod = productsList.find((p) => p.id === pid)
|
||||||
|
if (prod) {
|
||||||
|
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||||
|
if (prodName) updatePayloadValue('productName', prodName)
|
||||||
|
const slug = prod.slug || `product-${pid}`
|
||||||
|
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyPreset = () => {
|
||||||
|
updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', 3)
|
||||||
|
updatePayloadValue('totalFrames', 432)
|
||||||
|
updatePayloadValue('fps', 24)
|
||||||
|
updatePayloadValue('durationSeconds', 18)
|
||||||
|
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
updatePayloadValue('theme', 'dark')
|
||||||
|
updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSequenceSelected = (seq: FrameSequenceItem) => {
|
||||||
|
updatePayloadValue('folderPath', seq.folderPath)
|
||||||
|
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', seq.padding || 3)
|
||||||
|
updatePayloadValue('totalFrames', seq.totalFrames)
|
||||||
|
updatePayloadValue('fps', seq.fps || 24)
|
||||||
|
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
|
||||||
|
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-slate-200">
|
||||||
|
{/* ── Preset Banner ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-[#d5ff63] font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>VDO Frame-by-Frame Interactive Scrubber</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyPreset}
|
||||||
|
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sequence Collection Selector (Isolated Library) ── */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
|
||||||
|
>
|
||||||
|
<Film className="w-3.5 h-3.5" />
|
||||||
|
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Sequence Card */}
|
||||||
|
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
|
||||||
|
<img
|
||||||
|
src={`${folderPath}/frame-001.webp`}
|
||||||
|
alt="Sequence Poster"
|
||||||
|
className="w-full h-full object-contain p-1"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback if not yet loaded
|
||||||
|
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
|
||||||
|
<span>{totalFrames} FRAMES</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{fps} FPS</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{durationSeconds} วินาที</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="text-xs text-[#d5ff63] hover:underline font-mono"
|
||||||
|
>
|
||||||
|
เปลี่ยนชุด →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 1-Click Product Linking ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
<Package className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||||
|
</div>
|
||||||
|
{selectedProductId && (
|
||||||
|
<span className="text-[10px] text-[#d5ff63] font-mono">
|
||||||
|
Product ID: #{selectedProductId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedProductId}
|
||||||
|
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||||
|
disabled={loadingProducts}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||||
|
{productsList.map((p) => {
|
||||||
|
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||||
|
return (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{p.id} - {nameStr}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaLink}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||||
|
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||||
|
className="admin-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
{endCtaLink && (
|
||||||
|
<a
|
||||||
|
href={endCtaLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Headline & Header Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
ข้อความหัวเรื่อง (Header & Story Intro)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eyebrow}
|
||||||
|
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="FROM VIDEO TO INTERACTIVE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ป้ายซีรีส์ (Badge / Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productBadge}
|
||||||
|
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="18 SEC / 432 FRAMES"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ชื่อสินค้า (Product Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="SPROCKET / FRAME SCRUBBER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำแนะนำการเลื่อน (Instruction Cue)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวเรื่องหลัก (Main Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Theme, Accent, and Conversion CTA ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
สี Accent Highlight
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="admin-input flex-1 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
โทนพื้นหลัง (Theme)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
|
||||||
|
<option value="light">Light Studio (#f8f9fa)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ทุกมุม อยู่ในมือคุณ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaButtonText}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal for Frame Sequence Collection */}
|
||||||
|
<FrameSequenceModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSelectSequence={handleSequenceSelected}
|
||||||
|
selectedSlug={folderPath.split('/').pop()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { getAuthHeaders } from '../utils/auth';
|
|||||||
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
||||||
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||||
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||||
|
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
|
||||||
import HeroForm from '../components/page-builder/HeroForm';
|
import HeroForm from '../components/page-builder/HeroForm';
|
||||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||||
@@ -1470,6 +1471,7 @@ export default function PageBuilderPage() {
|
|||||||
onChange={(e) => handleTypeChange(e.target.value)}
|
onChange={(e) => handleTypeChange(e.target.value)}
|
||||||
className="admin-input w-full bg-slate-900 border-white/10"
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
>
|
>
|
||||||
|
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
|
||||||
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
||||||
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
||||||
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
||||||
@@ -1595,6 +1597,13 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
|
||||||
|
<VdoFrameScrubberForm
|
||||||
|
payload={editingSection.payload || {}}
|
||||||
|
updatePayloadValue={updatePayloadValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||||
<Interactive360ShowcaseForm
|
<Interactive360ShowcaseForm
|
||||||
payload={editingSection.payload || {}}
|
payload={editingSection.payload || {}}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |