truegleai commited on
Commit
6c17077
·
1 Parent(s): 9ea2467

feat: add Design tab with preset library, sliders, and apply functionality

Browse files
components/editor/DesignPanel.tsx ADDED
@@ -0,0 +1,836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import { Sparkles, Layers, Box, Type, Zap, ChevronRight, Check } from 'lucide-react'
5
+
6
+ interface DesignPanelProps {
7
+ onCodeUpdate?: (code: { html?: string; css?: string; js?: string }) => void
8
+ }
9
+
10
+ type Category = 'animations' | 'threejs' | 'ui' | 'text'
11
+
12
+ interface Param {
13
+ label: string
14
+ key: string
15
+ type: 'range' | 'color' | 'select'
16
+ min?: number
17
+ max?: number
18
+ step?: number
19
+ default: number | string
20
+ options?: string[]
21
+ unit?: string
22
+ }
23
+
24
+ interface Preset {
25
+ id: string
26
+ name: string
27
+ description: string
28
+ category: Category
29
+ emoji: string
30
+ params: Param[]
31
+ generate: (params: Record<string, any>) => { html?: string; css?: string; js?: string }
32
+ }
33
+
34
+ const PRESETS: Preset[] = [
35
+ // ── ANIMATIONS ──────────────────────────────────────────────────────────────
36
+ {
37
+ id: 'fade-in',
38
+ name: 'Fade In',
39
+ description: 'Smooth opacity reveal on load',
40
+ category: 'animations',
41
+ emoji: '✨',
42
+ params: [
43
+ { label: 'Duration (s)', key: 'duration', type: 'range', min: 0.1, max: 3, step: 0.1, default: 1, unit: 's' },
44
+ { label: 'Delay (s)', key: 'delay', type: 'range', min: 0, max: 2, step: 0.1, default: 0.2, unit: 's' },
45
+ ],
46
+ generate: (p) => ({
47
+ css: `.fade-in {
48
+ animation: fadeIn ${p.duration}s ease forwards;
49
+ animation-delay: ${p.delay}s;
50
+ opacity: 0;
51
+ }
52
+ @keyframes fadeIn {
53
+ from { opacity: 0; transform: translateY(20px); }
54
+ to { opacity: 1; transform: translateY(0); }
55
+ }`,
56
+ html: `<div class="fade-in">
57
+ <h2>Fade In Element</h2>
58
+ <p>This element fades in smoothly on load.</p>
59
+ </div>`,
60
+ }),
61
+ },
62
+ {
63
+ id: 'pulse-glow',
64
+ name: 'Pulse Glow',
65
+ description: 'Rhythmic glowing border pulse',
66
+ category: 'animations',
67
+ emoji: '💫',
68
+ params: [
69
+ { label: 'Color', key: 'color', type: 'color', default: '#667eea' },
70
+ { label: 'Speed (s)', key: 'speed', type: 'range', min: 0.5, max: 4, step: 0.1, default: 1.5, unit: 's' },
71
+ { label: 'Intensity (px)', key: 'blur', type: 'range', min: 5, max: 40, step: 1, default: 20, unit: 'px' },
72
+ ],
73
+ generate: (p) => ({
74
+ css: `.pulse-glow {
75
+ animation: pulseGlow ${p.speed}s ease-in-out infinite;
76
+ border: 2px solid ${p.color};
77
+ border-radius: 12px;
78
+ padding: 24px;
79
+ display: inline-block;
80
+ }
81
+ @keyframes pulseGlow {
82
+ 0%, 100% { box-shadow: 0 0 ${p.blur}px ${p.color}; }
83
+ 50% { box-shadow: 0 0 ${Number(p.blur) * 2}px ${p.color}, 0 0 ${Number(p.blur) * 3}px ${p.color}44; }
84
+ }`,
85
+ html: `<div class="pulse-glow">
86
+ <h3 style="color: ${p.color}; margin: 0">Pulse Glow</h3>
87
+ <p style="margin: 8px 0 0; opacity: 0.7">Glowing border element</p>
88
+ </div>`,
89
+ }),
90
+ },
91
+ {
92
+ id: 'slide-in',
93
+ name: 'Slide In',
94
+ description: 'Direction-based slide entrance',
95
+ category: 'animations',
96
+ emoji: '➡️',
97
+ params: [
98
+ { label: 'Direction', key: 'dir', type: 'select', default: 'left', options: ['left', 'right', 'top', 'bottom'] },
99
+ { label: 'Duration (s)', key: 'duration', type: 'range', min: 0.2, max: 2, step: 0.1, default: 0.6, unit: 's' },
100
+ { label: 'Distance (px)', key: 'dist', type: 'range', min: 20, max: 200, step: 10, default: 60, unit: 'px' },
101
+ ],
102
+ generate: (p) => {
103
+ const transforms: Record<string, string> = {
104
+ left: `translateX(-${p.dist}px)`,
105
+ right: `translateX(${p.dist}px)`,
106
+ top: `translateY(-${p.dist}px)`,
107
+ bottom: `translateY(${p.dist}px)`,
108
+ }
109
+ return {
110
+ css: `.slide-in {
111
+ animation: slideIn ${p.duration}s cubic-bezier(.22,.68,0,1.2) forwards;
112
+ opacity: 0;
113
+ }
114
+ @keyframes slideIn {
115
+ from { opacity: 0; transform: ${transforms[p.dir as string]}; }
116
+ to { opacity: 1; transform: translate(0); }
117
+ }`,
118
+ html: `<div class="slide-in">
119
+ <h2>Slide In from ${p.dir}</h2>
120
+ <p>Slides in with a smooth easing curve.</p>
121
+ </div>`,
122
+ }
123
+ },
124
+ },
125
+ {
126
+ id: 'stagger-list',
127
+ name: 'Stagger List',
128
+ description: 'Items reveal one after another',
129
+ category: 'animations',
130
+ emoji: '🎯',
131
+ params: [
132
+ { label: 'Stagger (ms)', key: 'stagger', type: 'range', min: 50, max: 400, step: 10, default: 120, unit: 'ms' },
133
+ { label: 'Color', key: 'color', type: 'color', default: '#8b9cff' },
134
+ ],
135
+ generate: (p) => ({
136
+ css: `.stagger-item {
137
+ opacity: 0;
138
+ transform: translateX(-20px);
139
+ animation: staggerReveal 0.5s ease forwards;
140
+ }
141
+ .stagger-item:nth-child(1) { animation-delay: ${Number(p.stagger) * 0}ms; }
142
+ .stagger-item:nth-child(2) { animation-delay: ${Number(p.stagger) * 1}ms; }
143
+ .stagger-item:nth-child(3) { animation-delay: ${Number(p.stagger) * 2}ms; }
144
+ .stagger-item:nth-child(4) { animation-delay: ${Number(p.stagger) * 3}ms; }
145
+ @keyframes staggerReveal {
146
+ to { opacity: 1; transform: translateX(0); }
147
+ }
148
+ .stagger-list { list-style: none; padding: 0; }
149
+ .stagger-list li { padding: 8px 0; border-left: 3px solid ${p.color}; padding-left: 12px; margin-bottom: 8px; }`,
150
+ html: `<ul class="stagger-list">
151
+ <li class="stagger-item">First item</li>
152
+ <li class="stagger-item">Second item</li>
153
+ <li class="stagger-item">Third item</li>
154
+ <li class="stagger-item">Fourth item</li>
155
+ </ul>`,
156
+ }),
157
+ },
158
+
159
+ // ── THREE.JS ────────────────────────────────────────────────────────────────
160
+ {
161
+ id: 'particle-field',
162
+ name: 'Particle Field',
163
+ description: 'Floating 3D particle system',
164
+ category: 'threejs',
165
+ emoji: '🌌',
166
+ params: [
167
+ { label: 'Count', key: 'count', type: 'range', min: 100, max: 2000, step: 50, default: 500 },
168
+ { label: 'Color', key: 'color', type: 'color', default: '#667eea' },
169
+ { label: 'Speed', key: 'speed', type: 'range', min: 0.1, max: 3, step: 0.1, default: 0.5 },
170
+ { label: 'Size', key: 'size', type: 'range', min: 0.01, max: 0.2, step: 0.01, default: 0.05 },
171
+ ],
172
+ generate: (p) => ({
173
+ html: `<canvas id="particle-canvas" style="width:100%;height:400px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
174
+ js: `(function() {
175
+ const script = document.createElement('script');
176
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
177
+ script.onload = function() {
178
+ const canvas = document.getElementById('particle-canvas');
179
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
180
+ renderer.setSize(canvas.clientWidth, canvas.clientHeight);
181
+ const scene = new THREE.Scene();
182
+ const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
183
+ camera.position.z = 3;
184
+ const geometry = new THREE.BufferGeometry();
185
+ const positions = new Float32Array(${p.count} * 3);
186
+ for (let i = 0; i < ${p.count} * 3; i++) positions[i] = (Math.random() - 0.5) * 10;
187
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
188
+ const material = new THREE.PointsMaterial({ color: '${p.color}', size: ${p.size} });
189
+ const particles = new THREE.Points(geometry, material);
190
+ scene.add(particles);
191
+ function animate() {
192
+ requestAnimationFrame(animate);
193
+ particles.rotation.y += ${Number(p.speed) * 0.002};
194
+ particles.rotation.x += ${Number(p.speed) * 0.001};
195
+ renderer.render(scene, camera);
196
+ }
197
+ animate();
198
+ };
199
+ document.head.appendChild(script);
200
+ })();`,
201
+ }),
202
+ },
203
+ {
204
+ id: 'rotating-cube',
205
+ name: 'Rotating Cube',
206
+ description: 'Smooth 3D rotating cube',
207
+ category: 'threejs',
208
+ emoji: '🎲',
209
+ params: [
210
+ { label: 'Color', key: 'color', type: 'color', default: '#764ba2' },
211
+ { label: 'Speed', key: 'speed', type: 'range', min: 0.1, max: 5, step: 0.1, default: 1 },
212
+ { label: 'Wireframe', key: 'wire', type: 'select', default: 'false', options: ['false', 'true'] },
213
+ { label: 'Size', key: 'size', type: 'range', min: 0.5, max: 3, step: 0.1, default: 1.5 },
214
+ ],
215
+ generate: (p) => ({
216
+ html: `<canvas id="cube-canvas" style="width:100%;height:360px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
217
+ js: `(function() {
218
+ const script = document.createElement('script');
219
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
220
+ script.onload = function() {
221
+ const canvas = document.getElementById('cube-canvas');
222
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
223
+ renderer.setSize(canvas.clientWidth, canvas.clientHeight);
224
+ renderer.setClearColor(0x0a0a0a);
225
+ const scene = new THREE.Scene();
226
+ const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
227
+ camera.position.z = 4;
228
+ const geometry = new THREE.BoxGeometry(${p.size}, ${p.size}, ${p.size});
229
+ const material = new THREE.MeshPhongMaterial({ color: '${p.color}', wireframe: ${p.wire} });
230
+ const cube = new THREE.Mesh(geometry, material);
231
+ scene.add(cube);
232
+ scene.add(new THREE.AmbientLight(0xffffff, 0.4));
233
+ const light = new THREE.DirectionalLight(0xffffff, 1);
234
+ light.position.set(5, 5, 5);
235
+ scene.add(light);
236
+ function animate() {
237
+ requestAnimationFrame(animate);
238
+ cube.rotation.x += ${Number(p.speed) * 0.01};
239
+ cube.rotation.y += ${Number(p.speed) * 0.015};
240
+ renderer.render(scene, camera);
241
+ }
242
+ animate();
243
+ };
244
+ document.head.appendChild(script);
245
+ })();`,
246
+ }),
247
+ },
248
+ {
249
+ id: 'wave-plane',
250
+ name: 'Wave Plane',
251
+ description: 'Animated wave mesh surface',
252
+ category: 'threejs',
253
+ emoji: '🌊',
254
+ params: [
255
+ { label: 'Color', key: 'color', type: 'color', default: '#00d4ff' },
256
+ { label: 'Amplitude', key: 'amp', type: 'range', min: 0.1, max: 2, step: 0.05, default: 0.5 },
257
+ { label: 'Frequency', key: 'freq', type: 'range', min: 0.5, max: 5, step: 0.1, default: 2 },
258
+ { label: 'Speed', key: 'speed', type: 'range', min: 0.5, max: 5, step: 0.1, default: 2 },
259
+ ],
260
+ generate: (p) => ({
261
+ html: `<canvas id="wave-canvas" style="width:100%;height:360px;display:block;background:#0a0a0a;border-radius:12px;"></canvas>`,
262
+ js: `(function() {
263
+ const script = document.createElement('script');
264
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
265
+ script.onload = function() {
266
+ const canvas = document.getElementById('wave-canvas');
267
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
268
+ renderer.setSize(canvas.clientWidth, canvas.clientHeight);
269
+ renderer.setClearColor(0x0a0a0a);
270
+ const scene = new THREE.Scene();
271
+ const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 100);
272
+ camera.position.set(0, 3, 5);
273
+ camera.lookAt(0, 0, 0);
274
+ const geo = new THREE.PlaneGeometry(10, 10, 40, 40);
275
+ const mat = new THREE.MeshWireframeBasicMaterial
276
+ ? new THREE.MeshBasicMaterial({ color: '${p.color}', wireframe: true })
277
+ : new THREE.MeshBasicMaterial({ color: '${p.color}', wireframe: true });
278
+ const plane = new THREE.Mesh(geo, mat);
279
+ plane.rotation.x = -Math.PI / 4;
280
+ scene.add(plane);
281
+ let t = 0;
282
+ function animate() {
283
+ requestAnimationFrame(animate);
284
+ t += 0.01 * ${p.speed};
285
+ const pos = geo.attributes.position;
286
+ for (let i = 0; i < pos.count; i++) {
287
+ const x = pos.getX(i), y = pos.getY(i);
288
+ pos.setZ(i, Math.sin(x * ${p.freq} + t) * ${p.amp} + Math.cos(y * ${p.freq} + t) * ${p.amp} * 0.5);
289
+ }
290
+ pos.needsUpdate = true;
291
+ renderer.render(scene, camera);
292
+ }
293
+ animate();
294
+ };
295
+ document.head.appendChild(script);
296
+ })();`,
297
+ }),
298
+ },
299
+
300
+ // ── UI COMPONENTS ────────────────────────────────────────────────────────────
301
+ {
302
+ id: 'glass-card',
303
+ name: 'Glass Card',
304
+ description: 'Frosted glass morphism card',
305
+ category: 'ui',
306
+ emoji: '🪟',
307
+ params: [
308
+ { label: 'Blur (px)', key: 'blur', type: 'range', min: 4, max: 40, step: 1, default: 16, unit: 'px' },
309
+ { label: 'Opacity', key: 'opacity', type: 'range', min: 0.05, max: 0.4, step: 0.01, default: 0.1 },
310
+ { label: 'Border Color', key: 'border', type: 'color', default: '#ffffff' },
311
+ { label: 'Radius (px)', key: 'radius', type: 'range', min: 4, max: 40, step: 2, default: 20, unit: 'px' },
312
+ ],
313
+ generate: (p) => ({
314
+ css: `.glass-card {
315
+ background: rgba(255,255,255,${p.opacity});
316
+ backdrop-filter: blur(${p.blur}px);
317
+ -webkit-backdrop-filter: blur(${p.blur}px);
318
+ border: 1px solid rgba(255,255,255,0.2);
319
+ border-radius: ${p.radius}px;
320
+ padding: 32px;
321
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
322
+ color: #fff;
323
+ max-width: 360px;
324
+ }`,
325
+ html: `<div style="background: linear-gradient(135deg,#667eea,#764ba2); padding: 40px; min-height: 200px; display:flex; align-items:center; justify-content:center;">
326
+ <div class="glass-card">
327
+ <h3 style="margin:0 0 8px">Glass Card</h3>
328
+ <p style="margin:0;opacity:0.8">Frosted glass morphism with backdrop blur effect.</p>
329
+ </div>
330
+ </div>`,
331
+ }),
332
+ },
333
+ {
334
+ id: 'neon-button',
335
+ name: 'Neon Button',
336
+ description: 'Glowing neon CTA button',
337
+ category: 'ui',
338
+ emoji: '⚡',
339
+ params: [
340
+ { label: 'Color', key: 'color', type: 'color', default: '#00ff88' },
341
+ { label: 'Glow Size (px)', key: 'glow', type: 'range', min: 5, max: 40, step: 1, default: 15, unit: 'px' },
342
+ { label: 'Border (px)', key: 'border', type: 'range', min: 1, max: 4, step: 1, default: 2, unit: 'px' },
343
+ ],
344
+ generate: (p) => ({
345
+ css: `.neon-btn {
346
+ background: transparent;
347
+ color: ${p.color};
348
+ border: ${p.border}px solid ${p.color};
349
+ padding: 14px 32px;
350
+ font-size: 1rem;
351
+ font-weight: 600;
352
+ letter-spacing: 2px;
353
+ text-transform: uppercase;
354
+ cursor: pointer;
355
+ border-radius: 4px;
356
+ transition: all 0.3s ease;
357
+ box-shadow: 0 0 ${p.glow}px ${p.color}88, inset 0 0 ${p.glow}px ${p.color}22;
358
+ text-shadow: 0 0 8px ${p.color};
359
+ }
360
+ .neon-btn:hover {
361
+ background: ${p.color}22;
362
+ box-shadow: 0 0 ${Number(p.glow) * 2}px ${p.color}, inset 0 0 ${p.glow}px ${p.color}44;
363
+ }`,
364
+ html: `<div style="background:#0a0a0a;padding:40px;display:flex;justify-content:center;">
365
+ <button class="neon-btn">Click Me</button>
366
+ </div>`,
367
+ }),
368
+ },
369
+ {
370
+ id: 'gradient-card',
371
+ name: 'Gradient Card',
372
+ description: 'Animated gradient background card',
373
+ category: 'ui',
374
+ emoji: '🎨',
375
+ params: [
376
+ { label: 'Color 1', key: 'c1', type: 'color', default: '#667eea' },
377
+ { label: 'Color 2', key: 'c2', type: 'color', default: '#764ba2' },
378
+ { label: 'Color 3', key: 'c3', type: 'color', default: '#f64f59' },
379
+ { label: 'Speed (s)', key: 'speed', type: 'range', min: 2, max: 12, step: 0.5, default: 6, unit: 's' },
380
+ ],
381
+ generate: (p) => ({
382
+ css: `.gradient-card {
383
+ background: linear-gradient(270deg, ${p.c1}, ${p.c2}, ${p.c3});
384
+ background-size: 400% 400%;
385
+ animation: gradientShift ${p.speed}s ease infinite;
386
+ border-radius: 20px;
387
+ padding: 40px;
388
+ color: white;
389
+ max-width: 400px;
390
+ }
391
+ @keyframes gradientShift {
392
+ 0% { background-position: 0% 50%; }
393
+ 50% { background-position: 100% 50%; }
394
+ 100% { background-position: 0% 50%; }
395
+ }`,
396
+ html: `<div class="gradient-card">
397
+ <h2 style="margin:0 0 12px">Gradient Card</h2>
398
+ <p style="margin:0;opacity:0.9">Animated flowing gradient background that shifts between colors.</p>
399
+ </div>`,
400
+ }),
401
+ },
402
+ {
403
+ id: 'progress-bar',
404
+ name: 'Animated Progress',
405
+ description: 'Smooth animated progress bar',
406
+ category: 'ui',
407
+ emoji: '📊',
408
+ params: [
409
+ { label: 'Color', key: 'color', type: 'color', default: '#667eea' },
410
+ { label: 'Progress (%)', key: 'pct', type: 'range', min: 5, max: 100, step: 1, default: 75 },
411
+ { label: 'Height (px)', key: 'h', type: 'range', min: 4, max: 24, step: 2, default: 10, unit: 'px' },
412
+ { label: 'Duration (s)', key: 'dur', type: 'range', min: 0.5, max: 3, step: 0.1, default: 1.2, unit: 's' },
413
+ ],
414
+ generate: (p) => ({
415
+ css: `.progress-track {
416
+ background: rgba(255,255,255,0.1);
417
+ border-radius: 999px;
418
+ height: ${p.h}px;
419
+ overflow: hidden;
420
+ width: 100%;
421
+ max-width: 400px;
422
+ }
423
+ .progress-fill {
424
+ height: 100%;
425
+ width: 0;
426
+ background: linear-gradient(90deg, ${p.color}88, ${p.color});
427
+ border-radius: 999px;
428
+ animation: fillProgress ${p.dur}s cubic-bezier(.4,0,.2,1) forwards;
429
+ box-shadow: 0 0 12px ${p.color}88;
430
+ }
431
+ @keyframes fillProgress {
432
+ to { width: ${p.pct}%; }
433
+ }`,
434
+ html: `<div style="padding:32px">
435
+ <p style="margin:0 0 8px;font-size:.85rem;opacity:.6">Loading... ${p.pct}%</p>
436
+ <div class="progress-track">
437
+ <div class="progress-fill"></div>
438
+ </div>
439
+ </div>`,
440
+ }),
441
+ },
442
+
443
+ // ── TEXT EFFECTS ─────────────────────────────────────────────────────────────
444
+ {
445
+ id: 'typewriter',
446
+ name: 'Typewriter',
447
+ description: 'Character-by-character typing effect',
448
+ category: 'text',
449
+ emoji: '⌨️',
450
+ params: [
451
+ { label: 'Speed (ms)', key: 'speed', type: 'range', min: 20, max: 300, step: 10, default: 80, unit: 'ms' },
452
+ { label: 'Color', key: 'color', type: 'color', default: '#8b9cff' },
453
+ { label: 'Cursor Color', key: 'cursor', type: 'color', default: '#667eea' },
454
+ ],
455
+ generate: (p) => ({
456
+ css: `.typewriter-text {
457
+ color: ${p.color};
458
+ font-size: 1.5rem;
459
+ font-weight: 600;
460
+ border-right: 3px solid ${p.cursor};
461
+ white-space: nowrap;
462
+ overflow: hidden;
463
+ display: inline-block;
464
+ animation: blink 0.75s step-end infinite;
465
+ }
466
+ @keyframes blink { 0%,100% { border-color: ${p.cursor}; } 50% { border-color: transparent; } }`,
467
+ html: `<div style="padding:32px">
468
+ <div id="tw-el" class="typewriter-text"></div>
469
+ </div>`,
470
+ js: `(function(){
471
+ const el = document.getElementById('tw-el');
472
+ const text = 'Hello, World! 👋';
473
+ let i = 0;
474
+ function type() {
475
+ if (i < text.length) {
476
+ el.textContent += text[i++];
477
+ setTimeout(type, ${p.speed});
478
+ }
479
+ }
480
+ type();
481
+ })();`,
482
+ }),
483
+ },
484
+ {
485
+ id: 'glitch-text',
486
+ name: 'Glitch Text',
487
+ description: 'Cyberpunk-style glitch distortion',
488
+ category: 'text',
489
+ emoji: '👾',
490
+ params: [
491
+ { label: 'Color', key: 'color', type: 'color', default: '#00ff88' },
492
+ { label: 'Speed (s)', key: 'speed', type: 'range', min: 0.5, max: 5, step: 0.1, default: 1.5, unit: 's' },
493
+ { label: 'Intensity (px)', key: 'dist', type: 'range', min: 2, max: 20, step: 1, default: 6, unit: 'px' },
494
+ ],
495
+ generate: (p) => ({
496
+ css: `.glitch {
497
+ font-size: 3rem;
498
+ font-weight: 900;
499
+ color: ${p.color};
500
+ position: relative;
501
+ display: inline-block;
502
+ text-transform: uppercase;
503
+ letter-spacing: 4px;
504
+ }
505
+ .glitch::before, .glitch::after {
506
+ content: attr(data-text);
507
+ position: absolute;
508
+ top: 0; left: 0;
509
+ width: 100%; height: 100%;
510
+ }
511
+ .glitch::before {
512
+ color: #ff0044;
513
+ animation: glitchTop ${p.speed}s infinite;
514
+ clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
515
+ }
516
+ .glitch::after {
517
+ color: #00eeff;
518
+ animation: glitchBot ${p.speed}s infinite;
519
+ clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
520
+ }
521
+ @keyframes glitchTop {
522
+ 0%,90%,100% { transform: translate(0); }
523
+ 92% { transform: translate(-${p.dist}px, 1px); }
524
+ 94% { transform: translate(${p.dist}px, -1px); }
525
+ }
526
+ @keyframes glitchBot {
527
+ 0%,90%,100% { transform: translate(0); }
528
+ 92% { transform: translate(${p.dist}px, 1px); }
529
+ 94% { transform: translate(-${p.dist}px, -1px); }
530
+ }`,
531
+ html: `<div style="background:#0a0a0a;padding:48px;display:flex;justify-content:center;">
532
+ <span class="glitch" data-text="GLITCH">GLITCH</span>
533
+ </div>`,
534
+ }),
535
+ },
536
+ {
537
+ id: 'gradient-text',
538
+ name: 'Gradient Text',
539
+ description: 'Animated gradient flowing through text',
540
+ category: 'text',
541
+ emoji: '🌈',
542
+ params: [
543
+ { label: 'Color 1', key: 'c1', type: 'color', default: '#667eea' },
544
+ { label: 'Color 2', key: 'c2', type: 'color', default: '#f64f59' },
545
+ { label: 'Color 3', key: 'c3', type: 'color', default: '#ffd700' },
546
+ { label: 'Speed (s)', key: 'speed', type: 'range', min: 1, max: 8, step: 0.5, default: 3, unit: 's' },
547
+ ],
548
+ generate: (p) => ({
549
+ css: `.gradient-text {
550
+ font-size: 3rem;
551
+ font-weight: 900;
552
+ background: linear-gradient(90deg, ${p.c1}, ${p.c2}, ${p.c3}, ${p.c1});
553
+ background-size: 300% 100%;
554
+ -webkit-background-clip: text;
555
+ -webkit-text-fill-color: transparent;
556
+ background-clip: text;
557
+ animation: textShift ${p.speed}s linear infinite;
558
+ display: inline-block;
559
+ }
560
+ @keyframes textShift {
561
+ 0% { background-position: 0% center; }
562
+ 100% { background-position: 300% center; }
563
+ }`,
564
+ html: `<div style="background:#0a0a0a;padding:48px;text-align:center;">
565
+ <span class="gradient-text">Beautiful Text</span>
566
+ </div>`,
567
+ }),
568
+ },
569
+ {
570
+ id: 'split-reveal',
571
+ name: 'Split Reveal',
572
+ description: 'Words reveal with split-clip animation',
573
+ category: 'text',
574
+ emoji: '✂️',
575
+ params: [
576
+ { label: 'Duration (s)', key: 'dur', type: 'range', min: 0.3, max: 1.5, step: 0.05, default: 0.7, unit: 's' },
577
+ { label: 'Stagger (ms)', key: 'stagger', type: 'range', min: 50, max: 400, step: 25, default: 150, unit: 'ms' },
578
+ { label: 'Color', key: 'color', type: 'color', default: '#ffffff' },
579
+ ],
580
+ generate: (p) => ({
581
+ css: `.split-word {
582
+ display: inline-block;
583
+ overflow: hidden;
584
+ vertical-align: top;
585
+ margin-right: 0.25em;
586
+ }
587
+ .split-inner {
588
+ display: inline-block;
589
+ transform: translateY(110%);
590
+ animation: splitReveal ${p.dur}s cubic-bezier(.16,1,.3,1) forwards;
591
+ color: ${p.color};
592
+ font-size: 2.5rem;
593
+ font-weight: 800;
594
+ }
595
+ .split-word:nth-child(1) .split-inner { animation-delay: 0ms; }
596
+ .split-word:nth-child(2) .split-inner { animation-delay: ${p.stagger}ms; }
597
+ .split-word:nth-child(3) .split-inner { animation-delay: ${Number(p.stagger) * 2}ms; }
598
+ .split-word:nth-child(4) .split-inner { animation-delay: ${Number(p.stagger) * 3}ms; }
599
+ @keyframes splitReveal { to { transform: translateY(0); } }`,
600
+ html: `<div style="padding:40px">
601
+ <div>
602
+ <span class="split-word"><span class="split-inner">Hello</span></span>
603
+ <span class="split-word"><span class="split-inner">Beautiful</span></span>
604
+ <span class="split-word"><span class="split-inner">World</span></span>
605
+ <span class="split-word"><span class="split-inner">✨</span></span>
606
+ </div>
607
+ </div>`,
608
+ }),
609
+ },
610
+ ]
611
+
612
+ const CATEGORIES: { id: Category; label: string; icon: React.ReactNode }[] = [
613
+ { id: 'animations', label: 'Animations', icon: <Zap className="w-4 h-4" /> },
614
+ { id: 'threejs', label: '3D / Three.js', icon: <Box className="w-4 h-4" /> },
615
+ { id: 'ui', label: 'UI Components', icon: <Layers className="w-4 h-4" /> },
616
+ { id: 'text', label: 'Text Effects', icon: <Type className="w-4 h-4" /> },
617
+ ]
618
+
619
+ export function DesignPanel({ onCodeUpdate }: DesignPanelProps) {
620
+ const [category, setCategory] = useState<Category>('animations')
621
+ const [selectedId, setSelectedId] = useState<string | null>(null)
622
+ const [paramValues, setParamValues] = useState<Record<string, Record<string, any>>>({})
623
+ const [applied, setApplied] = useState<string | null>(null)
624
+
625
+ const visiblePresets = PRESETS.filter(p => p.category === category)
626
+
627
+ const getParam = useCallback((presetId: string, key: string, defaultVal: any) => {
628
+ return paramValues[presetId]?.[key] ?? defaultVal
629
+ }, [paramValues])
630
+
631
+ const setParam = useCallback((presetId: string, key: string, value: any) => {
632
+ setParamValues(prev => ({
633
+ ...prev,
634
+ [presetId]: { ...(prev[presetId] || {}), [key]: value }
635
+ }))
636
+ }, [])
637
+
638
+ const handleApply = useCallback((preset: Preset) => {
639
+ const resolved: Record<string, any> = {}
640
+ preset.params.forEach(p => {
641
+ resolved[p.key] = getParam(preset.id, p.key, p.default)
642
+ })
643
+ const code = preset.generate(resolved)
644
+ if (onCodeUpdate) onCodeUpdate(code)
645
+ setApplied(preset.id)
646
+ setTimeout(() => setApplied(null), 1800)
647
+ }, [getParam, onCodeUpdate])
648
+
649
+ const selectedPreset = PRESETS.find(p => p.id === selectedId)
650
+
651
+ return (
652
+ <div className="h-full flex flex-col overflow-hidden" style={{ background: 'rgba(8,8,12,0.95)' }}>
653
+
654
+ {/* Header */}
655
+ <div className="px-4 py-3 flex items-center gap-2 flex-shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
656
+ <Sparkles className="w-4 h-4" style={{ color: '#8b9cff' }} />
657
+ <span className="text-sm font-semibold" style={{ color: '#8b9cff' }}>Design Library</span>
658
+ <span className="ml-auto text-xs" style={{ color: 'rgba(255,255,255,0.3)' }}>ReactBits · Three.js</span>
659
+ </div>
660
+
661
+ {/* Category Pills */}
662
+ <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)' }}>
663
+ {CATEGORIES.map(cat => (
664
+ <button
665
+ key={cat.id}
666
+ onClick={() => { setCategory(cat.id); setSelectedId(null) }}
667
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all"
668
+ style={category === cat.id ? {
669
+ background: 'rgba(102,126,234,0.25)',
670
+ color: '#8b9cff',
671
+ border: '1px solid rgba(102,126,234,0.4)',
672
+ } : {
673
+ background: 'rgba(255,255,255,0.04)',
674
+ color: 'rgba(255,255,255,0.5)',
675
+ border: '1px solid rgba(255,255,255,0.08)',
676
+ }}
677
+ >
678
+ {cat.icon}
679
+ {cat.label}
680
+ </button>
681
+ ))}
682
+ </div>
683
+
684
+ <div className="flex-1 overflow-hidden flex">
685
+
686
+ {/* Preset List */}
687
+ <div className="overflow-y-auto" style={{
688
+ width: selectedId ? '42%' : '100%',
689
+ borderRight: selectedId ? '1px solid rgba(255,255,255,0.08)' : 'none',
690
+ transition: 'width 0.25s ease',
691
+ }}>
692
+ <div className="p-3 grid gap-2" style={{ gridTemplateColumns: selectedId ? '1fr' : 'repeat(2, 1fr)' }}>
693
+ {visiblePresets.map(preset => (
694
+ <button
695
+ key={preset.id}
696
+ onClick={() => setSelectedId(selectedId === preset.id ? null : preset.id)}
697
+ className="text-left rounded-xl p-3 transition-all"
698
+ style={selectedId === preset.id ? {
699
+ background: 'rgba(102,126,234,0.15)',
700
+ border: '1px solid rgba(102,126,234,0.4)',
701
+ } : {
702
+ background: 'rgba(255,255,255,0.03)',
703
+ border: '1px solid rgba(255,255,255,0.07)',
704
+ }}
705
+ >
706
+ <div className="text-xl mb-1">{preset.emoji}</div>
707
+ <div className="text-xs font-semibold mb-0.5" style={{ color: selectedId === preset.id ? '#8b9cff' : '#fff' }}>
708
+ {preset.name}
709
+ </div>
710
+ {!selectedId && (
711
+ <div className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>
712
+ {preset.description}
713
+ </div>
714
+ )}
715
+ {selectedId === preset.id && (
716
+ <ChevronRight className="w-3 h-3 mt-1" style={{ color: '#8b9cff' }} />
717
+ )}
718
+ </button>
719
+ ))}
720
+ </div>
721
+ </div>
722
+
723
+ {/* Parameter Panel */}
724
+ {selectedPreset && (
725
+ <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-4">
726
+ <div>
727
+ <div className="text-base font-bold mb-0.5" style={{ color: '#fff' }}>
728
+ {selectedPreset.emoji} {selectedPreset.name}
729
+ </div>
730
+ <div className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>
731
+ {selectedPreset.description}
732
+ </div>
733
+ </div>
734
+
735
+ {/* Params */}
736
+ <div className="flex flex-col gap-4">
737
+ {selectedPreset.params.map(param => {
738
+ const val = getParam(selectedPreset.id, param.key, param.default)
739
+ return (
740
+ <div key={param.key}>
741
+ <div className="flex items-center justify-between mb-1.5">
742
+ <label className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.6)' }}>
743
+ {param.label}
744
+ </label>
745
+ {param.type === 'range' && (
746
+ <span className="text-xs font-mono px-2 py-0.5 rounded" style={{
747
+ background: 'rgba(102,126,234,0.15)',
748
+ color: '#8b9cff',
749
+ }}>
750
+ {Number(val).toFixed(param.step && param.step < 0.1 ? 2 : param.step && param.step < 1 ? 1 : 0)}{param.unit || ''}
751
+ </span>
752
+ )}
753
+ </div>
754
+
755
+ {param.type === 'range' && (
756
+ <input
757
+ type="range"
758
+ min={param.min}
759
+ max={param.max}
760
+ step={param.step}
761
+ value={val}
762
+ onChange={e => setParam(selectedPreset.id, param.key, parseFloat(e.target.value))}
763
+ className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
764
+ style={{
765
+ background: `linear-gradient(to right, #667eea ${((val - (param.min||0)) / ((param.max||1) - (param.min||0))) * 100}%, rgba(255,255,255,0.1) 0%)`,
766
+ outline: 'none',
767
+ accentColor: '#667eea',
768
+ }}
769
+ />
770
+ )}
771
+
772
+ {param.type === 'color' && (
773
+ <div className="flex items-center gap-2">
774
+ <input
775
+ type="color"
776
+ value={val}
777
+ onChange={e => setParam(selectedPreset.id, param.key, e.target.value)}
778
+ className="rounded cursor-pointer border-0"
779
+ style={{ width: 40, height: 32, background: 'none' }}
780
+ />
781
+ <span className="text-xs font-mono" style={{ color: 'rgba(255,255,255,0.4)' }}>{val}</span>
782
+ </div>
783
+ )}
784
+
785
+ {param.type === 'select' && (
786
+ <select
787
+ value={val}
788
+ onChange={e => setParam(selectedPreset.id, param.key, e.target.value)}
789
+ className="w-full px-3 py-2 rounded-lg text-sm"
790
+ style={{
791
+ background: 'rgba(255,255,255,0.06)',
792
+ border: '1px solid rgba(255,255,255,0.12)',
793
+ color: '#fff',
794
+ outline: 'none',
795
+ }}
796
+ >
797
+ {param.options?.map(opt => (
798
+ <option key={opt} value={opt} style={{ background: '#1a1a2e' }}>{opt}</option>
799
+ ))}
800
+ </select>
801
+ )}
802
+ </div>
803
+ )
804
+ })}
805
+ </div>
806
+
807
+ {/* Apply Button */}
808
+ <button
809
+ onClick={() => handleApply(selectedPreset)}
810
+ className="w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all mt-2"
811
+ style={applied === selectedPreset.id ? {
812
+ background: 'rgba(0,200,100,0.2)',
813
+ border: '1px solid rgba(0,200,100,0.4)',
814
+ color: '#00c864',
815
+ } : {
816
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
817
+ color: '#fff',
818
+ boxShadow: '0 8px 24px rgba(102,126,234,0.3)',
819
+ }}
820
+ >
821
+ {applied === selectedPreset.id
822
+ ? <><Check className="w-4 h-4" /> Applied!</>
823
+ : <><Sparkles className="w-4 h-4" /> Apply to Project</>
824
+ }
825
+ </button>
826
+
827
+ {/* Code preview hint */}
828
+ <p className="text-center text-xs" style={{ color: 'rgba(255,255,255,0.25)' }}>
829
+ Injects into HTML, CSS &amp; JS tabs
830
+ </p>
831
+ </div>
832
+ )}
833
+ </div>
834
+ </div>
835
+ )
836
+ }
components/editor/Editor.tsx CHANGED
@@ -7,10 +7,11 @@ import { FooterAd } from '@/components/ads/FooterAd'
7
  import { CodeEditor } from '@/components/editor/CodeEditor'
8
  import { PreviewFrame } from '@/components/editor/PreviewFrame'
9
  import { AIChat } from '@/components/editor/AIChat'
10
- import { Code2, Eye, MessageSquare, Save, Upload, Maximize2, ArrowLeft } from 'lucide-react'
 
11
  import { getProject, updateProject, type LocalProject } from '@/lib/local-storage'
12
 
13
- type Tab = 'code' | 'preview' | 'chat'
14
  type CodeTab = 'html' | 'css' | 'js'
15
 
16
  interface EditorProps {
@@ -81,6 +82,16 @@ export function Editor({ projectId }: EditorProps) {
81
  reader.readAsText(file)
82
  }, [])
83
 
 
 
 
 
 
 
 
 
 
 
84
  const combinedCode = `
85
  <!DOCTYPE html>
86
  <html>
@@ -207,6 +218,13 @@ export function Editor({ projectId }: EditorProps) {
207
  active={activeTab === 'chat'}
208
  onClick={() => setActiveTab('chat')}
209
  />
 
 
 
 
 
 
 
210
  </div>
211
 
212
  {/* Main Content */}
@@ -261,6 +279,10 @@ export function Editor({ projectId }: EditorProps) {
261
  }}
262
  />
263
  )}
 
 
 
 
264
  </div>
265
 
266
  {/* Fullscreen Preview */}
@@ -285,17 +307,25 @@ export function Editor({ projectId }: EditorProps) {
285
  )
286
  }
287
 
288
- function TabButton({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) {
 
 
 
 
 
 
 
 
289
  return (
290
  <button
291
  onClick={onClick}
292
  className="flex-1 py-3 px-4 flex items-center justify-center gap-2 text-sm font-medium transition-all duration-300"
293
  style={active ? {
294
- color: '#8b9cff',
295
- borderBottom: '2px solid #8b9cff',
296
- background: 'rgba(102, 126, 234, 0.05)',
297
  } : {
298
- color: 'rgba(255,255,255,0.6)',
299
  borderBottom: '2px solid transparent',
300
  }}
301
  >
 
7
  import { CodeEditor } from '@/components/editor/CodeEditor'
8
  import { PreviewFrame } from '@/components/editor/PreviewFrame'
9
  import { AIChat } from '@/components/editor/AIChat'
10
+ import { DesignPanel } from '@/components/editor/DesignPanel'
11
+ import { Code2, Eye, MessageSquare, Sparkles, Save, Upload, Maximize2, ArrowLeft } from 'lucide-react'
12
  import { getProject, updateProject, type LocalProject } from '@/lib/local-storage'
13
 
14
+ type Tab = 'code' | 'preview' | 'chat' | 'design'
15
  type CodeTab = 'html' | 'css' | 'js'
16
 
17
  interface EditorProps {
 
82
  reader.readAsText(file)
83
  }, [])
84
 
85
+ const handleDesignApply = useCallback((newCode: { html?: string; css?: string; js?: string }) => {
86
+ if (newCode.html) setHtmlCode(prev => prev + '\n\n' + newCode.html)
87
+ if (newCode.css) setCssCode(prev => prev + '\n\n' + newCode.css)
88
+ if (newCode.js) setJsCode(prev => prev + '\n\n' + newCode.js)
89
+ // Switch to code tab so user sees the applied code
90
+ setActiveTab('code')
91
+ if (newCode.css) setActiveCodeTab('css')
92
+ if (newCode.html) setActiveCodeTab('html')
93
+ }, [])
94
+
95
  const combinedCode = `
96
  <!DOCTYPE html>
97
  <html>
 
218
  active={activeTab === 'chat'}
219
  onClick={() => setActiveTab('chat')}
220
  />
221
+ <TabButton
222
+ icon={<Sparkles className="w-4 h-4" />}
223
+ label="Design"
224
+ active={activeTab === 'design'}
225
+ onClick={() => setActiveTab('design')}
226
+ accent
227
+ />
228
  </div>
229
 
230
  {/* Main Content */}
 
279
  }}
280
  />
281
  )}
282
+
283
+ {activeTab === 'design' && (
284
+ <DesignPanel onCodeUpdate={handleDesignApply} />
285
+ )}
286
  </div>
287
 
288
  {/* Fullscreen Preview */}
 
307
  )
308
  }
309
 
310
+ function TabButton({
311
+ icon, label, active, onClick, accent
312
+ }: {
313
+ icon: React.ReactNode
314
+ label: string
315
+ active: boolean
316
+ onClick: () => void
317
+ accent?: boolean
318
+ }) {
319
  return (
320
  <button
321
  onClick={onClick}
322
  className="flex-1 py-3 px-4 flex items-center justify-center gap-2 text-sm font-medium transition-all duration-300"
323
  style={active ? {
324
+ color: accent ? '#c084fc' : '#8b9cff',
325
+ borderBottom: `2px solid ${accent ? '#c084fc' : '#8b9cff'}`,
326
+ background: accent ? 'rgba(192,132,252,0.06)' : 'rgba(102, 126, 234, 0.05)',
327
  } : {
328
+ color: accent ? 'rgba(192,132,252,0.7)' : 'rgba(255,255,255,0.6)',
329
  borderBottom: '2px solid transparent',
330
  }}
331
  >