Compare commits
17 Commits
main
...
feat/produ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925043e055 | ||
|
|
a0a3b58b49 | ||
|
|
13612bfa99 | ||
|
|
acbe4257ae | ||
|
|
12569dba76 | ||
|
|
f43a315047 | ||
|
|
b0522d8b0f | ||
|
|
e53ec600ad | ||
|
|
c673745b09 | ||
|
|
3b80070c9a | ||
|
|
982654c3ef | ||
|
|
64ffa36c09 | ||
|
|
3523111019 | ||
|
|
e129fd3739 | ||
|
|
6a4a308fd9 | ||
|
|
b058997889 | ||
|
|
4baa86eed0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.env
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -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
24
docker-compose.yml
Normal 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
3000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/client/package.json
Normal file
20
packages/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -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>
|
||||
)}
|
||||
466
packages/client/src/components/AdminPanel2.jsx
Normal file
466
packages/client/src/components/AdminPanel2.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/client/src/components/AuthModal.jsx
Normal file
114
packages/client/src/components/AuthModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (0→1)
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
265
packages/client/src/components/Workshop.jsx
Normal file
265
packages/client/src/components/Workshop.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
@@ -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?.();
|
||||
}}
|
||||
/>
|
||||
@@ -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;
|
||||
@@ -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 />);
|
||||
79
packages/client/src/services/AuthContext.jsx
Normal file
79
packages/client/src/services/AuthContext.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
packages/client/src/services/api.js
Normal file
103
packages/client/src/services/api.js
Normal 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),
|
||||
};
|
||||
134
packages/client/src/services/syncService.js
Normal file
134
packages/client/src/services/syncService.js
Normal 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;
|
||||
}
|
||||
}
|
||||
13
packages/client/vite.config.js
Normal file
13
packages/client/vite.config.js
Normal 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 }
|
||||
});
|
||||
5
packages/server/.env.example
Normal file
5
packages/server/.env.example
Normal 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
|
||||
8
packages/server/drizzle.config.js
Normal file
8
packages/server/drizzle.config.js
Normal 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',
|
||||
},
|
||||
};
|
||||
29
packages/server/package.json
Normal file
29
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
9
packages/server/src/db/index.js
Normal file
9
packages/server/src/db/index.js
Normal 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 };
|
||||
80
packages/server/src/db/schema.js
Normal file
80
packages/server/src/db/schema.js
Normal 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(),
|
||||
});
|
||||
80
packages/server/src/index.js
Normal file
80
packages/server/src/index.js
Normal 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);
|
||||
}
|
||||
18
packages/server/src/middleware/auth.js
Normal file
18
packages/server/src/middleware/auth.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
103
packages/server/src/routes/admin.js
Normal file
103
packages/server/src/routes/admin.js
Normal 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;
|
||||
});
|
||||
}
|
||||
206
packages/server/src/routes/auth.js
Normal file
206
packages/server/src/routes/auth.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
95
packages/server/src/routes/levels.js
Normal file
95
packages/server/src/routes/levels.js
Normal 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;
|
||||
});
|
||||
}
|
||||
104
packages/server/src/routes/sync.js
Normal file
104
packages/server/src/routes/sync.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
56
packages/server/src/routes/users.js
Normal file
56
packages/server/src/routes/users.js
Normal 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;
|
||||
});
|
||||
}
|
||||
175
packages/server/src/routes/workshop.js
Normal file
175
packages/server/src/routes/workshop.js
Normal 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
180
producto.md
Normal 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.*
|
||||
@@ -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' }
|
||||
});
|
||||
Reference in New Issue
Block a user