Files
reaktor/packages/client/src/components/Workshop.jsx
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

266 lines
10 KiB
JavaScript

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