Files
reaktor/packages/server/src/routes/workshop.js
Jose Luis 982654c3ef feat: Phase 3 — Workshop (community patch sharing)
Server:
- GET /api/v1/workshop — browse patches (search, tags, sort)
- POST /api/v1/workshop — share a patch (auth required)
- GET /api/v1/workshop/:id — single patch detail
- DELETE /api/v1/workshop/:id — soft delete (owner/admin)
- POST/DELETE /api/v1/workshop/:id/like — like/unlike
- POST /api/v1/workshop/:id/report — flag for moderation

Client:
- Workshop page with nav bar (Sandbox/SynthQuest/Workshop tabs)
- Search bar + tag filters (ambient, bass, drums, etc.)
- Sort by recent/popular
- Patch cards: title, author, tags, likes, module count
- "Cargar" button loads patch into Sandbox
- Share modal: title, description, tags, shares current canvas
- User badge + login button in Workshop nav
- Responsive: single column on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:33:53 +01:00

176 lines
5.4 KiB
JavaScript

import { eq, sql, desc, and, ilike, or, inArray } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function workshopRoutes(fastify) {
// Browse patches (public)
fastify.get('/', async (request) => {
const { q, tags, sort = 'recent', page = 1 } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
const conditions = [eq(schema.sharedPatches.isDeleted, false)];
if (q) {
conditions.push(
or(
ilike(schema.sharedPatches.title, `%${q}%`),
ilike(schema.sharedPatches.description, `%${q}%`)
)
);
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim());
conditions.push(sql`${schema.sharedPatches.tags} && ARRAY[${sql.join(tagList.map(t => sql`${t}`), sql`, `)}]::text[]`);
}
const orderBy = sort === 'popular'
? desc(schema.sharedPatches.likesCount)
: desc(schema.sharedPatches.createdAt);
const patches = await db.select({
id: schema.sharedPatches.id,
title: schema.sharedPatches.title,
description: schema.sharedPatches.description,
tags: schema.sharedPatches.tags,
data: schema.sharedPatches.data,
likesCount: schema.sharedPatches.likesCount,
createdAt: schema.sharedPatches.createdAt,
userId: schema.sharedPatches.userId,
}).from(schema.sharedPatches)
.where(and(...conditions))
.orderBy(orderBy)
.limit(limit)
.offset(offset);
// Get usernames for patches
const userIds = [...new Set(patches.filter(p => p.userId).map(p => p.userId))];
let userMap = {};
if (userIds.length > 0) {
const users = await db.select({
id: schema.users.id,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
}).from(schema.users).where(inArray(schema.users.id, userIds));
userMap = Object.fromEntries(users.map(u => [u.id, u]));
}
const result = patches.map(p => ({
...p,
author: userMap[p.userId] || null,
userId: undefined,
}));
return { patches: result, page: +page, limit };
});
// Get single patch
fastify.get('/:id', async (request, reply) => {
const [patch] = await db.select().from(schema.sharedPatches)
.where(and(
eq(schema.sharedPatches.id, request.params.id),
eq(schema.sharedPatches.isDeleted, false)
)).limit(1);
if (!patch) return reply.code(404).send({ error: 'Not found' });
let author = null;
if (patch.userId) {
const [user] = await db.select({
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
}).from(schema.users).where(eq(schema.users.id, patch.userId)).limit(1);
author = user || null;
}
return { ...patch, author };
});
// Share a patch (requires auth)
fastify.post('/', { preHandler: [authenticate] }, async (request) => {
const { title, description, tags, data } = request.body;
if (!title || !data) return { error: 'title and data required' };
const [patch] = await db.insert(schema.sharedPatches).values({
userId: request.user.id,
title,
description: description || '',
tags: tags || [],
data,
}).returning();
return patch;
});
// Delete own patch
fastify.delete('/:id', { preHandler: [authenticate] }, async (request, reply) => {
const [patch] = await db.select().from(schema.sharedPatches)
.where(eq(schema.sharedPatches.id, request.params.id)).limit(1);
if (!patch) return reply.code(404).send({ error: 'Not found' });
// Owner or admin can delete
if (patch.userId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({ error: 'Forbidden' });
}
await db.update(schema.sharedPatches)
.set({ isDeleted: true })
.where(eq(schema.sharedPatches.id, request.params.id));
return { ok: true };
});
// Like a patch
fastify.post('/:id/like', { preHandler: [authenticate] }, async (request, reply) => {
const patchId = request.params.id;
// Check if already liked
const [existing] = await db.select().from(schema.likes)
.where(and(
eq(schema.likes.userId, request.user.id),
eq(schema.likes.patchId, patchId)
)).limit(1);
if (existing) return { liked: true, message: 'Already liked' };
await db.insert(schema.likes).values({
userId: request.user.id,
patchId,
});
await db.update(schema.sharedPatches)
.set({ likesCount: sql`${schema.sharedPatches.likesCount} + 1` })
.where(eq(schema.sharedPatches.id, patchId));
return { liked: true };
});
// Unlike a patch
fastify.delete('/:id/like', { preHandler: [authenticate] }, async (request) => {
const patchId = request.params.id;
const result = await db.delete(schema.likes)
.where(and(
eq(schema.likes.userId, request.user.id),
eq(schema.likes.patchId, patchId)
));
await db.update(schema.sharedPatches)
.set({ likesCount: sql`GREATEST(${schema.sharedPatches.likesCount} - 1, 0)` })
.where(eq(schema.sharedPatches.id, patchId));
return { liked: false };
});
// Report/flag a patch
fastify.post('/:id/report', { preHandler: [authenticate] }, async (request) => {
await db.update(schema.sharedPatches)
.set({ isFlagged: true })
.where(eq(schema.sharedPatches.id, request.params.id));
return { ok: true };
});
}