Compare commits

40 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:42:19 +01:00
Jose Luis
982654c3ef feat: Phase 3 — Workshop (community patch sharing)
Server:
- GET /api/v1/workshop — browse patches (search, tags, sort)
- POST /api/v1/workshop — share a patch (auth required)
- GET /api/v1/workshop/:id — single patch detail
- DELETE /api/v1/workshop/:id — soft delete (owner/admin)
- POST/DELETE /api/v1/workshop/:id/like — like/unlike
- POST /api/v1/workshop/:id/report — flag for moderation

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:29:52 +01:00
Jose Luis
4f4d2bfae5 Merge feat/mobile-ui: responsive mobile UI, audio engine fixes, new modules
Mobile UI:
- Responsive layout for all views (Sandbox, World Map, Puzzle View)
- Bottom sheet with swipe gestures for module palette and puzzle tabs
- Mobile tab bar navigation (Game/Sandbox/Config)
- Touch panning, pinch-to-zoom, native zoom blocking
- PWA support (installable, offline-capable)

New modules:
- Drum Pad (🥁): 4x4 colored pad grid with gate/freq output
- CV→Gate (): converts continuous CV to gate signal with threshold
- Fullscreen mode for Keyboard and Drum Pad (portal-based)

Audio engine:
- Global master clock (120 Hz) with time-derived ticks (no drift)
- Connection cache with dirty flag (zero overhead on cache hit)
- Reduced main thread pressure (throttled RAF loops, lower clock rate)
- VCA properly zeroes with CV control, envelope release min 0.001s
- Audio context unlocked on first interaction for immediate UI sounds
2026-03-21 19:12:17 +01:00
Jose Luis
02db83b896 fix: VCA CV scaler always 1 so envelope works regardless of gain param
Bug: if VCA gain was 0 when CV was connected, cvMod (initialized with
p.gain=0) would multiply envelope by 0 = silence forever.

Fix: cvMod always has gain=1 (full pass-through). The envelope (0-1)
controls the VCA amplitude directly. When CV is connected, base gain
is zeroed so only the envelope signal is heard. When disconnected,
base gain is restored from the param value.

Before: cvMod.gain = p.gain → envelope × 0 = 0 (broken)
After:  cvMod.gain = 1      → envelope × 1 = envelope (correct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:55:29 +01:00
Jose Luis
49c016d0a6 fix: prevent envelope release=0 causing sustain loop bug
Tone.Envelope with release=0 behaves unpredictably — the gate-off
ramp doesn't complete properly and the value snaps back to sustain.
Set minimum release to 0.001s (same as attack/decay already have).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:51:42 +01:00
Jose Luis
2a2b3b3341 fix: VCA zeroes on live CV connect + visual feedback for envelope control
VCA fix:
- connectWire now zeros VCA base gain immediately when CV is connected
  (previously only rebuildGraph did this, missing live-connect case)
- disconnectWire restores base gain from params when CV is removed

Visual modulation feedback:
- ModuleNode RAF loop now handles envelope sources (not just LFO)
- Reads actual Tone.js gain node value for real-time display
- VCA gain knob shows live envelope value during playback
- LFO visualization unchanged (simulated waveform as before)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:50:28 +01:00
Jose Luis
38dca9402f fix: VCA closes properly with envelope + add CV→Gate module
VCA fix:
- Add cvMod scaler (like oscillator/filter have) so envelope (0-1)
  is scaled by the gain param before modulating VCA
- Zero base gain when CV is connected (in rebuildGraph) so envelope
  = 0 produces silence instead of falling back to base gain
- updateParam keeps cvMod in sync with gain knob

New module: CV→Gate () in Utility category:
- Converts continuous CV signal (e.g. LFO) to gate on/off
- Threshold knob (0-1, default 0.5): signal above = gate on
- Reads analyser on master clock tick for threshold comparison
- Triggers/releases connected envelopes automatically
- Use case: LFO → CV→Gate → Envelope → VCA for rhythmic gating

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:44:28 +01:00
Jose Luis
7e6c960b0b fix: reduce main thread pressure to prevent audio buffer underruns
The periodic audio glitches were caused by main thread starvation:
~840 events/sec during playback starved the audio buffer.

Changes:
- Master clock 480→120 Hz (still 6x headroom for 300 BPM sixteenths)
- Connection cache: replace O(n) reduce hash with dirty flag (zero work
  on cache hit, flag set only when connections actually change)
- Tone.js lookAhead: 100ms→50ms for tighter scheduling
- ModuleNode LFO visualization RAF: 60fps→15fps (every 4th frame)
- ScopeDisplay RAF: 60fps→30fps (every 2nd frame)

Net effect: ~840 events/sec → ~200 events/sec during playback.
Audio processing gets 4x more main thread headroom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:19:12 +01:00
Jose Luis
7596aea491 fix: derive master clock ticks from AudioContext.currentTime
The _masterTicks++ counter fell behind when Tone.Clock callbacks were
delayed by GC pauses, UI interactions, or tab throttling. The counter
never recovered, causing cumulative drift between sequencers.

Now ticks are derived from the callback's time parameter (which comes
from AudioContext.currentTime — hardware clock, always precise):
  ticks = Math.round((time - startTime) * MASTER_TICK_RATE)

If a callback is delayed by 50ms, the time is still correct and ticks
jump ahead to the right value. No accumulation, no drift, self-healing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:08:10 +01:00
Jose Luis
7d3a19ec35 fix: use integer tick counter to eliminate floating-point beat drift
Root cause: floor(elapsed * rateA) vs floor(elapsed * rateB) where
rateB = 2*rateA doesn't maintain exact 2:1 ratio due to floating-point
multiplication errors. This creates a beat/aliasing pattern where
sequencers at 80 and 160 BPM periodically go in and out of phase.

Fix: Master clock now uses an integer tick counter (_masterTicks++)
instead of floating-point elapsed time. Sequencers derive steps via:
  stepIdx = floor(ticks / ticksPerStep) % numSteps
where ticks is an integer — no floating-point accumulation possible.

Also bumped master clock to 480 Hz for cleaner division at common BPMs:
  80 BPM: 480*60/320 = 90 ticks/step (exact)
  120 BPM: 480*60/480 = 60 ticks/step (exact)
  160 BPM: 480*60/640 = 45 ticks/step (exact)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:01:56 +01:00
Jose Luis
8bdb953b52 fix: capture master clock start time from first tick callback
The _masterTime was captured from Tone.now() BEFORE the clock started,
but the time parameter in Tone.Clock callbacks comes from a different
scheduler timeline. This caused elapsed to drift systematically.

Now _masterTime is set from the first callback's own time parameter,
guaranteeing both are on the exact same clock source. Zero drift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:57:10 +01:00
Jose Luis
18661961a1 fix: reduce master clock to 240 Hz + eliminate note-off timeouts
- Master clock 960→240 Hz: reduces CPU/GC pressure by 4x while still
  providing 12x headroom for 300 BPM sixteenths
- Remove Tone.getContext().setTimeout() for note-off scheduling —
  these accumulated over time causing periodic hiccups
- Note-off now happens at step boundary: previous gate turned off
  at the start of each new step (cleaner, zero accumulation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:52:38 +01:00
Jose Luis
1f941d7e39 feat: global master clock for drift-free multi-sequencer timing
Replace independent Tone.Clock per sequencer/pianoroll with a single
shared master clock running at 960 Hz in audioEngine.

Architecture:
- Master clock starts/stops with audio engine (startAudio/stopAudio)
- Widgets subscribe via subscribeTick(id, callback) receiving
  (audioTime, elapsed) on every tick
- Each widget derives its own step/position from elapsed time and
  its own BPM, so different BPMs stay perfectly in sync
- BPM/steps/bars changes are read from refs (no clock restart needed)

Benefits:
- All timing derived from one clock source = zero relative drift
- No clock recreation on param changes = no glitches
- 960 Hz tick rate ≈ 1ms precision (plenty for musical timing)
- Sequencer at 80 BPM and 160 BPM maintain perfect 1:2 ratio forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:45:10 +01:00
Jose Luis
9dba156961 fix: eliminate multi-sequencer drift with time-based step calculation
Problem: two sequencers at different BPMs (e.g. 80 and 160) would
drift apart over time because each used an independent step counter
(step++) that accumulated floating-point rounding errors.

Fix: derive step/position from audio clock time (Tone.now()), not
from an incrementing counter. Step = floor(elapsed * rate) % numSteps.
This makes timing mathematically exact regardless of how long it runs.

Also:
- Sequencer note-off uses Tone.getContext().setTimeout() (audio-thread)
  instead of Tone.Transport.scheduleOnce() which needs Transport running
- Clock runs at 2x rate for tighter step edge detection
- PianoRoll uses same time-based position calculation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:42:30 +01:00
Jose Luis
b91b35f23d fix: eliminate audio timing jitter and rhythm drift
Root causes fixed:
- Sequencer: replaced setTimeout note-off with Tone.Transport.scheduleOnce
  for sample-accurate timing instead of main-thread-dependent setTimeout
- Sequencer + PianoRoll: decoupled visual updates from audio callbacks.
  Audio clock only writes to refs, RAF loop reads refs for visual step
  indicator. No more React setState inside Tone.Clock callbacks.
- audioEngine: added connection lookup cache (Map) to replace O(n²)
  array iterations in setSequencerSignals/triggerKeyboard. Cache rebuilds
  lazily only when connections change.

These changes eliminate the feedback loop where:
audio callback → setState → React render → main thread blocks →
setTimeout delayed → note-off late → drift compounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:35:41 +01:00
Jose Luis
1cf39f9b13 fix: unlock audio context on first user interaction
UI sounds weren't playing until the user hit Play because Tone.js
AudioContext was suspended. Now Tone.start() is called on the first
pointerdown or keydown event, so UI sounds work immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:28:55 +01:00
Jose Luis
cf6e912905 fix: auto-center viewport when puzzle level loads
Call handleCenterView after level load with a short delay to let
the DOM settle, so modules are centered on screen on both mobile
and desktop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:31:12 +01:00
Jose Luis
52045897e5 feat: add PWA support (installable app)
- Web app manifest with name, icons, theme color, standalone display
- Service worker with stale-while-revalidate caching strategy
- 192px and 512px PNG icons generated from favicon.svg
- Apple-specific meta tags for iOS home screen support
- Register service worker on page load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:24:33 +01:00
Jose Luis
8b193126f7 fix: render fullscreen overlays via React Portal to document.body
The fullscreen piano/drumpad was rendering inside ModuleNode which has
CSS transform: scale(zoom). This breaks position: fixed (fixed elements
inside a transformed parent position relative to the transform, not the
viewport). Using createPortal to document.body fixes this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:15:29 +01:00
Jose Luis
f0e7f7f37a fix: expand button for fullscreen + disable text selection
- Replace double-tap trigger with ⤢ expand button in module header
  for keyboard and drumpad modules (more reliable, no text selection)
- Disable user-select globally (except inputs/textareas)
- Fullscreen state managed in ModuleNode, passed to widgets as props
- Remove unused imports (useIsMobile, useRef) from widgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:11:32 +01:00
Jose Luis
892195410b fix: proper fullscreen piano (1 octave, big keys) + block native zoom
Fullscreen piano redesign:
- 1 octave with 7 large white keys filling the entire screen
- Gradient-lit keys with cyan press highlight
- Octave navigation buttons (◀ ▶) to shift up/down
- Note labels on each key (C4, D4, etc.)
- Black keys proportionally sized at 58% height
- touch-action: none to prevent any browser interference

Block native browser zoom:
- viewport meta: maximum-scale=1.0, user-scalable=no
- html touch-action: manipulation (prevents double-tap zoom on Safari)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:08:10 +01:00
Jose Luis
816e7270ed feat: fullscreen keyboard + new Drum Pad module
Keyboard fullscreen:
- Double-tap keyboard widget to enter fullscreen piano mode
- 2-octave touch-friendly piano with labeled keys
- Active key highlights cyan, close button to exit

Drum Pad module (🥁):
- New module type with 4x4 colored pad grid
- Each pad triggers a unique frequency (C2-D4 range)
- Outputs freq + gate signals (same as keyboard)
- Double-tap for fullscreen pad mode with large touch targets
- Color-coded pads with hit animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:02:22 +01:00
Jose Luis
323f30cfb9 fix: collapsible bottom sheet + pinch-to-zoom on mobile
- Bottom sheet starts collapsed (handle bar only), swipe up to expand
- Tabs visible when collapsed in puzzle view, content hidden
- Swipe down or tap handle to collapse
- Add usePinchZoom hook: two-finger pinch gesture controls canvas zoom
- Pinch zoom wired into both Sandbox and Puzzle View canvases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:53:28 +01:00
Jose Luis
8b66944e52 fix: enable touch panning and prevent page scroll on mobile
- Add touch-action: none on canvas to prevent browser scroll hijack
- Single-finger touch on empty canvas now triggers pan (pointerType check)
- Fix page bounce on mobile with position: fixed and 100dvh height

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:46:28 +01:00
Jose Luis
cd88fb5444 feat: add mobile-responsive UI for all views
- Add useIsMobile hook (768px breakpoint with matchMedia)
- Add BottomSheet component (swipe up/down, optional tabs, handle bar)
- Add MobileTabBar component (bottom nav with icons + labels)
- Sandbox mobile: compact toolbar, hamburger menu, action bar with
  START button, bottom sheet with module grid tiles
- World Map mobile: compact header, single-column level list,
  bottom tab bar (JUEGO/SANDBOX/CONFIG)
- Puzzle View mobile: icon-only top bar buttons, sidebar replaced
  by bottom sheet with 3 tabs (MISION/OBJETIVOS/MODULOS)
- ~200 lines of CSS media queries: touch targets 44px, port dots
  18px, zoom controls larger, modals full-width, level complete
  full-width buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:40:50 +01:00
82 changed files with 7299 additions and 358 deletions

1
.gitignore vendored
View File

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

View File

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

24
docker-compose.yml Normal file
View File

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

3000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,9 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Reaktor — MontLab Modular Synth</title> <title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00e5ff" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,15 @@
{
"name": "Reaktor — MontLab Modular Synth",
"short_name": "Reaktor",
"description": "Modular synthesizer & SynthQuest puzzle game",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#08080f",
"theme_color": "#00e5ff",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -0,0 +1,33 @@
const CACHE_NAME = 'reaktor-v1';
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Only cache GET requests, skip API calls
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request).then(cached => {
const fetching = fetch(e.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
}
return response;
}).catch(() => cached);
return cached || fetching;
})
);
});

View File

@@ -8,16 +8,29 @@ import ModuleNode from './components/ModuleNode.jsx';
import WireLayer from './components/WireLayer.jsx'; import WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx'; import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx'; import PresetModal from './components/PresetModal.jsx';
import BottomSheet from './components/BottomSheet.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js'; 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 [, forceUpdate] = useState(0);
const containerRef = useRef(null); const containerRef = useRef(null);
const portPositions = useRef({}); const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null); const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null); const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null); const [presetModal, setPresetModal] = useState(null);
const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth();
const importRef = useRef(null); const importRef = useRef(null);
const isMobile = useIsMobile();
const [menuOpen, setMenuOpen] = useState(false);
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
// Subscribe to state changes // Subscribe to state changes
useEffect(() => { useEffect(() => {
@@ -25,11 +38,11 @@ export default function App({ onSwitchToGame }) {
return unsub; 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(() => { useEffect(() => {
if (state.modules.length > 0) return; // Already loaded (Workshop, etc.)
const loaded = autoLoad(); const loaded = autoLoad();
if (!loaded || state.modules.length === 0) { if (!loaded || state.modules.length === 0) {
// Load chiptune demo preset
deserialize(CHIPTUNE_PRESET); deserialize(CHIPTUNE_PRESET);
} }
}, []); }, []);
@@ -86,10 +99,17 @@ export default function App({ onSwitchToGame }) {
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } else if (e.button === 0 && !connectingRef.current) {
// On mobile (touch), single finger on empty canvas = pan
if (isMobile && e.pointerType === 'touch') {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
return;
}
state.selectedModuleId = null; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -249,43 +269,105 @@ export default function App({ onSwitchToGame }) {
emit(); emit();
}; };
// Flatten all modules for mobile grid
const allModuleDefs = Object.values(getModulesByCategory()).flat();
return ( return (
<div className="app"> <div className="app">
{/* Toolbar */} {/* Toolbar */}
<div className="toolbar"> <div className="toolbar">
{onSwitchToGame && ( {onSwitchToGame && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}> <button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game 🎮 Game
</button> </button>
)} )}
{onSwitchToWorkshop && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
🎵 Workshop
</button>
)}
<span className="toolbar-title">Reaktor</span> <span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" /> {!isMobile && <div className="toolbar-sep" />}
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}> {!isMobile && (
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'} {state.isRunning ? '⏹ Stop' : '▶ Start'}
</button> </button>
<div className="toolbar-sep" /> )}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<div className="toolbar-group"> <div className="toolbar-group">
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button> <button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button> <button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button> <button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button> <button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} /> <input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div> </div>
<div className="toolbar-sep" /> )}
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}> {!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<>
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo 🎮 Chiptune Demo
</button> </button>
<button className="toolbar-btn" onClick={handleClearCanvas}> <button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
🗑 Limpiar 🗑 Limpiar
</button> </button>
<div className="toolbar-sep" /> <div className="toolbar-sep" />
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}> </>
)}
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
{state.isRunning ? '● LIVE' : '○ OFF'} {state.isRunning ? '● LIVE' : '○ OFF'}
</span> </span>
{!isMobile && (
<span className="toolbar-label" style={{ marginLeft: 'auto' }}> <span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires {state.modules.length} modules · {state.connections.length} wires
</span> </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>
<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>
)}
</div>
{/* Mobile menu overlay */}
{isMobile && menuOpen && (
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
{onSwitchToGame && (
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
🎮 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>
)}
{/* Main canvas area */} {/* Main canvas area */}
<div className="main-area"> <div className="main-area">
@@ -310,10 +392,10 @@ export default function App({ onSwitchToGame }) {
<rect width="100%" height="100%" fill="url(#grid)" /> <rect width="100%" height="100%" fill="url(#grid)" />
</svg> </svg>
{/* Wire layer (behind modules, uses getBoundingClientRect) */} {/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} /> <WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container (offset by camera) */} {/* Modules container */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}> <div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => ( {state.modules.map(mod => (
<ModuleNode <ModuleNode
@@ -327,7 +409,7 @@ export default function App({ onSwitchToGame }) {
</div> </div>
</div> </div>
{/* Zoom controls — top right of canvas */} {/* Zoom controls */}
<div className="zoom-controls"> <div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button> <button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom"> <button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
@@ -337,11 +419,40 @@ export default function App({ onSwitchToGame }) {
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button> <button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button>
</div> </div>
{/* Module palette */} {/* Desktop palette */}
<ModulePalette onAddModule={handleAddModule} /> {!isMobile && <ModulePalette onAddModule={handleAddModule} />}
</div> </div>
{/* Status bar */} {/* Mobile action bar */}
{isMobile && (
<div className="mobile-action-bar">
<button
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ STOP' : '▶ START'}
</button>
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
</div>
)}
{/* Mobile bottom sheet with modules */}
{isMobile && (
<BottomSheet>
<div className="mobile-module-grid">
{allModuleDefs.map(def => (
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
))}
</div>
</BottomSheet>
)}
{/* Status bar (hidden on mobile via CSS) */}
<div className="status-bar"> <div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span> <span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span> <span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import { useState, useRef, useCallback } from 'react';
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
const [expanded, setExpanded] = useState(false);
const startY = useRef(0);
const handleTouchStart = useCallback((e) => {
startY.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - startY.current;
if (deltaY < -30) setExpanded(true);
if (deltaY > 30) setExpanded(false);
}, []);
return (
<div
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" onClick={() => setExpanded(v => !v)}>
<div className="bottom-sheet-handle-bar" />
{!expanded && !tabs && (
<span className="bottom-sheet-peek-label">Modulos </span>
)}
</div>
{tabs && tabs.length > 0 && (
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
{tabs.map(tab => (
<button
key={tab.id}
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
>
{tab.label}
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
</button>
))}
</div>
)}
{expanded && (
<div className="bottom-sheet-content">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
// 4x4 pad layout — each pad maps to a MIDI note
const PAD_NOTES = [
{ note: 36, label: 'C2', color: '#ff4466' },
{ note: 38, label: 'D2', color: '#ff6644' },
{ note: 40, label: 'E2', color: '#ffcc00' },
{ note: 42, label: 'F#2', color: '#44ff88' },
{ note: 43, label: 'G2', color: '#00e5ff' },
{ note: 45, label: 'A2', color: '#aa55ff' },
{ note: 47, label: 'B2', color: '#ff4466' },
{ note: 48, label: 'C3', color: '#ff6644' },
{ note: 50, label: 'D3', color: '#ffcc00' },
{ note: 52, label: 'E3', color: '#44ff88' },
{ note: 53, label: 'F3', color: '#00e5ff' },
{ note: 55, label: 'G3', color: '#aa55ff' },
{ note: 57, label: 'A3', color: '#ff4466' },
{ note: 59, label: 'B3', color: '#ff6644' },
{ note: 60, label: 'C4', color: '#ffcc00' },
{ note: 62, label: 'D4', color: '#44ff88' },
];
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function FullscreenDrumPad({ moduleId, onClose }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<div className="drumpad-fullscreen">
<div className="drumpad-fs-header">
<span className="drumpad-fs-title">🥁 Drum Pads</span>
<button className="drumpad-fs-close" onClick={onClose}></button>
</div>
<div className="drumpad-fs-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className="drumpad-fs-pad"
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: activePad === i ? pad.color : `${pad.color}40`,
color: activePad === i ? '#000' : pad.color,
}}
onPointerDown={() => hitPad(pad, i)}
>
{pad.label}
<span className="pad-label">{i + 1}</span>
</div>
))}
</div>
</div>
);
}
export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<>
<div>
<div className="drumpad-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className={`drumpad-pad ${activePad === i ? 'active' : ''}`}
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: `${pad.color}60`,
}}
onPointerDown={(e) => { e.stopPropagation(); hitPad(pad, i); }}
>
{pad.label}
</div>
))}
</div>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Tap pads to trigger
</div>
</div>
{fullscreen && createPortal(
<FullscreenDrumPad moduleId={moduleId} onClose={onCloseFullscreen} />,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
// Fullscreen piano — 1 octave, big comfortable keys like a real piano app
function FullscreenPiano({ moduleId, initialOctave, onClose }) {
const [oct, setOct] = useState(initialOctave);
const [activeNotes, setActiveNotes] = useState(new Set());
const play = useCallback((semitone) => {
const midi = (oct + 1) * 12 + semitone;
triggerKeyboard(moduleId, midiToFreq(midi), true);
setActiveNotes(prev => new Set(prev).add(semitone));
}, [moduleId, oct]);
const stop = useCallback((semitone) => {
setActiveNotes(prev => {
const next = new Set(prev);
next.delete(semitone);
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
return next;
});
}, [moduleId]);
// 1 octave: 7 white keys, 5 black keys
const whiteKeys = [
{ note: 0, name: 'C' },
{ note: 2, name: 'D' },
{ note: 4, name: 'E' },
{ note: 5, name: 'F' },
{ note: 7, name: 'G' },
{ note: 9, name: 'A' },
{ note: 11, name: 'B' },
];
// Black key positions relative to white key index (0-6)
const blackKeys = [
{ note: 1, name: 'C#', after: 0 },
{ note: 3, name: 'D#', after: 1 },
{ note: 6, name: 'F#', after: 3 },
{ note: 8, name: 'G#', after: 4 },
{ note: 10, name: 'A#', after: 5 },
];
return (
<div className="keyboard-fullscreen">
<div className="keyboard-fs-header">
<button className="keyboard-fs-close" onClick={onClose}></button>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}></button>
<span className="keyboard-fs-title">Octave {oct}</span>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}></button>
<div style={{ width: 36 }} />
</div>
<div className="keyboard-fs-keys">
{whiteKeys.map((k, i) => (
<div
key={k.note}
className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
</div>
))}
{blackKeys.map((k) => (
<div
key={k.note}
className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-black-label">{k.name}</span>
</div>
))}
</div>
</div>
);
}
export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<>
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
{fullscreen && createPortal(
<FullscreenPiano
moduleId={moduleId}
initialOctave={octave}
onClose={onCloseFullscreen}
/>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,16 @@
export default function MobileTabBar({ tabs, activeTab, onTabChange }) {
return (
<nav className="mobile-tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`mobile-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
<span className="mobile-tab-icon">{tab.icon}</span>
<span className="mobile-tab-label">{tab.label}</span>
</button>
))}
</nav>
);
}

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useState, useEffect, useRef } from 'react'; import React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js'; import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam } from '../engine/audioEngine.js'; import { updateParam, getAudioNode } from '../engine/audioEngine.js';
import Knob from './Knob.jsx'; import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx'; import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx'; import KeyboardWidget from './KeyboardWidget.jsx';
import DrumPadWidget from './DrumPadWidget.jsx';
import SequencerWidget from './SequencerWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx';
import PianoRollWidget from './PianoRollWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx';
@@ -45,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
if (!def) return null; if (!def) return null;
const isSelected = state.selectedModuleId === mod.id; const isSelected = state.selectedModuleId === mod.id;
const [fullscreen, setFullscreen] = useState(false);
// Merge default params // Merge default params
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params }; const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
@@ -58,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
} }
} }
// ==================== Live LFO modulation visualization ==================== // ==================== Live modulation visualization (any source any param) ====================
const [liveValues, setLiveValues] = useState({}); const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null); const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000); const startTimeRef = useRef(performance.now() / 1000);
@@ -69,40 +71,83 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
return; return;
} }
let frameCount = 0;
const tick = () => { const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
if (frameCount % 4 !== 0) return;
const t = performance.now() / 1000 - startTimeRef.current; const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {}; 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) { for (const conn of state.connections) {
if (conn.to.moduleId !== mod.id) continue; if (conn.to.moduleId !== mod.id) continue;
const paramName = portMap[conn.to.port]; const paramName = portMap[conn.to.port];
if (!paramName) continue; if (!paramName) continue;
const srcMod = state.modules.find(m => m.id === conn.from.moduleId); const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
if (!srcMod || srcMod.type !== 'lfo') continue; if (!srcMod) continue;
// Read LFO params from state 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 lfoDef = getModuleDef('lfo');
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
const freq = lfoP.frequency; const phase = (t * lfoP.frequency) % 1;
const amp = lfoP.amplitude; const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
const waveform = lfoP.waveform; newValues[paramName] = baseValue + lfoVal * getScale();
const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
// Compute modulated value (same scaling as audioEngine) } else if (srcMod.type === 'envelope') {
const baseValue = params[paramName]; // Envelope: read current level (0-1) from the source envelope node
let scale; const envEntry = getAudioNode(srcMod.id);
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5; if (envEntry?.node) {
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue; const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
else if (mod.type === 'vca' && paramName === 'gain') scale = 1; if (curMod.type === 'vca' && paramName === 'gain') {
else scale = baseValue || 1; newValues[paramName] = envValue; // Envelope directly drives gain (01)
} else {
newValues[paramName] = baseValue + envValue * getScale();
}
}
newValues[paramName] = baseValue + lfoVal * scale; } 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();
}
} }
setLiveValues(newValues); setLiveValues(newValues);
rafRef.current = requestAnimationFrame(tick);
}; };
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
@@ -176,6 +221,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
<div className="module-header" onPointerDown={handleHeaderDown}> <div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span> <span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</span> <span className="type-name">{def.name}</span>
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
<button
className="expand-btn"
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
title="Pantalla completa"
></button>
)}
<button className="close-btn" onClick={handleDelete}></button> <button className="close-btn" onClick={handleDelete}></button>
</div> </div>
@@ -246,7 +298,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />} {mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */} {/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />} {mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Drum Pad widget */}
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Sequencer widget */} {/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />} {mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals } from '../engine/audioEngine.js'; import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
import { parseMidi } from '../utils/midiParser.js'; import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -90,11 +90,10 @@ const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) { export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null); const drawingRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null); const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
@@ -196,8 +195,9 @@ export default function PianoRollWidget({ moduleId }) {
} }
// Playhead // Playhead
if (playPos >= 0 && playPos < totalBeats) { const currentPlayPos = playPosRef.current;
const px = KEY_W + playPos * beatW; if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644'; ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
@@ -221,7 +221,7 @@ export default function PianoRollWidget({ moduleId }) {
ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
} }
}, [totalBeats, beatW, playPos, rollW]); }, [totalBeats, beatW, rollW]);
// Animation loop // Animation loop
useEffect(() => { useEffect(() => {
@@ -233,83 +233,76 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]); }, [draw]);
// Playback uses independent Tone.Clock so multiple pianorolls/sequencers // Subscribe to global master clock for playback
// don't interfere with each other via the global Transport const bpmRef = useRef(bpm);
const loopRef = useRef(loop);
const totalBeatsRef = useRef(totalBeats);
bpmRef.current = bpm;
loopRef.current = loop;
totalBeatsRef.current = totalBeats;
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (partRef.current) { unsubscribeTick(`pr-${moduleId}`);
try { partRef.current.stop(); } catch {} playPosRef.current = -1;
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
setPlayPos(-1);
return; return;
} }
const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second let currentNote = null;
const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note let lastQuantPos = -1;
let tickCount = 0;
let currentNote = null; // track currently sounding note for on/off transitions
const clock = new Tone.Clock(() => { subscribeTick(`pr-${moduleId}`, (time, ticks) => {
const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats) const currentBpm = bpmRef.current;
const pos = loop ? rawPos % totalBeats : rawPos; const currentLoop = loopRef.current;
const prevRawPos = (tickCount - 1) * 0.25; const currentTotalBeats = totalBeatsRef.current;
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
tickCount++;
// Detect loop wrap (position jumped backwards) // Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
const looped = tickCount > 1 && pos < prevPos; // Position in sixteenths: ticks / (ticksPerSixteenth)
const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
const rawPos = ticks / ticksPerBeat; // in beats
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
const quantPos = Math.floor(pos * 4) / 4;
// Stop at end if not looping if (quantPos === lastQuantPos) return;
if (!loop && rawPos >= totalBeats) { const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos;
if (!currentLoop && rawPos >= currentTotalBeats) {
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
setPlayPos(-1); playPosRef.current = -1;
return; return;
} }
setPlayPos(pos); playPosRef.current = pos;
// Force note-off on loop boundary for clean retrigger
if (looped && currentNote) { if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
// Find the note active at this position
const allNotes = notesRef.current; const allNotes = notesRef.current;
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
if (activeNote) { if (activeNote) {
// New note or different note trigger
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true); setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote; currentNote = activeNote;
} }
// Same note sustaining do nothing
} else { } else {
// No note at this position gate off
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
} }
}, sixteenthRate); });
clock.start();
partRef.current = clock;
return () => { return () => {
if (partRef.current) { unsubscribeTick(`pr-${moduleId}`);
try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
}; };
}, [state.isRunning, moduleId, bpm, bars, loop]); }, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes // Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => { const handleMouseDown = useCallback((e) => {

View File

@@ -22,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) {
const w = canvas.width = 160; const w = canvas.width = 160;
const h = canvas.height = 60; const h = canvas.height = 60;
let frameCount = 0;
const draw = () => { const draw = () => {
frameCount++;
rafRef.current = requestAnimationFrame(draw);
// Throttle to ~30fps to reduce main thread pressure during playback
if (frameCount % 2 !== 0) return;
ctx.fillStyle = '#050510'; ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
@@ -58,7 +64,6 @@ export default function ScopeDisplay({ moduleId }) {
ctx.stroke(); ctx.stroke();
} }
rafRef.current = requestAnimationFrame(draw);
}; };
draw(); draw();

View File

@@ -1,14 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js'; import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
// Default notes: C minor pentatonic pattern
const DEFAULT_STEPS = [ const DEFAULT_STEPS = [
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true }, { midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
@@ -18,11 +17,12 @@ const DEFAULT_STEPS = [
export default function SequencerWidget({ moduleId }) { export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1); const currentStepRef = useRef(-1);
const clockRef = useRef(null); const [visualStep, setVisualStep] = useState(-1);
const stepsRef = useRef(null); const stepsRef = useRef(null);
const rafRef = useRef(null);
// Init steps data also grow/shrink when numSteps changes // Init steps data
const numSteps = parseInt(mod?.params?.steps || '16'); const numSteps = parseInt(mod?.params?.steps || '16');
if (mod) { if (mod) {
if (!mod.params._steps) { if (!mod.params._steps) {
@@ -30,12 +30,10 @@ export default function SequencerWidget({ moduleId }) {
while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
mod.params._steps = initial; mod.params._steps = initial;
} else if (mod.params._steps.length < numSteps) { } else if (mod.params._steps.length < numSteps) {
// Grow: pad with empty steps
while (mod.params._steps.length < numSteps) { while (mod.params._steps.length < numSteps) {
mod.params._steps.push({ midi: 60, gate: false }); mod.params._steps.push({ midi: 60, gate: false });
} }
} else if (mod.params._steps.length > numSteps) { } else if (mod.params._steps.length > numSteps) {
// Shrink: truncate
mod.params._steps = mod.params._steps.slice(0, numSteps); mod.params._steps = mod.params._steps.slice(0, numSteps);
} }
} }
@@ -44,54 +42,69 @@ export default function SequencerWidget({ moduleId }) {
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
// Start/stop sequencer when audio engine runs uses independent Tone.Clock // Visual update loop decoupled from audio, uses RAF
// so multiple sequencers don't interfere with each other via the global Transport useEffect(() => {
const tick = () => {
setVisualStep(currentStepRef.current);
rafRef.current = requestAnimationFrame(tick);
};
if (state.isRunning) {
rafRef.current = requestAnimationFrame(tick);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [state.isRunning]);
// Subscribe to global master clock derive step from elapsed time
const bpmRef = useRef(bpm);
const numStepsRef = useRef(numSteps);
bpmRef.current = bpm;
numStepsRef.current = numSteps;
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (clockRef.current) { unsubscribeTick(`seq-${moduleId}`);
try { clockRef.current.stop(); } catch {} currentStepRef.current = -1;
try { clockRef.current.dispose(); } catch {} setVisualStep(-1);
clockRef.current = null;
}
setCurrentStep(-1);
return; return;
} }
// Independent clock at 16th-note rate let lastStepIdx = -1;
const sixteenthRate = (bpm * 4) / 60; // Hz let lastGateOn = false;
let step = 0;
subscribeTick(`seq-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current;
const currentNumSteps = numStepsRef.current;
// ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
// sixteenthsPerSecond = bpm * 4 / 60
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
if (stepIdx === lastStepIdx) return;
lastStepIdx = stepIdx;
// Turn off previous note at step boundary (no setTimeout needed)
if (lastGateOn) {
setSequencerSignals(moduleId, 0, false);
lastGateOn = false;
}
const clock = new Tone.Clock((time) => {
const stepIdx = step % numSteps;
step++;
const s = stepsRef.current[stepIdx]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
setCurrentStep(stepIdx); currentStepRef.current = stepIdx;
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
// Schedule note-off at 80% of step duration lastGateOn = true;
const stepDuration = 1 / sixteenthRate;
setTimeout(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, stepDuration * 0.8 * 1000);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
}, sixteenthRate); });
clock.start();
clockRef.current = clock;
return () => { return () => {
if (clockRef.current) { unsubscribeTick(`seq-${moduleId}`);
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
}; };
}, [state.isRunning, moduleId, numSteps, bpm]); }, [state.isRunning, moduleId]);
const toggleGate = (idx) => { const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
@@ -115,20 +128,17 @@ export default function SequencerWidget({ moduleId }) {
return ( return (
<div style={{ width: W + 4, overflow: 'hidden' }}> <div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}> <svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* Steps */}
{steps.slice(0, numSteps).map((s, i) => { {steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W; const x = i * CELL_W;
const isActive = i === currentStep; const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return ( return (
<g key={i}> <g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H} <rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'} rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5} stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/> />
{/* Note bar */}
{s.gate && ( {s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight} <rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1} rx={1}
@@ -136,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9} opacity={0.9}
/> />
)} )}
{/* Inactive marker */}
{!s.gate && ( {!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3} <line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
stroke="#333" strokeWidth={1.5} /> stroke="#333" strokeWidth={1.5} />
)} )}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle" <text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace"> fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)} {noteLabel(s.midi)}
</text> </text>
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3} <rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }} fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)} onClick={() => changeNote(i, 1)}
@@ -162,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
</g> </g>
); );
})} })}
{/* Playhead line */} {visualStep >= 0 && (
{currentStep >= 0 && (
<line <line
x1={currentStep * CELL_W + CELL_W / 2} y1={0} x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H} x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4} stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/> />
)} )}

View File

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

View File

@@ -12,6 +12,48 @@ const audioNodes = {};
// Active keyboard state // Active keyboard state
const keyboardState = { frequency: 440, gate: false }; const keyboardState = { frequency: 440, gate: false };
// ==================== Global Master Clock ====================
// Single clock with integer tick counter. All sequencers/piano rolls
// derive their step positions from this shared tick count.
// Using integers avoids floating-point drift entirely.
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
let _masterClock = null;
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
export function subscribeTick(id, callback) {
_tickListeners.set(id, callback);
}
export function unsubscribeTick(id) {
_tickListeners.delete(id);
}
function startMasterClock() {
if (_masterClock) return;
let _startTime = 0;
let _started = false;
_masterClock = new Tone.Clock((time) => {
if (!_started) { _startTime = time; _started = true; }
// Derive ticks from precise AudioContext.currentTime, not a counter.
// Counters fall behind when callbacks are delayed (GC, UI, tab throttle).
// The time parameter is always accurate regardless of callback jitter.
const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE);
for (const cb of _tickListeners.values()) {
cb(time, ticks);
}
}, MASTER_TICK_RATE);
_masterClock.start();
}
function stopMasterClock() {
if (_masterClock) {
try { _masterClock.stop(); } catch {}
try { _masterClock.dispose(); } catch {}
_masterClock = null;
}
_tickListeners.clear();
}
// ==================== Node creation ==================== // ==================== Node creation ====================
function createNode(mod) { function createNode(mod) {
@@ -84,13 +126,17 @@ function createNode(mod) {
}; };
} }
case 'vca': { case 'vca': {
// Use a Multiply node: in × cv
const gain = new Tone.Gain(p.gain); const gain = new Tone.Gain(p.gain);
// CV scaler: always gain=1 so envelope (0-1) passes through fully.
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
const cvMod = new Tone.Gain(1);
cvMod.connect(gain.gain);
return { return {
node: gain, node: gain,
inputs: { in: gain, cv: gain.gain }, _cvMod: cvMod,
inputs: { in: gain, cv: cvMod },
outputs: { out: gain }, outputs: { out: gain },
dispose: () => gain.dispose(), dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
}; };
} }
case 'delay': { case 'delay': {
@@ -145,6 +191,20 @@ function createNode(mod) {
dispose: () => analyser.dispose(), dispose: () => analyser.dispose(),
}; };
} }
case 'cv2gate': {
// Converts a continuous CV signal to gate on/off based on threshold.
// Uses an analyser to read the CV value and triggers connected envelopes.
const analyser = new Tone.Analyser('waveform', 32);
const gateSig = new Tone.Signal(0);
return {
node: analyser,
_gateSig: gateSig,
_gateState: false,
inputs: { in: analyser },
outputs: { gate: gateSig },
dispose: () => { analyser.dispose(); gateSig.dispose(); },
};
}
case 'output': { case 'output': {
// True stereo output: separate left/right channels → merge → master gain → destination // True stereo output: separate left/right channels → merge → master gain → destination
const leftGain = new Tone.Gain(1); const leftGain = new Tone.Gain(1);
@@ -170,7 +230,8 @@ function createNode(mod) {
}, },
}; };
} }
case 'keyboard': { case 'keyboard':
case 'drumpad': {
const freqSig = new Tone.Signal(440); const freqSig = new Tone.Signal(440);
const gateSig = new Tone.Signal(0); const gateSig = new Tone.Signal(0);
return { return {
@@ -251,7 +312,7 @@ export function connectWire(conn) {
// set the oscillator frequency directly when notes are played. // set the oscillator frequency directly when notes are played.
const fromMod = state.modules.find(m => m.id === conn.from.moduleId); const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
const toMod = state.modules.find(m => m.id === conn.to.moduleId); const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (fromMod && ['keyboard', 'sequencer', 'pianoroll'].includes(fromMod.type) && if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') { conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
return; // handled imperatively in triggerKeyboard / setSequencerSignals return; // handled imperatively in triggerKeyboard / setSequencerSignals
} }
@@ -267,6 +328,11 @@ export function connectWire(conn) {
} catch (e) { } catch (e) {
console.warn('connect error', e); console.warn('connect error', e);
} }
// When CV is connected to VCA, zero the base gain so only envelope controls it
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = 0;
}
} }
export function disconnectWire(conn) { export function disconnectWire(conn) {
@@ -285,6 +351,12 @@ export function disconnectWire(conn) {
} catch (e) { } catch (e) {
// Tone.js may throw if not connected // Tone.js may throw if not connected
} }
// When CV is disconnected from VCA, restore base gain from params
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
}
} }
export function updateParam(moduleId, paramName, value) { export function updateParam(moduleId, paramName, value) {
@@ -329,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
else if (paramName === 'release') entry.node.release = value; else if (paramName === 'release') entry.node.release = value;
break; break;
case 'vca': case 'vca':
if (paramName === 'gain') entry.node.gain.value = value; if (paramName === 'gain') {
// Only update base gain if no CV is connected (CV zeroes it)
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
if (!hasCV) entry.node.gain.value = value;
// cvMod stays at 1 always — envelope controls full range
}
break; break;
case 'delay': case 'delay':
if (paramName === 'delayTime') entry.node.delayTime.value = value; if (paramName === 'delayTime') entry.node.delayTime.value = value;
@@ -354,6 +431,8 @@ export function updateParam(moduleId, paramName, value) {
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value); if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
break; break;
case 'keyboard': case 'keyboard':
case 'drumpad':
case 'cv2gate':
case 'sequencer': case 'sequencer':
case 'pianoroll': case 'pianoroll':
// All params stored in state, managed by widgets // All params stored in state, managed by widgets
@@ -361,33 +440,50 @@ export function updateParam(moduleId, paramName, value) {
} }
} }
// Cache connection lookups for hot-path audio scheduling
// Rebuilt only when connections actually change (dirty flag, no computation on hit)
let _connCacheDirty = true;
const _connByModulePort = new Map(); // "moduleId-portName" → [connections]
export function invalidateConnectionCache() {
_connCacheDirty = true;
}
function getConnectionsFrom(moduleId, portName) {
if (_connCacheDirty) {
_connByModulePort.clear();
for (const conn of state.connections) {
const key = `${conn.from.moduleId}-${conn.from.port}`;
if (!_connByModulePort.has(key)) _connByModulePort.set(key, []);
_connByModulePort.get(key).push(conn);
}
_connCacheDirty = false;
}
return _connByModulePort.get(`${moduleId}-${portName}`) || [];
}
export function setSequencerSignals(moduleId, freq, gate) { export function setSequencerSignals(moduleId, freq, gate) {
const entry = audioNodes[moduleId]; const entry = audioNodes[moduleId];
if (!entry) return; if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
const oscEntry = audioNodes[conn.to.moduleId]; const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node?.frequency) {
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq; oscEntry.node.frequency.value = freq;
} }
} }
}
// Trigger connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export function triggerKeyboard(moduleId, freq, gate) { export function triggerKeyboard(moduleId, freq, gate) {
@@ -396,44 +492,42 @@ export function triggerKeyboard(moduleId, freq, gate) {
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
const oscEntry = audioNodes[conn.to.moduleId]; const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node?.frequency) {
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq; oscEntry.node.frequency.value = freq;
} }
} }
}
// Also trigger any connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export async function startAudio() { export async function startAudio() {
await Tone.start(); await Tone.start();
state.isRunning = true; state.isRunning = true;
startMasterClock();
// Rebuild entire audio graph // Rebuild entire audio graph
rebuildGraph(); rebuildGraph();
} }
export function stopAudio() { export function stopAudio() {
// Stop and reset Transport so pianoroll/sequencer Parts don't get stranded stopMasterClock();
// Stop and reset Transport
try { try {
Tone.getTransport().stop(); Tone.getTransport().stop();
Tone.getTransport().cancel(); // Remove all scheduled events Tone.getTransport().cancel();
Tone.getTransport().position = 0; Tone.getTransport().position = 0;
} catch (e) { /* ignore if Transport not started */ } } catch (e) {}
// Destroy all nodes // Destroy all nodes
for (const id of Object.keys(audioNodes)) { for (const id of Object.keys(audioNodes)) {
@@ -458,6 +552,15 @@ export function rebuildGraph() {
connectWire(conn); connectWire(conn);
} }
// Zero base gain on VCAs with active CV connection.
// When envelope controls VCA, base gain must be 0 so silence is possible.
for (const mod of state.modules) {
if (mod.type !== 'vca') continue;
const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv');
const entry = audioNodes[mod.id];
if (entry && hasCV) entry.node.gain.value = 0;
}
// Auto-trigger envelopes that have no gate connection (free-running mode). // Auto-trigger envelopes that have no gate connection (free-running mode).
// This allows noise/ambient patches to work without a keyboard/sequencer. // This allows noise/ambient patches to work without a keyboard/sequencer.
for (const mod of state.modules) { for (const mod of state.modules) {
@@ -472,6 +575,31 @@ export function rebuildGraph() {
} }
} }
} }
// Register CV→Gate modules on master clock for threshold detection
for (const mod of state.modules) {
if (mod.type !== 'cv2gate') continue;
const entry = audioNodes[mod.id];
if (!entry) continue;
subscribeTick(`cv2gate-${mod.id}`, () => {
const data = entry.node.getValue();
const sample = typeof data === 'number' ? data : (data?.[0] ?? 0);
const threshold = mod.params?.threshold ?? 0.5;
const gateOn = sample > threshold;
if (gateOn !== entry._gateState) {
entry._gateState = gateOn;
entry._gateSig.value = gateOn ? 1 : 0;
// Trigger/release connected envelopes
for (const conn of getConnectionsFrom(mod.id, 'gate')) {
const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gateOn) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease();
}
}
}
});
}
} }
export function getAnalyserData(moduleId) { export function getAnalyserData(moduleId) {

View File

@@ -117,7 +117,7 @@ defineModule('envelope', {
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' }, attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' }, decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' }, sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
release: { type: 'knob', min: 0, max: 8, default: 0.5, unit: 's', label: 'Release' }, release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
}, },
}); });
@@ -226,6 +226,23 @@ defineModule('scope', {
params: {}, params: {},
}); });
// ==================== CV TO GATE ====================
defineModule('cv2gate', {
name: 'CV→Gate',
icon: '⚡',
category: 'Utility',
inputs: [
{ name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' },
],
outputs: [
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
params: {
threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' },
},
});
// ==================== OUTPUT ==================== // ==================== OUTPUT ====================
defineModule('output', { defineModule('output', {
@@ -258,6 +275,20 @@ defineModule('keyboard', {
}, },
}); });
// ==================== DRUM PAD ====================
defineModule('drumpad', {
name: 'Drum Pad',
icon: '🥁',
category: 'Source',
inputs: [],
outputs: [
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
params: {},
});
// ==================== SEQUENCER ==================== // ==================== SEQUENCER ====================
defineModule('sequencer', { defineModule('sequencer', {

View File

@@ -4,6 +4,7 @@
*/ */
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js'; import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js'; import { getModuleDef } from './moduleRegistry.js';
import { invalidateConnectionCache } from './audioEngine.js';
let _listeners = new Set(); let _listeners = new Set();
let _nextModuleId = 1; let _nextModuleId = 1;
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
const id = _nextConnectionId++; const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } }); state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
invalidateConnectionCache();
emit(); emit();
playConnect(); playConnect();
return id; return id;
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
export function removeConnection(id, _silent = false) { export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id); state.connections = state.connections.filter(c => c.id !== id);
invalidateConnectionCache();
emit(); emit();
if (!_silent) playDisconnect(); if (!_silent) playDisconnect();
} }

View File

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

View File

@@ -4,6 +4,9 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.jsx'; import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx'; import WireLayer from '../components/WireLayer.jsx';
import BottomSheet from '../components/BottomSheet.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { usePinchZoom } from '../hooks/usePinchZoom.js';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx'; import LevelComplete from './LevelComplete.jsx';
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
@@ -20,6 +23,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const [showHint, setShowHint] = useState(false); const [showHint, setShowHint] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [targetPlaying, setTargetPlaying] = useState(false); const [targetPlaying, setTargetPlaying] = useState(false);
const isMobile = useIsMobile();
const [mobileTab, setMobileTab] = useState('mission');
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
useEffect(() => { useEffect(() => {
const unsub = subscribe(() => { const unsub = subscribe(() => {
@@ -49,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
useEffect(() => { useEffect(() => {
loadLevel(); loadLevel();
// Center view on modules after level loads and DOM settles
const timer = setTimeout(() => handleCenterView(), 100);
return () => { return () => {
clearTimeout(timer);
stopAudio(); stopAudio();
stopTarget(); stopTarget();
}; };
@@ -127,10 +140,17 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } else if (e.button === 0 && !connectingRef.current) {
// On mobile (touch), single finger on empty canvas = pan
if (isMobile && e.pointerType === 'touch') {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
return;
}
state.selectedModuleId = null; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -335,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
<div className="gm-puzzle"> <div className="gm-puzzle">
{/* Top bar */} {/* Top bar */}
<div className="gm-puzzle-bar"> <div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}> Mapa</button> <button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
<div className="gm-puzzle-title"> <div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span> <span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span> <span className="gm-puzzle-name">{level.title}</span>
@@ -345,19 +365,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`} className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget} onClick={handlePlayTarget}
> >
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'} {targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button> </button>
<button <button
className={`gm-btn ${state.isRunning ? 'active' : ''}`} className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio} onClick={handleToggleAudio}
> >
{state.isRunning ? '⏹ Parar' : ' Mi Sonido'} {state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
</button> </button>
{!isMobile && (
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas"> <button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar 🗑 Limpiar
</button> </button>
)}
<button className="gm-btn check" onClick={handleCheck}> <button className="gm-btn check" onClick={handleCheck}>
Comprobar {!isMobile && <span className="btn-label"> Comprobar</span>}
</button> </button>
{adminMode && ( {adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel"> <button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
@@ -368,7 +390,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
<div className="gm-puzzle-content"> <div className="gm-puzzle-content">
{/* Left sidebar */} {/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar"> <div className="gm-puzzle-sidebar">
{/* Description — always visible */} {/* Description — always visible */}
<div className="gm-concept-panel"> <div className="gm-concept-panel">
@@ -502,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
</div> </div>
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
{isMobile && (
<BottomSheet
tabs={[
{ id: 'mission', label: 'MISION' },
{ id: 'objectives', label: 'OBJETIVOS' },
{ id: 'modules', label: 'MODULOS' },
]}
activeTab={mobileTab}
onTabChange={setMobileTab}
>
{mobileTab === 'mission' && (
<div>
<p className="puzzle-mission-text">{level.description}</p>
{!showHint ? (
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
<span className="puzzle-hint-icon">💡</span>
<span className="puzzle-hint-label">Mostrar Pista</span>
<span className="puzzle-hint-penalty">max </span>
</button>
) : (
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max </span></div>
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
</div>
)}
</div>
)}
{mobileTab === 'objectives' && (
<div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
const cappedByStar = hintUsed && check.star === 3;
return (
<div key={i} className="puzzle-obj-item">
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
{check.desc}
{cappedByStar && ' 🔒'}
</span>
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}></span>}
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}></span>}
</div>
);
})}
{hintUsed && (
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
Pista usada maximo 2 estrellas (permanente).
</div>
)}
</div>
)}
{mobileTab === 'modules' && (
<div>
{level.availableModules.length > 0 ? (
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
);
})}
</div>
) : (
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
)}
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
Reiniciar Nivel
</button>
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
🗑 Limpiar
</button>
</div>
)}
</BottomSheet>
)}
{/* Level complete overlay */} {/* Level complete overlay */}
{result && result.stars >= 1 && ( {result && result.stars >= 1 && (
<LevelComplete <LevelComplete

View File

@@ -1,4 +1,7 @@
import React, { useState, useRef } from 'react'; 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_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js'; import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js'; import { WORLD_3 } from './levels/world3.js';
@@ -39,11 +42,20 @@ function isWorldUnlocked(world) {
return getTotalStars() >= world.unlockStars; return getTotalStars() >= world.unlockStars;
} }
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { 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, onWorkshop }) {
const totalStars = getTotalStars(); const totalStars = getTotalStars();
const maxStars = getMaxStars(); const maxStars = getMaxStars();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const searchRef = useRef(null); const searchRef = useRef(null);
const isMobile = useIsMobile();
const { user, isLoggedIn, openAuth, logout } = useAuth();
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
@@ -81,6 +93,14 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
🛠 🛠
</button> </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>
</div> </div>
@@ -209,6 +229,19 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
); );
}) })
)} )}
{/* Mobile tab bar */}
{isMobile && (
<MobileTabBar
tabs={MOBILE_TABS}
activeTab="game"
onTabChange={(id) => {
if (id === 'sandbox') onSandbox?.();
if (id === 'workshop') onWorkshop?.();
if (id === 'config') onAdmin?.();
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [breakpoint]);
return isMobile;
}

View File

@@ -0,0 +1,48 @@
import { useRef, useEffect } from 'react';
export function usePinchZoom(containerRef, getZoom, setZoom) {
const pinchRef = useRef({ active: false, startDist: 0, startZoom: 1 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const getDistance = (t1, t2) =>
Math.sqrt((t1.clientX - t2.clientX) ** 2 + (t1.clientY - t2.clientY) ** 2);
const onTouchStart = (e) => {
if (e.touches.length === 2) {
e.preventDefault();
pinchRef.current = {
active: true,
startDist: getDistance(e.touches[0], e.touches[1]),
startZoom: getZoom(),
};
}
};
const onTouchMove = (e) => {
if (pinchRef.current.active && e.touches.length === 2) {
e.preventDefault();
const dist = getDistance(e.touches[0], e.touches[1]);
const scale = dist / pinchRef.current.startDist;
const newZoom = Math.max(0.3, Math.min(3, pinchRef.current.startZoom * scale));
setZoom(newZoom);
}
};
const onTouchEnd = () => {
pinchRef.current.active = false;
};
el.addEventListener('touchstart', onTouchStart, { passive: false });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [containerRef, getZoom, setZoom]);
}

View File

@@ -30,7 +30,14 @@ html, body, #root {
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif; font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 13px; font-size: 13px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
} }
/* Block native browser zoom gestures globally */
html { touch-action: manipulation; }
/* Prevent text selection globally (except inputs/textareas) */
* { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }
/* ===== Layout ===== */ /* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; } .app { display: flex; flex-direction: column; height: 100vh; }
@@ -117,6 +124,14 @@ html, body, #root {
} }
.module-header .close-btn:hover { background: var(--red); color: #fff; } .module-header .close-btn:hover { background: var(--red); color: #fff; }
.module-header .expand-btn {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--text2); cursor: pointer; font-size: 13px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
margin-left: auto;
}
.module-header .expand-btn:hover { background: var(--accent); color: #000; }
.module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } .module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
/* Ports */ /* Ports */
@@ -785,3 +800,706 @@ html, body, #root {
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); } .admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
.admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); 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;
background: #050510; display: flex; flex-direction: column;
animation: fadeIn 0.2s ease-out; touch-action: none;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.keyboard-fs-header {
display: flex; align-items: center; justify-content: center;
gap: 16px; padding: 8px 16px; background: var(--panel);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.keyboard-fs-title {
font-size: 16px; font-weight: 700; color: var(--text);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
}
.keyboard-fs-close {
width: 36px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.keyboard-fs-oct-btn {
width: 44px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--accent); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.keyboard-fs-oct-btn:active { background: var(--accent); color: #000; }
.keyboard-fs-keys {
flex: 1; position: relative; display: flex;
touch-action: none; user-select: none;
}
.keyboard-fs-white {
flex: 1; height: 100%; background: linear-gradient(to bottom, #1e1e38, #14142a);
border-right: 2px solid #0a0a18; position: relative;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 20px; cursor: pointer; transition: background 0.05s;
}
.keyboard-fs-white.pressed, .keyboard-fs-white:active {
background: linear-gradient(to bottom, var(--accent), #0a8a9e);
}
.keyboard-fs-note-label {
font-size: 14px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; pointer-events: none;
}
.keyboard-fs-white.pressed .keyboard-fs-note-label,
.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; }
.keyboard-fs-black {
position: absolute; top: 0; height: 58%;
background: linear-gradient(to bottom, #0a0a16, #060610);
border: 2px solid #222; border-top: none;
border-radius: 0 0 6px 6px;
z-index: 2; cursor: pointer; transition: background 0.05s;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 8px;
}
.keyboard-fs-black.pressed, .keyboard-fs-black:active {
background: linear-gradient(to bottom, #0088aa, #006688);
border-color: var(--accent);
}
.keyboard-fs-black-label {
font-size: 10px; font-weight: 600; color: #555;
font-family: 'JetBrains Mono', monospace; pointer-events: none;
}
.keyboard-fs-black.pressed .keyboard-fs-black-label,
.keyboard-fs-black:active .keyboard-fs-black-label { color: var(--accent); }
/* ===== Drum Pad ===== */
.drumpad-grid {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 3px; padding: 2px 0;
}
.drumpad-pad {
aspect-ratio: 1; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.05s; border: 1px solid var(--border);
font-size: 8px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; user-select: none;
touch-action: none;
}
.drumpad-pad:active { transform: scale(0.92); }
.drumpad-pad.active { border-color: var(--accent); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
.drumpad-fullscreen {
position: fixed; inset: 0; z-index: 500;
background: #050510; display: flex; flex-direction: column;
animation: fadeIn 0.2s ease-out;
}
.drumpad-fs-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
}
.drumpad-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
.drumpad-fs-close {
width: 36px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.drumpad-fs-grid {
flex: 1; display: grid; grid-template-columns: repeat(4, 1fr);
gap: 8px; padding: 16px; touch-action: none;
}
.drumpad-fs-pad {
border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 4px;
cursor: pointer; transition: all 0.05s;
border: 2px solid var(--border); font-size: 12px;
font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace;
user-select: none; touch-action: none;
}
.drumpad-fs-pad:active { transform: scale(0.95); border-color: var(--accent); }
.drumpad-fs-pad .pad-label { font-size: 10px; color: var(--text2); }
/* ============================================
MOBILE RESPONSIVE max-width: 768px
============================================ */
/* --- Bottom Sheet --- */
.bottom-sheet {
display: none;
}
/* --- Mobile Tab Bar --- */
.mobile-tab-bar {
display: none;
}
@media (max-width: 768px) {
/* --- Prevent page scroll/bounce --- */
html, body, #root { overflow: hidden; position: fixed; width: 100%; height: 100%; }
.app, .gm-puzzle { overflow: hidden; height: 100dvh; }
/* --- Sandbox Toolbar --- */
.toolbar { height: 44px; padding: 0 12px; gap: 6px; }
.toolbar-title { font-size: 13px; letter-spacing: 0.8px; }
.toolbar-sep, .toolbar .status-text,
.toolbar-btn.save-btn, .toolbar-btn.load-btn,
.toolbar-btn.export-btn, .toolbar-btn.import-btn,
.toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; }
.toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; }
/* Hamburger menu button (added via JS) */
.mobile-menu-btn {
padding: 6px 10px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px;
font-weight: 600; line-height: 1;
}
.mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); }
.mobile-menu-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 200; display: flex; justify-content: flex-end;
}
.mobile-menu-panel {
width: 260px; background: var(--panel); border-left: 1px solid var(--border);
padding: 16px; display: flex; flex-direction: column; gap: 6px;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.mobile-menu-panel .toolbar-btn {
display: flex; width: 100%; padding: 10px 14px;
font-size: 13px; text-align: left;
}
/* --- Mobile Action Bar (Sandbox) --- */
.mobile-action-bar {
display: flex; align-items: center; gap: 8px;
padding: 0 12px; height: 48px; background: var(--panel);
border-top: 1px solid var(--border); flex-shrink: 0;
}
.mobile-action-bar .start-btn-mobile {
flex: 1; padding: 8px 14px; background: var(--accent); color: #000;
border: none; border-radius: 6px; font-size: 12px; font-weight: 700;
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer;
text-transform: uppercase;
}
.mobile-action-bar .start-btn-mobile.active {
background: var(--red);
}
.mobile-action-bar .action-icon-btn {
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px;
}
.mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); }
/* --- Status Bar --- */
.status-bar { display: none; }
/* --- Module Palette --- */
.palette { display: none; }
/* --- Bottom Sheet (visible on mobile) --- */
.bottom-sheet {
display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
border-radius: 16px 16px 0 0; flex-shrink: 0;
transition: max-height 0.3s ease;
overflow: hidden;
}
.bottom-sheet.collapsed { max-height: 42px; }
.bottom-sheet.collapsed:has(.bottom-sheet-tabs) { max-height: 76px; }
.bottom-sheet.expanded { max-height: 55vh; }
.bottom-sheet-handle {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px;
}
.bottom-sheet-handle-bar {
width: 40px; height: 4px; background: var(--border);
border-radius: 2px;
}
.bottom-sheet-peek-label {
font-size: 10px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
text-transform: uppercase;
}
.bottom-sheet-tabs {
display: flex; padding: 0 16px; gap: 0;
border-bottom: 1px solid var(--border);
}
.bottom-sheet-tab {
flex: 1; padding: 8px 0; background: none; border: none;
color: var(--text2); font-size: 10px; font-weight: 700;
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
cursor: pointer; text-align: center; position: relative;
text-transform: uppercase;
}
.bottom-sheet-tab.active { color: var(--accent); }
.bottom-sheet-tab-line {
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; height: 2px; background: var(--accent); border-radius: 1px;
}
.bottom-sheet-content {
padding: 12px 16px; overflow-y: auto; flex: 1;
}
/* Module grid tiles (mobile palette) */
.mobile-module-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.mobile-module-tile {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 4px; background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; cursor: pointer; transition: all 0.15s;
}
.mobile-module-tile:hover, .mobile-module-tile:active {
border-color: var(--accent); background: var(--surface2);
}
.mobile-module-tile .tile-icon { font-size: 20px; }
.mobile-module-tile .tile-name {
font-size: 9px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
}
/* --- Canvas adjustments --- */
.node-canvas { cursor: default; touch-action: none; }
.zoom-controls { right: 8px; top: 8px; }
.zoom-btn { width: 40px; height: 36px; min-height: 44px; }
.port-dot { width: 18px; height: 18px; }
/* --- Mobile Tab Bar (visible on mobile) --- */
.mobile-tab-bar {
display: flex; align-items: center; height: 56px;
background: var(--panel); border-top: 1px solid var(--border);
flex-shrink: 0; z-index: 10;
}
.mobile-tab {
flex: 1; display: flex; flex-direction: column; align-items: center;
gap: 3px; padding: 6px 0; background: none; border: none;
cursor: pointer; color: var(--text2); transition: color 0.15s;
}
.mobile-tab.active { color: var(--accent); }
.mobile-tab-icon { font-size: 18px; }
.mobile-tab-label {
font-size: 9px; font-weight: 600; letter-spacing: 1px;
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
}
/* --- World Map Mobile --- */
.gm-worldmap { padding: 0 12px 80px; }
.gm-header { padding: 12px 0; gap: 6px; }
.gm-header-top { gap: 8px; }
.gm-logo-icon { width: 32px; height: 32px; font-size: 16px; }
.gm-title { font-size: 18px; }
.gm-tagline { display: none; }
.gm-header-actions .gm-btn { display: none; }
.gm-search-bar { margin: 0 0 12px; }
.gm-level-grid { grid-template-columns: 1fr; }
.gm-level-card { padding: 10px 12px; }
.gm-world-section { margin-bottom: 16px; }
/* --- Puzzle View Mobile --- */
.gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; }
.gm-puzzle-bar .gm-btn { padding: 6px 10px; }
.gm-puzzle-bar .gm-btn .btn-label { display: none; }
.gm-puzzle-name { font-size: 13px; }
.gm-puzzle-num { font-size: 9px; padding: 2px 6px; }
.gm-puzzle-sidebar { display: none; }
.gm-puzzle-canvas-wrap { width: 100%; }
/* Puzzle bottom sheet specific */
.puzzle-mission-text {
font-size: 12px; color: var(--text); line-height: 1.5;
}
.puzzle-hint-btn {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border: 1px dashed var(--yellow);
border-radius: 8px; background: rgba(255,204,0,0.04);
cursor: pointer; width: 100%; margin-top: 8px;
}
.puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
.puzzle-hint-icon { font-size: 16px; }
.puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; }
.puzzle-hint-penalty {
font-size: 9px; font-weight: 700; color: var(--red);
background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px;
}
.puzzle-obj-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0; border-bottom: 1px solid var(--border);
font-size: 12px;
}
.puzzle-obj-item:last-child { border-bottom: none; }
.puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; }
.puzzle-obj-desc { color: var(--text2); flex: 1; }
/* --- Level Complete Modal Mobile --- */
.gm-complete-overlay { padding: 0 16px; }
.gm-complete-card {
min-width: unset; max-width: unset; width: 100%;
padding: 24px 20px;
}
.gm-complete-actions { flex-direction: column; width: 100%; }
.gm-complete-actions .gm-btn { width: 100%; justify-content: center; }
.gm-complete-actions .gm-btn.primary {
order: -1; padding: 14px;
font-size: 13px; font-weight: 700;
}
/* --- Preset Modal Mobile --- */
.modal { min-width: unset; max-width: unset; width: calc(100% - 32px); }
/* --- General touch targets --- */
.gm-btn { min-height: 44px; display: flex; align-items: center; }
.gm-palette-item { padding: 12px 10px; }
}

View File

@@ -0,0 +1,52 @@
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' | 'workshop' | 'admin'
const nav = {
toGame: () => setMode('game'),
toSandbox: () => setMode('sandbox'),
toWorkshop: () => setMode('workshop'),
toAdmin: () => setMode('admin'),
};
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 />);
// Configure and unlock audio context on first user interaction
import * as Tone from 'tone';
Tone.getContext().lookAhead = 0.05; // 50ms — tighter than default 100ms
const unlockAudio = () => {
if (Tone.context.state !== 'running') {
Tone.start().catch(() => {});
}
document.removeEventListener('pointerdown', unlockAudio);
document.removeEventListener('keydown', unlockAudio);
};
document.addEventListener('pointerdown', unlockAudio);
document.addEventListener('keydown', unlockAudio);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

180
producto.md Normal file
View File

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

View File

@@ -1,91 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Computer keyboard to semitone offset mapping (2 octaves)
const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
export default function KeyboardWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Draw mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
import './index.css';
function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
if (mode === 'sandbox') {
return <App onSwitchToGame={() => setMode('game')} />;
}
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />;
}
createRoot(document.getElementById('root')).render(<Root />);

View File

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