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>
This commit is contained in:
@@ -15,7 +15,7 @@ import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||
import { useAuth } from './services/AuthContext.jsx';
|
||||
|
||||
export default function App({ onSwitchToGame }) {
|
||||
export default function App({ onSwitchToGame, onSwitchToWorkshop }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
@@ -281,6 +281,11 @@ export default function App({ onSwitchToGame }) {
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
{onSwitchToWorkshop && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
|
||||
🎵 Workshop
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
|
||||
228
packages/client/src/components/Workshop.jsx
Normal file
228
packages/client/src/components/Workshop.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { workshop as workshopApi } from '../services/api.js';
|
||||
import { useAuth } from '../services/AuthContext.jsx';
|
||||
import { state, deserialize } from '../engine/state.js';
|
||||
import { serialize } from '../engine/state.js';
|
||||
import { rebuildGraph } from '../engine/audioEngine.js';
|
||||
|
||||
const TAGS = ['ambient', 'bass', 'drums', 'pad', 'lead', 'fx', 'chiptune', 'experimental'];
|
||||
|
||||
function ShareModal({ onClose, onShared }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!title.trim()) { setError('Titulo requerido'); return; }
|
||||
if (state.modules.length === 0) { setError('No hay modulos en el canvas'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const patchData = serialize();
|
||||
await workshopApi.share({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
tags: selectedTags,
|
||||
data: patchData,
|
||||
});
|
||||
onShared?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-overlay" onClick={onClose}>
|
||||
<div className="auth-card" onClick={e => e.stopPropagation()} style={{ gap: 14 }}>
|
||||
<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">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}>
|
||||
{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 }) {
|
||||
const { isLoggedIn, openAuth, logout, user } = useAuth();
|
||||
const [patches, setPatches] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTag, setActiveTag] = useState('');
|
||||
const [sort, setSort] = useState('recent');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
|
||||
const loadPatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
if (activeTag) params.set('tags', activeTag);
|
||||
params.set('sort', sort);
|
||||
const data = await workshopApi.browse(params.toString());
|
||||
setPatches(data.patches || []);
|
||||
} catch (err) {
|
||||
console.warn('Workshop load failed:', err);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [search, activeTag, sort]);
|
||||
|
||||
useEffect(() => { loadPatches(); }, [loadPatches]);
|
||||
|
||||
const handleLoad = (patch) => {
|
||||
if (patch.data) {
|
||||
deserialize(patch.data);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
onSwitchToSandbox?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (patchId) => {
|
||||
if (!isLoggedIn) { openAuth(); return; }
|
||||
try {
|
||||
await workshopApi.like(patchId);
|
||||
loadPatches();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!isLoggedIn) { openAuth(); return; }
|
||||
setShowShare(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ws-page">
|
||||
<nav className="ws-nav">
|
||||
<div className="ws-nav-logo">
|
||||
<div className="auth-logo-box" style={{ width: 32, height: 32, fontSize: 16 }}>~</div>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Reaktor</span>
|
||||
</div>
|
||||
<div className="ws-nav-tabs">
|
||||
<button className="ws-nav-tab" onClick={onSwitchToSandbox}>Sandbox</button>
|
||||
<button className="ws-nav-tab" onClick={onSwitchToGame}>SynthQuest</button>
|
||||
<button className="ws-nav-tab active">Workshop</button>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -898,6 +898,121 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
|
||||
}
|
||||
.login-btn:hover { background: var(--accent); color: #000; }
|
||||
|
||||
/* ===== Workshop ===== */
|
||||
.ws-nav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 0 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ws-nav-logo { display: flex; align-items: center; gap: 8px; }
|
||||
.ws-nav-tabs { display: flex; gap: 4px; }
|
||||
.ws-nav-tab {
|
||||
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
|
||||
font-size: 13px; font-weight: 500; font-family: inherit;
|
||||
background: transparent; color: var(--text2); transition: all 0.15s;
|
||||
}
|
||||
.ws-nav-tab:hover { color: var(--text); }
|
||||
.ws-nav-tab.active {
|
||||
background: var(--surface); color: var(--accent); font-weight: 600;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.ws-page {
|
||||
display: flex; flex-direction: column; height: 100vh;
|
||||
background: var(--bg); padding: 32px 48px; gap: 24px; overflow-y: auto;
|
||||
}
|
||||
.ws-header { text-align: center; }
|
||||
.ws-title { font-size: 32px; font-weight: 700; color: var(--text); margin: 0; }
|
||||
.ws-subtitle { font-size: 14px; color: var(--text2); margin: 4px 0 0; }
|
||||
|
||||
.ws-toolbar {
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.ws-search {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.ws-search input {
|
||||
background: none; border: none; color: var(--text); font-size: 13px;
|
||||
font-family: inherit; width: 100%; outline: none;
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
.ws-search input::placeholder { color: var(--text2); }
|
||||
|
||||
.ws-tags { display: flex; gap: 6px; }
|
||||
.ws-tag {
|
||||
padding: 6px 12px; border-radius: 16px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text2); font-size: 11px;
|
||||
font-weight: 500; cursor: pointer; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.ws-tag.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
||||
.ws-tag:hover { border-color: var(--accent); }
|
||||
|
||||
.ws-sort {
|
||||
padding: 8px 14px; background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text2); font-size: 12px; cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ws-sort:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.ws-share-btn {
|
||||
padding: 8px 16px; background: var(--accent); border: none; border-radius: 8px;
|
||||
color: #000; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
font-family: inherit; white-space: nowrap;
|
||||
}
|
||||
.ws-share-btn:hover { opacity: 0.9; }
|
||||
|
||||
.ws-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ws-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
overflow: hidden; transition: all 0.15s; cursor: default;
|
||||
}
|
||||
.ws-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
|
||||
.ws-card-preview {
|
||||
height: 100px; background: var(--bg); display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.ws-card-wave { font-size: 24px; color: var(--accent); opacity: 0.2; }
|
||||
|
||||
.ws-card-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ws-card-title { font-size: 14px; font-weight: 600; color: var(--text); margin: 0; }
|
||||
.ws-card-author { font-size: 11px; color: var(--text2); margin: 0; }
|
||||
|
||||
.ws-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.ws-tag-pill {
|
||||
padding: 2px 8px; background: var(--bg); border-radius: 10px;
|
||||
font-size: 9px; color: var(--text2);
|
||||
}
|
||||
|
||||
.ws-card-footer {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 4px;
|
||||
}
|
||||
.ws-like-btn {
|
||||
background: none; border: none; color: var(--red); font-size: 11px;
|
||||
cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.ws-like-btn:hover { opacity: 0.8; }
|
||||
.ws-card-meta { font-size: 10px; color: var(--text2); flex: 1; }
|
||||
.ws-load-btn {
|
||||
padding: 4px 12px; background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: 4px; color: var(--accent); font-size: 10px; font-weight: 600;
|
||||
cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.ws-load-btn:hover { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ws-page { padding: 16px; gap: 16px; }
|
||||
.ws-title { font-size: 22px; }
|
||||
.ws-toolbar { flex-direction: column; }
|
||||
.ws-tags { overflow-x: auto; flex-wrap: nowrap; }
|
||||
.ws-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== Fullscreen Keyboard ===== */
|
||||
.keyboard-fullscreen {
|
||||
position: fixed; inset: 0; z-index: 500;
|
||||
|
||||
@@ -2,19 +2,25 @@ import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import GameApp from './game/GameApp.jsx';
|
||||
import Workshop from './components/Workshop.jsx';
|
||||
import { AuthProvider } from './services/AuthContext.jsx';
|
||||
import AuthModal from './components/AuthModal.jsx';
|
||||
import './index.css';
|
||||
|
||||
function Root() {
|
||||
const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
|
||||
const [mode, setMode] = useState('game'); // 'game' | 'sandbox' | 'workshop'
|
||||
|
||||
const nav = {
|
||||
toGame: () => setMode('game'),
|
||||
toSandbox: () => setMode('sandbox'),
|
||||
toWorkshop: () => setMode('workshop'),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
{mode === 'sandbox'
|
||||
? <App onSwitchToGame={() => setMode('game')} />
|
||||
: <GameApp onSwitchToSandbox={() => setMode('sandbox')} />
|
||||
}
|
||||
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} />}
|
||||
{mode === 'game' && <GameApp onSwitchToSandbox={nav.toSandbox} onSwitchToWorkshop={nav.toWorkshop} />}
|
||||
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} />}
|
||||
<AuthModal />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,17 @@ export const users = {
|
||||
update: (data) => request('PATCH', '/users/me', data),
|
||||
};
|
||||
|
||||
// Workshop
|
||||
export const workshop = {
|
||||
browse: (params = '') => request('GET', `/workshop${params ? '?' + params : ''}`),
|
||||
get: (id) => request('GET', `/workshop/${id}`),
|
||||
share: (data) => request('POST', '/workshop', data),
|
||||
remove: (id) => request('DELETE', `/workshop/${id}`),
|
||||
like: (id) => request('POST', `/workshop/${id}/like`),
|
||||
unlike: (id) => request('DELETE', `/workshop/${id}/like`),
|
||||
report: (id) => request('POST', `/workshop/${id}/report`),
|
||||
};
|
||||
|
||||
// Admin
|
||||
export const admin = {
|
||||
stats: () => request('GET', '/admin/stats'),
|
||||
|
||||
@@ -12,6 +12,7 @@ import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import syncRoutes from './routes/sync.js';
|
||||
import workshopRoutes from './routes/workshop.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -41,6 +42,7 @@ await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
|
||||
await fastify.register(workshopRoutes, { prefix: '/api/v1/workshop' });
|
||||
|
||||
// Rate limit auth endpoints more aggressively
|
||||
fastify.addHook('onRoute', (routeOptions) => {
|
||||
|
||||
175
packages/server/src/routes/workshop.js
Normal file
175
packages/server/src/routes/workshop.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { eq, sql, desc, and, ilike, or, inArray } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
export default async function workshopRoutes(fastify) {
|
||||
|
||||
// Browse patches (public)
|
||||
fastify.get('/', async (request) => {
|
||||
const { q, tags, sort = 'recent', page = 1 } = request.query;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(schema.sharedPatches.isDeleted, false)];
|
||||
|
||||
if (q) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(schema.sharedPatches.title, `%${q}%`),
|
||||
ilike(schema.sharedPatches.description, `%${q}%`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
const tagList = tags.split(',').map(t => t.trim());
|
||||
conditions.push(sql`${schema.sharedPatches.tags} && ARRAY[${sql.join(tagList.map(t => sql`${t}`), sql`, `)}]::text[]`);
|
||||
}
|
||||
|
||||
const orderBy = sort === 'popular'
|
||||
? desc(schema.sharedPatches.likesCount)
|
||||
: desc(schema.sharedPatches.createdAt);
|
||||
|
||||
const patches = await db.select({
|
||||
id: schema.sharedPatches.id,
|
||||
title: schema.sharedPatches.title,
|
||||
description: schema.sharedPatches.description,
|
||||
tags: schema.sharedPatches.tags,
|
||||
data: schema.sharedPatches.data,
|
||||
likesCount: schema.sharedPatches.likesCount,
|
||||
createdAt: schema.sharedPatches.createdAt,
|
||||
userId: schema.sharedPatches.userId,
|
||||
}).from(schema.sharedPatches)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Get usernames for patches
|
||||
const userIds = [...new Set(patches.filter(p => p.userId).map(p => p.userId))];
|
||||
let userMap = {};
|
||||
if (userIds.length > 0) {
|
||||
const users = await db.select({
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
}).from(schema.users).where(inArray(schema.users.id, userIds));
|
||||
userMap = Object.fromEntries(users.map(u => [u.id, u]));
|
||||
}
|
||||
|
||||
const result = patches.map(p => ({
|
||||
...p,
|
||||
author: userMap[p.userId] || null,
|
||||
userId: undefined,
|
||||
}));
|
||||
|
||||
return { patches: result, page: +page, limit };
|
||||
});
|
||||
|
||||
// Get single patch
|
||||
fastify.get('/:id', async (request, reply) => {
|
||||
const [patch] = await db.select().from(schema.sharedPatches)
|
||||
.where(and(
|
||||
eq(schema.sharedPatches.id, request.params.id),
|
||||
eq(schema.sharedPatches.isDeleted, false)
|
||||
)).limit(1);
|
||||
|
||||
if (!patch) return reply.code(404).send({ error: 'Not found' });
|
||||
|
||||
let author = null;
|
||||
if (patch.userId) {
|
||||
const [user] = await db.select({
|
||||
username: schema.users.username,
|
||||
avatarUrl: schema.users.avatarUrl,
|
||||
}).from(schema.users).where(eq(schema.users.id, patch.userId)).limit(1);
|
||||
author = user || null;
|
||||
}
|
||||
|
||||
return { ...patch, author };
|
||||
});
|
||||
|
||||
// Share a patch (requires auth)
|
||||
fastify.post('/', { preHandler: [authenticate] }, async (request) => {
|
||||
const { title, description, tags, data } = request.body;
|
||||
if (!title || !data) return { error: 'title and data required' };
|
||||
|
||||
const [patch] = await db.insert(schema.sharedPatches).values({
|
||||
userId: request.user.id,
|
||||
title,
|
||||
description: description || '',
|
||||
tags: tags || [],
|
||||
data,
|
||||
}).returning();
|
||||
|
||||
return patch;
|
||||
});
|
||||
|
||||
// Delete own patch
|
||||
fastify.delete('/:id', { preHandler: [authenticate] }, async (request, reply) => {
|
||||
const [patch] = await db.select().from(schema.sharedPatches)
|
||||
.where(eq(schema.sharedPatches.id, request.params.id)).limit(1);
|
||||
|
||||
if (!patch) return reply.code(404).send({ error: 'Not found' });
|
||||
|
||||
// Owner or admin can delete
|
||||
if (patch.userId !== request.user.id && request.user.role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await db.update(schema.sharedPatches)
|
||||
.set({ isDeleted: true })
|
||||
.where(eq(schema.sharedPatches.id, request.params.id));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Like a patch
|
||||
fastify.post('/:id/like', { preHandler: [authenticate] }, async (request, reply) => {
|
||||
const patchId = request.params.id;
|
||||
|
||||
// Check if already liked
|
||||
const [existing] = await db.select().from(schema.likes)
|
||||
.where(and(
|
||||
eq(schema.likes.userId, request.user.id),
|
||||
eq(schema.likes.patchId, patchId)
|
||||
)).limit(1);
|
||||
|
||||
if (existing) return { liked: true, message: 'Already liked' };
|
||||
|
||||
await db.insert(schema.likes).values({
|
||||
userId: request.user.id,
|
||||
patchId,
|
||||
});
|
||||
|
||||
await db.update(schema.sharedPatches)
|
||||
.set({ likesCount: sql`${schema.sharedPatches.likesCount} + 1` })
|
||||
.where(eq(schema.sharedPatches.id, patchId));
|
||||
|
||||
return { liked: true };
|
||||
});
|
||||
|
||||
// Unlike a patch
|
||||
fastify.delete('/:id/like', { preHandler: [authenticate] }, async (request) => {
|
||||
const patchId = request.params.id;
|
||||
|
||||
const result = await db.delete(schema.likes)
|
||||
.where(and(
|
||||
eq(schema.likes.userId, request.user.id),
|
||||
eq(schema.likes.patchId, patchId)
|
||||
));
|
||||
|
||||
await db.update(schema.sharedPatches)
|
||||
.set({ likesCount: sql`GREATEST(${schema.sharedPatches.likesCount} - 1, 0)` })
|
||||
.where(eq(schema.sharedPatches.id, patchId));
|
||||
|
||||
return { liked: false };
|
||||
});
|
||||
|
||||
// Report/flag a patch
|
||||
fastify.post('/:id/report', { preHandler: [authenticate] }, async (request) => {
|
||||
await db.update(schema.sharedPatches)
|
||||
.set({ isFlagged: true })
|
||||
.where(eq(schema.sharedPatches.id, request.params.id));
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user