diff --git a/apps/admin/src/components/page-builder/AddSectionModal.tsx b/apps/admin/src/components/page-builder/AddSectionModal.tsx index 0d6ae9b..d4f6575 100644 --- a/apps/admin/src/components/page-builder/AddSectionModal.tsx +++ b/apps/admin/src/components/page-builder/AddSectionModal.tsx @@ -143,7 +143,34 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS ) }, - // 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: () => ( +
+
+ + FRAME SCRUBBER + +
+ FROM VIDEO TO INTERACTIVE +
+
+ 432 FRAMES + 00:18.0s @ 24FPS +
+
+
+ +
+
+ ) + }, + // 2.95 INTERACTIVE_360_SHOWCASE { type: 'INTERACTIVE_360_SHOWCASE', category: 'hero', diff --git a/apps/admin/src/components/page-builder/FrameSequenceModal.tsx b/apps/admin/src/components/page-builder/FrameSequenceModal.tsx new file mode 100644 index 0000000..8f2b3d7 --- /dev/null +++ b/apps/admin/src/components/page-builder/FrameSequenceModal.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + + // Upload Form State + const [showUploadForm, setShowUploadForm] = useState(false) + const [uploadName, setUploadName] = useState('') + const [uploadFps, setUploadFps] = useState('24') + const [zipFile, setZipFile] = useState(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 ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ คลังชุดวิดีโอเฟรม (Frame Sequence Library) + + แยกจาก Media ปกติ + +

+

+ เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป +

+
+
+ +
+ + + +
+
+ + {/* Modal Body */} +
+ {/* Upload Form Accordion */} + {showUploadForm && ( +
+
+ + อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP +
+

+ บีบอัดโฟลเดอร์ภาพ เช่น frame-001.webp ถึง{' '} + frame-432.webp เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ +

+ + {uploadError && ( +
+ {uploadError} +
+ )} + +
+
+ + setUploadName(e.target.value)} + placeholder="เช่น Chrome Sprocket 18s Spin" + className="admin-input w-full text-xs" + required + /> +
+ +
+ + +
+
+ +
+ + setZipFile(e.target.files?.[0] || null)} + className="admin-input w-full text-xs" + required + /> +
+ +
+ + + +
+
+ )} + + {/* Sequence Cards Grid */} + {loading ? ( +
+ + กำลังโหลดชุดวิดีโอเฟรม... +
+ ) : sequences.length === 0 ? ( +
+ +
ยังไม่มีชุด Frame Sequence ในระบบ
+

+ คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที +

+
+ ) : ( +
+ {sequences.map((seq) => { + const isSelected = selectedSlug === seq.slug + return ( +
{ + 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 */} +
+ {seq.name} +
+ {seq.totalFrames} FRAMES • {seq.durationSeconds}s +
+ + {isSelected && ( +
+ +
+ )} +
+ + {/* Metadata */} +
+

+ {seq.name} +

+

+ {seq.folderPath} +

+
+ + {/* Action Bar */} +
+ + {seq.fps} FPS + + +
+ + + +
+
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/apps/admin/src/components/page-builder/VdoFrameScrubberForm.tsx b/apps/admin/src/components/page-builder/VdoFrameScrubberForm.tsx new file mode 100644 index 0000000..4d4d5bb --- /dev/null +++ b/apps/admin/src/components/page-builder/VdoFrameScrubberForm.tsx @@ -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 + updatePayloadValue: (field: string, value: unknown) => void +} + +export default function VdoFrameScrubberForm({ + payload, + updatePayloadValue, +}: Props) { + const [productsList, setProductsList] = useState([]) + 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 ( +
+ {/* ── Preset Banner ── */} +
+
+
+ + VDO Frame-by-Frame Interactive Scrubber +
+

+ แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub) +

+
+ + +
+ + {/* ── Sequence Collection Selector (Isolated Library) ── */} +
+
+
+ + ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection) +
+ + +
+ + {/* Selected Sequence Card */} +
+
+
+ Sequence Poster { + // Fallback if not yet loaded + (e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png' + }} + /> +
+ +
+
+ + {folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'} + + + พร้อมใช้งาน + +
+
+ {totalFrames} FRAMES + • + {fps} FPS + • + {durationSeconds} วินาที +
+

+ โฟลเดอร์: {folderPath} (รูปแบบ: {pattern}) +

+
+
+ + +
+
+ + {/* ── 1-Click Product Linking ── */} +
+
+
+ + ผูกกับสินค้าในระบบ (1-Click Bind Product) +
+ {selectedProductId && ( + + Product ID: #{selectedProductId} + + )} +
+ +
+
+ +
+ +
+ updatePayloadValue('endCtaLink', e.target.value)} + placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1" + className="admin-input flex-1 text-xs" + /> + {endCtaLink && ( + + + + )} +
+
+
+ + {/* ── Headline & Header Texts ── */} +
+
+ ข้อความหัวเรื่อง (Header & Story Intro) +
+ +
+
+ + updatePayloadValue('eyebrow', e.target.value)} + className="admin-input w-full text-xs font-mono" + placeholder="FROM VIDEO TO INTERACTIVE" + /> +
+ +
+ + updatePayloadValue('productBadge', e.target.value)} + className="admin-input w-full text-xs font-mono" + placeholder="18 SEC / 432 FRAMES" + /> +
+ +
+ + updatePayloadValue('productName', e.target.value)} + className="admin-input w-full text-xs" + placeholder="SPROCKET / FRAME SCRUBBER" + /> +
+ +
+ + updatePayloadValue('instruction', e.target.value)} + className="admin-input w-full text-xs" + placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ" + /> +
+
+ +
+ + updatePayloadValue('title', e.target.value)} + className="admin-input w-full text-xs" + placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม" + /> +
+ +
+ +