17 Commits

Author SHA1 Message Date
Jose Luis
925043e055 feat: universal modulation animation for all source types
- Read params fresh each tick instead of stale closure
- Add oscillator FM, noise, and generic fallback animations
- Any modulation source now shows visual feedback on target knob

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:08:44 +01:00
Jose Luis
a0a3b58b49 fix: envelope param animation reads source node instead of receiver
Was reading the receiving module's gain.value (always 0 when CV connected)
instead of the envelope's Tone.Envelope.value for live modulation display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:58:19 +01:00
Jose Luis
13612bfa99 fix: Workshop load doesn't stop audio — matches loadPreset pattern
Calling stopAudio() before deserialize+rebuildGraph broke the audio
graph because rebuildGraph needs isRunning=true to work properly.
Now follows the same pattern as loadPreset(): deserialize then
rebuildGraph (which destroys and recreates all nodes internally).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:11:02 +01:00
Jose Luis
acbe4257ae docs: explain why base levels can't be edited from admin
The 96 base levels have JavaScript test() functions in their checks
that validate gameplay objectives. These can't be serialized to a
database — they need to stay as code. Custom levels from admin panel
work for tutorials/challenges but without the star check system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:07:39 +01:00
Jose Luis
12569dba76 feat: Admin SynthQuest level management + user dropdown with admin access
SynthQuest admin:
- New "🎮 SynthQuest" section in admin sidebar
- List custom levels with world, ID, title, patch status
- Create new level: world selector, title, subtitle, description,
  concept (hint), available modules (tag input), boss flag, sort order
- Edit existing levels inline
- Import patch base from sandbox JSON export (📥 button per level)
- Delete levels with confirmation

Server:
- custom_levels table (PostgreSQL)
- CRUD API at /api/v1/admin/levels
- POST /:id/import-patch to import sandbox JSON as preplaced modules

Admin access:
- User badge is now a hover dropdown with "🛠 Admin" + "Cerrar sesion"
- Admin visible in Sandbox toolbar, Workshop nav, and user dropdown
- onSwitchToAdmin passed through navigation chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:05:36 +01:00
Jose Luis
f43a315047 fix: Workshop nav — replace tabs with back arrow to Sandbox
Simpler navigation: "← Volver" button + "Workshop" title instead
of the Sandbox/SynthQuest/Workshop tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:55:44 +01:00
Jose Luis
b0522d8b0f fix: don't overwrite Workshop-loaded patch with autoLoad/chiptune
When switching from Workshop to Sandbox after loading a patch,
the Sandbox's useEffect was running autoLoad() which overwrote
the just-loaded patch. Now it skips if modules are already present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:54:18 +01:00
Jose Luis
e53ec600ad feat: Phase 4 — Admin panel (dashboard, users, moderation)
AdminPanel2 component with sidebar navigation:
- Dashboard: KPI cards (users, patches, premium, flagged)
- Users: search, filter by role, table with role dropdown to
  change user/premium/admin/banned per user
- Workshop moderation: filter flagged/deleted, approve/delete/restore
  actions per patch with status badges

Features:
- Role-protected: non-admins see 🔒 locked screen
- Sidebar nav: Dashboard / Usuarios / Workshop / Volver
- Admin button visible in Workshop nav for admin users
- Responsive: sidebar becomes horizontal tabs on mobile,
  KPIs 2x2 grid, table rows wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:53:32 +01:00
Jose Luis
c673745b09 fix: Workshop mobile layout + navigation from all modes
Mobile:
- Workshop nav tabs full-width, hide logo, hide hero header
- Search/sort/share go full-width stacked
- Tags scroll horizontally
- Share button large and prominent
- Patch cards single column, shorter previews
- Auth modal fits mobile viewport

Navigation:
- Workshop button in Sandbox hamburger menu (mobile)
- Workshop tab in WorldMap mobile tab bar
- GameApp passes onWorkshop prop through to WorldMap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:49:13 +01:00
Jose Luis
3b80070c9a fix: Workshop share from saved presets + clean load
Share:
- Share modal now shows user's saved presets to pick from
- No longer grabs live canvas (which had serialization issues)
- Auto-fills title from preset name
- Shows module/wire count per preset

Load:
- Stops audio before loading (prevents ghost sounds)
- Deep clones patch data (prevents reference issues)
- Calls deserialize → rebuildGraph → emit in correct order
- Switches to Sandbox after loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:42:19 +01:00
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
Jose Luis
64ffa36c09 feat: Phase 2 — data sync (presets + game progress)
Server:
- GET/PUT /api/v1/sync/presets — upsert with last-write-wins
- DELETE /api/v1/sync/presets/:id
- GET/PUT /api/v1/sync/progress — game progress upsert

Client:
- syncService.js: offline-first sync layer
  - localStorage remains primary store
  - Pushes to server when logged in
  - Merges server data into local on sync
  - Auto-sync every 30s + on tab focus
- AuthContext starts/stops sync on login/logout
- Sync runs on session restore (refresh token)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:28:46 +01:00
Jose Luis
3523111019 feat: frontend auth — login/register modal + user badge
- API service (api.js): fetch wrapper with JWT, auto-refresh on 401
- AuthContext: user state, login/register/logout, loading, roles
- AuthModal: tabbed login/register form matching .pen design
- User badge in toolbar (Sandbox + WorldMap) with initial avatar
- "Entrar" button when not logged in
- CSS: auth overlay, card, tabs, inputs, error state, user badge
- Auth is opt-in: app works fully without login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:22:43 +01:00
Jose Luis
e129fd3739 fix: Dockerfile install devDeps for vite build + build tools for argon2 2026-03-21 20:10:03 +01:00
Jose Luis
6a4a308fd9 feat: Phase 1 — Fastify backend with auth, users, admin API
Backend stack:
- Fastify v5 with JWT auth, CORS, cookies, rate limiting
- PostgreSQL via Drizzle ORM with full schema:
  users, presets, game_progress, shared_patches, likes, refresh_tokens
- Argon2 password hashing, httpOnly refresh cookie rotation

API endpoints:
- POST /api/v1/auth/register|login|refresh|logout
- GET|PATCH /api/v1/users/me (profile)
- GET /api/v1/admin/stats (dashboard KPIs)
- GET|PATCH /api/v1/admin/users (list, role change, ban)
- GET|PATCH /api/v1/admin/patches (moderation)
- GET /api/health

Infrastructure:
- Vite proxy /api → localhost:3001 for dev
- .env.example with all config vars
- Dockerfile updated: installs server deps, serves SPA + API
- npm run dev:server for backend hot-reload
- npm run db:push for schema sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:58:21 +01:00
Jose Luis
b058997889 refactor: restructure to monorepo with npm workspaces (Phase 0)
Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.

Structure:
  reaktor/
    packages/client/  (React + Vite + Tone.js frontend)
    packages/server/  (static file server, future API)
    dist/             (built output, shared)
    docker-compose.yml (app + PostgreSQL for future backend)

- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:52:57 +01:00
Jose Luis
4baa86eed0 docs: add producto.md — product roadmap and vision
Living product document covering:
- Current features (Sandbox, SynthQuest, Mobile PWA)
- 6-phase roadmap (monorepo → auth → sync → workshop → admin → payments)
- Tech stack decisions (Fastify, PostgreSQL, Drizzle, JWT)
- Design principles (offline-first, opt-in, mobile-first)
- Success metrics per phase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:29:52 +01:00
80 changed files with 5833 additions and 73 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.vite
.env

View File

@@ -1,13 +1,22 @@
FROM node:20-alpine AS build
# Stage 1: Build frontend
FROM node:20-alpine AS build-client
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
COPY packages/client/package.json packages/client/
COPY packages/server/package.json packages/server/
RUN npm install --include=dev
COPY packages/client packages/client
RUN npm run build -w packages/client
# Stage 2: Production
FROM node:20-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY server.js .
COPY package.json package-lock.json* ./
COPY packages/server/package.json packages/server/
RUN npm install -w packages/server --omit=dev && apk del python3 make g++
COPY --from=build-client /app/dist ./dist
COPY packages/server packages/server
ENV NODE_ENV=production PORT=80
EXPOSE 80
CMD ["node", "server.js"]
CMD ["node", "packages/server/src/index.js"]

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
app:
build: .
ports:
- "80:80"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: reaktor
POSTGRES_PASSWORD: reaktor_dev
POSTGRES_DB: reaktor
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reaktor"]
interval: 5s
retries: 5
volumes:
pgdata:

3000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,12 @@
{
"name": "reaktor-montlab",
"version": "1.0.0",
"name": "reaktor",
"private": true,
"type": "module",
"workspaces": ["packages/*"],
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tone": "^14.8.49"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
"dev": "npm run dev -w packages/client",
"dev:server": "npm run dev -w packages/server",
"build": "npm run build -w packages/client",
"start": "node packages/server/src/index.js",
"db:push": "npm run db:push -w packages/server"
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@reaktor/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tone": "^14.8.49"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -13,14 +13,16 @@ import { CHIPTUNE_PRESET } from './presets/chiptune.js';
import { useIsMobile } from './hooks/useIsMobile.js';
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, onSwitchToAdmin }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null);
const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth();
const importRef = useRef(null);
const isMobile = useIsMobile();
const [menuOpen, setMenuOpen] = useState(false);
@@ -36,11 +38,11 @@ export default function App({ onSwitchToGame }) {
return unsub;
}, []);
// Auto-load on mount, or load chiptune demo if empty
// Auto-load on mount, but skip if modules already loaded (e.g. from Workshop)
useEffect(() => {
if (state.modules.length > 0) return; // Already loaded (Workshop, etc.)
const loaded = autoLoad();
if (!loaded || state.modules.length === 0) {
// Load chiptune demo preset
deserialize(CHIPTUNE_PRESET);
}
}, []);
@@ -279,6 +281,11 @@ export default function App({ onSwitchToGame }) {
🎮 Game
</button>
)}
{onSwitchToWorkshop && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
🎵 Workshop
</button>
)}
<span className="toolbar-title">Reaktor</span>
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
@@ -316,6 +323,20 @@ export default function App({ onSwitchToGame }) {
{state.modules.length} modules · {state.connections.length} wires
</span>
)}
{isLoggedIn ? (
<div className="user-dropdown">
<div className="user-badge">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
<div className="user-dropdown-menu">
{isAdmin && onSwitchToAdmin && <button onClick={onSwitchToAdmin}>🛠 Admin</button>}
<button onClick={logout}>Cerrar sesion</button>
</div>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
{isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)}
@@ -339,6 +360,11 @@ export default function App({ onSwitchToGame }) {
🎮 Chiptune Demo
</button>
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
{onSwitchToWorkshop && (
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToWorkshop(); }} style={{ color: 'var(--accent)' }}>
🎵 Workshop
</button>
)}
</div>
</div>
)}

View File

@@ -0,0 +1,466 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { admin as adminApi, levels as levelsApi } from '../services/api.js';
import { useAuth } from '../services/AuthContext.jsx';
function Sidebar({ active, onNavigate, onBack }) {
const items = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'users', icon: '👥', label: 'Usuarios' },
{ id: 'workshop', icon: '🎛', label: 'Workshop' },
{ id: 'levels', icon: '🎮', label: 'SynthQuest' },
];
return (
<div className="adm-sidebar">
<div className="adm-sidebar-logo">
<div className="auth-logo-box" style={{ width: 28, height: 28, fontSize: 14 }}>~</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>Admin</span>
</div>
{items.map(item => (
<button key={item.id}
className={`adm-sidebar-item ${active === item.id ? 'active' : ''}`}
onClick={() => onNavigate(item.id)}>
<span>{item.icon}</span> {item.label}
</button>
))}
<div style={{ flex: 1 }} />
<button className="adm-sidebar-item" onClick={onBack}>
Volver a la app
</button>
</div>
);
}
function DashboardView() {
const [stats, setStats] = useState(null);
useEffect(() => {
adminApi.stats().then(setStats).catch(() => {});
}, []);
if (!stats) return <p style={{ color: 'var(--text2)' }}>Cargando...</p>;
const kpis = [
{ label: 'USUARIOS TOTALES', value: stats.users, color: 'var(--text)' },
{ label: 'PATCHES COMPARTIDOS', value: stats.patches, color: 'var(--text)' },
{ label: 'PREMIUM', value: stats.premium, color: 'var(--yellow)' },
{ label: 'REPORTADOS', value: stats.flagged, color: 'var(--red)' },
];
return (
<div>
<h2 className="adm-page-title">Dashboard</h2>
<div className="adm-kpi-grid">
{kpis.map(k => (
<div key={k.label} className="adm-kpi-card">
<span className="adm-kpi-label">{k.label}</span>
<span className="adm-kpi-value" style={{ color: k.color }}>{k.value}</span>
</div>
))}
</div>
</div>
);
}
function UsersView() {
const [users, setUsers] = useState([]);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (search) params.set('q', search);
if (filter) params.set('role', filter);
const data = await adminApi.users(params.toString());
setUsers(data.users || []);
}, [search, filter]);
useEffect(() => { load(); }, [load]);
const changeRole = async (id, role) => {
await adminApi.updateUser(id, { role });
load();
};
return (
<div>
<h2 className="adm-page-title">Usuarios</h2>
<div className="adm-toolbar">
<div className="ws-search" style={{ flex: 1 }}>
<span>🔍</span>
<input placeholder="Buscar usuario..." value={search}
onChange={e => setSearch(e.target.value)} />
</div>
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'premium' ? 'active' : ''}`} onClick={() => setFilter('premium')} style={{ color: 'var(--yellow)' }}>Premium</button>
<button className={`ws-tag ${filter === 'banned' ? 'active' : ''}`} onClick={() => setFilter('banned')} style={{ color: 'var(--red)' }}>Banned</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">USUARIO</span>
<span className="adm-col-md">EMAIL</span>
<span className="adm-col-sm">ROL</span>
<span className="adm-col-sm">REGISTRO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{users.map(u => (
<div key={u.id} className={`adm-table-row ${u.role === 'banned' ? 'banned' : ''}`}>
<div className="adm-col-grow adm-user-cell">
<div className="user-avatar" style={{ background: u.role === 'banned' ? 'var(--red)' : 'var(--accent)' }}>
{u.username?.[0]?.toUpperCase()}
</div>
<span>{u.username}</span>
</div>
<span className="adm-col-md adm-text-muted">{u.email}</span>
<span className="adm-col-sm">
<span className={`adm-role-badge ${u.role}`}>
{u.role === 'premium' ? '★ ' : u.role === 'banned' ? '🚫 ' : ''}{u.role}
</span>
</span>
<span className="adm-col-sm adm-text-muted">
{new Date(u.createdAt).toLocaleDateString('es')}
</span>
<div className="adm-col-xs">
<select className="adm-action-select" value={u.role}
onChange={e => changeRole(u.id, e.target.value)}>
<option value="user">User</option>
<option value="premium">Premium</option>
<option value="admin">Admin</option>
<option value="banned">Banned</option>
</select>
</div>
</div>
))}
</div>
</div>
);
}
function WorkshopModView() {
const [patches, setPatches] = useState([]);
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (filter === 'flagged') params.set('flagged', 'true');
if (filter === 'deleted') params.set('deleted', 'true');
const data = await adminApi.patches(params.toString());
setPatches(data.patches || []);
}, [filter]);
useEffect(() => { load(); }, [load]);
const moderate = async (id, action) => {
await adminApi.updatePatch(id, { action });
load();
};
return (
<div>
<h2 className="adm-page-title">Workshop Moderacion</h2>
<div className="adm-toolbar">
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'flagged' ? 'active' : ''}`}
onClick={() => setFilter('flagged')} style={{ color: 'var(--yellow)' }}> Reportados</button>
<button className={`ws-tag ${filter === 'deleted' ? 'active' : ''}`}
onClick={() => setFilter('deleted')} style={{ color: 'var(--red)' }}>🚫 Eliminados</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">PATCH</span>
<span className="adm-col-sm">LIKES</span>
<span className="adm-col-sm">ESTADO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{patches.map(p => (
<div key={p.id} className={`adm-table-row ${p.isDeleted ? 'banned' : ''}`}>
<div className="adm-col-grow">
<strong style={{ color: 'var(--text)' }}>{p.title}</strong>
{p.tags?.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{p.tags.map(t => <span key={t} className="ws-tag-pill">{t}</span>)}
</div>
)}
</div>
<span className="adm-col-sm" style={{ color: 'var(--red)' }}> {p.likesCount}</span>
<span className="adm-col-sm">
{p.isDeleted
? <span className="adm-role-badge banned">🚫 Eliminado</span>
: p.isFlagged
? <span className="adm-role-badge" style={{ background: 'rgba(255,204,0,0.15)', color: 'var(--yellow)' }}> Reportado</span>
: <span className="adm-role-badge" style={{ background: 'rgba(68,255,136,0.15)', color: 'var(--green)' }}> Activo</span>
}
</span>
<div className="adm-col-xs adm-actions">
{p.isDeleted ? (
<button className="adm-act-btn green" onClick={() => moderate(p.id, 'restore')}>Restaurar</button>
) : (
<>
{p.isFlagged && <button className="adm-act-btn green" onClick={() => moderate(p.id, 'unflag')}>Aprobar</button>}
<button className="adm-act-btn red" onClick={() => moderate(p.id, 'delete')}>Eliminar</button>
</>
)}
</div>
</div>
))}
{patches.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>No hay patches</p>
)}
</div>
</div>
);
}
function LevelsView() {
const [levels, setLevels] = useState([]);
const [editing, setEditing] = useState(null); // level being edited
const [showCreate, setShowCreate] = useState(false);
const fileRef = useRef(null);
const load = useCallback(async () => {
try {
const data = await levelsApi.list();
setLevels(data.levels || []);
} catch {}
}, []);
useEffect(() => { load(); }, [load]);
const handleCreate = async (form) => {
await levelsApi.create(form);
setShowCreate(false);
load();
};
const handleUpdate = async (id, form) => {
await levelsApi.update(id, form);
setEditing(null);
load();
};
const handleDelete = async (id) => {
if (!confirm('Eliminar este nivel?')) return;
await levelsApi.remove(id);
load();
};
const handleImportPatch = async (levelId) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
try {
const data = JSON.parse(text);
await levelsApi.importPatch(levelId, {
modules: data.modules || [],
connections: data.connections || [],
});
load();
} catch (err) {
alert('Error importando: ' + err.message);
}
};
input.click();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 className="adm-page-title" style={{ margin: 0 }}>SynthQuest Niveles</h2>
<span style={{ fontSize: 11, color: 'var(--text2)', background: 'var(--surface)', padding: '4px 10px', borderRadius: 12 }}>
{levels.length} custom
</span>
<div style={{ flex: 1 }} />
<button className="ws-share-btn" onClick={() => setShowCreate(true)}>+ Nuevo Nivel</button>
</div>
{showCreate && (
<LevelForm onSave={handleCreate} onCancel={() => setShowCreate(false)} />
)}
{editing && (
<LevelForm level={editing} onSave={(form) => handleUpdate(editing.id, form)} onCancel={() => setEditing(null)} />
)}
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-xs">MUNDO</span>
<span className="adm-col-sm">ID</span>
<span className="adm-col-grow">TITULO</span>
<span className="adm-col-sm">PATCH</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{levels.map(lvl => (
<div key={lvl.id} className="adm-table-row">
<span className="adm-col-xs" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--accent)' }}>
{lvl.worldId}
</span>
<span className="adm-col-sm" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--text2)' }}>
{lvl.levelId}
</span>
<div className="adm-col-grow">
<strong style={{ color: lvl.isBoss ? 'var(--yellow)' : 'var(--text)', fontSize: 13 }}>
{lvl.isBoss ? '👑 ' : ''}{lvl.title}
</strong>
{lvl.subtitle && <div style={{ fontSize: 11, color: 'var(--text2)' }}>{lvl.subtitle}</div>}
</div>
<span className="adm-col-sm">
{lvl.preplacedData ? (
<span style={{ fontSize: 10, color: 'var(--green)' }}>
{lvl.preplacedData.modules?.length || 0} modules
</span>
) : (
<button className="adm-act-btn green" onClick={() => handleImportPatch(lvl.id)}>
📥 Importar
</button>
)}
</span>
<div className="adm-col-xs adm-actions">
<button className="adm-act-btn" style={{ borderColor: 'var(--accent)', color: 'var(--accent)' }}
onClick={() => setEditing(lvl)}>Editar</button>
<button className="adm-act-btn red" onClick={() => handleDelete(lvl.id)}></button>
</div>
</div>
))}
{levels.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>
No hay niveles custom. Los 96 niveles base estan hardcoded en el codigo.
</p>
)}
</div>
</div>
);
}
function LevelForm({ level, onSave, onCancel }) {
const [form, setForm] = useState({
worldId: level?.worldId || 'w1',
levelId: level?.levelId || '',
title: level?.title || '',
subtitle: level?.subtitle || '',
description: level?.description || '',
concept: level?.concept || '',
availableModules: level?.availableModules || [],
isBoss: level?.isBoss || false,
sortOrder: level?.sortOrder || 0,
});
const [modInput, setModInput] = useState('');
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const addMod = () => {
if (modInput.trim() && !form.availableModules.includes(modInput.trim())) {
set('availableModules', [...form.availableModules, modInput.trim()]);
setModInput('');
}
};
const removeMod = (m) => set('availableModules', form.availableModules.filter(x => x !== m));
return (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<h3 style={{ color: 'var(--text)', margin: '0 0 16px', fontSize: 16 }}>
{level ? 'Editar Nivel' : 'Nuevo Nivel'}
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="auth-label">MUNDO</label>
<select className="adm-action-select" style={{ width: '100%', padding: 8 }} value={form.worldId} onChange={e => set('worldId', e.target.value)}>
{Array.from({ length: 12 }, (_, i) => <option key={i} value={`w${i + 1}`}>Mundo {i + 1}</option>)}
</select>
</div>
<div>
<label className="auth-label">LEVEL ID</label>
<input className="auth-input" value={form.levelId} onChange={e => set('levelId', e.target.value)}
placeholder="w1-9" disabled={!!level} />
</div>
<div>
<label className="auth-label">TITULO</label>
<input className="auth-input" value={form.title} onChange={e => set('title', e.target.value)} />
</div>
<div>
<label className="auth-label">SUBTITULO</label>
<input className="auth-input" value={form.subtitle} onChange={e => set('subtitle', e.target.value)} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">DESCRIPCION (MISION)</label>
<textarea className="auth-input" rows={3} value={form.description} onChange={e => set('description', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">PISTA (CONCEPTO)</label>
<textarea className="auth-input" rows={2} value={form.concept} onChange={e => set('concept', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">MODULOS DISPONIBLES</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
{form.availableModules.map(m => (
<span key={m} className="ws-tag active" onClick={() => removeMod(m)} style={{ cursor: 'pointer' }}>
{m}
</span>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input className="auth-input" style={{ flex: 1 }} placeholder="oscillator, filter, vca..."
value={modInput} onChange={e => setModInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addMod())} />
<button className="adm-act-btn green" onClick={addMod} type="button">+ Añadir</button>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text2)', fontSize: 12 }}>
<input type="checkbox" checked={form.isBoss} onChange={e => set('isBoss', e.target.checked)} />
Boss Level
</label>
<label className="auth-label" style={{ margin: 0 }}>ORDEN</label>
<input className="auth-input" type="number" style={{ width: 60 }} value={form.sortOrder}
onChange={e => set('sortOrder', parseInt(e.target.value) || 0)} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="auth-submit" style={{ flex: 1 }} onClick={() => onSave(form)}>
{level ? 'Guardar' : 'Crear Nivel'}
</button>
<button className="adm-act-btn" style={{ padding: '10px 20px' }} onClick={onCancel}>Cancelar</button>
</div>
</div>
);
}
export default function AdminPanel2({ onBack }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState('dashboard');
if (!isAdmin) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg)' }}>
<div style={{ textAlign: 'center', color: 'var(--text2)' }}>
<p style={{ fontSize: 48 }}>🔒</p>
<p>Acceso restringido a administradores</p>
<button className="login-btn" onClick={onBack} style={{ marginTop: 16 }}>Volver</button>
</div>
</div>
);
}
return (
<div className="adm-layout">
<Sidebar active={page} onNavigate={setPage} onBack={onBack} />
<div className="adm-main">
{page === 'dashboard' && <DashboardView />}
{page === 'users' && <UsersView />}
{page === 'workshop' && <WorkshopModView />}
{page === 'levels' && <LevelsView />}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useAuth } from '../services/AuthContext.jsx';
export default function AuthModal() {
const { showAuth, closeAuth, login, register } = useAuth();
const [tab, setTab] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
if (!showAuth) return null;
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (tab === 'login') {
await login(email, password);
} else {
await register(email, username, password);
}
} catch (err) {
setError(err.message || 'Error');
}
setLoading(false);
};
const reset = () => {
setError('');
setEmail('');
setUsername('');
setPassword('');
};
return (
<div className="auth-overlay" onClick={closeAuth}>
<div className="auth-card" onClick={e => e.stopPropagation()}>
<div className="auth-logo">
<div className="auth-logo-box">~</div>
<span className="auth-logo-name">Reaktor</span>
</div>
<div className="auth-tabs">
<button
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => { setTab('login'); reset(); }}
>
Iniciar Sesion
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => { setTab('register'); reset(); }}
>
Registrarse
</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
<label className="auth-label">EMAIL</label>
<input
type="email"
className="auth-input"
placeholder="tu@email.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
{tab === 'register' && (
<>
<label className="auth-label">USUARIO</label>
<input
type="text"
className="auth-input"
placeholder="username"
value={username}
onChange={e => setUsername(e.target.value)}
minLength={3}
maxLength={50}
required
/>
</>
)}
<label className="auth-label">CONTRASEÑA</label>
<input
type="password"
className="auth-input"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
minLength={6}
required
/>
{error && <div className="auth-error">{error}</div>}
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? '...' : tab === 'login' ? 'Entrar' : 'Crear Cuenta'}
</button>
</form>
<button className="auth-skip" onClick={closeAuth}>
Continuar sin cuenta
</button>
<button className="auth-close" onClick={closeAuth}></button>
</div>
</div>
);
}

View File

@@ -60,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
}
}
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
// ==================== Live modulation visualization (any source any param) ====================
const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000);
@@ -80,6 +80,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {};
// Read current params fresh from state each tick (avoid stale closure)
const curMod = state.modules.find(m => m.id === mod.id);
if (!curMod) return;
const curDef = getModuleDef(curMod.type);
if (!curDef) return;
const curParams = { ...Object.fromEntries(Object.entries(curDef.params).map(([k, v]) => [k, v.default])), ...curMod.params };
for (const conn of state.connections) {
if (conn.to.moduleId !== mod.id) continue;
const paramName = portMap[conn.to.port];
@@ -88,31 +95,55 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
if (!srcMod) continue;
const baseValue = curParams[paramName];
// Modulation scale based on target parameter
const getScale = () => {
if (curMod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
if (curMod.type === 'filter' && paramName === 'frequency') return baseValue;
if (curMod.type === 'vca' && paramName === 'gain') return 1;
return baseValue || 1;
};
if (srcMod.type === 'lfo') {
// LFO: simulate waveform for smooth visual
const lfoDef = getModuleDef('lfo');
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
const freq = lfoP.frequency;
const amp = lfoP.amplitude;
const waveform = lfoP.waveform;
const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
const phase = (t * lfoP.frequency) % 1;
const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
newValues[paramName] = baseValue + lfoVal * getScale();
const baseValue = params[paramName];
let scale;
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
else scale = baseValue || 1;
newValues[paramName] = baseValue + lfoVal * scale;
} else if (srcMod.type === 'envelope') {
// Envelope: read the actual audio node gain value for real-time display
const audioEntry = getAudioNode(mod.id);
if (audioEntry?.node?.gain) {
const currentGain = audioEntry.node.gain.value;
newValues[paramName] = currentGain;
// Envelope: read current level (0-1) from the source envelope node
const envEntry = getAudioNode(srcMod.id);
if (envEntry?.node) {
const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
if (curMod.type === 'vca' && paramName === 'gain') {
newValues[paramName] = envValue; // Envelope directly drives gain (01)
} else {
newValues[paramName] = baseValue + envValue * getScale();
}
}
} else if (srcMod.type === 'oscillator') {
// Oscillator FM: simulate modulating oscillator waveform
const srcDef = getModuleDef('oscillator');
const srcP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
// Clamp visual frequency to avoid aliasing show a slow representation
const visFreq = Math.min(srcP.frequency, 8);
const phase = (t * visFreq) % 1;
const modVal = simulateLFO(srcP.waveform, phase) * 0.5;
newValues[paramName] = baseValue + modVal * getScale();
} else if (srcMod.type === 'noise') {
// Noise: random jitter
const noiseVal = (Math.random() * 2 - 1) * 0.3;
newValues[paramName] = baseValue + noiseVal * getScale();
} else {
// Generic fallback: subtle visual pulse so user sees modulation is active
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
newValues[paramName] = baseValue + pulseVal * getScale();
}
}

View File

@@ -0,0 +1,265 @@
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 { rebuildGraph } from '../engine/audioEngine.js';
import { getPresets } from '../engine/presets.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 [selectedPreset, setSelectedPreset] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const presets = getPresets();
const handleShare = async () => {
if (!title.trim()) { setError('Titulo requerido'); return; }
if (!selectedPreset) { setError('Selecciona un preset para compartir'); return; }
setLoading(true);
setError('');
try {
// Use the preset data directly (already serialized correctly)
const patchData = {
modules: selectedPreset.modules || [],
connections: selectedPreset.connections || [],
camera: selectedPreset.camera || { camX: 0, camY: 0, zoom: 1 },
masterVolume: selectedPreset.masterVolume ?? -6,
};
await workshopApi.share({
title: title.trim(),
description: description.trim(),
tags: selectedTags,
data: patchData,
});
onShared?.();
onClose();
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-overlay" onClick={onClose}>
<div className="auth-card" onClick={e => e.stopPropagation()} style={{ gap: 14, maxHeight: '80vh', overflow: 'auto' }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', margin: 0 }}>Compartir Patch</h2>
<div className="auth-form" style={{ gap: 10 }}>
<label className="auth-label">SELECCIONA UN PRESET</label>
{presets.length === 0 ? (
<p style={{ fontSize: 12, color: 'var(--text2)' }}>
No tienes presets guardados. Ve al Sandbox, crea algo y guardalo con "Save" primero.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
{presets.map((p, i) => (
<button key={i} type="button"
style={{
padding: '10px 12px', background: selectedPreset === p ? 'var(--surface2)' : 'var(--bg)',
border: `1px solid ${selectedPreset === p ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6, cursor: 'pointer', textAlign: 'left',
color: 'var(--text)', fontSize: 13, fontFamily: 'inherit',
}}
onClick={() => { setSelectedPreset(p); if (!title) setTitle(p.name || ''); }}
>
<strong>{p.name}</strong>
<span style={{ color: 'var(--text2)', fontSize: 11, marginLeft: 8 }}>
{p.modules?.length || 0} modules · {p.connections?.length || 0} wires
</span>
</button>
))}
</div>
)}
<label className="auth-label">TITULO</label>
<input className="auth-input" placeholder="Nombre de tu patch"
value={title} onChange={e => setTitle(e.target.value)} />
<label className="auth-label">DESCRIPCION</label>
<textarea className="auth-input" placeholder="Describe tu creacion..."
value={description} onChange={e => setDescription(e.target.value)}
rows={3} style={{ resize: 'vertical', fontFamily: 'inherit' }} />
<label className="auth-label">TAGS</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{TAGS.map(tag => (
<button key={tag} type="button"
className={`ws-tag ${selectedTags.includes(tag) ? 'active' : ''}`}
onClick={() => setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
)}
>{tag}</button>
))}
</div>
{error && <div className="auth-error">{error}</div>}
<button className="auth-submit" onClick={handleShare}
disabled={loading || presets.length === 0}>
{loading ? 'Compartiendo...' : 'Compartir'}
</button>
</div>
<button className="auth-close" onClick={onClose}></button>
</div>
</div>
);
}
function PatchCard({ patch, onLoad, onLike }) {
const moduleCount = patch.data?.modules?.length || 0;
const wireCount = patch.data?.connections?.length || 0;
return (
<div className="ws-card">
<div className="ws-card-preview">
<span className="ws-card-wave">{moduleCount > 6 ? '~ ~ ~ ~' : '~ ~'}</span>
</div>
<div className="ws-card-body">
<h3 className="ws-card-title">{patch.title}</h3>
<p className="ws-card-author">por {patch.author?.username || 'Anonimo'}</p>
{patch.tags?.length > 0 && (
<div className="ws-card-tags">
{patch.tags.map(t => <span key={t} className="ws-tag-pill">{t}</span>)}
</div>
)}
<div className="ws-card-footer">
<button className="ws-like-btn" onClick={() => onLike(patch.id)}>
{patch.likesCount || 0}
</button>
<span className="ws-card-meta">{moduleCount} modules · {wireCount} wires</span>
<button className="ws-load-btn" onClick={() => onLoad(patch)}>Cargar</button>
</div>
</div>
</div>
);
}
export default function Workshop({ onSwitchToSandbox, onSwitchToGame, onSwitchToAdmin }) {
const { isLoggedIn, isAdmin, 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) return;
// Deep clone and load — same pattern as loadPreset()
// Don't stop audio first: rebuildGraph destroys and recreates all nodes
const cleanData = JSON.parse(JSON.stringify(patch.data));
deserialize(cleanData);
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 (
<div className="ws-page">
<nav className="ws-nav">
<button className="ws-back-btn" onClick={onSwitchToSandbox}> Volver</button>
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Workshop</span>
<div style={{ flex: 1 }} />
{isAdmin && onSwitchToAdmin && (
<button className="ws-nav-tab" onClick={onSwitchToAdmin} style={{ color: 'var(--yellow)' }}>🛠 Admin</button>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user?.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user?.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
</nav>
<div className="ws-header">
<h1 className="ws-title">Workshop</h1>
<p className="ws-subtitle">Explora, comparte y descubre sonidos de la comunidad</p>
</div>
<div className="ws-toolbar">
<div className="ws-search">
<span>🔍</span>
<input placeholder="Buscar patches..." value={search}
onChange={e => setSearch(e.target.value)} />
</div>
<div className="ws-tags">
<button className={`ws-tag ${!activeTag ? 'active' : ''}`}
onClick={() => setActiveTag('')}>Todos</button>
{TAGS.slice(0, 5).map(tag => (
<button key={tag} className={`ws-tag ${activeTag === tag ? 'active' : ''}`}
onClick={() => setActiveTag(activeTag === tag ? '' : tag)}>{tag}</button>
))}
</div>
<select className="ws-sort" value={sort} onChange={e => setSort(e.target.value)}>
<option value="recent">Recientes</option>
<option value="popular">Popular</option>
</select>
<button className="ws-share-btn" onClick={handleShare}>
+ Compartir Patch
</button>
</div>
<div className="ws-grid">
{loading ? (
<p style={{ color: 'var(--text2)', gridColumn: '1/-1', textAlign: 'center', padding: 40 }}>
Cargando...
</p>
) : patches.length === 0 ? (
<p style={{ color: 'var(--text2)', gridColumn: '1/-1', textAlign: 'center', padding: 40 }}>
No hay patches aun. Se el primero en compartir!
</p>
) : (
patches.map(p => (
<PatchCard key={p.id} patch={p} onLoad={handleLoad} onLike={handleLike} />
))
)}
</div>
{showShare && <ShareModal onClose={() => setShowShare(false)} onShared={loadPatches} />}
</div>
);
}

View File

@@ -17,7 +17,7 @@ import { WORLD_12 } from './levels/world12.js';
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
export default function GameApp({ onSwitchToSandbox }) {
export default function GameApp({ onSwitchToSandbox, onSwitchToWorkshop }) {
const [view, setView] = useState('map');
const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
@@ -78,6 +78,7 @@ export default function GameApp({ onSwitchToSandbox }) {
<WorldMap
onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox}
onWorkshop={onSwitchToWorkshop}
onAdmin={() => setShowAdmin(true)}
/>
{showAdmin && (

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef } from 'react';
import MobileTabBar from '../components/MobileTabBar.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { useAuth } from '../services/AuthContext.jsx';
import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
@@ -44,15 +45,17 @@ function isWorldUnlocked(world) {
const MOBILE_TABS = [
{ id: 'game', label: 'JUEGO', icon: '🎮' },
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
{ id: 'workshop', label: 'WORKSHOP', icon: '🎵' },
{ id: 'config', label: 'CONFIG', icon: '⚙' },
];
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin, onWorkshop }) {
const totalStars = getTotalStars();
const maxStars = getMaxStars();
const [search, setSearch] = useState('');
const searchRef = useRef(null);
const isMobile = useIsMobile();
const { user, isLoggedIn, openAuth, logout } = useAuth();
const query = search.trim().toLowerCase();
@@ -90,6 +93,14 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
🛠
</button>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
</div>
</div>
@@ -226,6 +237,7 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
activeTab="game"
onTabChange={(id) => {
if (id === 'sandbox') onSandbox?.();
if (id === 'workshop') onWorkshop?.();
if (id === 'config') onAdmin?.();
}}
/>

View File

@@ -801,6 +801,346 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
.admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
/* ===== Auth Modal ===== */
.auth-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.75);
display: flex; align-items: center; justify-content: center;
z-index: 600; animation: fadeIn 0.2s;
}
.auth-card {
width: 400px; max-width: calc(100% - 32px); background: var(--panel);
border: 1px solid var(--border); border-radius: 16px;
padding: 32px; display: flex; flex-direction: column; gap: 20px;
align-items: center; position: relative;
}
.auth-logo { display: flex; align-items: center; gap: 10px; }
.auth-logo-box {
width: 40px; height: 40px; background: var(--surface); border-radius: 8px;
border: 1px solid var(--accent); display: flex; align-items: center;
justify-content: center; font-size: 22px; font-weight: 700; color: var(--accent);
}
.auth-logo-name { font-size: 22px; font-weight: 700; color: var(--text); }
.auth-tabs {
display: flex; width: 100%; background: var(--surface); border-radius: 8px;
padding: 4px; gap: 4px;
}
.auth-tab {
flex: 1; padding: 10px 0; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 600; font-family: inherit; text-align: center;
background: transparent; color: var(--text2); transition: all 0.15s;
}
.auth-tab.active { background: var(--accent); color: #000; }
.auth-form {
display: flex; flex-direction: column; gap: 12px; width: 100%;
}
.auth-label {
font-size: 10px; font-weight: 700; color: var(--text2);
letter-spacing: 1px; text-transform: uppercase;
}
.auth-input {
width: 100%; padding: 12px 14px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 14px; font-family: inherit;
-webkit-user-select: text; user-select: text;
}
.auth-input:focus { outline: none; border-color: var(--accent); }
.auth-submit {
width: 100%; padding: 14px 0; background: var(--accent);
border: none; border-radius: 8px; color: #000;
font-size: 14px; font-weight: 700; cursor: pointer;
font-family: inherit; transition: opacity 0.15s;
}
.auth-submit:hover { opacity: 0.9; }
.auth-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.auth-error {
padding: 8px 12px; background: rgba(255,68,102,0.1);
border: 1px solid var(--red); border-radius: 6px;
color: var(--red); font-size: 12px; text-align: center;
}
.auth-skip {
background: none; border: none; color: var(--text2);
font-size: 13px; cursor: pointer; font-family: inherit;
padding: 8px;
}
.auth-skip:hover { color: var(--text); }
.auth-close {
position: absolute; top: 12px; right: 12px;
width: 32px; height: 32px; border-radius: 16px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
/* User badge in toolbar */
.user-badge {
display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 4px 10px; border-radius: 6px; background: var(--surface);
border: 1px solid var(--border);
}
.user-badge:hover { border-color: var(--accent); }
.user-avatar {
width: 22px; height: 22px; border-radius: 11px;
background: var(--accent); display: flex; align-items: center;
justify-content: center; font-size: 10px; font-weight: 700; color: #000;
}
.user-name { font-size: 11px; font-weight: 600; color: var(--text); }
.user-dropdown { position: relative; }
.user-dropdown-menu {
display: none; position: absolute; top: 100%; right: 0; margin-top: 4px;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 4px; min-width: 150px; z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.user-dropdown:hover .user-dropdown-menu { display: flex; flex-direction: column; }
.user-dropdown-menu button {
background: none; border: none; padding: 8px 12px; color: var(--text);
font-size: 12px; font-family: inherit; cursor: pointer; text-align: left;
border-radius: 4px;
}
.user-dropdown-menu button:hover { background: var(--surface); }
.login-btn {
padding: 4px 12px; border: 1px solid var(--accent); border-radius: 6px;
background: transparent; color: var(--accent); cursor: pointer;
font-size: 11px; font-weight: 600; font-family: inherit;
}
.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-back-btn {
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text2); cursor: pointer;
font-size: 13px; font-weight: 500; font-family: inherit; transition: all 0.15s;
}
.ws-back-btn:hover { border-color: var(--accent); color: var(--text); }
.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: 12px; gap: 12px; height: 100dvh; }
.ws-nav { flex-wrap: wrap; gap: 8px; padding: 0 0 12px; }
.ws-nav-logo { display: none; }
.ws-nav-tabs { width: 100%; gap: 2px; }
.ws-nav-tab { flex: 1; text-align: center; font-size: 12px; padding: 8px 4px; }
.ws-header { display: none; }
.ws-title { font-size: 22px; }
.ws-toolbar { flex-direction: column; gap: 8px; }
.ws-search { min-width: unset; }
.ws-tags { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
.ws-sort { width: 100%; }
.ws-share-btn { width: 100%; text-align: center; padding: 12px; font-size: 14px; }
.ws-grid { grid-template-columns: 1fr; gap: 10px; }
.ws-card-preview { height: 60px; }
.ws-card-body { padding: 10px 12px; }
.user-badge { padding: 4px 8px; }
.login-btn { padding: 6px 12px; }
.auth-card { padding: 24px 20px; max-height: 90vh; overflow-y: auto; }
}
/* ===== Admin Panel v2 ===== */
.adm-layout { display: flex; height: 100vh; background: var(--bg); }
.adm-sidebar {
width: 220px; background: var(--panel); display: flex; flex-direction: column;
padding: 20px 16px; gap: 4px; flex-shrink: 0;
}
.adm-sidebar-logo { display: flex; align-items: center; gap: 8px; padding-bottom: 16px; }
.adm-sidebar-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border: none; border-radius: 6px; background: transparent; color: var(--text2);
font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
text-align: left; width: 100%; transition: all 0.15s;
}
.adm-sidebar-item:hover { color: var(--text); }
.adm-sidebar-item.active { background: var(--surface); color: var(--accent); font-weight: 600; }
.adm-main { flex: 1; padding: 24px 32px; overflow-y: auto; }
.adm-page-title { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 20px; }
.adm-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.adm-kpi-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; display: flex; flex-direction: column; gap: 4px;
}
.adm-kpi-label { font-size: 10px; font-weight: 700; color: var(--text2); letter-spacing: 1px; }
.adm-kpi-value { font-size: 32px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.adm-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.adm-table {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.adm-table-head {
display: flex; padding: 12px 20px; gap: 12px; align-items: center;
border-bottom: 1px solid var(--border);
}
.adm-table-head span {
font-size: 10px; font-weight: 700; color: var(--text2); letter-spacing: 1px;
}
.adm-table-row {
display: flex; padding: 10px 20px; gap: 12px; align-items: center;
border-bottom: 1px solid rgba(37,37,69,0.5);
}
.adm-table-row:last-child { border-bottom: none; }
.adm-table-row.banned { opacity: 0.4; }
.adm-col-grow { flex: 1; min-width: 0; }
.adm-col-md { width: 200px; flex-shrink: 0; }
.adm-col-sm { width: 120px; flex-shrink: 0; }
.adm-col-xs { width: 100px; flex-shrink: 0; }
.adm-text-muted { font-size: 12px; color: var(--text2); }
.adm-user-cell { display: flex; align-items: center; gap: 8px; }
.adm-user-cell span { font-size: 12px; font-weight: 500; color: var(--text); }
.adm-role-badge {
display: inline-block; padding: 3px 8px; border-radius: 10px;
font-size: 10px; font-weight: 600;
}
.adm-role-badge.user { background: rgba(100,116,139,0.15); color: var(--text2); }
.adm-role-badge.premium { background: rgba(255,204,0,0.15); color: var(--yellow); }
.adm-role-badge.admin { background: rgba(0,229,255,0.15); color: var(--accent); }
.adm-role-badge.banned { background: rgba(255,68,102,0.15); color: var(--red); }
.adm-action-select {
padding: 4px 8px; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-size: 11px; cursor: pointer;
font-family: inherit;
}
.adm-action-select:focus { outline: none; border-color: var(--accent); }
.adm-actions { display: flex; gap: 6px; }
.adm-act-btn {
padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border);
background: var(--bg); cursor: pointer; font-size: 10px; font-family: inherit;
}
.adm-act-btn.green { border-color: var(--green); color: var(--green); }
.adm-act-btn.red { border-color: var(--red); color: var(--red); }
@media (max-width: 768px) {
.adm-layout { flex-direction: column; }
.adm-sidebar { width: 100%; flex-direction: row; padding: 8px 12px; gap: 4px; overflow-x: auto; }
.adm-sidebar-logo { display: none; }
.adm-sidebar-item { white-space: nowrap; padding: 8px 12px; font-size: 12px; }
.adm-main { padding: 16px; }
.adm-kpi-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.adm-kpi-value { font-size: 24px; }
.adm-table-head { display: none; }
.adm-table-row { flex-wrap: wrap; gap: 6px; }
.adm-col-md, .adm-col-sm, .adm-col-xs { width: auto; flex-shrink: 1; }
.adm-col-grow { width: 100%; }
}
/* ===== Fullscreen Keyboard ===== */
.keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500;

View File

@@ -2,16 +2,31 @@ 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 AdminPanel2 from './components/AdminPanel2.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' | 'admin'
if (mode === 'sandbox') {
return <App onSwitchToGame={() => setMode('game')} />;
}
const nav = {
toGame: () => setMode('game'),
toSandbox: () => setMode('sandbox'),
toWorkshop: () => setMode('workshop'),
toAdmin: () => setMode('admin'),
};
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />;
return (
<AuthProvider>
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'game' && <GameApp onSwitchToSandbox={nav.toSandbox} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'admin' && <AdminPanel2 onBack={nav.toGame} />}
<AuthModal />
</AuthProvider>
);
}
createRoot(document.getElementById('root')).render(<Root />);

View File

@@ -0,0 +1,79 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { auth as authApi, users as usersApi, setAccessToken, setOnUnauthorized } from './api.js';
import { startAutoSync, stopAutoSync } from './syncService.js';
const AuthContext = createContext(null);
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [showAuth, setShowAuth] = useState(false);
const logout = useCallback(async () => {
stopAutoSync();
try { await authApi.logout(); } catch {}
setAccessToken(null);
setUser(null);
}, []);
// On mount, try to refresh session
useEffect(() => {
setOnUnauthorized(() => {
setUser(null);
setAccessToken(null);
});
authApi.refresh().then(async (ok) => {
if (ok) {
try {
const me = await usersApi.me();
setUser(me);
startAutoSync();
} catch {}
}
setLoading(false);
});
}, []);
const login = useCallback(async (email, password) => {
const data = await authApi.login(email, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
startAutoSync();
return data.user;
}, []);
const register = useCallback(async (email, username, password) => {
const data = await authApi.register(email, username, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
startAutoSync();
return data.user;
}, []);
const value = {
user,
loading,
isLoggedIn: !!user,
isAdmin: user?.role === 'admin',
isPremium: user?.role === 'premium' || user?.role === 'admin',
login,
register,
logout,
showAuth,
openAuth: () => setShowAuth(true),
closeAuth: () => setShowAuth(false),
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,103 @@
const API_BASE = '/api/v1';
let _accessToken = null;
let _onUnauthorized = null;
export function setAccessToken(token) { _accessToken = token; }
export function getAccessToken() { return _accessToken; }
export function setOnUnauthorized(fn) { _onUnauthorized = fn; }
async function request(method, path, body = null, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: 'include', // send cookies (refresh token)
body: body ? JSON.stringify(body) : null,
...opts,
});
if (res.status === 401 && !path.includes('/auth/')) {
// Try to refresh
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${_accessToken}`;
const retry = await fetch(`${API_BASE}${path}`, {
method, headers, credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (retry.ok) return retry.json();
}
_onUnauthorized?.();
throw new Error('Unauthorized');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
async function refreshToken() {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const data = await res.json();
_accessToken = data.accessToken;
return true;
} catch {
return false;
}
}
// Auth
export const auth = {
register: (email, username, password) =>
request('POST', '/auth/register', { email, username, password }),
login: (email, password) =>
request('POST', '/auth/login', { email, password }),
logout: () => request('POST', '/auth/logout'),
refresh: refreshToken,
};
// Users
export const users = {
me: () => request('GET', '/users/me'),
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 Levels
export const levels = {
list: () => request('GET', '/admin/levels'),
create: (data) => request('POST', '/admin/levels', data),
update: (id, data) => request('PATCH', `/admin/levels/${id}`, data),
remove: (id) => request('DELETE', `/admin/levels/${id}`),
importPatch: (id, patchData) => request('POST', `/admin/levels/${id}/import-patch`, patchData),
};
// Admin
export const admin = {
stats: () => request('GET', '/admin/stats'),
users: (params = '') => request('GET', `/admin/users${params ? '?' + params : ''}`),
updateUser: (id, data) => request('PATCH', `/admin/users/${id}`, data),
patches: (params = '') => request('GET', `/admin/patches${params ? '?' + params : ''}`),
updatePatch: (id, data) => request('PATCH', `/admin/patches/${id}`, data),
};

View File

@@ -0,0 +1,134 @@
import { getAccessToken } from './api.js';
const API_BASE = '/api/v1/sync';
const SYNC_INTERVAL = 30000; // 30 seconds
let _syncTimer = null;
async function apiFetch(method, path, body = null) {
const token = getAccessToken();
if (!token) return null;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (!res.ok) return null;
return res.json();
}
// ==================== Preset Sync ====================
export async function syncPresets() {
if (!getAccessToken()) return;
try {
// Get local presets
const localRaw = localStorage.getItem('reaktor_presets');
const localPresets = localRaw ? JSON.parse(localRaw) : [];
// Get server presets
const serverData = await apiFetch('GET', '/presets');
if (!serverData) return;
// Push local presets to server
if (localPresets.length > 0) {
await apiFetch('PUT', '/presets', {
presets: localPresets.map(p => ({
name: p.name,
data: p.data,
updatedAt: p.savedAt || new Date().toISOString(),
})),
});
}
// Merge server presets into local (add any missing)
const serverPresets = serverData.presets || [];
const localNames = new Set(localPresets.map(p => p.name));
let merged = [...localPresets];
for (const sp of serverPresets) {
if (!localNames.has(sp.name)) {
merged.push({
name: sp.name,
data: sp.data,
savedAt: sp.updatedAt,
});
}
}
localStorage.setItem('reaktor_presets', JSON.stringify(merged));
} catch (err) {
console.warn('[sync] preset sync failed:', err.message);
}
}
// ==================== Game Progress Sync ====================
export async function syncProgress() {
if (!getAccessToken()) return;
try {
const localRaw = localStorage.getItem('synthquest-progress');
const localProgress = localRaw ? JSON.parse(localRaw) : null;
// Get server progress
const serverData = await apiFetch('GET', '/progress');
if (localProgress) {
// Push local to server
await apiFetch('PUT', '/progress', {
data: localProgress,
updatedAt: new Date().toISOString(),
});
}
// If server has progress and local doesn't, pull it
if (serverData?.progress && !localProgress) {
localStorage.setItem('synthquest-progress', JSON.stringify(serverData.progress));
}
// Also sync hint data
const hintsRaw = localStorage.getItem('synthquest-hints');
// Hints are local-only for now (anti-cheat integrity)
} catch (err) {
console.warn('[sync] progress sync failed:', err.message);
}
}
// ==================== Auto Sync ====================
export function startAutoSync() {
if (_syncTimer) return;
// Initial sync
syncPresets();
syncProgress();
// Periodic sync
_syncTimer = setInterval(() => {
syncPresets();
syncProgress();
}, SYNC_INTERVAL);
// Sync on tab focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncPresets();
syncProgress();
}
});
}
export function stopAutoSync() {
if (_syncTimer) {
clearInterval(_syncTimer);
_syncTimer = null;
}
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
},
},
build: { outDir: '../../dist', emptyOutDir: true }
});

View File

@@ -0,0 +1,5 @@
DATABASE_URL=postgres://reaktor:reaktor_dev@localhost:5432/reaktor
JWT_SECRET=change-this-to-a-random-secret
PORT=3001
CORS_ORIGIN=http://localhost:3000
NODE_ENV=development

View File

@@ -0,0 +1,8 @@
export default {
schema: './src/db/schema.js',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor',
},
};

View File

@@ -0,0 +1,29 @@
{
"name": "@reaktor/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.8.2",
"postgres": "^3.4.8",
"uuid": "^13.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.10"
}
}

View File

@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'dist');
const STATIC_DIR = path.join(__dirname, '..', '..', 'dist');
const MIME = {
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',

View File

@@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString = process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export { schema };

View File

@@ -0,0 +1,80 @@
import { pgTable, uuid, varchar, text, boolean, integer, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
username: varchar('username', { length: 50 }).unique().notNull(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
avatarUrl: varchar('avatar_url', { length: 500 }),
bio: text('bio'),
role: varchar('role', { length: 20 }).default('user').notNull(), // user | premium | admin
authProvider: varchar('auth_provider', { length: 20 }).default('local'),
providerId: varchar('provider_id', { length: 255 }),
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
subscriptionStatus: varchar('subscription_status', { length: 20 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const presets = pgTable('presets', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: varchar('name', { length: 200 }).notNull(),
data: jsonb('data').notNull(),
isAutosave: boolean('is_autosave').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const gameProgress = pgTable('game_progress', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).primaryKey(),
data: jsonb('data').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const sharedPatches = pgTable('shared_patches', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
tags: text('tags').array(),
data: jsonb('data').notNull(),
previewUrl: varchar('preview_url', { length: 500 }),
likesCount: integer('likes_count').default(0).notNull(),
isFlagged: boolean('is_flagged').default(false).notNull(),
isDeleted: boolean('is_deleted').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const likes = pgTable('likes', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
patchId: uuid('patch_id').references(() => sharedPatches.id, { onDelete: 'cascade' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.patchId] }),
}));
export const customLevels = pgTable('custom_levels', {
id: uuid('id').defaultRandom().primaryKey(),
worldId: varchar('world_id', { length: 20 }).notNull(),
levelId: varchar('level_id', { length: 50 }).unique().notNull(),
title: varchar('title', { length: 200 }).notNull(),
subtitle: varchar('subtitle', { length: 200 }),
description: text('description'),
concept: text('concept'),
availableModules: text('available_modules').array(),
preplacedData: jsonb('preplaced_data'), // imported from sandbox export
targetData: jsonb('target_data'),
sortOrder: integer('sort_order').default(0),
isBoss: boolean('is_boss').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -0,0 +1,80 @@
import 'dotenv/config';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
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';
import levelRoutes from './routes/levels.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const fastify = Fastify({ logger: true });
// Plugins
await fastify.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});
await fastify.register(cookie);
await fastify.register(jwt, {
secret: JWT_SECRET,
});
await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// API routes
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' });
await fastify.register(levelRoutes, { prefix: '/api/v1/admin/levels' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {
if (routeOptions.url?.startsWith('/api/v1/auth')) {
routeOptions.config = { ...routeOptions.config, rateLimit: { max: 10, timeWindow: '1 minute' } };
}
});
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// In production, serve static files (SPA)
if (process.env.NODE_ENV === 'production') {
const distDir = path.join(__dirname, '..', '..', '..', 'dist');
await fastify.register(fastifyStatic, { root: distDir });
// SPA fallback
fastify.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'Not found' });
} else {
reply.sendFile('index.html');
}
});
}
// Start
try {
await fastify.listen({ port: PORT, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,18 @@
export async function authenticate(request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}
export async function requireAdmin(request, reply) {
try {
await request.jwtVerify();
if (request.user.role !== 'admin') {
reply.code(403).send({ error: 'Forbidden' });
}
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,103 @@
import { eq, sql, desc, ilike } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function adminRoutes(fastify) {
// Dashboard stats
fastify.get('/stats', { preHandler: [requireAdmin] }, async () => {
const [userCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users);
const [patchCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isDeleted, false));
const [premiumCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users).where(eq(schema.users.role, 'premium'));
const [flaggedCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isFlagged, true));
return {
users: userCount.count,
patches: patchCount.count,
premium: premiumCount.count,
flagged: flaggedCount.count,
};
});
// List users
fastify.get('/users', { preHandler: [requireAdmin] }, async (request) => {
const { q, page = 1, role } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
role: schema.users.role,
avatarUrl: schema.users.avatarUrl,
createdAt: schema.users.createdAt,
}).from(schema.users);
if (q) {
query = query.where(
sql`${schema.users.username} ILIKE ${'%' + q + '%'} OR ${schema.users.email} ILIKE ${'%' + q + '%'}`
);
}
if (role) {
query = query.where(eq(schema.users.role, role));
}
const users = await query.orderBy(desc(schema.users.createdAt)).limit(limit).offset(offset);
return { users, page, limit };
});
// Update user role / ban
fastify.patch('/users/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const { role } = request.body || {};
if (!role || !['user', 'premium', 'admin', 'banned'].includes(role)) {
return reply.code(400).send({ error: 'Invalid role' });
}
const [user] = await db.update(schema.users)
.set({ role, updatedAt: new Date() })
.where(eq(schema.users.id, request.params.id))
.returning({ id: schema.users.id, username: schema.users.username, role: schema.users.role });
if (!user) return reply.code(404).send({ error: 'User not found' });
return user;
});
// List shared patches (moderation)
fastify.get('/patches', { preHandler: [requireAdmin] }, async (request) => {
const { flagged, deleted, page = 1 } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select().from(schema.sharedPatches);
if (flagged === 'true') {
query = query.where(eq(schema.sharedPatches.isFlagged, true));
}
if (deleted === 'true') {
query = query.where(eq(schema.sharedPatches.isDeleted, true));
}
const patches = await query.orderBy(desc(schema.sharedPatches.createdAt)).limit(limit).offset(offset);
return { patches, page, limit };
});
// Moderate patch (delete, unflag, restore)
fastify.patch('/patches/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = {};
const { action } = request.body || {};
if (action === 'delete') updates.isDeleted = true;
else if (action === 'restore') updates.isDeleted = false;
else if (action === 'unflag') updates.isFlagged = false;
else return reply.code(400).send({ error: 'Invalid action' });
const [patch] = await db.update(schema.sharedPatches)
.set(updates)
.where(eq(schema.sharedPatches.id, request.params.id))
.returning();
if (!patch) return reply.code(404).send({ error: 'Patch not found' });
return patch;
});
}

View File

@@ -0,0 +1,206 @@
import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
const REFRESH_EXPIRY_DAYS = 30;
async function hashRefreshToken(token) {
return argon2.hash(token, { type: argon2.argon2id });
}
export default async function authRoutes(fastify) {
// Register
fastify.post('/register', {
schema: {
body: {
type: 'object',
required: ['email', 'username', 'password'],
properties: {
email: { type: 'string', format: 'email' },
username: { type: 'string', minLength: 3, maxLength: 50 },
password: { type: 'string', minLength: 6 },
},
},
},
}, async (request, reply) => {
const { email, username, password } = request.body;
// Check existing
const existing = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'Email already registered' });
}
const existingUsername = await db.select().from(schema.users)
.where(eq(schema.users.username, username)).limit(1);
if (existingUsername.length > 0) {
return reply.code(409).send({ error: 'Username already taken' });
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const [user] = await db.insert(schema.users).values({
email,
username,
passwordHash,
}).returning({ id: schema.users.id, email: schema.users.email, username: schema.users.username, role: schema.users.role });
// Generate tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { user, accessToken };
});
// Login
fastify.post('/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return {
user: { id: user.id, email: user.email, username: user.username, role: user.role, avatarUrl: user.avatarUrl },
accessToken,
};
});
// Refresh token
fastify.post('/refresh', async (request, reply) => {
const token = request.cookies.refreshToken;
if (!token) {
return reply.code(401).send({ error: 'No refresh token' });
}
// Find all non-expired tokens and verify
const candidates = await db.select().from(schema.refreshTokens)
.where(eq(schema.refreshTokens.expiresAt, new Date())); // will fix below
// Actually, find all tokens and check hash
const allTokens = await db.select().from(schema.refreshTokens);
let matchedToken = null;
for (const t of allTokens) {
if (t.expiresAt < new Date()) continue;
try {
if (await argon2.verify(t.tokenHash, token)) {
matchedToken = t;
break;
}
} catch {}
}
if (!matchedToken) {
return reply.code(401).send({ error: 'Invalid refresh token' });
}
// Delete old token (rotation)
await db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.id, matchedToken.id));
// Get user
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.id, matchedToken.userId)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'User not found' });
}
// Issue new tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const newRefreshToken = uuidv4();
const refreshHash = await hashRefreshToken(newRefreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { accessToken };
});
// Logout
fastify.post('/logout', async (request, reply) => {
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
return { ok: true };
});
}

View File

@@ -0,0 +1,95 @@
import { eq, asc } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function levelRoutes(fastify) {
// List all custom levels
fastify.get('/', { preHandler: [requireAdmin] }, async () => {
const levels = await db.select().from(schema.customLevels)
.orderBy(asc(schema.customLevels.worldId), asc(schema.customLevels.sortOrder));
return { levels };
});
// Create a new level (import from sandbox JSON)
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
const { worldId, levelId, title, subtitle, description, concept,
availableModules, preplacedData, targetData, sortOrder, isBoss } = request.body;
if (!worldId || !levelId || !title) {
return reply.code(400).send({ error: 'worldId, levelId and title required' });
}
// Check for duplicate levelId
const [existing] = await db.select().from(schema.customLevels)
.where(eq(schema.customLevels.levelId, levelId)).limit(1);
if (existing) {
return reply.code(409).send({ error: `Level ${levelId} already exists` });
}
const [level] = await db.insert(schema.customLevels).values({
worldId,
levelId,
title,
subtitle: subtitle || '',
description: description || '',
concept: concept || '',
availableModules: availableModules || [],
preplacedData: preplacedData || null,
targetData: targetData || null,
sortOrder: sortOrder || 0,
isBoss: isBoss || false,
}).returning();
return level;
});
// Update a level
fastify.patch('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = { ...request.body, updatedAt: new Date() };
delete updates.id;
delete updates.createdAt;
const [level] = await db.update(schema.customLevels)
.set(updates)
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
// Delete a level
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request) => {
await db.delete(schema.customLevels)
.where(eq(schema.customLevels.id, request.params.id));
return { ok: true };
});
// Import preplaced modules from sandbox JSON export
fastify.post('/:id/import-patch', { preHandler: [requireAdmin] }, async (request, reply) => {
const { modules, connections } = request.body;
if (!modules) return reply.code(400).send({ error: 'modules required' });
// Convert sandbox export to preplacedModules format
const preplacedData = {
modules: modules.map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: m.params || {},
locked: true,
})),
connections: connections || [],
};
const [level] = await db.update(schema.customLevels)
.set({ preplacedData, updatedAt: new Date() })
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
}

View File

@@ -0,0 +1,104 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function syncRoutes(fastify) {
// Get all user presets
fastify.get('/presets', { preHandler: [authenticate] }, async (request) => {
const presets = await db.select({
id: schema.presets.id,
name: schema.presets.name,
data: schema.presets.data,
isAutosave: schema.presets.isAutosave,
updatedAt: schema.presets.updatedAt,
}).from(schema.presets)
.where(eq(schema.presets.userId, request.user.id))
.orderBy(schema.presets.updatedAt);
return { presets };
});
// Upsert presets (merge from client)
fastify.put('/presets', { preHandler: [authenticate] }, async (request) => {
const { presets } = request.body;
if (!Array.isArray(presets)) return { error: 'presets must be array' };
const results = [];
for (const p of presets) {
if (!p.name || !p.data) continue;
if (p.id) {
// Update existing
const [existing] = await db.select().from(schema.presets)
.where(eq(schema.presets.id, p.id)).limit(1);
if (existing && existing.userId === request.user.id) {
// Only update if client is newer
const clientTime = p.updatedAt ? new Date(p.updatedAt) : new Date();
if (clientTime >= new Date(existing.updatedAt)) {
const [updated] = await db.update(schema.presets)
.set({ name: p.name, data: p.data, isAutosave: p.isAutosave || false, updatedAt: new Date() })
.where(eq(schema.presets.id, p.id))
.returning();
results.push(updated);
} else {
results.push(existing); // Server is newer, keep it
}
continue;
}
}
// Insert new
const [inserted] = await db.insert(schema.presets).values({
userId: request.user.id,
name: p.name,
data: p.data,
isAutosave: p.isAutosave || false,
}).returning();
results.push(inserted);
}
return { presets: results };
});
// Delete a preset
fastify.delete('/presets/:id', { preHandler: [authenticate] }, async (request, reply) => {
await db.delete(schema.presets)
.where(eq(schema.presets.id, request.params.id));
return { ok: true };
});
// Get game progress
fastify.get('/progress', { preHandler: [authenticate] }, async (request) => {
const [progress] = await db.select().from(schema.gameProgress)
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
return { progress: progress?.data || null, updatedAt: progress?.updatedAt || null };
});
// Upsert game progress (last-write-wins)
fastify.put('/progress', { preHandler: [authenticate] }, async (request) => {
const { data, updatedAt } = request.body;
if (!data) return { error: 'data required' };
const [existing] = await db.select().from(schema.gameProgress)
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
if (existing) {
const clientTime = updatedAt ? new Date(updatedAt) : new Date();
if (clientTime >= new Date(existing.updatedAt)) {
await db.update(schema.gameProgress)
.set({ data, updatedAt: new Date() })
.where(eq(schema.gameProgress.userId, request.user.id));
}
} else {
await db.insert(schema.gameProgress).values({
userId: request.user.id,
data,
});
}
return { ok: true };
});
}

View File

@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function userRoutes(fastify) {
// Get my profile
fastify.get('/me', { preHandler: [authenticate] }, async (request) => {
const [user] = await db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
bio: schema.users.bio,
role: schema.users.role,
createdAt: schema.users.createdAt,
}).from(schema.users).where(eq(schema.users.id, request.user.id)).limit(1);
if (!user) return { error: 'User not found' };
return user;
});
// Update my profile
fastify.patch('/me', {
preHandler: [authenticate],
schema: {
body: {
type: 'object',
properties: {
username: { type: 'string', minLength: 3, maxLength: 50 },
bio: { type: 'string', maxLength: 500 },
},
},
},
}, async (request, reply) => {
const updates = {};
if (request.body.username) updates.username = request.body.username;
if (request.body.bio !== undefined) updates.bio = request.body.bio;
updates.updatedAt = new Date();
if (Object.keys(updates).length === 1) {
return reply.code(400).send({ error: 'Nothing to update' });
}
const [user] = await db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, request.user.id))
.returning({
id: schema.users.id,
username: schema.users.username,
bio: schema.users.bio,
});
return user;
});
}

View File

@@ -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 };
});
}

180
producto.md Normal file
View File

@@ -0,0 +1,180 @@
# Reaktor — Product Document
## Vision
Reaktor es una plataforma web de sintesis modular que combina un **sandbox creativo** con un **sistema de aprendizaje gamificado (SynthQuest)**. La plataforma permitira a los usuarios crear, aprender, compartir y descubrir sonidos sintetizados.
---
## Producto Actual (v1 — Live)
### Sandbox Mode
- Sintetizador modular completo en el navegador (Tone.js)
- 15+ tipos de modulos: Oscillator, Filter, Envelope, VCA, LFO, Mixer, Sequencer, Piano Roll, Keyboard, Drum Pad, CV→Gate, Delay, Reverb, Distortion, Scope, Output, Noise
- Conexion visual de modulos con cables de audio/control/trigger
- Canvas con zoom, pan, grid
- Guardado/carga de presets (localStorage)
- Export/import de patches como JSON
- Demo Chiptune incluido
### SynthQuest (Game Mode)
- 12 mundos tematicos, 96 niveles progresivos
- Sistema de 3 estrellas por nivel
- Hints con penalizacion (max 2 estrellas)
- Boss levels por mundo
- Progreso persistente (localStorage)
### Mobile
- UI responsiva completa (bottom sheet, tab bar, touch panning, pinch zoom)
- Keyboard y Drum Pad a pantalla completa
- PWA instalable
---
## Roadmap
### Phase 0 — Estructura (monorepo)
**Objetivo:** Preparar la base tecnica para el backend sin romper nada.
- [ ] Reestructurar a monorepo (`packages/client` + `packages/server`)
- [ ] Actualizar Dockerfile (multi-stage: client build + server)
- [ ] Docker Compose con PostgreSQL
- [ ] Verificar que deploy funciona igual que antes
### Phase 1 — Usuarios y Auth
**Objetivo:** Sistema de cuentas de usuario.
- [ ] Backend API (Fastify + PostgreSQL + Drizzle ORM)
- [ ] Registro por email + password (argon2)
- [ ] Login con JWT (access token 15min + refresh cookie 30d)
- [ ] Perfil de usuario (username, avatar, bio)
- [ ] Roles: `user`, `premium`, `admin`
- [ ] UI: modal de login/registro en el frontend
- [ ] Auth context en React (user, isLoggedIn, role)
- [ ] OAuth con Google/GitHub (opcional, puede ir en Phase 1.5)
### Phase 2 — Sincronizacion de Datos
**Objetivo:** Los datos del usuario viajan con su cuenta, no con el dispositivo.
- [ ] Sync de presets a la nube (offline-first, localStorage primary)
- [ ] Sync de progreso de SynthQuest
- [ ] Merge inteligente: last-write-wins por timestamp
- [ ] Cola de sincronizacion offline (flush al reconectar)
- [ ] Multi-dispositivo: login en otro dispositivo y tener todo
### Phase 3 — Workshop / Comunidad
**Objetivo:** Compartir creaciones y descubrir sonidos de otros usuarios.
- [ ] Publicar patches con titulo, descripcion, tags
- [ ] Preview de audio (generado client-side con Tone.js Recorder)
- [ ] Galeria publica: buscar, filtrar por tags, ordenar (popular/reciente)
- [ ] Sistema de likes/favoritos
- [ ] Cargar patch compartido directamente en el Sandbox
- [ ] Perfil publico de usuario con sus patches compartidos
- [ ] Comentarios en patches (v2, opcional)
### Phase 4 — Panel de Administracion
**Objetivo:** Control total sobre la plataforma.
- [ ] Dashboard con KPIs:
- Usuarios totales, DAU, MAU
- Patches compartidos (total, por dia)
- Usuarios premium vs free
- Niveles completados (metricas del juego)
- [ ] Gestion de usuarios:
- Lista, busqueda, filtros
- Ver detalle de usuario (patches, progreso, rol)
- Cambiar rol (user → premium → admin)
- Banear/desbanear
- [ ] Moderacion del Workshop:
- Ver patches reportados/flagged
- Eliminar contenido (soft delete)
- Editar titulo/descripcion
- Ver historial de moderacion
### Phase 5 — Monetizacion (futuro)
**Objetivo:** Cursos premium y sostenibilidad.
- [ ] Definir proveedor de pagos (Stripe, LemonSqueezy, Paddle)
- [ ] Plan Premium: acceso a cursos avanzados de sintesis
- [ ] Checkout flow
- [ ] Gestion de suscripciones (portal del usuario)
- [ ] Metricas de revenue en admin dashboard
- [ ] Sandbox permanece gratuito
### Phase 6 — Cursos (futuro)
**Objetivo:** Contenido educativo estructurado de pago.
- [ ] Sistema de cursos con lecciones
- [ ] Lecciones interactivas (como SynthQuest pero mas profundo)
- [ ] Certificados de completado
- [ ] Tracks tematicos: "Sound Design", "Beat Making", "Ambient Textures"
---
## Stack Tecnico
| Capa | Tecnologia |
|------|-----------|
| Frontend | React 18, Vite, Tone.js |
| Backend | Fastify v5 (Node.js) |
| Base de datos | PostgreSQL 16 + Drizzle ORM |
| Auth | JWT + httpOnly refresh cookies + argon2 |
| Storage | Filesystem (Docker volume) |
| Deploy | Docker + Docker Compose |
| Hosting | montlab.dev (self-hosted) |
| Git | Gitea (git.montlab.dev) |
---
## Principios de Diseno
1. **Offline-first** — La app funciona sin internet. El backend es un extra, no una dependencia.
2. **Opt-in** — Todo funciona sin cuenta. Login desbloquea sync + comunidad.
3. **Mobile-first** — Cada feature se disena primero para movil.
4. **Progresivo** — Cada phase se puede deployar independientemente.
5. **Simple** — Preferir soluciones simples sobre arquitecturas complejas.
---
## Metricas de Exito
- **Phase 1:** 100 usuarios registrados en el primer mes
- **Phase 3:** 50 patches compartidos en el Workshop
- **Phase 5:** 10 suscriptores premium
- **Long-term:** Reaktor como referencia en educacion de sintesis modular web
---
## Notas Tecnicas
### SynthQuest: Niveles base vs niveles custom
Los **96 niveles base** (12 mundos × 8 niveles) estan hardcoded en ficheros JS (`packages/client/src/game/levels/world1.js` ... `world12.js`). Estos niveles **no se pueden editar desde el admin panel** porque contienen funciones `test()` en JavaScript que validan si el jugador ha completado el objetivo:
```javascript
checks: [
{
star: 1,
desc: 'Conecta el oscilador a la salida',
test: (mods, conns) => {
// Logica JS que inspecciona los modulos y conexiones
return conns.some(c => c.from.moduleId === osc.id && ...);
},
},
]
```
Estas funciones `test()` no se pueden serializar en una base de datos — son codigo ejecutable que depende del contexto del engine. Para editar los niveles base hay que modificar directamente los ficheros JS y hacer deploy.
Los **niveles custom** creados desde el admin panel se almacenan en PostgreSQL y permiten definir titulo, descripcion, modulos disponibles y patch base (importado del Sandbox). Sin embargo, **no soportan checks/objetivos custom** porque requeririan escribir funciones JS. Los niveles custom se pueden usar para:
- Tutoriales simples tipo "monta este circuito"
- Challenges de la comunidad
- Contenido adicional sin sistema de estrellas
Para añadir niveles con sistema de estrellas completo, hay que crear un fichero `worldN.js` con los checks en JS.
---
*Documento vivo — actualizar conforme avanza el desarrollo.*

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3000 },
build: { outDir: 'dist' }
});