fractafrag/services/frontend/src/pages/Editor.tsx
John Lightner 5936ab167e feat(M001): Desire Economy
Completed slices:
- S01: Desire Embedding & Clustering
- S02: Fulfillment Flow & Frontend

Branch: milestone/M001
2026-03-25 02:22:50 -05:00

359 lines
13 KiB
TypeScript

/**
* 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<string | null>(null);
const [editingExisting, setEditingExisting] = useState(false);
// Resizable pane state
const [editorWidth, setEditorWidth] = useState(50); // percentage
const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// 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 (
<div className="h-[calc(100vh-3.5rem)] flex flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-surface-1 border-b border-surface-3">
<div className="flex items-center gap-3">
<input
type="text"
value={title}
onChange={(e) => 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..."
/>
<button
onClick={() => setShowMeta(!showMeta)}
className="btn-ghost text-xs py-1 px-2"
>
{showMeta ? 'Hide details' : 'Details'}
</button>
{editingExisting && existingShader && (
<span className="text-xs text-gray-500">
v{existingShader.current_version}
</span>
)}
</div>
<div className="flex items-center gap-2">
{compileError && (
<span className="text-xs text-red-400 max-w-xs truncate" title={compileError}>
{compileError.split('\n')[0]}
</span>
)}
{savedStatus && (
<span className="text-xs text-green-400 animate-fade-in">{savedStatus}</span>
)}
<button
onClick={() => handleSave('draft')}
disabled={submitting}
className="btn-secondary text-sm py-1.5"
>
{submitting ? '...' : 'Save Draft'}
</button>
<button
onClick={() => handleSave('published')}
disabled={submitting || !!compileError}
className="btn-primary text-sm py-1.5"
>
{submitting ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
{/* Metadata panel */}
{showMeta && (
<div className="px-4 py-3 bg-surface-1 border-b border-surface-3 flex gap-4 items-end animate-slide-up">
<div className="flex-1">
<label className="text-xs text-gray-500">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input text-sm mt-1"
placeholder="What does this shader do?"
/>
</div>
<div className="w-48">
<label className="text-xs text-gray-500">Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="input text-sm mt-1"
placeholder="fractal, noise, 3d"
/>
</div>
<div className="w-32">
<label className="text-xs text-gray-500">Type</label>
<select
value={shaderType}
onChange={(e) => setShaderType(e.target.value)}
className="input text-sm mt-1"
>
<option value="2d">2D</option>
<option value="3d">3D</option>
<option value="audio-reactive">Audio</option>
</select>
</div>
</div>
)}
{/* Submit error */}
{submitError && (
<div className="px-4 py-2 bg-red-600/10 text-red-400 text-sm border-b border-red-600/20">
{submitError}
</div>
)}
{/* Desire fulfillment context banner */}
{fulfillDesire && (
<div className="px-4 py-3 bg-amber-600/10 border-b border-amber-600/20 flex items-center gap-3">
<span className="text-amber-400 text-sm font-medium">🎯 Fulfilling desire:</span>
<span className="text-gray-300 text-sm flex-1">{fulfillDesire.prompt_text}</span>
{fulfillDesire.style_hints && (
<span className="text-xs text-gray-500">Style hints available</span>
)}
</div>
)}
{/* Split pane: editor + drag handle + preview */}
<div ref={containerRef} className="flex-1 flex min-h-0">
{/* Code editor */}
<div className="flex flex-col" style={{ width: `${editorWidth}%` }}>
<div className="px-3 py-1.5 bg-surface-2 text-xs text-gray-500 border-b border-surface-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500" />
fragment.glsl
</div>
<textarea
value={code}
onChange={(e) => handleCodeChange(e.target.value)}
className="flex-1 bg-surface-0 text-gray-200 font-mono text-sm p-4
resize-none focus:outline-none leading-relaxed
selection:bg-fracta-600/30"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
/>
</div>
{/* Drag handle */}
<div
onMouseDown={handleMouseDown}
className="w-1.5 bg-surface-3 hover:bg-fracta-600 cursor-col-resize
transition-colors flex-shrink-0 relative group"
>
<div className="absolute inset-y-0 -left-1 -right-1" /> {/* Wider hit area */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
w-1 h-8 bg-gray-600 group-hover:bg-fracta-400 rounded-full transition-colors" />
</div>
{/* Live preview */}
<div className="flex-1 bg-black relative min-w-0">
<ShaderCanvas
code={liveCode}
className="w-full h-full"
animate={true}
onError={(err) => setCompileError(err)}
onCompileSuccess={() => setCompileError('')}
/>
{!liveCode.trim() && (
<div className="absolute inset-0 flex items-center justify-center text-gray-600">
Write some GLSL to see it rendered live
</div>
)}
</div>
</div>
</div>
);
}