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:
parent
365c033e0e
commit
1047a1f5fe
11 changed files with 2765 additions and 165 deletions
97
db/init.sql
97
db/init.sql
|
|
@ -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,9 +39,12 @@ 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,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE, -- generated by fractafrag platform
|
||||||
ai_provider TEXT, -- anthropic, openai, ollama, null
|
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
|
||||||
|
|
@ -49,10 +53,11 @@ CREATE TABLE shaders (
|
||||||
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,6 +66,24 @@ 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
|
||||||
-- ════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -94,18 +117,17 @@ 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,
|
||||||
|
|
@ -122,7 +144,7 @@ CREATE TABLE bounty_tips (
|
||||||
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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -134,10 +156,10 @@ CREATE TABLE creator_payouts (
|
||||||
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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -147,10 +169,10 @@ CREATE TABLE creator_payouts (
|
||||||
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,
|
||||||
|
|
@ -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
2043
scripts/seed_shaders.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
detail={
|
|
||||||
"message": "GLSL validation failed",
|
"message": "GLSL validation failed",
|
||||||
"errors": validation.errors,
|
"errors": validation.errors,
|
||||||
"warnings": validation.warnings,
|
"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
|
||||||
|
v1 = ShaderVersion(
|
||||||
|
shader_id=shader.id,
|
||||||
|
version_number=1,
|
||||||
|
glsl_code=body.glsl_code,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
tags=body.tags,
|
||||||
|
style_metadata=body.style_metadata,
|
||||||
|
change_note="Initial version",
|
||||||
|
)
|
||||||
|
db.add(v1)
|
||||||
|
|
||||||
|
# Enqueue render for published shaders
|
||||||
|
if body.status == "published":
|
||||||
from app.worker import celery_app
|
from app.worker import celery_app
|
||||||
try:
|
try:
|
||||||
celery_app.send_task("render_shader", args=[str(shader.id)])
|
celery_app.send_task("render_shader", args=[str(shader.id)])
|
||||||
except Exception:
|
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"
|
shader.render_status = "ready"
|
||||||
|
|
||||||
# If this shader fulfills a desire, link them
|
# 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,
|
|
||||||
detail={
|
|
||||||
"message": "GLSL validation failed",
|
"message": "GLSL validation failed",
|
||||||
"errors": validation.errors,
|
"errors": validation.errors,
|
||||||
"warnings": validation.warnings,
|
"warnings": validation.warnings,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field, value in updates.items():
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
# Re-render if code changed
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
|
||||||
try {
|
const payload = {
|
||||||
const { data } = await api.post('/shaders', {
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
glsl_code: code,
|
glsl_code: code,
|
||||||
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
shader_type: shaderType,
|
shader_type: shaderType,
|
||||||
|
status: publishStatus,
|
||||||
|
is_public: publishStatus === 'published',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingExisting && id) {
|
||||||
|
// Update existing shader
|
||||||
|
const { data } = await api.put(`/shaders/${id}`, {
|
||||||
|
...payload,
|
||||||
|
change_note: publishStatus === 'published' ? 'Updated' : undefined,
|
||||||
});
|
});
|
||||||
|
setSavedStatus(publishStatus === 'draft' ? 'Draft saved' : 'Published');
|
||||||
|
if (publishStatus === 'published') {
|
||||||
|
setTimeout(() => navigate(`/shader/${data.id}`), 800);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new shader
|
||||||
|
const { data } = await api.post('/shaders', payload);
|
||||||
|
if (publishStatus === 'published') {
|
||||||
navigate(`/shader/${data.id}`);
|
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"
|
||||||
|
|
|
||||||
197
services/frontend/src/pages/MyShaders.tsx
Normal file
197
services/frontend/src/pages/MyShaders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue