Completed slices: - S01: Desire Embedding & Clustering - S02: Fulfillment Flow & Frontend Branch: milestone/M001
359 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|