fractafrag/services/api/app/routers/feed.py
John Lightner 1047a1f5fe 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
2026-03-24 22:00:10 -05:00

81 lines
2.2 KiB
Python

"""Feed router — personalized feed, trending, new."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import User, Shader
from app.schemas import ShaderFeedItem, DwellReport
from app.middleware.auth import get_optional_user, get_current_user
router = APIRouter()
# Common filter for public, published shaders
_FEED_FILTER = [Shader.is_public == True, Shader.status == "published"]
@router.get("", response_model=list[ShaderFeedItem])
async def get_feed(
limit: int = Query(20, ge=1, le=50),
cursor: str | None = Query(None),
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
query = (
select(Shader)
.where(*_FEED_FILTER)
.order_by(Shader.created_at.desc())
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@router.get("/trending", response_model=list[ShaderFeedItem])
async def get_trending(
limit: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
):
query = (
select(Shader)
.where(*_FEED_FILTER)
.order_by(Shader.score.desc())
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@router.get("/new", response_model=list[ShaderFeedItem])
async def get_new(
limit: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
):
query = (
select(Shader)
.where(*_FEED_FILTER)
.order_by(Shader.created_at.desc())
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@router.post("/dwell", status_code=204)
async def report_dwell(
body: DwellReport,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
from app.models import EngagementEvent
event = EngagementEvent(
user_id=user.id if user else None,
session_id=body.session_id,
shader_id=body.shader_id,
event_type="dwell",
dwell_secs=body.dwell_secs,
event_metadata={"replayed": body.replayed},
)
db.add(event)