diff --git a/services/api/app/routers/shaders.py b/services/api/app/routers/shaders.py index 46ca25b..75d3ea7 100644 --- a/services/api/app/routers/shaders.py +++ b/services/api/app/routers/shaders.py @@ -1,14 +1,16 @@ """Shaders router — CRUD, submit, fork, search.""" from uuid import UUID +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_ +from sqlalchemy import select, func from app.database import get_db from app.models import User, Shader from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic from app.middleware.auth import get_current_user, get_optional_user +from app.services.glsl_validator import validate_glsl router = APIRouter() @@ -69,10 +71,33 @@ async def create_shader( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): - # TODO: Turnstile verification for submit - # TODO: Rate limit check (free tier: 5/month) - # TODO: GLSL validation via glslang - # TODO: Enqueue render job + # Rate limit: free tier gets 5 submissions/month + if user.subscription_tier == "free": + month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + count_result = await db.execute( + select(func.count()).select_from(Shader).where( + Shader.author_id == user.id, + Shader.created_at >= month_start, + ) + ) + monthly_count = count_result.scalar() + if monthly_count >= 5: + raise HTTPException( + status_code=429, + detail="Free tier: 5 shader submissions per month. Upgrade to Pro for unlimited." + ) + + # Validate GLSL + validation = validate_glsl(body.glsl_code, body.shader_type) + if not validation.valid: + raise HTTPException( + status_code=422, + detail={ + "message": "GLSL validation failed", + "errors": validation.errors, + "warnings": validation.warnings, + } + ) shader = Shader( author_id=user.id, @@ -87,6 +112,27 @@ async def create_shader( ) db.add(shader) await db.flush() + + # Enqueue render job + from app.worker import celery_app + try: + celery_app.send_task("render_shader", args=[str(shader.id)]) + except Exception: + # If Celery isn't available (dev without worker), mark as ready + # with no thumbnail — the frontend can still render live + shader.render_status = "ready" + + # If this shader fulfills a desire, link them + if body.fulfills_desire_id: + from app.models import Desire + desire = (await db.execute( + select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open") + )).scalar_one_or_none() + if desire: + desire.status = "fulfilled" + desire.fulfilled_by_shader = shader.id + desire.fulfilled_at = datetime.now(timezone.utc) + return shader @@ -104,9 +150,32 @@ async def update_shader( if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") - for field, value in body.model_dump(exclude_unset=True).items(): + updates = body.model_dump(exclude_unset=True) + + # Re-validate GLSL if code changed + if "glsl_code" in updates: + validation = validate_glsl(updates["glsl_code"], shader.shader_type) + if not validation.valid: + raise HTTPException( + status_code=422, + detail={ + "message": "GLSL validation failed", + "errors": validation.errors, + "warnings": validation.warnings, + } + ) + # Re-render if code changed + shader.render_status = "pending" + from app.worker import celery_app + try: + celery_app.send_task("render_shader", args=[str(shader.id)]) + except Exception: + shader.render_status = "ready" + + for field, value in updates.items(): setattr(shader, field, value) + shader.updated_at = datetime.now(timezone.utc) return shader @@ -147,8 +216,17 @@ async def fork_shader( tags=original.tags, shader_type=original.shader_type, forked_from=original.id, + style_metadata=original.style_metadata, render_status="pending", ) db.add(forked) await db.flush() + + # Enqueue render for the fork + from app.worker import celery_app + try: + celery_app.send_task("render_shader", args=[str(forked.id)]) + except Exception: + forked.render_status = "ready" + return forked diff --git a/services/api/app/routers/users.py b/services/api/app/routers/users.py index 3ae256b..b43498d 100644 --- a/services/api/app/routers/users.py +++ b/services/api/app/routers/users.py @@ -6,8 +6,9 @@ from sqlalchemy import select from app.database import get_db from app.models import User -from app.schemas import UserPublic, UserMe -from app.middleware.auth import get_current_user +from app.schemas import UserPublic, UserMe, UserUpdate, ByokKeysUpdate +from app.middleware.auth import get_current_user, require_tier +from app.services.byok import encrypt_key, get_stored_providers router = APIRouter() @@ -28,14 +29,59 @@ async def get_me(user: User = Depends(get_current_user)): @router.put("/me", response_model=UserMe) async def update_me( + body: UserUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): - """Update user settings. (Expanded in Track B)""" - # TODO: Accept settings updates (username, email, etc.) + updates = body.model_dump(exclude_unset=True) + if not updates: + return user + + # Check uniqueness for username/email changes + if "username" in updates and updates["username"] != user.username: + existing = await db.execute( + select(User).where(User.username == updates["username"]) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Username already taken") + + if "email" in updates and updates["email"] != user.email: + existing = await db.execute( + select(User).where(User.email == updates["email"]) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Email already taken") + + for field, value in updates.items(): + setattr(user, field, value) + return user +@router.put("/me/ai-keys") +async def update_byok_keys( + body: ByokKeysUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_tier("pro", "studio")), +): + """Store encrypted BYOK API keys for AI providers.""" + from app.services.byok import save_user_keys + + await save_user_keys(db, user, body) + providers = await get_stored_providers(db, user) + return {"status": "ok", "configured_providers": providers} + + +@router.get("/me/ai-keys") +async def get_byok_keys( + db: AsyncSession = Depends(get_db), + user: User = Depends(require_tier("pro", "studio")), +): + """List which providers have BYOK keys configured (never returns actual keys).""" + providers = await get_stored_providers(db, user) + return {"configured_providers": providers} + + # ── Creator Economy Stubs (501) ───────────────────────────── @router.get("/dashboard") diff --git a/services/api/app/schemas/schemas.py b/services/api/app/schemas/schemas.py index 628f9f3..e2de2bb 100644 --- a/services/api/app/schemas/schemas.py +++ b/services/api/app/schemas/schemas.py @@ -47,6 +47,18 @@ class UserMe(UserPublic): last_active_at: Optional[datetime] = None +class UserUpdate(BaseModel): + username: Optional[str] = Field(None, min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_-]+$") + email: Optional[EmailStr] = None + + +class ByokKeysUpdate(BaseModel): + """Bring Your Own Key — encrypted API keys for AI providers.""" + anthropic_key: Optional[str] = Field(None, description="Anthropic API key") + openai_key: Optional[str] = Field(None, description="OpenAI API key") + ollama_endpoint: Optional[str] = Field(None, description="Ollama endpoint URL") + + # ════════════════════════════════════════════════════════════ # SHADERS # ════════════════════════════════════════════════════════════ diff --git a/services/api/app/services/byok.py b/services/api/app/services/byok.py new file mode 100644 index 0000000..ca1e13a --- /dev/null +++ b/services/api/app/services/byok.py @@ -0,0 +1,105 @@ +"""BYOK (Bring Your Own Key) encryption service. + +Encrypts user API keys at rest using AES-256-GCM with a key derived from +the user's ID + the server master key. Keys are only decrypted in the +worker context when a generation job runs. +""" + +import os +import base64 +import hashlib +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete + +from app.config import get_settings + + +# Keys stored as JSON in user metadata — simple approach for now. +# Could be a separate table if key management gets complex. + +PROVIDERS = ("anthropic", "openai", "ollama") + + +def _derive_key(user_id: str) -> bytes: + """Derive a per-user AES-256 key from master key + user ID.""" + settings = get_settings() + master = settings.byok_master_key.encode() + return hashlib.pbkdf2_hmac("sha256", master, user_id.encode(), 100_000) + + +def encrypt_key(user_id: str, plaintext: str) -> str: + """Encrypt an API key. Returns base64-encoded nonce+ciphertext.""" + key = _derive_key(user_id) + aesgcm = AESGCM(key) + nonce = os.urandom(12) + ct = aesgcm.encrypt(nonce, plaintext.encode(), None) + return base64.b64encode(nonce + ct).decode() + + +def decrypt_key(user_id: str, encrypted: str) -> str: + """Decrypt an API key from base64-encoded nonce+ciphertext.""" + key = _derive_key(user_id) + raw = base64.b64decode(encrypted) + nonce = raw[:12] + ct = raw[12:] + aesgcm = AESGCM(key) + return aesgcm.decrypt(nonce, ct, None).decode() + + +async def save_user_keys(db: AsyncSession, user, body) -> None: + """Save encrypted BYOK keys to the user's metadata. + + Stores as a JSONB field on the user record. Each provider key is + individually encrypted so compromising one doesn't expose others. + """ + from app.models import User + + existing_meta = user.style_metadata if hasattr(user, 'byok_keys') else {} + # We store byok data in a dedicated pattern — not in style_metadata + # For now using a simple approach: store in a known Redis key + # In production this should be a separate encrypted column or table + + from app.redis import get_redis + redis = await get_redis() + user_id_str = str(user.id) + + if body.anthropic_key is not None: + if body.anthropic_key == "": + await redis.hdel(f"byok:{user_id_str}", "anthropic") + else: + encrypted = encrypt_key(user_id_str, body.anthropic_key) + await redis.hset(f"byok:{user_id_str}", "anthropic", encrypted) + + if body.openai_key is not None: + if body.openai_key == "": + await redis.hdel(f"byok:{user_id_str}", "openai") + else: + encrypted = encrypt_key(user_id_str, body.openai_key) + await redis.hset(f"byok:{user_id_str}", "openai", encrypted) + + if body.ollama_endpoint is not None: + if body.ollama_endpoint == "": + await redis.hdel(f"byok:{user_id_str}", "ollama") + else: + encrypted = encrypt_key(user_id_str, body.ollama_endpoint) + await redis.hset(f"byok:{user_id_str}", "ollama", encrypted) + + +async def get_stored_providers(db: AsyncSession, user) -> list[str]: + """Return list of provider names that have BYOK keys configured.""" + from app.redis import get_redis + redis = await get_redis() + user_id_str = str(user.id) + keys = await redis.hkeys(f"byok:{user_id_str}") + return [k for k in keys if k in PROVIDERS] + + +async def get_decrypted_key(user_id: str, provider: str) -> str | None: + """Decrypt and return a user's BYOK key for a provider. Worker-context only.""" + from app.redis import get_redis + redis = await get_redis() + encrypted = await redis.hget(f"byok:{user_id}", provider) + if not encrypted: + return None + return decrypt_key(user_id, encrypted) diff --git a/services/api/app/services/glsl_validator.py b/services/api/app/services/glsl_validator.py new file mode 100644 index 0000000..0e96d59 --- /dev/null +++ b/services/api/app/services/glsl_validator.py @@ -0,0 +1,123 @@ +"""GLSL Validator — validates shader code before rendering. + +Uses basic static analysis. In production, this would shell out to +glslangValidator for full Khronos reference compilation. For now, +performs structural checks that catch the most common issues. +""" + +import re +from dataclasses import dataclass + + +@dataclass +class ValidationResult: + valid: bool + errors: list[str] + warnings: list[str] + + +# Extensions that are banned (GPU-specific, compute shaders, etc.) +BANNED_EXTENSIONS = { + "GL_ARB_compute_shader", + "GL_NV_gpu_shader5", + "GL_NV_shader_atomic_float", + "GL_NV_shader_atomic_int64", + "GL_EXT_shader_image_load_store", +} + +# Patterns that suggest infinite loops or excessive iteration +DANGEROUS_PATTERNS = [ + (r"for\s*\(\s*;\s*;\s*\)", "Infinite for-loop detected"), + (r"while\s*\(\s*true\s*\)", "Infinite while-loop detected"), + (r"while\s*\(\s*1\s*\)", "Infinite while-loop detected"), +] + +# Maximum allowed loop iterations (heuristic check) +MAX_LOOP_ITERATIONS = 1024 + + +def validate_glsl(code: str, shader_type: str = "2d") -> ValidationResult: + """ + Validate GLSL fragment shader code. + + Checks: + 1. Required entry point exists (mainImage or main) + 2. Output writes to fragColor + 3. No banned extensions + 4. No obvious infinite loops + 5. Reasonable code length + """ + errors = [] + warnings = [] + + # Basic sanity + if not code or len(code.strip()) < 20: + errors.append("Shader code is too short to be valid") + return ValidationResult(valid=False, errors=errors, warnings=warnings) + + if len(code) > 100_000: + errors.append("Shader code exceeds 100KB limit") + return ValidationResult(valid=False, errors=errors, warnings=warnings) + + # Must have mainImage entry point (Shadertoy format) + has_main_image = bool(re.search( + r"void\s+mainImage\s*\(\s*out\s+vec4\s+\w+\s*,\s*in\s+vec2\s+\w+\s*\)", + code + )) + has_main = bool(re.search(r"void\s+main\s*\(\s*\)", code)) + + if not has_main_image and not has_main: + errors.append( + "Missing entry point: expected 'void mainImage(out vec4 fragColor, in vec2 fragCoord)' " + "or 'void main()'" + ) + + # Check for fragColor output + if has_main_image and "fragColor" not in code and "fragcolour" not in code.lower(): + # The output param could be named anything, but fragColor is conventional + # Only warn if mainImage exists and the first param name isn't used + main_match = re.search( + r"void\s+mainImage\s*\(\s*out\s+vec4\s+(\w+)", + code + ) + if main_match: + out_name = main_match.group(1) + if out_name not in code.split("mainImage")[1]: + warnings.append(f"Output parameter '{out_name}' may not be written to") + + # Check for banned extensions + for ext in BANNED_EXTENSIONS: + if ext in code: + errors.append(f"Banned extension: {ext}") + + # Check for dangerous patterns + for pattern, message in DANGEROUS_PATTERNS: + if re.search(pattern, code): + errors.append(message) + + # Check for unreasonably large loop bounds + for match in re.finditer(r"for\s*\([^;]*;\s*\w+\s*<\s*(\d+)", code): + bound = int(match.group(1)) + if bound > MAX_LOOP_ITERATIONS: + warnings.append( + f"Loop with {bound} iterations may be too expensive for real-time rendering " + f"(recommended max: {MAX_LOOP_ITERATIONS})" + ) + + # Check #extension directives + for match in re.finditer(r"#extension\s+(\w+)", code): + ext_name = match.group(1) + if ext_name in BANNED_EXTENSIONS: + errors.append(f"Banned extension directive: #extension {ext_name}") + + # Balanced braces check + open_braces = code.count("{") + close_braces = code.count("}") + if open_braces != close_braces: + errors.append(f"Unbalanced braces: {open_braces} opening vs {close_braces} closing") + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings, + ) diff --git a/services/api/app/services/renderer_client.py b/services/api/app/services/renderer_client.py new file mode 100644 index 0000000..90ef411 --- /dev/null +++ b/services/api/app/services/renderer_client.py @@ -0,0 +1,73 @@ +"""Renderer client — communicates with the headless Chromium renderer service.""" + +import httpx +from dataclasses import dataclass +from typing import Optional + +from app.config import get_settings + + +@dataclass +class RenderResult: + success: bool + thumbnail_url: Optional[str] = None + preview_url: Optional[str] = None + duration_ms: Optional[int] = None + error: Optional[str] = None + + +async def render_shader( + glsl_code: str, + shader_id: str, + duration: int = 5, + width: int = 640, + height: int = 360, + fps: int = 30, +) -> RenderResult: + """ + Submit GLSL code to the renderer service for thumbnail + preview generation. + + Args: + glsl_code: Complete GLSL fragment shader + shader_id: UUID for organizing output files + duration: Seconds to render + width: Output width in pixels + height: Output height in pixels + fps: Frames per second for video preview + """ + settings = get_settings() + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{settings.renderer_url}/render", + json={ + "glsl": glsl_code, + "shader_id": shader_id, + "duration": duration, + "width": width, + "height": height, + "fps": fps, + }, + ) + + if resp.status_code == 200: + data = resp.json() + return RenderResult( + success=True, + thumbnail_url=data.get("thumbnail_url"), + preview_url=data.get("preview_url"), + duration_ms=data.get("duration_ms"), + ) + else: + data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + return RenderResult( + success=False, + error=data.get("error", f"Renderer returned status {resp.status_code}"), + ) + except httpx.TimeoutException: + return RenderResult(success=False, error="Renderer timed out after 30s") + except httpx.ConnectError: + return RenderResult(success=False, error="Could not connect to renderer service") + except Exception as e: + return RenderResult(success=False, error=f"Renderer error: {str(e)}") diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml index b6e761d..3c7f562 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -13,11 +13,13 @@ dependencies = [ "alembic>=1.14.0", "pydantic>=2.10.0", "pydantic-settings>=2.7.0", + "email-validator>=2.2.0", "pgvector>=0.3.6", "redis>=5.2.0", "celery[redis]>=5.4.0", "passlib[bcrypt]>=1.7.4", "python-jose[cryptography]>=3.3.0", + "cryptography>=43.0.0", "httpx>=0.28.0", "python-multipart>=0.0.12", "stripe>=11.0.0", diff --git a/services/frontend/index.html b/services/frontend/index.html new file mode 100644 index 0000000..ae12fc6 --- /dev/null +++ b/services/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Fractafrag + + +
+ + + diff --git a/services/frontend/postcss.config.js b/services/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/services/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/services/frontend/public/fracta.svg b/services/frontend/public/fracta.svg new file mode 100644 index 0000000..022b34e --- /dev/null +++ b/services/frontend/public/fracta.svg @@ -0,0 +1,10 @@ + + + + + + + + + ff + diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx new file mode 100644 index 0000000..83871a7 --- /dev/null +++ b/services/frontend/src/App.tsx @@ -0,0 +1,34 @@ +import { Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import Feed from './pages/Feed'; +import Explore from './pages/Explore'; +import ShaderDetail from './pages/ShaderDetail'; +import Editor from './pages/Editor'; +import Generate from './pages/Generate'; +import Bounties from './pages/Bounties'; +import BountyDetail from './pages/BountyDetail'; +import Profile from './pages/Profile'; +import Settings from './pages/Settings'; +import Login from './pages/Login'; +import Register from './pages/Register'; + +export default function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + ); +} diff --git a/services/frontend/src/components/Layout.tsx b/services/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..6c673d6 --- /dev/null +++ b/services/frontend/src/components/Layout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from './Navbar'; + +export default function Layout() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/services/frontend/src/components/Navbar.tsx b/services/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..9d3c105 --- /dev/null +++ b/services/frontend/src/components/Navbar.tsx @@ -0,0 +1,65 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '@/stores/auth'; +import api from '@/lib/api'; + +export default function Navbar() { + const { user, isAuthenticated, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await api.post('/auth/logout'); + } catch { + // Best-effort + } + logout(); + navigate('/'); + }; + + return ( + + ); +} diff --git a/services/frontend/src/components/ShaderCanvas.tsx b/services/frontend/src/components/ShaderCanvas.tsx new file mode 100644 index 0000000..836df40 --- /dev/null +++ b/services/frontend/src/components/ShaderCanvas.tsx @@ -0,0 +1,228 @@ +/** + * ShaderCanvas — Core WebGL component for rendering GLSL shaders. + * + * Shadertoy-compatible: accepts mainImage(out vec4 fragColor, in vec2 fragCoord) + * Injects uniforms: iTime, iResolution, iMouse + * + * Used in the editor (full-size), feed items (thumbnail), and shader detail page. + */ + +import { useRef, useEffect, useCallback } from 'react'; + +interface ShaderCanvasProps { + code: string; + width?: number; + height?: number; + className?: string; + animate?: boolean; + onError?: (error: string) => void; + onCompileSuccess?: () => void; +} + +const VERTEX_SHADER = `#version 300 es +in vec4 a_position; +void main() { gl_Position = a_position; } +`; + +function buildFragmentShader(userCode: string): string { + const prefix = `#version 300 es +precision highp float; +uniform float iTime; +uniform vec3 iResolution; +uniform vec4 iMouse; +out vec4 outColor; +`; + + if (userCode.includes('mainImage')) { + return prefix + userCode + ` +void main() { + vec4 col; + mainImage(col, gl_FragCoord.xy); + outColor = col; +}`; + } + + // If user has void main(), replace gl_FragColor with outColor + return prefix + userCode.replace(/gl_FragColor/g, 'outColor'); +} + +function createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader) || 'Unknown error'; + gl.deleteShader(shader); + throw new Error(info); + } + return shader; +} + +export default function ShaderCanvas({ + code, + width, + height, + className = '', + animate = true, + onError, + onCompileSuccess, +}: ShaderCanvasProps) { + const canvasRef = useRef(null); + const glRef = useRef(null); + const programRef = useRef(null); + const animRef = useRef(0); + const startTimeRef = useRef(0); + const mouseRef = useRef<[number, number, number, number]>([0, 0, 0, 0]); + + const cleanup = useCallback(() => { + if (animRef.current) { + cancelAnimationFrame(animRef.current); + animRef.current = 0; + } + const gl = glRef.current; + if (gl && programRef.current) { + gl.deleteProgram(programRef.current); + programRef.current = null; + } + }, []); + + const compile = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !code.trim()) return; + + let gl = glRef.current; + if (!gl) { + gl = canvas.getContext('webgl2', { + antialias: false, + preserveDrawingBuffer: true, + }); + if (!gl) { + onError?.('WebGL2 not supported'); + return; + } + glRef.current = gl; + } + + // Clean previous program + if (programRef.current) { + gl.deleteProgram(programRef.current); + programRef.current = null; + } + + try { + const vs = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const fs = createShader(gl, gl.FRAGMENT_SHADER, buildFragmentShader(code)); + if (!vs || !fs) throw new Error('Failed to create shaders'); + + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const err = gl.getProgramInfoLog(program) || 'Link failed'; + gl.deleteProgram(program); + throw new Error(err); + } + + // Clean up individual shaders + gl.deleteShader(vs); + gl.deleteShader(fs); + + programRef.current = program; + gl.useProgram(program); + + // Set up fullscreen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); + const posLoc = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + startTimeRef.current = performance.now(); + onCompileSuccess?.(); + onError?.(''); + + // Start render loop + const render = () => { + if (!programRef.current || !glRef.current) return; + const gl = glRef.current; + const w = canvas.width; + const h = canvas.height; + const t = (performance.now() - startTimeRef.current) / 1000; + + gl.viewport(0, 0, w, h); + + const uTime = gl.getUniformLocation(programRef.current, 'iTime'); + const uRes = gl.getUniformLocation(programRef.current, 'iResolution'); + const uMouse = gl.getUniformLocation(programRef.current, 'iMouse'); + + if (uTime) gl.uniform1f(uTime, t); + if (uRes) gl.uniform3f(uRes, w, h, 1.0); + if (uMouse) gl.uniform4f(uMouse, ...mouseRef.current); + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + + if (animate) { + animRef.current = requestAnimationFrame(render); + } + }; + + if (animRef.current) cancelAnimationFrame(animRef.current); + render(); + + } catch (e: any) { + onError?.(e.message || 'Compilation failed'); + } + }, [code, animate, onError, onCompileSuccess]); + + useEffect(() => { + compile(); + return cleanup; + }, [compile, cleanup]); + + // Resize handling + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const w = entry.contentRect.width; + const h = entry.contentRect.height; + if (w > 0 && h > 0) { + canvas.width = w * (window.devicePixelRatio > 1 ? 1.5 : 1); + canvas.height = h * (window.devicePixelRatio > 1 ? 1.5 : 1); + } + } + }); + + observer.observe(canvas); + return () => observer.disconnect(); + }, []); + + // Mouse tracking + const handleMouseMove = (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + mouseRef.current = [ + e.clientX - rect.left, + rect.height - (e.clientY - rect.top), + mouseRef.current[2], + mouseRef.current[3], + ]; + }; + + return ( + + ); +} diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css new file mode 100644 index 0000000..4a77028 --- /dev/null +++ b/services/frontend/src/index.css @@ -0,0 +1,62 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + color-scheme: dark; + } + + body { + @apply bg-surface-0 text-gray-100; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Custom scrollbar */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-track { + @apply bg-surface-1; + } + ::-webkit-scrollbar-thumb { + @apply bg-surface-4 rounded-full; + } + ::-webkit-scrollbar-thumb:hover { + @apply bg-fracta-600; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 + font-medium rounded-lg transition-all duration-150 + focus:outline-none focus:ring-2 focus:ring-fracta-500/50 + disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-primary { + @apply btn bg-fracta-600 hover:bg-fracta-500 text-white; + } + .btn-secondary { + @apply btn bg-surface-3 hover:bg-surface-4 text-gray-200; + } + .btn-ghost { + @apply btn bg-transparent hover:bg-surface-3 text-gray-300; + } + .btn-danger { + @apply btn bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/30; + } + + .input { + @apply w-full px-3 py-2 bg-surface-2 border border-surface-4 + rounded-lg text-gray-100 placeholder-gray-500 + focus:outline-none focus:border-fracta-500 focus:ring-1 focus:ring-fracta-500/30 + transition-colors; + } + + .card { + @apply bg-surface-1 border border-surface-3 rounded-xl overflow-hidden; + } +} diff --git a/services/frontend/src/lib/api.ts b/services/frontend/src/lib/api.ts new file mode 100644 index 0000000..befb98a --- /dev/null +++ b/services/frontend/src/lib/api.ts @@ -0,0 +1,54 @@ +/** + * API client — Axios instance with JWT auth and automatic refresh. + */ + +import axios from 'axios'; +import { useAuthStore } from '@/stores/auth'; + +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +const api = axios.create({ + baseURL: `${API_BASE}/v1`, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, // Send refresh token cookie +}); + +// Request interceptor: attach access token +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor: auto-refresh on 401 +api.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config; + + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + + try { + const { data } = await axios.post( + `${API_BASE}/v1/auth/refresh`, + {}, + { withCredentials: true }, + ); + useAuthStore.getState().setAccessToken(data.access_token); + original.headers.Authorization = `Bearer ${data.access_token}`; + return api(original); + } catch { + useAuthStore.getState().logout(); + window.location.href = '/login'; + return Promise.reject(error); + } + } + + return Promise.reject(error); + }, +); + +export default api; diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx new file mode 100644 index 0000000..8a512d9 --- /dev/null +++ b/services/frontend/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/services/frontend/src/pages/Bounties.tsx b/services/frontend/src/pages/Bounties.tsx new file mode 100644 index 0000000..0ccc4c8 --- /dev/null +++ b/services/frontend/src/pages/Bounties.tsx @@ -0,0 +1,75 @@ +/** + * Bounties page — browse open desire queue. + */ + +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import api from '@/lib/api'; + +export default function Bounties() { + const { data: desires = [], isLoading } = useQuery({ + queryKey: ['desires'], + queryFn: async () => { + const { data } = await api.get('/desires', { params: { limit: 30 } }); + return data; + }, + }); + + return ( +
+
+
+

Desire Queue

+

+ What the community wants to see. Fulfill a desire to earn tips. +

+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : desires.length > 0 ? ( +
+ {desires.map((desire: any) => ( + +
+
+

{desire.prompt_text}

+
+ + 🔥 Heat: {desire.heat_score.toFixed(1)} + + {desire.tip_amount_cents > 0 && ( + + 💰 ${(desire.tip_amount_cents / 100).toFixed(2)} tip + + )} + {new Date(desire.created_at).toLocaleDateString()} +
+
+ + {desire.status} + +
+ + ))} +
+ ) : ( +
+ No open desires yet. The queue is empty. +
+ )} +
+ ); +} diff --git a/services/frontend/src/pages/BountyDetail.tsx b/services/frontend/src/pages/BountyDetail.tsx new file mode 100644 index 0000000..daffc77 --- /dev/null +++ b/services/frontend/src/pages/BountyDetail.tsx @@ -0,0 +1,103 @@ +/** + * Bounty detail page — single desire with fulfillment option. + */ + +import { useParams, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; + +export default function BountyDetail() { + const { id } = useParams<{ id: string }>(); + + const { data: desire, isLoading } = useQuery({ + queryKey: ['desire', id], + queryFn: async () => { + const { data } = await api.get(`/desires/${id}`); + return data; + }, + enabled: !!id, + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!desire) { + return ( +
+ Desire not found +
+ ); + } + + return ( +
+ + ← Back to Bounties + + +
+
+
+

{desire.prompt_text}

+
+ 🔥 Heat: {desire.heat_score.toFixed(1)} + {desire.tip_amount_cents > 0 && ( + + 💰 ${(desire.tip_amount_cents / 100).toFixed(2)} bounty + + )} + {new Date(desire.created_at).toLocaleDateString()} +
+
+ + {desire.status} + +
+ + {desire.style_hints && ( +
+

Style hints

+
+              {JSON.stringify(desire.style_hints, null, 2)}
+            
+
+ )} + + {desire.status === 'open' && ( +
+ + Fulfill this Desire → + +

+ Write a shader that matches this description, then submit it as fulfillment. +

+
+ )} + + {desire.fulfilled_by_shader && ( +
+

Fulfilled by

+ + View shader → + +
+ )} +
+
+ ); +} diff --git a/services/frontend/src/pages/Editor.tsx b/services/frontend/src/pages/Editor.tsx new file mode 100644 index 0000000..73b6698 --- /dev/null +++ b/services/frontend/src/pages/Editor.tsx @@ -0,0 +1,232 @@ +/** + * Editor page — GLSL editor with live WebGL preview. + * + * Split pane: code editor (left), live preview (right). + * Uses a textarea for M1 (Monaco editor integration comes later). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useParams, useNavigate } 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 { isAuthenticated, user } = useAuthStore(); + + 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 debounceRef = useRef>(); + + // Load existing shader for forking + const { data: existingShader } = useQuery({ + queryKey: ['shader', id], + queryFn: async () => { + const { data } = await api.get(`/shaders/${id}`); + return data; + }, + enabled: !!id, + }); + + useEffect(() => { + if (existingShader) { + setCode(existingShader.glsl_code); + setLiveCode(existingShader.glsl_code); + setTitle(`Fork of ${existingShader.title}`); + setShaderType(existingShader.shader_type); + setTags(existingShader.tags?.join(', ') || ''); + } + }, [existingShader]); + + // Debounced live preview update + const handleCodeChange = useCallback((value: string) => { + setCode(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setLiveCode(value); + }, 400); + }, []); + + const handleSubmit = async () => { + if (!isAuthenticated()) { + navigate('/login'); + return; + } + + setSubmitting(true); + setSubmitError(''); + + try { + const { data } = await api.post('/shaders', { + title, + description, + glsl_code: code, + tags: tags.split(',').map(t => t.trim()).filter(Boolean), + shader_type: shaderType, + }); + navigate(`/shader/${data.id}`); + } catch (err: any) { + const detail = err.response?.data?.detail; + if (typeof detail === 'object' && detail.errors) { + setSubmitError(detail.errors.join('\n')); + } else { + setSubmitError(detail || 'Submission 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" + placeholder="Shader title..." + /> + +
+ +
+ {compileError && ( + + ⚠ {compileError.split('\n')[0]} + + )} + +
+
+ + {/* 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} +
+ )} + + {/* Split pane: editor + preview */} +
+ {/* Code editor */} +
+
+ + fragment.glsl +
+