feat(cms): add VDO Frame Scrubber block with isolated sequence library
This commit is contained in:
1 parent
64f7ecf729
commit
0bf544720b
877 files changed
+1632
-6
No files matched your search
@@ -143,7 +143,34 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||
// 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
|
||||
{
|
||||
type: 'INTERACTIVE_360_SHOWCASE',
|
||||
category: 'hero',
|
||||
|
||||
@@ -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 AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
|
||||
import HeroForm from '../components/page-builder/HeroForm';
|
||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||
@@ -1470,6 +1471,7 @@ export default function PageBuilderPage() {
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
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="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
||||
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
||||
@@ -1595,7 +1597,14 @@ export default function PageBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
|
||||
<VdoFrameScrubberForm
|
||||
payload={editingSection.payload || {}}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||
<Interactive360ShowcaseForm
|
||||
payload={editingSection.payload || {}}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
|
||||
Reference in new issue
Block a user