Andrew-dev1.1 / components /editor /DesignPanel.tsx
truegleai
feat: add live preview iframe to design panel
1933f79
raw
history blame
35.2 kB
'use client'
import { useState, useCallback, useMemo } from 'react'
import { Sparkles, Layers, Box, Type, Zap, ChevronRight, Check, RefreshCw } from 'lucide-react'
interface DesignPanelProps {
onCodeUpdate?: (code: { html?: string; css?: string; js?: string }) => void
}
type Category = 'animations' | 'threejs' | 'ui' | 'text'
interface Param {
label: string
key: string
type: 'range' | 'color' | 'select'
min?: number
max?: number
step?: number
default: number | string
options?: string[]
unit?: string
}
interface Preset {
id: string
name: string
description: string
category: Category
emoji: string
params: Param[]
generate: (params: Record<string, any>) => { html?: string; css?: string; js?: string }
}
const PRESETS: Preset[] = [
// ── ANIMATIONS ──────────────────────────────────────────────────────────────
{
id: 'fade-in',
name: 'Fade In',
description: 'Smooth opacity reveal on load',
category: 'animations',
emoji: '✨',
params: [
{ label: 'Duration (s)', key: 'duration', type: 'range', min: 0.1, max: 3, step: 0.1, default: 1, unit: 's' },
{ label: 'Delay (s)', key: 'delay', type: 'range', min: 0, max: 2, step: 0.1, default: 0.2, unit: 's' },
],
generate: (p) => ({
css: `.fade-in {
animation: fadeIn ${p.duration}s ease forwards;
animation-delay: ${p.delay}s;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}`,
html: `<div class="fade-in">
<h2>Fade In Element</h2>
<p>This element fades in smoothly on load.</p>
</div>`,
}),
},
{
id: 'pulse-glow',
name: 'Pulse Glow',
description: 'Rhythmic glowing border pulse',
category: 'animations',
emoji: '💫',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#667eea' },
{ label: 'Speed (s)', key: 'speed', type: 'range', min: 0.5, max: 4, step: 0.1, default: 1.5, unit: 's' },
{ label: 'Intensity (px)', key: 'blur', type: 'range', min: 5, max: 40, step: 1, default: 20, unit: 'px' },
],
generate: (p) => ({
css: `.pulse-glow {
animation: pulseGlow ${p.speed}s ease-in-out infinite;
border: 2px solid ${p.color};
border-radius: 12px;
padding: 24px;
display: inline-block;
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 ${p.blur}px ${p.color}; }
50% { box-shadow: 0 0 ${Number(p.blur) * 2}px ${p.color}, 0 0 ${Number(p.blur) * 3}px ${p.color}44; }
}`,
html: `<div class="pulse-glow">
<h3 style="color: ${p.color}; margin: 0">Pulse Glow</h3>
<p style="margin: 8px 0 0; opacity: 0.7">Glowing border element</p>
</div>`,
}),
},
{
id: 'slide-in',
name: 'Slide In',
description: 'Direction-based slide entrance',
category: 'animations',
emoji: '➡️',
params: [
{ label: 'Direction', key: 'dir', type: 'select', default: 'left', options: ['left', 'right', 'top', 'bottom'] },
{ label: 'Duration (s)', key: 'duration', type: 'range', min: 0.2, max: 2, step: 0.1, default: 0.6, unit: 's' },
{ label: 'Distance (px)', key: 'dist', type: 'range', min: 20, max: 200, step: 10, default: 60, unit: 'px' },
],
generate: (p) => {
const transforms: Record<string, string> = {
left: `translateX(-${p.dist}px)`,
right: `translateX(${p.dist}px)`,
top: `translateY(-${p.dist}px)`,
bottom: `translateY(${p.dist}px)`,
}
return {
css: `.slide-in {
animation: slideIn ${p.duration}s cubic-bezier(.22,.68,0,1.2) forwards;
opacity: 0;
}
@keyframes slideIn {
from { opacity: 0; transform: ${transforms[p.dir as string]}; }
to { opacity: 1; transform: translate(0); }
}`,
html: `<div class="slide-in">
<h2>Slide In from ${p.dir}</h2>
<p>Slides in with a smooth easing curve.</p>
</div>`,
}
},
},
{
id: 'stagger-list',
name: 'Stagger List',
description: 'Items reveal one after another',
category: 'animations',
emoji: '🎯',
params: [
{ label: 'Stagger (ms)', key: 'stagger', type: 'range', min: 50, max: 400, step: 10, default: 120, unit: 'ms' },
{ label: 'Color', key: 'color', type: 'color', default: '#8b9cff' },
],
generate: (p) => ({
css: `.stagger-item {
opacity: 0;
transform: translateX(-20px);
animation: staggerReveal 0.5s ease forwards;
}
.stagger-item:nth-child(1) { animation-delay: ${Number(p.stagger) * 0}ms; }
.stagger-item:nth-child(2) { animation-delay: ${Number(p.stagger) * 1}ms; }
.stagger-item:nth-child(3) { animation-delay: ${Number(p.stagger) * 2}ms; }
.stagger-item:nth-child(4) { animation-delay: ${Number(p.stagger) * 3}ms; }
@keyframes staggerReveal {
to { opacity: 1; transform: translateX(0); }
}
.stagger-list { list-style: none; padding: 0; }
.stagger-list li { padding: 8px 0; border-left: 3px solid ${p.color}; padding-left: 12px; margin-bottom: 8px; }`,
html: `<ul class="stagger-list">
<li class="stagger-item">First item</li>
<li class="stagger-item">Second item</li>
<li class="stagger-item">Third item</li>
<li class="stagger-item">Fourth item</li>
</ul>`,
}),
},
// ── THREE.JS ────────────────────────────────────────────────────────────────
{
id: 'particle-field',
name: 'Particle Field',
description: 'Floating 3D particle system',
category: 'threejs',
emoji: '🌌',
params: [
{ label: 'Count', key: 'count', type: 'range', min: 100, max: 2000, step: 50, default: 500 },
{ label: 'Color', key: 'color', type: 'color', default: '#667eea' },
{ label: 'Speed', key: 'speed', type: 'range', min: 0.1, max: 3, step: 0.1, default: 0.5 },
{ label: 'Size', key: 'size', type: 'range', min: 0.01, max: 0.2, step: 0.01, default: 0.05 },
],
generate: (p) => ({
html: `<canvas id="particle-canvas" style="width:100%;height:400px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
js: `(function() {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
script.onload = function() {
const canvas = document.getElementById('particle-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
camera.position.z = 3;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(${p.count} * 3);
for (let i = 0; i < ${p.count} * 3; i++) positions[i] = (Math.random() - 0.5) * 10;
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({ color: '${p.color}', size: ${p.size} });
const particles = new THREE.Points(geometry, material);
scene.add(particles);
function animate() {
requestAnimationFrame(animate);
particles.rotation.y += ${Number(p.speed) * 0.002};
particles.rotation.x += ${Number(p.speed) * 0.001};
renderer.render(scene, camera);
}
animate();
};
document.head.appendChild(script);
})();`,
}),
},
{
id: 'rotating-cube',
name: 'Rotating Cube',
description: 'Smooth 3D rotating cube',
category: 'threejs',
emoji: '🎲',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#764ba2' },
{ label: 'Speed', key: 'speed', type: 'range', min: 0.1, max: 5, step: 0.1, default: 1 },
{ label: 'Wireframe', key: 'wire', type: 'select', default: 'false', options: ['false', 'true'] },
{ label: 'Size', key: 'size', type: 'range', min: 0.5, max: 3, step: 0.1, default: 1.5 },
],
generate: (p) => ({
html: `<canvas id="cube-canvas" style="width:100%;height:360px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
js: `(function() {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
script.onload = function() {
const canvas = document.getElementById('cube-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
renderer.setClearColor(0x0a0a0a);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
camera.position.z = 4;
const geometry = new THREE.BoxGeometry(${p.size}, ${p.size}, ${p.size});
const material = new THREE.MeshPhongMaterial({ color: '${p.color}', wireframe: ${p.wire} });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += ${Number(p.speed) * 0.01};
cube.rotation.y += ${Number(p.speed) * 0.015};
renderer.render(scene, camera);
}
animate();
};
document.head.appendChild(script);
})();`,
}),
},
{
id: 'wave-plane',
name: 'Wave Plane',
description: 'Animated wave mesh surface',
category: 'threejs',
emoji: '🌊',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#00d4ff' },
{ label: 'Amplitude', key: 'amp', type: 'range', min: 0.1, max: 2, step: 0.05, default: 0.5 },
{ label: 'Frequency', key: 'freq', type: 'range', min: 0.5, max: 5, step: 0.1, default: 2 },
{ label: 'Speed', key: 'speed', type: 'range', min: 0.5, max: 5, step: 0.1, default: 2 },
],
generate: (p) => ({
html: `<canvas id="wave-canvas" style="width:100%;height:360px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
js: `(function() {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
script.onload = function() {
const canvas = document.getElementById('wave-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
renderer.setClearColor(0x0a0a0a);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
camera.position.set(0, 3, 5);
camera.lookAt(0, 0, 0);
const geo = new THREE.PlaneGeometry(10, 10, 40, 40);
const mat = new THREE.MeshBasicMaterial({ color: '${p.color}', wireframe: true });
const plane = new THREE.Mesh(geo, mat);
plane.rotation.x = -Math.PI / 4;
scene.add(plane);
let t = 0;
function animate() {
requestAnimationFrame(animate);
t += 0.01 * ${p.speed};
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i);
pos.setZ(i, Math.sin(x * ${p.freq} + t) * ${p.amp} + Math.cos(y * ${p.freq} + t) * ${p.amp} * 0.5);
}
pos.needsUpdate = true;
renderer.render(scene, camera);
}
animate();
};
document.head.appendChild(script);
})();`,
}),
},
// ── UI COMPONENTS ────────────────────────────────────────────────────────────
{
id: 'glass-card',
name: 'Glass Card',
description: 'Frosted glass morphism card',
category: 'ui',
emoji: '🪟',
params: [
{ label: 'Blur (px)', key: 'blur', type: 'range', min: 4, max: 40, step: 1, default: 16, unit: 'px' },
{ label: 'Opacity', key: 'opacity', type: 'range', min: 0.05, max: 0.4, step: 0.01, default: 0.1 },
{ label: 'Border Color', key: 'border', type: 'color', default: '#ffffff' },
{ label: 'Radius (px)', key: 'radius', type: 'range', min: 4, max: 40, step: 2, default: 20, unit: 'px' },
],
generate: (p) => ({
css: `.glass-card {
background: rgba(255,255,255,${p.opacity});
backdrop-filter: blur(${p.blur}px);
-webkit-backdrop-filter: blur(${p.blur}px);
border: 1px solid rgba(255,255,255,0.2);
border-radius: ${p.radius}px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
color: #fff;
max-width: 360px;
}`,
html: `<div style="background: linear-gradient(135deg,#667eea,#764ba2); padding: 40px; min-height: 200px; display:flex; align-items:center; justify-content:center;">
<div class="glass-card">
<h3 style="margin:0 0 8px">Glass Card</h3>
<p style="margin:0;opacity:0.8">Frosted glass morphism with backdrop blur effect.</p>
</div>
</div>`,
}),
},
{
id: 'neon-button',
name: 'Neon Button',
description: 'Glowing neon CTA button',
category: 'ui',
emoji: '⚡',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#00ff88' },
{ label: 'Glow Size (px)', key: 'glow', type: 'range', min: 5, max: 40, step: 1, default: 15, unit: 'px' },
{ label: 'Border (px)', key: 'border', type: 'range', min: 1, max: 4, step: 1, default: 2, unit: 'px' },
],
generate: (p) => ({
css: `.neon-btn {
background: transparent;
color: ${p.color};
border: ${p.border}px solid ${p.color};
padding: 14px 32px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s ease;
box-shadow: 0 0 ${p.glow}px ${p.color}88, inset 0 0 ${p.glow}px ${p.color}22;
text-shadow: 0 0 8px ${p.color};
}
.neon-btn:hover {
background: ${p.color}22;
box-shadow: 0 0 ${Number(p.glow) * 2}px ${p.color}, inset 0 0 ${p.glow}px ${p.color}44;
}`,
html: `<div style="background:#0a0a0a;padding:40px;display:flex;justify-content:center;">
<button class="neon-btn">Click Me</button>
</div>`,
}),
},
{
id: 'gradient-card',
name: 'Gradient Card',
description: 'Animated gradient background card',
category: 'ui',
emoji: '🎨',
params: [
{ label: 'Color 1', key: 'c1', type: 'color', default: '#667eea' },
{ label: 'Color 2', key: 'c2', type: 'color', default: '#764ba2' },
{ label: 'Color 3', key: 'c3', type: 'color', default: '#f64f59' },
{ label: 'Speed (s)', key: 'speed', type: 'range', min: 2, max: 12, step: 0.5, default: 6, unit: 's' },
],
generate: (p) => ({
css: `.gradient-card {
background: linear-gradient(270deg, ${p.c1}, ${p.c2}, ${p.c3});
background-size: 400% 400%;
animation: gradientShift ${p.speed}s ease infinite;
border-radius: 20px;
padding: 40px;
color: white;
max-width: 400px;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}`,
html: `<div class="gradient-card">
<h2 style="margin:0 0 12px">Gradient Card</h2>
<p style="margin:0;opacity:0.9">Animated flowing gradient background that shifts between colors.</p>
</div>`,
}),
},
{
id: 'progress-bar',
name: 'Animated Progress',
description: 'Smooth animated progress bar',
category: 'ui',
emoji: '📊',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#667eea' },
{ label: 'Progress (%)', key: 'pct', type: 'range', min: 5, max: 100, step: 1, default: 75 },
{ label: 'Height (px)', key: 'h', type: 'range', min: 4, max: 24, step: 2, default: 10, unit: 'px' },
{ label: 'Duration (s)', key: 'dur', type: 'range', min: 0.5, max: 3, step: 0.1, default: 1.2, unit: 's' },
],
generate: (p) => ({
css: `.progress-track {
background: rgba(255,255,255,0.1);
border-radius: 999px;
height: ${p.h}px;
overflow: hidden;
width: 100%;
max-width: 400px;
}
.progress-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, ${p.color}88, ${p.color});
border-radius: 999px;
animation: fillProgress ${p.dur}s cubic-bezier(.4,0,.2,1) forwards;
box-shadow: 0 0 12px ${p.color}88;
}
@keyframes fillProgress {
to { width: ${p.pct}%; }
}`,
html: `<div style="padding:32px">
<p style="margin:0 0 8px;font-size:.85rem;opacity:.6">Loading... ${p.pct}%</p>
<div class="progress-track">
<div class="progress-fill"></div>
</div>
</div>`,
}),
},
// ── TEXT EFFECTS ─────────────────────────────────────────────────────────────
{
id: 'typewriter',
name: 'Typewriter',
description: 'Character-by-character typing effect',
category: 'text',
emoji: '⌨️',
params: [
{ label: 'Speed (ms)', key: 'speed', type: 'range', min: 20, max: 300, step: 10, default: 80, unit: 'ms' },
{ label: 'Color', key: 'color', type: 'color', default: '#8b9cff' },
{ label: 'Cursor Color', key: 'cursor', type: 'color', default: '#667eea' },
],
generate: (p) => ({
css: `.typewriter-text {
color: ${p.color};
font-size: 1.5rem;
font-weight: 600;
border-right: 3px solid ${p.cursor};
white-space: nowrap;
overflow: hidden;
display: inline-block;
animation: blink 0.75s step-end infinite;
}
@keyframes blink { 0%,100% { border-color: ${p.cursor}; } 50% { border-color: transparent; } }`,
html: `<div style="padding:32px">
<div id="tw-el" class="typewriter-text"></div>
</div>`,
js: `(function(){
const el = document.getElementById('tw-el');
const text = 'Hello, World! 👋';
let i = 0;
function type() {
if (i < text.length) {
el.textContent += text[i++];
setTimeout(type, ${p.speed});
}
}
type();
})();`,
}),
},
{
id: 'glitch-text',
name: 'Glitch Text',
description: 'Cyberpunk-style glitch distortion',
category: 'text',
emoji: '👾',
params: [
{ label: 'Color', key: 'color', type: 'color', default: '#00ff88' },
{ label: 'Speed (s)', key: 'speed', type: 'range', min: 0.5, max: 5, step: 0.1, default: 1.5, unit: 's' },
{ label: 'Intensity (px)', key: 'dist', type: 'range', min: 2, max: 20, step: 1, default: 6, unit: 'px' },
],
generate: (p) => ({
css: `.glitch {
font-size: 3rem;
font-weight: 900;
color: ${p.color};
position: relative;
display: inline-block;
text-transform: uppercase;
letter-spacing: 4px;
}
.glitch::before, .glitch::after {
content: attr(data-text);
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
.glitch::before {
color: #ff0044;
animation: glitchTop ${p.speed}s infinite;
clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
}
.glitch::after {
color: #00eeff;
animation: glitchBot ${p.speed}s infinite;
clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
}
@keyframes glitchTop {
0%,90%,100% { transform: translate(0); }
92% { transform: translate(-${p.dist}px, 1px); }
94% { transform: translate(${p.dist}px, -1px); }
}
@keyframes glitchBot {
0%,90%,100% { transform: translate(0); }
92% { transform: translate(${p.dist}px, 1px); }
94% { transform: translate(-${p.dist}px, -1px); }
}`,
html: `<div style="background:#0a0a0a;padding:48px;display:flex;justify-content:center;">
<span class="glitch" data-text="GLITCH">GLITCH</span>
</div>`,
}),
},
{
id: 'gradient-text',
name: 'Gradient Text',
description: 'Animated gradient flowing through text',
category: 'text',
emoji: '🌈',
params: [
{ label: 'Color 1', key: 'c1', type: 'color', default: '#667eea' },
{ label: 'Color 2', key: 'c2', type: 'color', default: '#f64f59' },
{ label: 'Color 3', key: 'c3', type: 'color', default: '#ffd700' },
{ label: 'Speed (s)', key: 'speed', type: 'range', min: 1, max: 8, step: 0.5, default: 3, unit: 's' },
],
generate: (p) => ({
css: `.gradient-text {
font-size: 3rem;
font-weight: 900;
background: linear-gradient(90deg, ${p.c1}, ${p.c2}, ${p.c3}, ${p.c1});
background-size: 300% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: textShift ${p.speed}s linear infinite;
display: inline-block;
}
@keyframes textShift {
0% { background-position: 0% center; }
100% { background-position: 300% center; }
}`,
html: `<div style="background:#0a0a0a;padding:48px;text-align:center;">
<span class="gradient-text">Beautiful Text</span>
</div>`,
}),
},
{
id: 'split-reveal',
name: 'Split Reveal',
description: 'Words reveal with split-clip animation',
category: 'text',
emoji: '✂️',
params: [
{ label: 'Duration (s)', key: 'dur', type: 'range', min: 0.3, max: 1.5, step: 0.05, default: 0.7, unit: 's' },
{ label: 'Stagger (ms)', key: 'stagger', type: 'range', min: 50, max: 400, step: 25, default: 150, unit: 'ms' },
{ label: 'Color', key: 'color', type: 'color', default: '#ffffff' },
],
generate: (p) => ({
css: `.split-word {
display: inline-block;
overflow: hidden;
vertical-align: top;
margin-right: 0.25em;
}
.split-inner {
display: inline-block;
transform: translateY(110%);
animation: splitReveal ${p.dur}s cubic-bezier(.16,1,.3,1) forwards;
color: ${p.color};
font-size: 2.5rem;
font-weight: 800;
}
.split-word:nth-child(1) .split-inner { animation-delay: 0ms; }
.split-word:nth-child(2) .split-inner { animation-delay: ${p.stagger}ms; }
.split-word:nth-child(3) .split-inner { animation-delay: ${Number(p.stagger) * 2}ms; }
.split-word:nth-child(4) .split-inner { animation-delay: ${Number(p.stagger) * 3}ms; }
@keyframes splitReveal { to { transform: translateY(0); } }`,
html: `<div style="padding:40px">
<div>
<span class="split-word"><span class="split-inner">Hello</span></span>
<span class="split-word"><span class="split-inner">Beautiful</span></span>
<span class="split-word"><span class="split-inner">World</span></span>
<span class="split-word"><span class="split-inner">✨</span></span>
</div>
</div>`,
}),
},
]
const CATEGORIES: { id: Category; label: string; icon: React.ReactNode }[] = [
{ id: 'animations', label: 'Animations', icon: <Zap className="w-4 h-4" /> },
{ id: 'threejs', label: '3D / Three.js', icon: <Box className="w-4 h-4" /> },
{ id: 'ui', label: 'UI Components', icon: <Layers className="w-4 h-4" /> },
{ id: 'text', label: 'Text Effects', icon: <Type className="w-4 h-4" /> },
]
// Build a full srcdoc HTML string from generated code
function buildPreviewDoc(code: { html?: string; css?: string; js?: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #0a0a0a; color: #fff; font-family: system-ui, sans-serif; }
${code.css || ''}
</style>
</head>
<body>
${code.html || ''}
<script>${code.js || ''}<\/script>
</body>
</html>`
}
export function DesignPanel({ onCodeUpdate }: DesignPanelProps) {
const [category, setCategory] = useState<Category>('animations')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [paramValues, setParamValues] = useState<Record<string, Record<string, any>>>({})
const [applied, setApplied] = useState<string | null>(null)
const [previewKey, setPreviewKey] = useState(0)
const visiblePresets = PRESETS.filter(p => p.category === category)
const getParam = useCallback((presetId: string, key: string, defaultVal: any) => {
return paramValues[presetId]?.[key] ?? defaultVal
}, [paramValues])
const setParam = useCallback((presetId: string, key: string, value: any) => {
setParamValues(prev => ({
...prev,
[presetId]: { ...(prev[presetId] || {}), [key]: value }
}))
}, [])
const selectedPreset = PRESETS.find(p => p.id === selectedId)
// Resolve current params for the selected preset
const resolvedParams = useMemo(() => {
if (!selectedPreset) return {}
const out: Record<string, any> = {}
selectedPreset.params.forEach(p => {
out[p.key] = getParam(selectedPreset.id, p.key, p.default)
})
return out
}, [selectedPreset, paramValues, getParam])
// Generate live preview srcdoc — updates any time params change
const previewDoc = useMemo(() => {
if (!selectedPreset) return ''
const code = selectedPreset.generate(resolvedParams)
return buildPreviewDoc(code)
}, [selectedPreset, resolvedParams])
const handleApply = useCallback((preset: Preset) => {
const code = preset.generate(resolvedParams)
if (onCodeUpdate) onCodeUpdate(code)
setApplied(preset.id)
setTimeout(() => setApplied(null), 1800)
}, [resolvedParams, onCodeUpdate])
return (
<div className="h-full flex flex-col overflow-hidden" style={{ background: 'rgba(8,8,12,0.95)' }}>
{/* Header */}
<div className="px-4 py-3 flex items-center gap-2 flex-shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<Sparkles className="w-4 h-4" style={{ color: '#8b9cff' }} />
<span className="text-sm font-semibold" style={{ color: '#8b9cff' }}>Design Library</span>
<span className="ml-auto text-xs" style={{ color: 'rgba(255,255,255,0.3)' }}>ReactBits · Three.js</span>
</div>
{/* Category Pills */}
<div className="flex gap-2 px-4 py-3 flex-shrink-0 overflow-x-auto" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
{CATEGORIES.map(cat => (
<button
key={cat.id}
onClick={() => { setCategory(cat.id); setSelectedId(null) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all"
style={category === cat.id ? {
background: 'rgba(102,126,234,0.25)',
color: '#8b9cff',
border: '1px solid rgba(102,126,234,0.4)',
} : {
background: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.5)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
{cat.icon}
{cat.label}
</button>
))}
</div>
<div className="flex-1 overflow-hidden flex">
{/* Preset List */}
<div className="overflow-y-auto flex-shrink-0" style={{
width: selectedId ? '38%' : '100%',
borderRight: selectedId ? '1px solid rgba(255,255,255,0.08)' : 'none',
transition: 'width 0.25s ease',
}}>
<div className="p-3 grid gap-2" style={{ gridTemplateColumns: selectedId ? '1fr' : 'repeat(2, 1fr)' }}>
{visiblePresets.map(preset => (
<button
key={preset.id}
onClick={() => setSelectedId(selectedId === preset.id ? null : preset.id)}
className="text-left rounded-xl p-3 transition-all"
style={selectedId === preset.id ? {
background: 'rgba(102,126,234,0.15)',
border: '1px solid rgba(102,126,234,0.4)',
} : {
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
}}
>
<div className="text-xl mb-1">{preset.emoji}</div>
<div className="text-xs font-semibold mb-0.5" style={{ color: selectedId === preset.id ? '#8b9cff' : '#fff' }}>
{preset.name}
</div>
{!selectedId && (
<div className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>
{preset.description}
</div>
)}
{selectedId === preset.id && (
<ChevronRight className="w-3 h-3 mt-1" style={{ color: '#8b9cff' }} />
)}
</button>
))}
</div>
</div>
{/* Right Panel: Live Preview + Params */}
{selectedPreset && (
<div className="flex-1 overflow-hidden flex flex-col">
{/* ── LIVE PREVIEW ── */}
<div className="flex-shrink-0" style={{
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(0,0,0,0.4)',
}}>
{/* Preview header */}
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.4)' }}>
Live Preview
</span>
<button
onClick={() => setPreviewKey(k => k + 1)}
className="flex items-center gap-1 text-xs px-2 py-1 rounded transition-all hover:bg-white/10"
style={{ color: 'rgba(255,255,255,0.4)' }}
title="Replay animation"
>
<RefreshCw className="w-3 h-3" />
Replay
</button>
</div>
{/* iframe */}
<iframe
key={previewKey}
srcDoc={previewDoc}
sandbox="allow-scripts"
style={{
width: '100%',
height: '200px',
border: 'none',
display: 'block',
background: '#0a0a0a',
}}
title="Live Preview"
/>
</div>
{/* ── PARAMS + APPLY ── */}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-4">
<div>
<div className="text-sm font-bold mb-0.5" style={{ color: '#fff' }}>
{selectedPreset.emoji} {selectedPreset.name}
</div>
<div className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>
{selectedPreset.description}
</div>
</div>
{/* Params */}
<div className="flex flex-col gap-4">
{selectedPreset.params.map(param => {
const val = getParam(selectedPreset.id, param.key, param.default)
return (
<div key={param.key}>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.6)' }}>
{param.label}
</label>
{param.type === 'range' && (
<span className="text-xs font-mono px-2 py-0.5 rounded" style={{
background: 'rgba(102,126,234,0.15)',
color: '#8b9cff',
}}>
{Number(val).toFixed(param.step && param.step < 0.1 ? 2 : param.step && param.step < 1 ? 1 : 0)}{param.unit || ''}
</span>
)}
</div>
{param.type === 'range' && (
<input
type="range"
min={param.min}
max={param.max}
step={param.step}
value={val}
onChange={e => setParam(selectedPreset.id, param.key, parseFloat(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #667eea ${((val - (param.min||0)) / ((param.max||1) - (param.min||0))) * 100}%, rgba(255,255,255,0.1) 0%)`,
outline: 'none',
accentColor: '#667eea',
}}
/>
)}
{param.type === 'color' && (
<div className="flex items-center gap-2">
<input
type="color"
value={val}
onChange={e => setParam(selectedPreset.id, param.key, e.target.value)}
className="rounded cursor-pointer border-0"
style={{ width: 40, height: 32, background: 'none' }}
/>
<span className="text-xs font-mono" style={{ color: 'rgba(255,255,255,0.4)' }}>{val}</span>
</div>
)}
{param.type === 'select' && (
<select
value={val}
onChange={e => setParam(selectedPreset.id, param.key, e.target.value)}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
color: '#fff',
outline: 'none',
}}
>
{param.options?.map(opt => (
<option key={opt} value={opt} style={{ background: '#1a1a2e' }}>{opt}</option>
))}
</select>
)}
</div>
)
})}
</div>
{/* Apply Button */}
<button
onClick={() => handleApply(selectedPreset)}
className="w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all mt-2"
style={applied === selectedPreset.id ? {
background: 'rgba(0,200,100,0.2)',
border: '1px solid rgba(0,200,100,0.4)',
color: '#00c864',
} : {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
boxShadow: '0 8px 24px rgba(102,126,234,0.3)',
}}
>
{applied === selectedPreset.id
? <><Check className="w-4 h-4" /> Applied!</>
: <><Sparkles className="w-4 h-4" /> Apply to Project</>
}
</button>
<p className="text-center text-xs" style={{ color: 'rgba(255,255,255,0.25)' }}>
Injects into HTML, CSS &amp; JS tabs
</p>
</div>
</div>
)}
</div>
</div>
)
}