/** * Editor page — GLSL editor with live WebGL preview. * * Features: * - Resizable split pane with drag handle * - Save as draft or publish * - Version history (when editing existing shader) * - Live preview with 400ms debounce */ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; import ShaderCanvas from '@/components/ShaderCanvas'; const DEFAULT_SHADER = `// Fractafrag — write your shader here // Shadertoy-compatible: mainImage(out vec4 fragColor, in vec2 fragCoord) // Available uniforms: iTime, iResolution, iMouse void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; float t = iTime; // Gradient with time-based animation vec3 col = 0.5 + 0.5 * cos(t + uv.xyx + vec3(0, 2, 4)); // Add some structure float d = length(uv - 0.5); col *= 1.0 - smoothstep(0.0, 0.5, d); col += 0.05; fragColor = vec4(col, 1.0); } `; export default function Editor() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { isAuthenticated, user } = useAuthStore(); // Fulfillment context — read once from URL, persist in ref so it survives navigation const fulfillId = searchParams.get('fulfill'); const fulfillDesireId = useRef(fulfillId); const [code, setCode] = useState(DEFAULT_SHADER); const [liveCode, setLiveCode] = useState(DEFAULT_SHADER); const [title, setTitle] = useState('Untitled Shader'); const [description, setDescription] = useState(''); const [tags, setTags] = useState(''); const [shaderType, setShaderType] = useState('2d'); const [compileError, setCompileError] = useState(''); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(''); const [showMeta, setShowMeta] = useState(false); const [savedStatus, setSavedStatus] = useState(null); const [editingExisting, setEditingExisting] = useState(false); // Resizable pane state const [editorWidth, setEditorWidth] = useState(50); // percentage const isDragging = useRef(false); const containerRef = useRef(null); const debounceRef = useRef>(); // Load existing shader for editing or forking const { data: existingShader } = useQuery({ queryKey: ['shader', id], queryFn: async () => { const { data } = await api.get(`/shaders/${id}`); return data; }, enabled: !!id, }); // Fetch desire context when fulfilling const { data: fulfillDesire } = useQuery({ queryKey: ['desire', fulfillDesireId.current], queryFn: async () => { const { data } = await api.get(`/desires/${fulfillDesireId.current}`); return data; }, enabled: !!fulfillDesireId.current, }); useEffect(() => { if (existingShader) { setCode(existingShader.glsl_code); setLiveCode(existingShader.glsl_code); setTitle(existingShader.title); setDescription(existingShader.description || ''); setShaderType(existingShader.shader_type); setTags(existingShader.tags?.join(', ') || ''); // If we own it, we're editing; otherwise forking if (user && existingShader.author_id === user.id) { setEditingExisting(true); } else { setTitle(`Fork of ${existingShader.title}`); setEditingExisting(false); } } }, [existingShader, user]); // ── Drag handle for resizable pane ────────────────────── const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; setEditorWidth(Math.max(20, Math.min(80, pct))); }; const handleMouseUp = () => { isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); // ── Debounced live preview ────────────────────────────── const handleCodeChange = useCallback((value: string) => { setCode(value); setSavedStatus(null); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setLiveCode(value); }, 400); }, []); // ── Save / Publish ───────────────────────────────────── const handleSave = async (publishStatus: 'draft' | 'published') => { if (!isAuthenticated()) { navigate('/login'); return; } setSubmitting(true); setSubmitError(''); const payload = { title, description, glsl_code: code, tags: tags.split(',').map(t => t.trim()).filter(Boolean), shader_type: shaderType, status: publishStatus, is_public: publishStatus === 'published', }; try { if (editingExisting && id) { // Update existing shader const { data } = await api.put(`/shaders/${id}`, { ...payload, change_note: publishStatus === 'published' ? 'Updated' : undefined, }); setSavedStatus(publishStatus === 'draft' ? 'Draft saved' : 'Published'); if (publishStatus === 'published') { setTimeout(() => navigate(`/shader/${data.id}`), 800); } } else { // Create new shader const { data } = await api.post('/shaders', { ...payload, fulfills_desire_id: fulfillDesireId.current || undefined, }); if (publishStatus === 'published') { navigate(`/shader/${data.id}`); } else { // Redirect to editor with the new ID so subsequent saves are updates setSavedStatus('Draft saved'); navigate(`/editor/${data.id}`, { replace: true }); } } } catch (err: any) { const detail = err.response?.data?.detail; if (typeof detail === 'object' && detail.errors) { setSubmitError(detail.errors.join('\n')); } else { setSubmitError(detail || 'Save failed'); } } finally { setSubmitting(false); } }; return (
{/* Toolbar */}
setTitle(e.target.value)} className="bg-transparent text-lg font-medium text-gray-100 focus:outline-none border-b border-transparent focus:border-fracta-500 transition-colors w-64" placeholder="Shader title..." /> {editingExisting && existingShader && ( v{existingShader.current_version} )}
{compileError && ( ⚠ {compileError.split('\n')[0]} )} {savedStatus && ( {savedStatus} )}
{/* Metadata panel */} {showMeta && (
setDescription(e.target.value)} className="input text-sm mt-1" placeholder="What does this shader do?" />
setTags(e.target.value)} className="input text-sm mt-1" placeholder="fractal, noise, 3d" />
)} {/* Submit error */} {submitError && (
{submitError}
)} {/* Desire fulfillment context banner */} {fulfillDesire && (
🎯 Fulfilling desire: {fulfillDesire.prompt_text} {fulfillDesire.style_hints && ( Style hints available )}
)} {/* Split pane: editor + drag handle + preview */}
{/* Code editor */}
fragment.glsl