fractafrag/services/frontend/src/pages/Explore.tsx
John Lightner c4b8c0fe38 Tracks B+C+D: Auth system, renderer, full frontend shell
Track B — Auth & User System (complete):
- User registration with bcrypt + Turnstile verification
- JWT access/refresh token flow with httpOnly cookie rotation
- Redis refresh token blocklist for logout
- User profile + settings update endpoints (username, email)
- API key generation with bcrypt hashing (ff_key_ prefix)
- BYOK key management with AES-256-GCM encryption at rest
- Free tier rate limiting (5 shaders/month)
- Tier-gated endpoints (Pro/Studio for BYOK, API keys, bounty posting)

Track C — Shader Submission & Renderer (complete):
- GLSL validator: entry point check, banned extensions, infinite loop detection,
  brace balancing, loop bound warnings, code length limits
- Puppeteer/headless Chromium renderer with Shadertoy-compatible uniform injection
  (iTime, iResolution, iMouse), WebGL2 with SwiftShader fallback
- Shader compilation error detection via page title signaling
- Thumbnail capture at t=1s, preview frame at t=duration
- Renderer client service for API→renderer HTTP communication
- Shader submission pipeline: validate GLSL → create record → enqueue render job
- Desire fulfillment linking on shader submit
- Re-validation and re-render on shader code update
- Fork endpoint copies code, tags, metadata, enqueues new render

Track D — Frontend Shell (complete):
- React 18 + Vite + TypeScript + Tailwind CSS + TanStack Query + Zustand
- Dark theme with custom fracta color palette and surface tones
- Responsive layout with sticky navbar, gradient branding
- Auth: Login + Register pages with JWT token management
- API client with automatic 401 refresh interceptor
- ShaderCanvas: Full WebGL2 renderer component with Shadertoy uniforms,
  mouse tracking, ResizeObserver, debounced recompilation, error callbacks
- GLSL Editor: Split pane (code textarea + live preview), 400ms debounced
  preview, metadata panel (description, tags, type), GLSL validation errors,
  shader publish flow, fork-from-existing support
- Feed: Infinite scroll with IntersectionObserver sentinel, dwell time tracking,
  skeleton loading states, empty state with CTA
- Explore: Search + tag filter + sort tabs (trending/new/top), grid layout
- ShaderDetail: Full-screen preview, vote controls, view source toggle, fork button
- Bounties: Desire queue list sorted by heat score, status badges, tip display
- BountyDetail: Single desire view with style hints, fulfill CTA
- Profile: User header with avatar initial, shader grid
- Settings: Account info, API key management (create/revoke/copy), subscription tiers
- Generate: AI generation UI stub with prompt input, style controls, example prompts

76 files, ~5,700 lines of application code.
2026-03-24 20:56:42 -05:00

127 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Explore page — browse shaders by tag, trending, new, top.
*/
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import api from '@/lib/api';
import ShaderCanvas from '@/components/ShaderCanvas';
type SortOption = 'trending' | 'new' | 'top';
export default function Explore() {
const [searchParams, setSearchParams] = useSearchParams();
const [sort, setSort] = useState<SortOption>((searchParams.get('sort') as SortOption) || 'trending');
const [query, setQuery] = useState(searchParams.get('q') || '');
const tagFilter = searchParams.get('tags')?.split(',').filter(Boolean) || [];
const { data: shaders = [], isLoading } = useQuery({
queryKey: ['explore', sort, query, tagFilter.join(',')],
queryFn: async () => {
const params: any = { sort, limit: 30 };
if (query) params.q = query;
if (tagFilter.length) params.tags = tagFilter;
const { data } = await api.get('/shaders', { params });
return data;
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ sort, q: query });
};
return (
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">Explore</h1>
{/* Sort tabs */}
<div className="flex gap-1 bg-surface-2 rounded-lg p-1">
{(['trending', 'new', 'top'] as SortOption[]).map((s) => (
<button
key={s}
onClick={() => setSort(s)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
sort === s ? 'bg-fracta-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="mb-6">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input max-w-md"
placeholder="Search shaders..."
/>
</form>
{/* Tag filter pills */}
{tagFilter.length > 0 && (
<div className="flex gap-2 mb-4">
{tagFilter.map((tag) => (
<span key={tag} className="text-xs px-2 py-1 bg-fracta-600/20 text-fracta-400 rounded-full flex items-center gap-1">
#{tag}
<button
onClick={() => {
const newTags = tagFilter.filter(t => t !== tag);
setSearchParams(newTags.length ? { tags: newTags.join(',') } : {});
}}
className="hover:text-white"
>
×
</button>
</span>
))}
</div>
)}
{/* Grid */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="card animate-pulse">
<div className="aspect-video bg-surface-3" />
<div className="p-3 space-y-2">
<div className="h-4 bg-surface-3 rounded w-3/4" />
<div className="h-3 bg-surface-3 rounded w-1/2" />
</div>
</div>
))}
</div>
) : shaders.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{shaders.map((shader: any) => (
<Link key={shader.id} to={`/shader/${shader.id}`} className="card group">
<div className="aspect-video bg-surface-2 overflow-hidden">
<ShaderCanvas code={shader.glsl_code} className="w-full h-full" animate={true} />
</div>
<div className="p-3">
<h3 className="font-medium text-gray-100 group-hover:text-fracta-400 transition-colors truncate">
{shader.title}
</h3>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{shader.shader_type}</span>
<span>·</span>
<span>{shader.view_count} views</span>
</div>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-20 text-gray-500">
No shaders found. Try a different search or sort.
</div>
)}
</div>
);
}