/** * ProjectPanel.jsx — Complete project share/import/export system * * Features: * - Import preview: shows project contents BEFORE loading * - Full bundle export: safely encodes large GLBs (no stack overflow) * - Per-model embed toggle: choose which models to embed * - Recent projects list from localStorage * - Auto-save with configurable interval * - Share link with URL param parsing on startup * - File size estimation before export * - Import validation with error details */ import { useState, useRef, useCallback, useEffect } from 'react' import useStore from '../store/useStore' // ── Helpers ─────────────────────────────────────────────────────────────────── function fmtBytes(b) { if (!b || b === 0) return '0 B' if (b < 1024) return `${b} B` if (b < 1024**2) return `${(b/1024).toFixed(1)} KB` return `${(b/1024/1024).toFixed(2)} MB` } function fmtDate(iso) { if (!iso) return '' try { return new Date(iso).toLocaleString(undefined, { dateStyle:'short', timeStyle:'short' }) } catch { return iso } } // ── Sub-components ──────────────────────────────────────────────────────────── function Divider({ label }) { return (
{label && {label}}
) } function Chip({ color='var(--accent)', children }) { return ( {children} ) } function StatusBox({ status }) { if (!status) return null const cfg = { ok: { bg:'rgba(6,214,160,0.08)', border:'rgba(6,214,160,0.25)', color:'var(--accent3)', icon:'✅' }, err: { bg:'rgba(239,68,68,0.08)', border:'rgba(239,68,68,0.25)', color:'var(--danger)', icon:'❌' }, warn: { bg:'rgba(245,158,11,0.08)', border:'rgba(245,158,11,0.25)', color:'var(--warn)', icon:'⚠️' }, info: { bg:'rgba(79,142,255,0.08)', border:'rgba(79,142,255,0.25)', color:'var(--accent)', icon:'ℹ️' }, }[status.type] || {} return (
{cfg.icon} {status.msg}
) } function BigBtn({ icon, label, sub, onClick, color='var(--accent)', disabled, loading, badge }) { const [h, setH] = useState(false) return ( ) } // ── Import Preview Modal ─────────────────────────────────────────────────────── function ImportPreview({ preview, onLoad, onCancel }) { const dur = preview.totalFrames && preview.fps ? `${(preview.totalFrames/preview.fps).toFixed(1)}s` : '?' return (
{/* Header */}
📦
{preview.projectName}
{preview.bundleDate ? fmtDate(preview.bundleDate) : `v${preview.version}`}
{/* Stats grid */}
{[ ['📦 Models', preview.modelCount], ['◆ Keyframes', preview.keyframeCount], ['🎥 Cameras', preview.cameraCount], ['⏱ Duration', dur], ['🎬 FPS', preview.fps], ['💡 Lighting', preview.lightingPreset], ].map(([k,v]) => (
{k}
{v}
))}
{/* Model list */} {preview.models?.length > 0 && (
Models
{preview.models.map(m => (
{m.hasBlob ? '✅' : '🔗'} {m.name} {m.hasBlob ? 'embedded' : 'URL ref'}
))}
)} {/* Warning if URL-only */} {preview.embeddedModels < preview.modelCount && (
⚠️ {preview.modelCount - preview.embeddedModels} model{preview.modelCount-preview.embeddedModels>1?'s':''} use URL references — internet required to load them.
)} {/* Actions */}
) } // ── Main Panel ───────────────────────────────────────────────────────────────── export default function ProjectPanel() { const { projectName, setProjectName, models, keyframes, cameras, fps, totalFrames, lightingPreset, saveProject, loadProject, exportProjectJSON, exportProjectBundle, previewBundle, loadBundle, getRecentProjects, clearRecentProjects, } = useStore() const [status, setStatus] = useState(null) const [exporting, setExporting] = useState(false) const [progress, setProgress] = useState({ msg:'', pct:0 }) const [dragging, setDragging] = useState(false) const [editName, setEditName] = useState(false) const [nameVal, setNameVal] = useState(projectName) const [preview, setPreview] = useState(null) // import preview const [skipModels, setSkipModels] = useState(new Set()) // models to NOT embed const [showSkipUI, setShowSkipUI] = useState(false) const [autoSave, setAutoSave] = useState(false) const [lastSaved, setLastSaved] = useState(null) const [recent, setRecent] = useState([]) const [showRecent, setShowRecent] = useState(false) const fileRef = useRef() const autoRef = useRef() const kfCount = Object.keys(keyframes).length const hasModels= models.length > 0 const duration = totalFrames && fps ? `${(totalFrames/fps).toFixed(1)}s` : '0s' // Load recent on mount useEffect(() => { setRecent(getRecentProjects?.() || []) }, []) // Auto-save useEffect(() => { if (autoSave) { autoRef.current = setInterval(() => { const ok = saveProject() if (ok) setLastSaved(new Date().toLocaleTimeString()) }, 60_000) // every 60s } return () => clearInterval(autoRef.current) }, [autoSave]) const showMsg = (type, msg, ms=5000) => { setStatus({ type, msg }) if (ms > 0) setTimeout(() => setStatus(null), ms) } // ── Quick export ─────────────────────────────────────────────────────────── const handleQuickExport = () => { try { exportProjectJSON() showMsg('ok', 'Exported! Models saved as URLs — recipients need internet to reload them.') } catch(e) { showMsg('err', `Export failed: ${e.message}`) } } // ── Bundle export ────────────────────────────────────────────────────────── const handleBundle = async () => { if (exporting || !hasModels) return setExporting(true) setProgress({ msg:'Starting…', pct:0 }) try { const result = await exportProjectBundle( (msg, pct) => setProgress({ msg, pct }), { skip: [...skipModels] } ) const parts = [] if (result.embeddedCount > 0) parts.push(`${result.embeddedCount} model${result.embeddedCount>1?'s':''} embedded`) if (result.failedCount > 0) parts.push(`${result.failedCount} failed to fetch`) const sizeStr = fmtBytes(result.size) showMsg('ok', `Bundle saved! ${parts.join(' · ')} · ${sizeStr}`, 8000) setRecent(getRecentProjects?.() || []) } catch(e) { showMsg('err', `Bundle export failed: ${e.message}`) } finally { setExporting(false) setProgress({ msg:'', pct:0 }) } } // ── Save to browser ──────────────────────────────────────────────────────── const handleSave = () => { const ok = saveProject() if (ok) { setLastSaved(new Date().toLocaleTimeString()); showMsg('ok', 'Saved to browser storage ✓') } else showMsg('err', 'Browser storage save failed (storage may be full)') } // ── Load from browser ────────────────────────────────────────────────────── const handleLoad = () => { const ok = loadProject() if (ok) { showMsg('ok', 'Project loaded from browser storage'); setRecent(getRecentProjects?.() || []) } else showMsg('warn', 'No saved project found in browser storage') } // ── Import: preview first ────────────────────────────────────────────────── const handleFile = useCallback(async (file) => { if (!file) return const ext = file.name.split('.').pop().toLowerCase() if (!['glbstudio','json'].includes(ext)) { showMsg('err','Only .glbstudio files supported'); return } showMsg('info', `Reading "${file.name}"…`, 0) const p = await previewBundle(file) setStatus(null) if (!p.ok) { showMsg('err', `Cannot read file: ${p.error}`); return } setPreview(p) }, [previewBundle]) const handleLoadPreview = () => { if (!preview) return const result = loadBundle(preview) setPreview(null) if (result.ok) { setRecent(getRecentProjects?.() || []) showMsg('ok', `Loaded "${preview.projectName}" — ${result.modelCount} model${result.modelCount>1?'s':''}${result.embeddedCount?' (models embedded ✓)':''}`) } else { showMsg('err', 'Failed to load project') } } // ── Share link ──────────────────────────────────────────────────────────── const handleShare = () => { const shareable = models.filter(m => m.url && !m.url.startsWith('blob:') && !m.url.startsWith('data:')) if (!shareable.length) { showMsg('warn','No shareable models — local uploads cannot be shared via link'); return } const payload = { n: projectName, u: shareable.map(m => m.url), m: shareable.map(m => m.name), f: fps, t: totalFrames, l: lightingPreset, } const base = window.location.href.split('?')[0] const link = `${base}?project=${encodeURIComponent(JSON.stringify(payload))}` navigator.clipboard?.writeText(link) .then(() => showMsg('ok', `Share link copied! (${shareable.length} model${shareable.length>1?'s':''} included)`)) .catch(() => showMsg('info', link, 0)) } // Estimate bundle size const estimatedSize = models.reduce((acc, m) => { if (skipModels.has(m.id)) return acc // rough: each KB of URL ~= the actual file is fetched; use 500KB as avg GLB estimate return acc + 500_000 }, 0) return (
{/* Import preview modal */} {preview && ( setPreview(null)} /> )} {/* Project header */}
Project Name
{editName ? ( setNameVal(e.target.value)} onBlur={()=>{ setProjectName(nameVal); setEditName(false) }} onKeyDown={e=>{ if(e.key==='Enter'||e.key==='Escape'){ setProjectName(nameVal); setEditName(false) }}} autoFocus style={{ fontSize:15, fontWeight:700, width:'100%' }}/> ) : (
{projectName}
)}
{models.length} model{models.length!==1?'s':''} {kfCount} keyframe{kfCount!==1?'s':''} {cameras.length} cam{cameras.length!==1?'s':''} {duration} · {fps}fps
{/* ── Auto-save ── */}
Auto-save
{autoSave ? `Saves every 60s · Last: ${lastSaved||'not yet'}` : 'Saves to browser every 60 seconds'}
{/* Browser save */} {/* Quick export */} {/* Bundle export */}
0 ? ` · Est. ~${fmtBytes(estimatedSize)}` : ''}`} onClick={handleBundle} disabled={!hasModels || exporting} loading={exporting} badge={skipModels.size > 0 ? `${models.length - skipModels.size}/${models.length} models` : undefined} /> {/* Per-model embed toggle */} {hasModels && ( )} {showSkipUI && (
{models.map(m => { const skip = skipModels.has(m.id) const isLocal = m.url?.startsWith('blob:') || m.url?.startsWith('data:') return (
setSkipModels(prev => { const n = new Set(prev) if (n.has(m.id)) n.delete(m.id); else n.add(m.id) return n })} style={{ accentColor:'var(--accent2)', width:14, height:14 }} /> {m.name} {isLocal ? '📁 local' : '🔗 url'}
) })}
)} {/* Progress bar */} {exporting && (
{progress.msg} {progress.pct}%
)}
{/* Share link */} {/* Drop zone */}
{ e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]) }} onDragOver={e=>{ e.preventDefault(); setDragging(true) }} onDragLeave={()=>setDragging(false)} onClick={()=>fileRef.current?.click()} style={{ border:`2px dashed ${dragging?'var(--accent)':'var(--border-hi)'}`, borderRadius:'var(--radius)', padding:'22px 16px', textAlign:'center', cursor:'pointer', background: dragging ? 'rgba(79,142,255,0.06)' : 'var(--bg2)', transition:'all 0.15s', }}>
{dragging ? '📂' : '📥'}
{dragging ? 'Drop to preview & import' : 'Drop .glbstudio here'}
or click to browse · Preview shown before loading
{ handleFile(e.target.files[0]); e.target.value='' }}/>
{/* Recent projects */} {recent.length > 0 && (
{showRecent && (
{recent.map((r,i) => (
{r.type==='bundle'?'📦':'📄'}
{r.name}
{fmtDate(r.date)} · {r.models} model{r.models!==1?'s':''}
))}
)}
)} {/* Format guide */}
📋 Format guide
{[ ['📄 Quick Export', 'URLs only · small file · needs internet'], ['📦 Full Bundle', 'Models embedded · self-contained · shareable offline'], ['🔗 Share Link', 'URL only · no file · public models only'], ['💾 Browser Save', 'Instant · same device · clears with browser data'], ].map(([k,v])=>(
{k} — {v}
))}
) }