diff --git a/packages/client/src/App.jsx b/packages/client/src/App.jsx
index b37b446..af9caf1 100644
--- a/packages/client/src/App.jsx
+++ b/packages/client/src/App.jsx
@@ -15,7 +15,7 @@ import { usePinchZoom } from './hooks/usePinchZoom.js';
import { getModulesByCategory } from './engine/moduleRegistry.js';
import { useAuth } from './services/AuthContext.jsx';
-export default function App({ onSwitchToGame }) {
+export default function App({ onSwitchToGame, onSwitchToWorkshop }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
@@ -281,6 +281,11 @@ export default function App({ onSwitchToGame }) {
๐ฎ Game
)}
+ {onSwitchToWorkshop && !isMobile && (
+
+ )}
Reaktor
{!isMobile &&
}
{!isMobile && (
diff --git a/packages/client/src/components/Workshop.jsx b/packages/client/src/components/Workshop.jsx
new file mode 100644
index 0000000..3596ede
--- /dev/null
+++ b/packages/client/src/components/Workshop.jsx
@@ -0,0 +1,228 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { workshop as workshopApi } from '../services/api.js';
+import { useAuth } from '../services/AuthContext.jsx';
+import { state, deserialize } from '../engine/state.js';
+import { serialize } from '../engine/state.js';
+import { rebuildGraph } from '../engine/audioEngine.js';
+
+const TAGS = ['ambient', 'bass', 'drums', 'pad', 'lead', 'fx', 'chiptune', 'experimental'];
+
+function ShareModal({ onClose, onShared }) {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleShare = async () => {
+ if (!title.trim()) { setError('Titulo requerido'); return; }
+ if (state.modules.length === 0) { setError('No hay modulos en el canvas'); return; }
+
+ setLoading(true);
+ setError('');
+ try {
+ const patchData = serialize();
+ await workshopApi.share({
+ title: title.trim(),
+ description: description.trim(),
+ tags: selectedTags,
+ data: patchData,
+ });
+ onShared?.();
+ onClose();
+ } catch (err) {
+ setError(err.message);
+ }
+ setLoading(false);
+ };
+
+ return (
+
+
e.stopPropagation()} style={{ gap: 14 }}>
+
Compartir Patch
+
+
+
+
setTitle(e.target.value)} />
+
+
+
+
+
+
+
+ );
+}
+
+function PatchCard({ patch, onLoad, onLike }) {
+ const moduleCount = patch.data?.modules?.length || 0;
+ const wireCount = patch.data?.connections?.length || 0;
+
+ return (
+
+
+ {moduleCount > 6 ? '~ ~ ~ ~' : '~ ~'}
+
+
+
{patch.title}
+
por {patch.author?.username || 'Anonimo'}
+ {patch.tags?.length > 0 && (
+
+ {patch.tags.map(t => {t})}
+
+ )}
+
+
+ {moduleCount} modules ยท {wireCount} wires
+
+
+
+
+ );
+}
+
+export default function Workshop({ onSwitchToSandbox, onSwitchToGame }) {
+ const { isLoggedIn, openAuth, logout, user } = useAuth();
+ const [patches, setPatches] = useState([]);
+ const [search, setSearch] = useState('');
+ const [activeTag, setActiveTag] = useState('');
+ const [sort, setSort] = useState('recent');
+ const [loading, setLoading] = useState(true);
+ const [showShare, setShowShare] = useState(false);
+
+ const loadPatches = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (search) params.set('q', search);
+ if (activeTag) params.set('tags', activeTag);
+ params.set('sort', sort);
+ const data = await workshopApi.browse(params.toString());
+ setPatches(data.patches || []);
+ } catch (err) {
+ console.warn('Workshop load failed:', err);
+ }
+ setLoading(false);
+ }, [search, activeTag, sort]);
+
+ useEffect(() => { loadPatches(); }, [loadPatches]);
+
+ const handleLoad = (patch) => {
+ if (patch.data) {
+ deserialize(patch.data);
+ if (state.isRunning) rebuildGraph();
+ onSwitchToSandbox?.();
+ }
+ };
+
+ const handleLike = async (patchId) => {
+ if (!isLoggedIn) { openAuth(); return; }
+ try {
+ await workshopApi.like(patchId);
+ loadPatches();
+ } catch {}
+ };
+
+ const handleShare = () => {
+ if (!isLoggedIn) { openAuth(); return; }
+ setShowShare(true);
+ };
+
+ return (
+
+
+
+
+
Workshop
+
Explora, comparte y descubre sonidos de la comunidad
+
+
+
+
+ ๐
+ setSearch(e.target.value)} />
+
+
+
+
+ {TAGS.slice(0, 5).map(tag => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ Cargando...
+
+ ) : patches.length === 0 ? (
+
+ No hay patches aun. Se el primero en compartir!
+
+ ) : (
+ patches.map(p => (
+
+ ))
+ )}
+
+
+ {showShare &&
setShowShare(false)} onShared={loadPatches} />}
+
+ );
+}
diff --git a/packages/client/src/index.css b/packages/client/src/index.css
index d3014a1..8c5cb35 100644
--- a/packages/client/src/index.css
+++ b/packages/client/src/index.css
@@ -898,6 +898,121 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
}
.login-btn:hover { background: var(--accent); color: #000; }
+/* ===== Workshop ===== */
+.ws-nav {
+ display: flex; align-items: center; gap: 16px;
+ padding: 0 0 16px; border-bottom: 1px solid var(--border);
+}
+.ws-nav-logo { display: flex; align-items: center; gap: 8px; }
+.ws-nav-tabs { display: flex; gap: 4px; }
+.ws-nav-tab {
+ padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
+ font-size: 13px; font-weight: 500; font-family: inherit;
+ background: transparent; color: var(--text2); transition: all 0.15s;
+}
+.ws-nav-tab:hover { color: var(--text); }
+.ws-nav-tab.active {
+ background: var(--surface); color: var(--accent); font-weight: 600;
+ border: 1px solid var(--accent);
+}
+
+.ws-page {
+ display: flex; flex-direction: column; height: 100vh;
+ background: var(--bg); padding: 32px 48px; gap: 24px; overflow-y: auto;
+}
+.ws-header { text-align: center; }
+.ws-title { font-size: 32px; font-weight: 700; color: var(--text); margin: 0; }
+.ws-subtitle { font-size: 14px; color: var(--text2); margin: 4px 0 0; }
+
+.ws-toolbar {
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
+}
+.ws-search {
+ display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
+ padding: 10px 16px;
+}
+.ws-search input {
+ background: none; border: none; color: var(--text); font-size: 13px;
+ font-family: inherit; width: 100%; outline: none;
+ -webkit-user-select: text; user-select: text;
+}
+.ws-search input::placeholder { color: var(--text2); }
+
+.ws-tags { display: flex; gap: 6px; }
+.ws-tag {
+ padding: 6px 12px; border-radius: 16px; border: 1px solid var(--border);
+ background: var(--surface); color: var(--text2); font-size: 11px;
+ font-weight: 500; cursor: pointer; font-family: inherit; transition: all 0.15s;
+}
+.ws-tag.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
+.ws-tag:hover { border-color: var(--accent); }
+
+.ws-sort {
+ padding: 8px 14px; background: var(--surface); border: 1px solid var(--border);
+ border-radius: 8px; color: var(--text2); font-size: 12px; cursor: pointer;
+ font-family: inherit;
+}
+.ws-sort:focus { outline: none; border-color: var(--accent); }
+
+.ws-share-btn {
+ padding: 8px 16px; background: var(--accent); border: none; border-radius: 8px;
+ color: #000; font-size: 12px; font-weight: 700; cursor: pointer;
+ font-family: inherit; white-space: nowrap;
+}
+.ws-share-btn:hover { opacity: 0.9; }
+
+.ws-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.ws-card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
+ overflow: hidden; transition: all 0.15s; cursor: default;
+}
+.ws-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+
+.ws-card-preview {
+ height: 100px; background: var(--bg); display: flex;
+ align-items: center; justify-content: center;
+}
+.ws-card-wave { font-size: 24px; color: var(--accent); opacity: 0.2; }
+
+.ws-card-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
+.ws-card-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
+.ws-card-author { font-size: 11px; color: var(--text2); margin: 0; }
+
+.ws-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
+.ws-tag-pill {
+ padding: 2px 8px; background: var(--bg); border-radius: 10px;
+ font-size: 9px; color: var(--text2);
+}
+
+.ws-card-footer {
+ display: flex; align-items: center; gap: 8px; margin-top: 4px;
+}
+.ws-like-btn {
+ background: none; border: none; color: var(--red); font-size: 11px;
+ cursor: pointer; padding: 0; font-family: inherit;
+}
+.ws-like-btn:hover { opacity: 0.8; }
+.ws-card-meta { font-size: 10px; color: var(--text2); flex: 1; }
+.ws-load-btn {
+ padding: 4px 12px; background: var(--surface2); border: 1px solid var(--border);
+ border-radius: 4px; color: var(--accent); font-size: 10px; font-weight: 600;
+ cursor: pointer; font-family: inherit;
+}
+.ws-load-btn:hover { background: var(--accent); color: #000; border-color: var(--accent); }
+
+@media (max-width: 768px) {
+ .ws-page { padding: 16px; gap: 16px; }
+ .ws-title { font-size: 22px; }
+ .ws-toolbar { flex-direction: column; }
+ .ws-tags { overflow-x: auto; flex-wrap: nowrap; }
+ .ws-grid { grid-template-columns: 1fr; }
+}
+
/* ===== Fullscreen Keyboard ===== */
.keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500;
diff --git a/packages/client/src/main.jsx b/packages/client/src/main.jsx
index 48d766c..fe2d268 100644
--- a/packages/client/src/main.jsx
+++ b/packages/client/src/main.jsx
@@ -2,19 +2,25 @@ import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
+import Workshop from './components/Workshop.jsx';
import { AuthProvider } from './services/AuthContext.jsx';
import AuthModal from './components/AuthModal.jsx';
import './index.css';
function Root() {
- const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
+ const [mode, setMode] = useState('game'); // 'game' | 'sandbox' | 'workshop'
+
+ const nav = {
+ toGame: () => setMode('game'),
+ toSandbox: () => setMode('sandbox'),
+ toWorkshop: () => setMode('workshop'),
+ };
return (
- {mode === 'sandbox'
- ? setMode('game')} />
- : setMode('sandbox')} />
- }
+ {mode === 'sandbox' && }
+ {mode === 'game' && }
+ {mode === 'workshop' && }
);
diff --git a/packages/client/src/services/api.js b/packages/client/src/services/api.js
index b30f515..1c9a15e 100644
--- a/packages/client/src/services/api.js
+++ b/packages/client/src/services/api.js
@@ -73,6 +73,17 @@ export const users = {
update: (data) => request('PATCH', '/users/me', data),
};
+// Workshop
+export const workshop = {
+ browse: (params = '') => request('GET', `/workshop${params ? '?' + params : ''}`),
+ get: (id) => request('GET', `/workshop/${id}`),
+ share: (data) => request('POST', '/workshop', data),
+ remove: (id) => request('DELETE', `/workshop/${id}`),
+ like: (id) => request('POST', `/workshop/${id}/like`),
+ unlike: (id) => request('DELETE', `/workshop/${id}/like`),
+ report: (id) => request('POST', `/workshop/${id}/report`),
+};
+
// Admin
export const admin = {
stats: () => request('GET', '/admin/stats'),
diff --git a/packages/server/src/index.js b/packages/server/src/index.js
index 550b8ae..b32c7da 100644
--- a/packages/server/src/index.js
+++ b/packages/server/src/index.js
@@ -12,6 +12,7 @@ import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import adminRoutes from './routes/admin.js';
import syncRoutes from './routes/sync.js';
+import workshopRoutes from './routes/workshop.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
@@ -41,6 +42,7 @@ await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
+await fastify.register(workshopRoutes, { prefix: '/api/v1/workshop' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {
diff --git a/packages/server/src/routes/workshop.js b/packages/server/src/routes/workshop.js
new file mode 100644
index 0000000..f38af3d
--- /dev/null
+++ b/packages/server/src/routes/workshop.js
@@ -0,0 +1,175 @@
+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 };
+ });
+}