Versioning, drafts, resizable editor, My Shaders, 200 seed shaders

Architecture — Shader versioning & draft system:
- New shader_versions table: immutable snapshots of every edit
- Shaders now have status: draft, published, archived
- current_version counter tracks version number
- Every create/update creates a ShaderVersion record
- Restore-from-version endpoint creates new version (never destructive)
- Drafts are private, only visible to author
- Forks start as drafts
- Free tier rate limit applies only to published shaders (drafts unlimited)

Architecture — Platform identity:
- System account 'fractafrag' (UUID 00000000-...-000001) created in init.sql
- is_system flag on users and shaders
- system_label field: 'fractafrag-curated', future: 'fractafrag-generated'
- Feed/explore can filter by is_system
- System shaders display distinctly from user/AI content

API changes:
- GET /shaders/mine — user workspace (drafts, published, archived)
- GET /shaders/{id}/versions — version history
- GET /shaders/{id}/versions/{n} — specific version
- POST /shaders/{id}/versions/{n}/restore — restore old version
- POST /shaders accepts status: 'draft' | 'published'
- PUT /shaders/{id} accepts change_note for version descriptions
- PUT status transitions: draft→published, published→archived, archived→published

Frontend — Editor improvements:
- Resizable split pane with drag handle (20-80% range, smooth col-resize cursor)
- Save Draft button (creates/updates as draft, no publish)
- Publish button (validates, publishes, redirects to shader page)
- Version badge shows current version number when editing existing
- Owner detection: editing own shader vs forking someone else's
- Saved status indicator ('Draft saved', 'Published')

Frontend — My Shaders workspace:
- /my-shaders route with status tabs (All, Draft, Published, Archived)
- Count badges per tab
- Status badges on shader cards (draft=yellow, published=green, archived=grey)
- Version badges (v1, v2, etc.)
- Quick actions: Edit, Publish, Archive, Restore, Delete per status
- Drafts link to editor, published link to detail page

Seed data — 200 fractafrag-curated shaders:
- 171 2D + 29 3D shaders
- 500 unique tags across all shaders
- All 200 titles are unique
- Covers: fractals (Mandelbrot, Julia sets), noise (fbm, Voronoi, Perlin),
  raymarching (metaballs, terrain, torus knots, metall/glass),
  effects (glitch, VHS, plasma, aurora, lightning, fireworks),
  patterns (circuit, hex grid, stained glass, herringbone, moiré),
  physics (wave interference, pendulum, caustics, gravity lens),
  minimal (single shapes, gradients, dot grids),
  nature (ink, watercolor, smoke, sand garden, coral, nebula),
  color theory (RGB separation, CMY overlap, hue wheel),
  domain warping (acid trip, lava rift, storm eye),
  particles (fireflies, snow, ember, bubbles)
- Each shader has style_metadata (chaos_level, color_temperature, motion_type)
- Distributed creation times over 30 days for feed ranking variety
- Random initial scores for algorithm testing
- All authored by 'fractafrag' system account, is_system=true
- system_label='fractafrag-curated' for clear provenance

Schema:
- shader_versions table with (shader_id, version_number) unique constraint
- HNSW indexes on version lookup
- System account indexes
- Status-aware feed indexes
This commit is contained in:
John Lightner 2026-03-24 22:00:10 -05:00
parent 365c033e0e
commit 1047a1f5fe
11 changed files with 2765 additions and 165 deletions

View file

@ -16,6 +16,7 @@ CREATE TABLE users (
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', -- user, moderator, admin role TEXT NOT NULL DEFAULT 'user', -- user, moderator, admin
trust_tier TEXT NOT NULL DEFAULT 'standard', -- standard, creator, trusted_api trust_tier TEXT NOT NULL DEFAULT 'standard', -- standard, creator, trusted_api
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- platform system account (fractafrag)
stripe_customer_id TEXT, stripe_customer_id TEXT,
subscription_tier TEXT DEFAULT 'free', -- free, pro, studio subscription_tier TEXT DEFAULT 'free', -- free, pro, studio
ai_credits_remaining INTEGER DEFAULT 0, ai_credits_remaining INTEGER DEFAULT 0,
@ -38,21 +39,25 @@ CREATE TABLE shaders (
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
glsl_code TEXT NOT NULL, glsl_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published', -- draft, published, archived
is_public BOOLEAN DEFAULT TRUE, is_public BOOLEAN DEFAULT TRUE,
is_ai_generated BOOLEAN DEFAULT FALSE, is_ai_generated BOOLEAN DEFAULT FALSE,
ai_provider TEXT, -- anthropic, openai, ollama, null is_system BOOLEAN DEFAULT FALSE, -- generated by fractafrag platform
ai_provider TEXT, -- anthropic, openai, ollama, null
system_label TEXT, -- e.g. 'fractafrag-curated', 'fractafrag-generated'
thumbnail_url TEXT, thumbnail_url TEXT,
preview_url TEXT, preview_url TEXT,
render_status TEXT DEFAULT 'pending', -- pending, rendering, ready, failed render_status TEXT DEFAULT 'pending', -- pending, rendering, ready, failed
style_vector vector(512), -- pgvector: visual style embedding style_vector vector(512), -- pgvector: visual style embedding
style_metadata JSONB, -- { chaos_level, color_temp, motion_type, ... } style_metadata JSONB, -- { chaos_level, color_temp, motion_type, ... }
tags TEXT[], tags TEXT[],
shader_type TEXT DEFAULT '2d', -- 2d, 3d, audio-reactive shader_type TEXT DEFAULT '2d', -- 2d, 3d, audio-reactive
forked_from UUID REFERENCES shaders(id) ON DELETE SET NULL, forked_from UUID REFERENCES shaders(id) ON DELETE SET NULL,
current_version INTEGER NOT NULL DEFAULT 1, -- current version number
view_count INTEGER DEFAULT 0, view_count INTEGER DEFAULT 0,
score FLOAT DEFAULT 0, -- cached hot score for feed ranking score FLOAT DEFAULT 0, -- cached hot score for feed ranking
-- Creator economy stubs (Section 11f) -- Creator economy stubs (Section 11f)
access_tier TEXT DEFAULT 'open', -- open, source_locked, commercial access_tier TEXT DEFAULT 'open',
source_unlock_price_cents INTEGER, source_unlock_price_cents INTEGER,
commercial_license_price_cents INTEGER, commercial_license_price_cents INTEGER,
verified_creator_shader BOOLEAN DEFAULT FALSE, verified_creator_shader BOOLEAN DEFAULT FALSE,
@ -61,15 +66,33 @@ CREATE TABLE shaders (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
-- ════════════════════════════════════════════════════════════
-- SHADER VERSIONS — immutable snapshots of each edit
-- ════════════════════════════════════════════════════════════
CREATE TABLE shader_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shader_id UUID NOT NULL REFERENCES shaders(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
glsl_code TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
tags TEXT[],
style_metadata JSONB,
change_note TEXT, -- optional: "fixed the color bleeding", "added mouse interaction"
thumbnail_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (shader_id, version_number)
);
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- VOTES -- VOTES
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE votes ( CREATE TABLE votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE,
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE, shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
value SMALLINT NOT NULL CHECK (value IN (-1, 1)), value SMALLINT NOT NULL CHECK (value IN (-1, 1)),
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, shader_id) UNIQUE (user_id, shader_id)
); );
@ -78,10 +101,10 @@ CREATE TABLE votes (
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE engagement_events ( CREATE TABLE engagement_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- null for anonymous user_id UUID REFERENCES users(id) ON DELETE SET NULL,
session_id TEXT, -- anonymous session token session_id TEXT,
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE, shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, -- dwell, replay, share, generate_similar event_type TEXT NOT NULL,
dwell_secs FLOAT, dwell_secs FLOAT,
metadata JSONB, metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
@ -91,21 +114,20 @@ CREATE TABLE engagement_events (
-- DESIRES / BOUNTIES -- DESIRES / BOUNTIES
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE desires ( CREATE TABLE desires (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID REFERENCES users(id) ON DELETE SET NULL, author_id UUID REFERENCES users(id) ON DELETE SET NULL,
prompt_text TEXT NOT NULL, prompt_text TEXT NOT NULL,
prompt_embedding vector(512), -- embedded for similarity grouping prompt_embedding vector(512),
style_hints JSONB, -- { chaos_level, color_temp, etc } style_hints JSONB,
tip_amount_cents INTEGER DEFAULT 0, tip_amount_cents INTEGER DEFAULT 0,
status TEXT DEFAULT 'open', -- open, in_progress, fulfilled, expired status TEXT DEFAULT 'open',
heat_score FLOAT DEFAULT 1, -- updated as similar desires accumulate heat_score FLOAT DEFAULT 1,
fulfilled_by_shader UUID REFERENCES shaders(id) ON DELETE SET NULL, fulfilled_by_shader UUID REFERENCES shaders(id) ON DELETE SET NULL,
fulfilled_at TIMESTAMPTZ, fulfilled_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- Similar desire grouping (many-to-many)
CREATE TABLE desire_clusters ( CREATE TABLE desire_clusters (
cluster_id UUID, cluster_id UUID,
desire_id UUID REFERENCES desires(id) ON DELETE CASCADE, desire_id UUID REFERENCES desires(id) ON DELETE CASCADE,
@ -117,27 +139,27 @@ CREATE TABLE desire_clusters (
-- BOUNTY TIPS -- BOUNTY TIPS
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE bounty_tips ( CREATE TABLE bounty_tips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
desire_id UUID REFERENCES desires(id) ON DELETE CASCADE, desire_id UUID REFERENCES desires(id) ON DELETE CASCADE,
tipper_id UUID REFERENCES users(id) ON DELETE SET NULL, tipper_id UUID REFERENCES users(id) ON DELETE SET NULL,
amount_cents INTEGER NOT NULL, amount_cents INTEGER NOT NULL,
stripe_payment_intent_id TEXT, stripe_payment_intent_id TEXT,
status TEXT DEFAULT 'held', -- held, released, refunded status TEXT DEFAULT 'held',
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- CREATOR PAYOUTS -- CREATOR PAYOUTS
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE creator_payouts ( CREATE TABLE creator_payouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creator_id UUID REFERENCES users(id) ON DELETE SET NULL, creator_id UUID REFERENCES users(id) ON DELETE SET NULL,
desire_id UUID REFERENCES desires(id) ON DELETE SET NULL, desire_id UUID REFERENCES desires(id) ON DELETE SET NULL,
gross_amount_cents INTEGER, gross_amount_cents INTEGER,
platform_fee_cents INTEGER, -- 10% platform_fee_cents INTEGER,
net_amount_cents INTEGER, -- 90% net_amount_cents INTEGER,
stripe_transfer_id TEXT, stripe_transfer_id TEXT,
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@ -145,17 +167,17 @@ CREATE TABLE creator_payouts (
-- API KEYS (for MCP clients) -- API KEYS (for MCP clients)
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE api_keys ( CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT UNIQUE NOT NULL, -- bcrypt hash of the actual key key_hash TEXT UNIQUE NOT NULL,
key_prefix TEXT NOT NULL, -- first 8 chars for display (ff_key_XXXXXXXX) key_prefix TEXT NOT NULL,
name TEXT, -- user-given label name TEXT,
trust_tier TEXT DEFAULT 'probation', -- probation, trusted, premium trust_tier TEXT DEFAULT 'probation',
submissions_approved INTEGER DEFAULT 0, submissions_approved INTEGER DEFAULT 0,
rate_limit_per_hour INTEGER DEFAULT 10, rate_limit_per_hour INTEGER DEFAULT 10,
last_used_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ revoked_at TIMESTAMPTZ
); );
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
@ -168,7 +190,7 @@ CREATE TABLE generation_log (
provider TEXT NOT NULL, provider TEXT NOT NULL,
prompt_text TEXT, prompt_text TEXT,
tokens_used INTEGER, tokens_used INTEGER,
cost_cents INTEGER, -- platform cost for credit-based generations cost_cents INTEGER,
success BOOLEAN, success BOOLEAN,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@ -192,7 +214,7 @@ CREATE TABLE source_unlocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE, shader_id UUID REFERENCES shaders(id) ON DELETE CASCADE,
buyer_id UUID REFERENCES users(id) ON DELETE SET NULL, buyer_id UUID REFERENCES users(id) ON DELETE SET NULL,
license_type TEXT NOT NULL, -- personal, commercial license_type TEXT NOT NULL,
amount_cents INTEGER NOT NULL, amount_cents INTEGER NOT NULL,
platform_fee_cents INTEGER NOT NULL, platform_fee_cents INTEGER NOT NULL,
stripe_payment_intent_id TEXT, stripe_payment_intent_id TEXT,
@ -215,22 +237,18 @@ CREATE TABLE creator_engagement_snapshots (
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- Feed performance -- Feed performance
CREATE INDEX idx_shaders_score ON shaders(score DESC) WHERE is_public = TRUE; CREATE INDEX idx_shaders_score ON shaders(score DESC) WHERE is_public = TRUE AND status = 'published';
CREATE INDEX idx_shaders_created ON shaders(created_at DESC) WHERE is_public = TRUE; CREATE INDEX idx_shaders_created ON shaders(created_at DESC) WHERE is_public = TRUE AND status = 'published';
CREATE INDEX idx_shaders_tags ON shaders USING GIN(tags); CREATE INDEX idx_shaders_tags ON shaders USING GIN(tags);
CREATE INDEX idx_shaders_render_status ON shaders(render_status) WHERE render_status != 'ready'; CREATE INDEX idx_shaders_render_status ON shaders(render_status) WHERE render_status != 'ready';
CREATE INDEX idx_shaders_status ON shaders(status);
CREATE INDEX idx_shaders_author_status ON shaders(author_id, status, updated_at DESC);
CREATE INDEX idx_shaders_system ON shaders(is_system) WHERE is_system = TRUE;
-- Recommendation (pgvector ANN — ivfflat, will rebuild after data exists) -- Versioning
-- NOTE: ivfflat indexes require data in the table to build properly. CREATE INDEX idx_shader_versions_shader ON shader_versions(shader_id, version_number DESC);
-- Run these AFTER seeding initial data:
-- CREATE INDEX idx_shaders_style_vector ON shaders
-- USING ivfflat (style_vector vector_cosine_ops) WITH (lists = 100);
-- CREATE INDEX idx_users_taste_vector ON users
-- USING ivfflat (taste_vector vector_cosine_ops) WITH (lists = 50);
-- CREATE INDEX idx_desires_embedding ON desires
-- USING ivfflat (prompt_embedding vector_cosine_ops) WITH (lists = 50);
-- For now, use HNSW (works on empty tables, better perf at small scale) -- Recommendation (pgvector HNSW — works on empty tables)
CREATE INDEX idx_shaders_style_vector ON shaders CREATE INDEX idx_shaders_style_vector ON shaders
USING hnsw (style_vector vector_cosine_ops) WITH (m = 16, ef_construction = 64); USING hnsw (style_vector vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_users_taste_vector ON users CREATE INDEX idx_users_taste_vector ON users
@ -263,3 +281,20 @@ CREATE INDEX idx_comments_parent ON comments(parent_id);
-- Text search -- Text search
CREATE INDEX idx_shaders_title_trgm ON shaders USING GIN(title gin_trgm_ops); CREATE INDEX idx_shaders_title_trgm ON shaders USING GIN(title gin_trgm_ops);
CREATE INDEX idx_desires_prompt_trgm ON desires USING GIN(prompt_text gin_trgm_ops); CREATE INDEX idx_desires_prompt_trgm ON desires USING GIN(prompt_text gin_trgm_ops);
-- ════════════════════════════════════════════════════════════
-- SYSTEM ACCOUNT: The "fractafrag" platform user
-- All system-generated/curated shaders are authored by this account
-- ════════════════════════════════════════════════════════════
INSERT INTO users (id, username, email, password_hash, role, trust_tier, is_system, subscription_tier, is_verified_creator)
VALUES (
'00000000-0000-0000-0000-000000000001',
'fractafrag',
'system@fractafrag.local',
'$2b$12$000000000000000000000000000000000000000000000000000000', -- not a valid login
'admin',
'trusted_api',
TRUE,
'studio',
TRUE
);

2043
scripts/seed_shaders.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,14 @@
"""Models package.""" """Models package."""
from app.models.models import ( from app.models.models import (
User, Shader, Vote, EngagementEvent, Desire, DesireCluster, SYSTEM_USER_ID,
User, Shader, ShaderVersion, Vote, EngagementEvent, Desire, DesireCluster,
BountyTip, CreatorPayout, ApiKey, GenerationLog, Comment, BountyTip, CreatorPayout, ApiKey, GenerationLog, Comment,
SourceUnlock, CreatorEngagementSnapshot, SourceUnlock, CreatorEngagementSnapshot,
) )
__all__ = [ __all__ = [
"User", "Shader", "Vote", "EngagementEvent", "Desire", "DesireCluster", "SYSTEM_USER_ID",
"User", "Shader", "ShaderVersion", "Vote", "EngagementEvent", "Desire", "DesireCluster",
"BountyTip", "CreatorPayout", "ApiKey", "GenerationLog", "Comment", "BountyTip", "CreatorPayout", "ApiKey", "GenerationLog", "Comment",
"SourceUnlock", "CreatorEngagementSnapshot", "SourceUnlock", "CreatorEngagementSnapshot",
] ]

View file

@ -11,6 +11,9 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
# System account UUID — the "fractafrag" platform user
SYSTEM_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -21,19 +24,17 @@ class User(Base):
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") role = Column(String, nullable=False, default="user")
trust_tier = Column(String, nullable=False, default="standard") trust_tier = Column(String, nullable=False, default="standard")
is_system = Column(Boolean, nullable=False, default=False)
stripe_customer_id = Column(String, nullable=True) stripe_customer_id = Column(String, nullable=True)
subscription_tier = Column(String, default="free") subscription_tier = Column(String, default="free")
ai_credits_remaining = Column(Integer, default=0) ai_credits_remaining = Column(Integer, default=0)
taste_vector = Column(Vector(512), nullable=True) taste_vector = Column(Vector(512), nullable=True)
# Creator economy stubs
is_verified_creator = Column(Boolean, default=False) is_verified_creator = Column(Boolean, default=False)
verified_creator_at = Column(DateTime(timezone=True), nullable=True) verified_creator_at = Column(DateTime(timezone=True), nullable=True)
stripe_connect_account_id = Column(String, nullable=True) stripe_connect_account_id = Column(String, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
last_active_at = Column(DateTime(timezone=True), nullable=True) last_active_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
shaders = relationship("Shader", back_populates="author") shaders = relationship("Shader", back_populates="author")
votes = relationship("Vote", back_populates="user") votes = relationship("Vote", back_populates="user")
api_keys = relationship("ApiKey", back_populates="user") api_keys = relationship("ApiKey", back_populates="user")
@ -47,9 +48,12 @@ class Shader(Base):
title = Column(String, nullable=False) title = Column(String, nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
glsl_code = Column(Text, nullable=False) glsl_code = Column(Text, nullable=False)
status = Column(String, nullable=False, default="published") # draft, published, archived
is_public = Column(Boolean, default=True) is_public = Column(Boolean, default=True)
is_ai_generated = Column(Boolean, default=False) is_ai_generated = Column(Boolean, default=False)
is_system = Column(Boolean, default=False)
ai_provider = Column(String, nullable=True) ai_provider = Column(String, nullable=True)
system_label = Column(String, nullable=True)
thumbnail_url = Column(String, nullable=True) thumbnail_url = Column(String, nullable=True)
preview_url = Column(String, nullable=True) preview_url = Column(String, nullable=True)
render_status = Column(String, default="pending") render_status = Column(String, default="pending")
@ -58,20 +62,38 @@ class Shader(Base):
tags = Column(ARRAY(String), default=list) tags = Column(ARRAY(String), default=list)
shader_type = Column(String, default="2d") shader_type = Column(String, default="2d")
forked_from = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True) forked_from = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="SET NULL"), nullable=True)
current_version = Column(Integer, nullable=False, default=1)
view_count = Column(Integer, default=0) view_count = Column(Integer, default=0)
score = Column(Float, default=0.0) score = Column(Float, default=0.0)
# Creator economy stubs
access_tier = Column(String, default="open") access_tier = Column(String, default="open")
source_unlock_price_cents = Column(Integer, nullable=True) source_unlock_price_cents = Column(Integer, nullable=True)
commercial_license_price_cents = Column(Integer, nullable=True) commercial_license_price_cents = Column(Integer, nullable=True)
verified_creator_shader = Column(Boolean, default=False) verified_creator_shader = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
author = relationship("User", back_populates="shaders") author = relationship("User", back_populates="shaders")
votes = relationship("Vote", back_populates="shader") votes = relationship("Vote", back_populates="shader")
versions = relationship("ShaderVersion", back_populates="shader", order_by="ShaderVersion.version_number.desc()")
class ShaderVersion(Base):
__tablename__ = "shader_versions"
__table_args__ = (UniqueConstraint("shader_id", "version_number"),)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
shader_id = Column(UUID(as_uuid=True), ForeignKey("shaders.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False)
glsl_code = Column(Text, nullable=False)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
tags = Column(ARRAY(String), default=list)
style_metadata = Column(JSONB, nullable=True)
change_note = Column(Text, nullable=True)
thumbnail_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
shader = relationship("Shader", back_populates="versions")
class Vote(Base): class Vote(Base):
@ -195,7 +217,6 @@ class Comment(Base):
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
# Creator economy stubs (dormant)
class SourceUnlock(Base): class SourceUnlock(Base):
__tablename__ = "source_unlocks" __tablename__ = "source_unlocks"

View file

@ -11,6 +11,9 @@ from app.middleware.auth import get_optional_user, get_current_user
router = APIRouter() router = APIRouter()
# Common filter for public, published shaders
_FEED_FILTER = [Shader.is_public == True, Shader.status == "published"]
@router.get("", response_model=list[ShaderFeedItem]) @router.get("", response_model=list[ShaderFeedItem])
async def get_feed( async def get_feed(
@ -19,15 +22,9 @@ async def get_feed(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user), user: User | None = Depends(get_optional_user),
): ):
"""
Personalized feed for authenticated users (pgvector taste match).
Trending/new for anonymous users.
"""
# TODO: Implement full recommendation engine (Track F)
# For now: return newest public shaders
query = ( query = (
select(Shader) select(Shader)
.where(Shader.is_public == True, Shader.render_status == "ready") .where(*_FEED_FILTER)
.order_by(Shader.created_at.desc()) .order_by(Shader.created_at.desc())
.limit(limit) .limit(limit)
) )
@ -42,7 +39,7 @@ async def get_trending(
): ):
query = ( query = (
select(Shader) select(Shader)
.where(Shader.is_public == True, Shader.render_status == "ready") .where(*_FEED_FILTER)
.order_by(Shader.score.desc()) .order_by(Shader.score.desc())
.limit(limit) .limit(limit)
) )
@ -57,7 +54,7 @@ async def get_new(
): ):
query = ( query = (
select(Shader) select(Shader)
.where(Shader.is_public == True, Shader.render_status == "ready") .where(*_FEED_FILTER)
.order_by(Shader.created_at.desc()) .order_by(Shader.created_at.desc())
.limit(limit) .limit(limit)
) )
@ -71,7 +68,6 @@ async def report_dwell(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user), user: User | None = Depends(get_optional_user),
): ):
"""Report dwell time signal for recommendation engine."""
from app.models import EngagementEvent from app.models import EngagementEvent
event = EngagementEvent( event = EngagementEvent(
@ -83,4 +79,3 @@ async def report_dwell(
event_metadata={"replayed": body.replayed}, event_metadata={"replayed": body.replayed},
) )
db.add(event) db.add(event)
# TODO: Update user taste vector (Track F)

View file

@ -1,4 +1,4 @@
"""Shaders router — CRUD, submit, fork, search.""" """Shaders router — CRUD, versioning, drafts, fork, search."""
from uuid import UUID from uuid import UUID
from datetime import datetime, timezone from datetime import datetime, timezone
@ -7,25 +7,31 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.database import get_db from app.database import get_db
from app.models import User, Shader from app.models import User, Shader, ShaderVersion
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic, ShaderVersionPublic
from app.middleware.auth import get_current_user, get_optional_user from app.middleware.auth import get_current_user, get_optional_user
from app.services.glsl_validator import validate_glsl from app.services.glsl_validator import validate_glsl
router = APIRouter() router = APIRouter()
# ── Public list / search ──────────────────────────────────
@router.get("", response_model=list[ShaderPublic]) @router.get("", response_model=list[ShaderPublic])
async def list_shaders( async def list_shaders(
q: str | None = Query(None, description="Search query"), q: str | None = Query(None, description="Search query"),
tags: list[str] | None = Query(None, description="Filter by tags"), tags: list[str] | None = Query(None, description="Filter by tags"),
shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"), shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"),
sort: str = Query("trending", description="Sort: trending, new, top"), sort: str = Query("trending", description="Sort: trending, new, top"),
is_system: bool | None = Query(None, description="Filter to system/platform shaders"),
limit: int = Query(20, ge=1, le=50), limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(Shader).where(Shader.is_public == True, Shader.render_status == "ready") query = select(Shader).where(
Shader.is_public == True,
Shader.status == "published",
)
if q: if q:
query = query.where(Shader.title.ilike(f"%{q}%")) query = query.where(Shader.title.ilike(f"%{q}%"))
@ -33,12 +39,14 @@ async def list_shaders(
query = query.where(Shader.tags.overlap(tags)) query = query.where(Shader.tags.overlap(tags))
if shader_type: if shader_type:
query = query.where(Shader.shader_type == shader_type) query = query.where(Shader.shader_type == shader_type)
if is_system is not None:
query = query.where(Shader.is_system == is_system)
if sort == "new": if sort == "new":
query = query.order_by(Shader.created_at.desc()) query = query.order_by(Shader.created_at.desc())
elif sort == "top": elif sort == "top":
query = query.order_by(Shader.score.desc()) query = query.order_by(Shader.score.desc())
else: # trending else:
query = query.order_by(Shader.score.desc(), Shader.created_at.desc()) query = query.order_by(Shader.score.desc(), Shader.created_at.desc())
query = query.limit(limit).offset(offset) query = query.limit(limit).offset(offset)
@ -46,6 +54,27 @@ async def list_shaders(
return result.scalars().all() return result.scalars().all()
# ── My shaders (workspace) ───────────────────────────────
@router.get("/mine", response_model=list[ShaderPublic])
async def my_shaders(
status_filter: str | None = Query(None, alias="status", description="draft, published, archived"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List the authenticated user's shaders — drafts, published, archived."""
query = select(Shader).where(Shader.author_id == user.id)
if status_filter:
query = query.where(Shader.status == status_filter)
query = query.order_by(Shader.updated_at.desc()).limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
# ── Single shader ─────────────────────────────────────────
@router.get("/{shader_id}", response_model=ShaderPublic) @router.get("/{shader_id}", response_model=ShaderPublic)
async def get_shader( async def get_shader(
shader_id: UUID, shader_id: UUID,
@ -57,47 +86,87 @@ async def get_shader(
if not shader: if not shader:
raise HTTPException(status_code=404, detail="Shader not found") raise HTTPException(status_code=404, detail="Shader not found")
# Drafts are only visible to their author
if shader.status == "draft" and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
if not shader.is_public and (not user or user.id != shader.author_id): if not shader.is_public and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found") raise HTTPException(status_code=404, detail="Shader not found")
# Increment view count
shader.view_count += 1 shader.view_count += 1
return shader return shader
# ── Version history ───────────────────────────────────────
@router.get("/{shader_id}/versions", response_model=list[ShaderVersionPublic])
async def list_versions(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
"""Get the version history of a shader."""
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.status == "draft" and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
result = await db.execute(
select(ShaderVersion)
.where(ShaderVersion.shader_id == shader_id)
.order_by(ShaderVersion.version_number.desc())
)
return result.scalars().all()
@router.get("/{shader_id}/versions/{version_number}", response_model=ShaderVersionPublic)
async def get_version(
shader_id: UUID,
version_number: int,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ShaderVersion).where(
ShaderVersion.shader_id == shader_id,
ShaderVersion.version_number == version_number,
)
)
version = result.scalar_one_or_none()
if not version:
raise HTTPException(status_code=404, detail="Version not found")
return version
# ── Create shader (draft or published) ───────────────────
@router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def create_shader( async def create_shader(
body: ShaderCreate, body: ShaderCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
# Rate limit: free tier gets 5 submissions/month # Rate limit published shaders for free tier (drafts are unlimited)
if user.subscription_tier == "free": if body.status == "published" and user.subscription_tier == "free":
month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
count_result = await db.execute( count_result = await db.execute(
select(func.count()).select_from(Shader).where( select(func.count()).select_from(Shader).where(
Shader.author_id == user.id, Shader.author_id == user.id,
Shader.status == "published",
Shader.created_at >= month_start, Shader.created_at >= month_start,
) )
) )
monthly_count = count_result.scalar() monthly_count = count_result.scalar()
if monthly_count >= 5: if monthly_count >= 5:
raise HTTPException( raise HTTPException(status_code=429, detail="Free tier: 5 published shaders/month. Upgrade to Pro for unlimited.")
status_code=429,
detail="Free tier: 5 shader submissions per month. Upgrade to Pro for unlimited."
)
# Validate GLSL # Validate GLSL
validation = validate_glsl(body.glsl_code, body.shader_type) validation = validate_glsl(body.glsl_code, body.shader_type)
if not validation.valid: if not validation.valid:
raise HTTPException( raise HTTPException(status_code=422, detail={
status_code=422, "message": "GLSL validation failed",
detail={ "errors": validation.errors,
"message": "GLSL validation failed", "warnings": validation.warnings,
"errors": validation.errors, })
"warnings": validation.warnings,
}
)
shader = Shader( shader = Shader(
author_id=user.id, author_id=user.id,
@ -106,23 +175,37 @@ async def create_shader(
glsl_code=body.glsl_code, glsl_code=body.glsl_code,
tags=body.tags, tags=body.tags,
shader_type=body.shader_type, shader_type=body.shader_type,
is_public=body.is_public, is_public=body.is_public if body.status == "published" else False,
status=body.status,
style_metadata=body.style_metadata, style_metadata=body.style_metadata,
render_status="pending", render_status="ready" if body.status == "draft" else "pending",
current_version=1,
) )
db.add(shader) db.add(shader)
await db.flush() await db.flush()
# Enqueue render job # Create version 1 snapshot
from app.worker import celery_app v1 = ShaderVersion(
try: shader_id=shader.id,
celery_app.send_task("render_shader", args=[str(shader.id)]) version_number=1,
except Exception: glsl_code=body.glsl_code,
# If Celery isn't available (dev without worker), mark as ready title=body.title,
# with no thumbnail — the frontend can still render live description=body.description,
shader.render_status = "ready" tags=body.tags,
style_metadata=body.style_metadata,
change_note="Initial version",
)
db.add(v1)
# If this shader fulfills a desire, link them # Enqueue render for published shaders
if body.status == "published":
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
# Link to desire if fulfilling
if body.fulfills_desire_id: if body.fulfills_desire_id:
from app.models import Desire from app.models import Desire
desire = (await db.execute( desire = (await db.execute(
@ -136,6 +219,8 @@ async def create_shader(
return shader return shader
# ── Update shader (creates new version) ──────────────────
@router.put("/{shader_id}", response_model=ShaderPublic) @router.put("/{shader_id}", response_model=ShaderPublic)
async def update_shader( async def update_shader(
shader_id: UUID, shader_id: UUID,
@ -151,20 +236,40 @@ async def update_shader(
raise HTTPException(status_code=403, detail="Not the shader owner") raise HTTPException(status_code=403, detail="Not the shader owner")
updates = body.model_dump(exclude_unset=True) updates = body.model_dump(exclude_unset=True)
change_note = updates.pop("change_note", None)
code_changed = "glsl_code" in updates
# Re-validate GLSL if code changed # Re-validate GLSL if code changed
if "glsl_code" in updates: if code_changed:
validation = validate_glsl(updates["glsl_code"], shader.shader_type) validation = validate_glsl(updates["glsl_code"], shader.shader_type)
if not validation.valid: if not validation.valid:
raise HTTPException( raise HTTPException(status_code=422, detail={
status_code=422, "message": "GLSL validation failed",
detail={ "errors": validation.errors,
"message": "GLSL validation failed", "warnings": validation.warnings,
"errors": validation.errors, })
"warnings": validation.warnings,
} # Apply updates
) for field, value in updates.items():
# Re-render if code changed setattr(shader, field, value)
# Create a new version snapshot if code or metadata changed
if code_changed or "title" in updates or "description" in updates or "tags" in updates:
shader.current_version += 1
new_version = ShaderVersion(
shader_id=shader.id,
version_number=shader.current_version,
glsl_code=shader.glsl_code,
title=shader.title,
description=shader.description,
tags=shader.tags,
style_metadata=shader.style_metadata,
change_note=change_note,
)
db.add(new_version)
# Re-render if code changed and shader is published
if code_changed and shader.status == "published":
shader.render_status = "pending" shader.render_status = "pending"
from app.worker import celery_app from app.worker import celery_app
try: try:
@ -172,13 +277,22 @@ async def update_shader(
except Exception: except Exception:
shader.render_status = "ready" shader.render_status = "ready"
for field, value in updates.items(): # If publishing a draft, ensure it's public and queue render
setattr(shader, field, value) if "status" in updates and updates["status"] == "published" and shader.render_status != "ready":
shader.is_public = True
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"
shader.updated_at = datetime.now(timezone.utc) shader.updated_at = datetime.now(timezone.utc)
return shader return shader
# ── Delete ────────────────────────────────────────────────
@router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shader( async def delete_shader(
shader_id: UUID, shader_id: UUID,
@ -191,10 +305,11 @@ async def delete_shader(
raise HTTPException(status_code=404, detail="Shader not found") raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin": if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner") raise HTTPException(status_code=403, detail="Not the shader owner")
await db.delete(shader) await db.delete(shader)
# ── Fork ──────────────────────────────────────────────────
@router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) @router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def fork_shader( async def fork_shader(
shader_id: UUID, shader_id: UUID,
@ -205,7 +320,7 @@ async def fork_shader(
original = result.scalar_one_or_none() original = result.scalar_one_or_none()
if not original: if not original:
raise HTTPException(status_code=404, detail="Shader not found") raise HTTPException(status_code=404, detail="Shader not found")
if not original.is_public: if not original.is_public and original.status != "published":
raise HTTPException(status_code=404, detail="Shader not found") raise HTTPException(status_code=404, detail="Shader not found")
forked = Shader( forked = Shader(
@ -217,16 +332,83 @@ async def fork_shader(
shader_type=original.shader_type, shader_type=original.shader_type,
forked_from=original.id, forked_from=original.id,
style_metadata=original.style_metadata, style_metadata=original.style_metadata,
render_status="pending", status="draft", # Forks start as drafts
is_public=False,
render_status="ready",
current_version=1,
) )
db.add(forked) db.add(forked)
await db.flush() await db.flush()
# Enqueue render for the fork v1 = ShaderVersion(
from app.worker import celery_app shader_id=forked.id,
try: version_number=1,
celery_app.send_task("render_shader", args=[str(forked.id)]) glsl_code=original.glsl_code,
except Exception: title=forked.title,
forked.render_status = "ready" description=forked.description,
tags=original.tags,
style_metadata=original.style_metadata,
change_note=f"Forked from {original.title}",
)
db.add(v1)
return forked return forked
# ── Restore a version ────────────────────────────────────
@router.post("/{shader_id}/versions/{version_number}/restore", response_model=ShaderPublic)
async def restore_version(
shader_id: UUID,
version_number: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Restore a shader to a previous version (creates a new version snapshot)."""
shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
version = (await db.execute(
select(ShaderVersion).where(
ShaderVersion.shader_id == shader_id,
ShaderVersion.version_number == version_number,
)
)).scalar_one_or_none()
if not version:
raise HTTPException(status_code=404, detail="Version not found")
# Apply version data to shader
shader.glsl_code = version.glsl_code
shader.title = version.title
shader.description = version.description
shader.tags = version.tags
shader.style_metadata = version.style_metadata
shader.current_version += 1
shader.updated_at = datetime.now(timezone.utc)
# Create a new version snapshot for the restore
restore_v = ShaderVersion(
shader_id=shader.id,
version_number=shader.current_version,
glsl_code=version.glsl_code,
title=version.title,
description=version.description,
tags=version.tags,
style_metadata=version.style_metadata,
change_note=f"Restored from version {version_number}",
)
db.add(restore_v)
# Re-render if published
if shader.status == "published":
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"
return shader

View file

@ -35,6 +35,7 @@ class UserPublic(BaseModel):
id: UUID id: UUID
username: str username: str
role: str role: str
is_system: bool
subscription_tier: str subscription_tier: str
is_verified_creator: bool is_verified_creator: bool
created_at: datetime created_at: datetime
@ -53,7 +54,6 @@ class UserUpdate(BaseModel):
class ByokKeysUpdate(BaseModel): class ByokKeysUpdate(BaseModel):
"""Bring Your Own Key — encrypted API keys for AI providers."""
anthropic_key: Optional[str] = Field(None, description="Anthropic API key") anthropic_key: Optional[str] = Field(None, description="Anthropic API key")
openai_key: Optional[str] = Field(None, description="OpenAI API key") openai_key: Optional[str] = Field(None, description="OpenAI API key")
ollama_endpoint: Optional[str] = Field(None, description="Ollama endpoint URL") ollama_endpoint: Optional[str] = Field(None, description="Ollama endpoint URL")
@ -70,6 +70,7 @@ class ShaderCreate(BaseModel):
tags: list[str] = Field(default_factory=list, max_length=10) tags: list[str] = Field(default_factory=list, max_length=10)
shader_type: str = Field(default="2d", pattern=r"^(2d|3d|audio-reactive)$") shader_type: str = Field(default="2d", pattern=r"^(2d|3d|audio-reactive)$")
is_public: bool = True is_public: bool = True
status: str = Field(default="published", pattern=r"^(draft|published)$")
style_metadata: Optional[dict] = None style_metadata: Optional[dict] = None
fulfills_desire_id: Optional[UUID] = None fulfills_desire_id: Optional[UUID] = None
@ -80,6 +81,8 @@ class ShaderUpdate(BaseModel):
glsl_code: Optional[str] = Field(None, min_length=10) glsl_code: Optional[str] = Field(None, min_length=10)
tags: Optional[list[str]] = None tags: Optional[list[str]] = None
is_public: Optional[bool] = None is_public: Optional[bool] = None
status: Optional[str] = Field(None, pattern=r"^(draft|published|archived)$")
change_note: Optional[str] = Field(None, max_length=200)
class ShaderPublic(BaseModel): class ShaderPublic(BaseModel):
@ -90,9 +93,12 @@ class ShaderPublic(BaseModel):
title: str title: str
description: Optional[str] description: Optional[str]
glsl_code: str glsl_code: str
status: str
is_public: bool is_public: bool
is_ai_generated: bool is_ai_generated: bool
is_system: bool
ai_provider: Optional[str] ai_provider: Optional[str]
system_label: Optional[str]
thumbnail_url: Optional[str] thumbnail_url: Optional[str]
preview_url: Optional[str] preview_url: Optional[str]
render_status: str render_status: str
@ -100,6 +106,7 @@ class ShaderPublic(BaseModel):
tags: list[str] tags: list[str]
shader_type: str shader_type: str
forked_from: Optional[UUID] forked_from: Optional[UUID]
current_version: int
view_count: int view_count: int
score: float score: float
created_at: datetime created_at: datetime
@ -121,10 +128,28 @@ class ShaderFeedItem(BaseModel):
score: float score: float
view_count: int view_count: int
is_ai_generated: bool is_ai_generated: bool
is_system: bool
system_label: Optional[str]
style_metadata: Optional[dict] style_metadata: Optional[dict]
created_at: datetime created_at: datetime
class ShaderVersionPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
shader_id: UUID
version_number: int
glsl_code: str
title: str
description: Optional[str]
tags: list[str]
style_metadata: Optional[dict]
change_note: Optional[str]
thumbnail_url: Optional[str]
created_at: datetime
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# VOTES & ENGAGEMENT # VOTES & ENGAGEMENT
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@ -170,13 +195,13 @@ class DesirePublic(BaseModel):
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):
prompt: str = Field(..., min_length=5, max_length=500) prompt: str = Field(..., min_length=5, max_length=500)
provider: Optional[str] = None # anthropic, openai, ollama — auto-selected if None provider: Optional[str] = None
style_metadata: Optional[dict] = None style_metadata: Optional[dict] = None
class GenerateStatusResponse(BaseModel): class GenerateStatusResponse(BaseModel):
job_id: str job_id: str
status: str # queued, generating, rendering, complete, failed status: str
shader_id: Optional[UUID] = None shader_id: Optional[UUID] = None
error: Optional[str] = None error: Optional[str] = None
@ -202,7 +227,6 @@ class ApiKeyPublic(BaseModel):
class ApiKeyCreated(ApiKeyPublic): class ApiKeyCreated(ApiKeyPublic):
"""Returned only on creation — includes the full key (shown once)."""
full_key: str full_key: str

View file

@ -4,6 +4,7 @@ import Feed from './pages/Feed';
import Explore from './pages/Explore'; import Explore from './pages/Explore';
import ShaderDetail from './pages/ShaderDetail'; import ShaderDetail from './pages/ShaderDetail';
import Editor from './pages/Editor'; import Editor from './pages/Editor';
import MyShaders from './pages/MyShaders';
import Generate from './pages/Generate'; import Generate from './pages/Generate';
import Bounties from './pages/Bounties'; import Bounties from './pages/Bounties';
import BountyDetail from './pages/BountyDetail'; import BountyDetail from './pages/BountyDetail';
@ -21,6 +22,7 @@ export default function App() {
<Route path="/shader/:id" element={<ShaderDetail />} /> <Route path="/shader/:id" element={<ShaderDetail />} />
<Route path="/editor" element={<Editor />} /> <Route path="/editor" element={<Editor />} />
<Route path="/editor/:id" element={<Editor />} /> <Route path="/editor/:id" element={<Editor />} />
<Route path="/my-shaders" element={<MyShaders />} />
<Route path="/generate" element={<Generate />} /> <Route path="/generate" element={<Generate />} />
<Route path="/bounties" element={<Bounties />} /> <Route path="/bounties" element={<Bounties />} />
<Route path="/bounties/:id" element={<BountyDetail />} /> <Route path="/bounties/:id" element={<BountyDetail />} />

View file

@ -41,6 +41,7 @@ export default function Navbar() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isAuthenticated() && user ? ( {isAuthenticated() && user ? (
<> <>
<Link to="/my-shaders" className="btn-ghost text-sm py-1 px-3">My Shaders</Link>
<Link <Link
to={`/profile/${user.username}`} to={`/profile/${user.username}`}
className="text-sm text-gray-300 hover:text-white transition-colors" className="text-sm text-gray-300 hover:text-white transition-colors"

View file

@ -1,8 +1,11 @@
/** /**
* Editor page GLSL editor with live WebGL preview. * Editor page GLSL editor with live WebGL preview.
* *
* Split pane: code editor (left), live preview (right). * Features:
* Uses a textarea for M1 (Monaco editor integration comes later). * - 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 { useState, useEffect, useCallback, useRef } from 'react';
@ -47,10 +50,17 @@ export default function Editor() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [showMeta, setShowMeta] = useState(false); 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>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Load existing shader for forking // Load existing shader for editing or forking
const { data: existingShader } = useQuery({ const { data: existingShader } = useQuery({
queryKey: ['shader', id], queryKey: ['shader', id],
queryFn: async () => { queryFn: async () => {
@ -64,22 +74,61 @@ export default function Editor() {
if (existingShader) { if (existingShader) {
setCode(existingShader.glsl_code); setCode(existingShader.glsl_code);
setLiveCode(existingShader.glsl_code); setLiveCode(existingShader.glsl_code);
setTitle(`Fork of ${existingShader.title}`); setTitle(existingShader.title);
setDescription(existingShader.description || '');
setShaderType(existingShader.shader_type); setShaderType(existingShader.shader_type);
setTags(existingShader.tags?.join(', ') || ''); setTags(existingShader.tags?.join(', ') || '');
}
}, [existingShader]);
// Debounced live preview update // 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) => { const handleCodeChange = useCallback((value: string) => {
setCode(value); setCode(value);
setSavedStatus(null);
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
setLiveCode(value); setLiveCode(value);
}, 400); }, 400);
}, []); }, []);
const handleSubmit = async () => { // ── Save / Publish ─────────────────────────────────────
const handleSave = async (publishStatus: 'draft' | 'published') => {
if (!isAuthenticated()) { if (!isAuthenticated()) {
navigate('/login'); navigate('/login');
return; return;
@ -88,21 +137,44 @@ export default function Editor() {
setSubmitting(true); setSubmitting(true);
setSubmitError(''); 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 { try {
const { data } = await api.post('/shaders', { if (editingExisting && id) {
title, // Update existing shader
description, const { data } = await api.put(`/shaders/${id}`, {
glsl_code: code, ...payload,
tags: tags.split(',').map(t => t.trim()).filter(Boolean), change_note: publishStatus === 'published' ? 'Updated' : undefined,
shader_type: shaderType, });
}); setSavedStatus(publishStatus === 'draft' ? 'Draft saved' : 'Published');
navigate(`/shader/${data.id}`); if (publishStatus === 'published') {
setTimeout(() => navigate(`/shader/${data.id}`), 800);
}
} else {
// Create new shader
const { data } = await api.post('/shaders', payload);
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) { } catch (err: any) {
const detail = err.response?.data?.detail; const detail = err.response?.data?.detail;
if (typeof detail === 'object' && detail.errors) { if (typeof detail === 'object' && detail.errors) {
setSubmitError(detail.errors.join('\n')); setSubmitError(detail.errors.join('\n'));
} else { } else {
setSubmitError(detail || 'Submission failed'); setSubmitError(detail || 'Save failed');
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -119,7 +191,7 @@ export default function Editor() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="bg-transparent text-lg font-medium text-gray-100 focus:outline-none className="bg-transparent text-lg font-medium text-gray-100 focus:outline-none
border-b border-transparent focus:border-fracta-500 transition-colors" border-b border-transparent focus:border-fracta-500 transition-colors w-64"
placeholder="Shader title..." placeholder="Shader title..."
/> />
<button <button
@ -128,6 +200,11 @@ export default function Editor() {
> >
{showMeta ? 'Hide details' : 'Details'} {showMeta ? 'Hide details' : 'Details'}
</button> </button>
{editingExisting && existingShader && (
<span className="text-xs text-gray-500">
v{existingShader.current_version}
</span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -136,8 +213,18 @@ export default function Editor() {
{compileError.split('\n')[0]} {compileError.split('\n')[0]}
</span> </span>
)} )}
{savedStatus && (
<span className="text-xs text-green-400 animate-fade-in">{savedStatus}</span>
)}
<button <button
onClick={handleSubmit} onClick={() => handleSave('draft')}
disabled={submitting}
className="btn-secondary text-sm py-1.5"
>
{submitting ? '...' : 'Save Draft'}
</button>
<button
onClick={() => handleSave('published')}
disabled={submitting || !!compileError} disabled={submitting || !!compileError}
className="btn-primary text-sm py-1.5" className="btn-primary text-sm py-1.5"
> >
@ -191,10 +278,10 @@ export default function Editor() {
</div> </div>
)} )}
{/* Split pane: editor + preview */} {/* Split pane: editor + drag handle + preview */}
<div className="flex-1 flex min-h-0"> <div ref={containerRef} className="flex-1 flex min-h-0">
{/* Code editor */} {/* Code editor */}
<div className="w-1/2 flex flex-col border-r border-surface-3"> <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"> <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" /> <span className="w-2 h-2 rounded-full bg-green-500" />
fragment.glsl fragment.glsl
@ -211,8 +298,19 @@ export default function Editor() {
/> />
</div> </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 */} {/* Live preview */}
<div className="w-1/2 bg-black relative"> <div className="flex-1 bg-black relative min-w-0">
<ShaderCanvas <ShaderCanvas
code={liveCode} code={liveCode}
className="w-full h-full" className="w-full h-full"

View file

@ -0,0 +1,197 @@
/**
* My Shaders personal workspace with drafts, published, archived.
* Version history access and iteration workflow.
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import api from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import ShaderCanvas from '@/components/ShaderCanvas';
type StatusTab = 'all' | 'draft' | 'published' | 'archived';
export default function MyShaders() {
const { isAuthenticated, user } = useAuthStore();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [tab, setTab] = useState<StatusTab>('all');
if (!isAuthenticated()) {
navigate('/login');
return null;
}
const { data: shaders = [], isLoading } = useQuery({
queryKey: ['my-shaders', tab],
queryFn: async () => {
const params: any = { limit: 100 };
if (tab !== 'all') params.status = tab;
const { data } = await api.get('/shaders/mine', { params });
return data;
},
});
const archiveMutation = useMutation({
mutationFn: async (id: string) => {
await api.put(`/shaders/${id}`, { status: 'archived' });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['my-shaders'] }),
});
const publishMutation = useMutation({
mutationFn: async (id: string) => {
await api.put(`/shaders/${id}`, { status: 'published', is_public: true });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['my-shaders'] }),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/shaders/${id}`);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['my-shaders'] }),
});
const counts = {
all: shaders.length,
draft: shaders.filter((s: any) => s.status === 'draft').length,
published: shaders.filter((s: any) => s.status === 'published').length,
archived: shaders.filter((s: any) => s.status === 'archived').length,
};
return (
<div className="max-w-6xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">My Shaders</h1>
<p className="text-gray-500 text-sm mt-1">
Your workspace drafts, published shaders, and version history.
</p>
</div>
<Link to="/editor" className="btn-primary text-sm">+ New Shader</Link>
</div>
{/* Status tabs */}
<div className="flex gap-1 bg-surface-2 rounded-lg p-1 mb-6 w-fit">
{(['all', 'draft', 'published', 'archived'] as StatusTab[]).map((s) => (
<button
key={s}
onClick={() => setTab(s)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5 ${
tab === s ? 'bg-fracta-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
{s === 'all' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
tab === s ? 'bg-fracta-500/50' : 'bg-surface-4'
}`}>
{counts[s]}
</span>
</button>
))}
</div>
{/* Shader list */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).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 gap-4">
{shaders.map((shader: any) => (
<div key={shader.id} className="card group">
<Link to={shader.status === 'draft' ? `/editor/${shader.id}` : `/shader/${shader.id}`}>
<div className="aspect-video bg-surface-2 relative overflow-hidden">
<ShaderCanvas code={shader.glsl_code} className="w-full h-full" animate={true} />
{/* Status badge */}
<span className={`absolute top-2 left-2 px-2 py-0.5 text-xs rounded-full ${
shader.status === 'draft' ? 'bg-yellow-600/80 text-yellow-100' :
shader.status === 'archived' ? 'bg-gray-600/80 text-gray-300' :
'bg-green-600/80 text-green-100'
}`}>
{shader.status}
</span>
{/* Version badge */}
<span className="absolute top-2 right-2 px-2 py-0.5 text-xs rounded-full bg-surface-0/80 text-gray-400">
v{shader.current_version}
</span>
</div>
</Link>
<div className="p-3">
<Link to={shader.status === 'draft' ? `/editor/${shader.id}` : `/shader/${shader.id}`}>
<h3 className="font-medium text-gray-100 group-hover:text-fracta-400 transition-colors truncate">
{shader.title}
</h3>
</Link>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{new Date(shader.updated_at).toLocaleDateString()} · {shader.shader_type}
</span>
<div className="flex gap-1">
{shader.status === 'draft' && (
<>
<Link to={`/editor/${shader.id}`} className="btn-ghost text-xs py-0.5 px-2">
Edit
</Link>
<button
onClick={(e) => { e.stopPropagation(); publishMutation.mutate(shader.id); }}
className="btn-primary text-xs py-0.5 px-2"
>
Publish
</button>
</>
)}
{shader.status === 'published' && (
<>
<Link to={`/editor/${shader.id}`} className="btn-ghost text-xs py-0.5 px-2">
Edit
</Link>
<button
onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(shader.id); }}
className="btn-ghost text-xs py-0.5 px-2 text-yellow-400"
>
Archive
</button>
</>
)}
{shader.status === 'archived' && (
<>
<button
onClick={(e) => { e.stopPropagation(); publishMutation.mutate(shader.id); }}
className="btn-ghost text-xs py-0.5 px-2 text-green-400"
>
Restore
</button>
<button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(shader.id); }}
className="btn-ghost text-xs py-0.5 px-2 text-red-400"
>
Delete
</button>
</>
)}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-gray-400 text-lg">No {tab === 'all' ? '' : tab + ' '}shaders yet</p>
<Link to="/editor" className="btn-primary mt-4 inline-flex">Create Your First Shader</Link>
</div>
)}
</div>
);
}