feat: Add multi-canvas system with project canvas support
- Add project_canvas and canvas_components tables for persistent canvas storage - Add ProjectCanvas store with full CRUD operations - Add ProjectCanvasPage for rendering saved canvas with components - Add ProjectsPage for managing canvas list (create, clone, delete) - Add HomePage that loads default canvas or falls back to dynamic canvas - Add toolbar support for displaying canvas as pages with custom icons - Add component usage validation to prevent deletion of components in use - Add MCP tools for canvas management (list, create, update, delete, clone) - Update router with /canvas/:id and /projects routes - Update Toolbar to show dynamic canvas pages from database
This commit is contained in:
375
server/index.ts
375
server/index.ts
@@ -52,6 +52,50 @@ db.run(`
|
||||
)
|
||||
`)
|
||||
|
||||
// Tabla para project canvas
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS project_canvas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'project',
|
||||
theme_id TEXT,
|
||||
config TEXT,
|
||||
tools TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
show_in_toolbar INTEGER DEFAULT 0,
|
||||
toolbar_icon TEXT,
|
||||
toolbar_order INTEGER DEFAULT 99,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Migrar tabla existente si falta la columna
|
||||
try {
|
||||
db.run(`ALTER TABLE project_canvas ADD COLUMN show_in_toolbar INTEGER DEFAULT 0`)
|
||||
db.run(`ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT`)
|
||||
db.run(`ALTER TABLE project_canvas ADD COLUMN toolbar_order INTEGER DEFAULT 99`)
|
||||
} catch (e) {
|
||||
// Columnas ya existen
|
||||
}
|
||||
|
||||
// Tabla para relación canvas-componentes
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
canvas_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
props TEXT,
|
||||
layout TEXT,
|
||||
is_visible INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(canvas_id, component_id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Insertar temas del sistema si no existen
|
||||
const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number }
|
||||
if (existingThemes.count === 0) {
|
||||
@@ -277,7 +321,7 @@ Bun.serve({
|
||||
}
|
||||
|
||||
// Obtener componente por ID
|
||||
if (url.pathname.startsWith('/api/components/')) {
|
||||
if (url.pathname.startsWith('/api/components/') && !url.pathname.includes('/usage')) {
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (req.method === 'GET') {
|
||||
@@ -289,6 +333,22 @@ Bun.serve({
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
// Verificar si el componente está en uso por algún canvas
|
||||
const usage = db.query(`
|
||||
SELECT pc.id, pc.name
|
||||
FROM canvas_components cc
|
||||
JOIN project_canvas pc ON cc.canvas_id = pc.id
|
||||
WHERE cc.component_id = ?
|
||||
`).all(id) as { id: string; name: string }[]
|
||||
|
||||
if (usage.length > 0) {
|
||||
return Response.json({
|
||||
error: 'Component in use',
|
||||
message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`,
|
||||
usedBy: usage
|
||||
}, { status: 409, headers: corsHeaders })
|
||||
}
|
||||
|
||||
db.run('DELETE FROM vue_components WHERE id = ?', [id])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
@@ -488,6 +548,319 @@ Bun.serve({
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// API de Canvas
|
||||
// =====================
|
||||
|
||||
// GET /api/canvas/toolbar - Canvas para mostrar en toolbar
|
||||
if (url.pathname === '/api/canvas/toolbar') {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM project_canvas WHERE show_in_toolbar = 1 ORDER BY toolbar_order ASC, name ASC').all()
|
||||
const canvases = (rows as any[]).map(row => ({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
show_in_toolbar: !!row.show_in_toolbar,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : []
|
||||
}))
|
||||
return Response.json(canvases, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/canvas/default - Canvas por defecto (para homepage)
|
||||
if (url.pathname === '/api/canvas/default') {
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT * FROM project_canvas WHERE is_default = 1 LIMIT 1').get() as any
|
||||
if (!row) {
|
||||
return Response.json({ hasDefault: false }, { headers: corsHeaders })
|
||||
}
|
||||
return Response.json({
|
||||
hasDefault: true,
|
||||
canvas: {
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
show_in_toolbar: !!row.show_in_toolbar,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : []
|
||||
}
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/canvas - Lista todos los canvas
|
||||
// POST /api/canvas - Crea un nuevo canvas
|
||||
if (url.pathname === '/api/canvas') {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM project_canvas ORDER BY is_system DESC, is_default DESC, name ASC').all()
|
||||
const canvases = (rows as any[]).map(row => ({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
show_in_toolbar: !!row.show_in_toolbar,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : []
|
||||
}))
|
||||
return Response.json(canvases, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `canvas-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO project_canvas
|
||||
(id, name, description, type, theme_id, config, tools, is_default, is_system, show_in_toolbar, toolbar_icon, toolbar_order, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.description || '',
|
||||
body.type || 'project',
|
||||
body.theme_id || null,
|
||||
JSON.stringify(body.config || {}),
|
||||
JSON.stringify(body.tools || []),
|
||||
body.is_default ? 1 : 0,
|
||||
body.is_system ? 1 : 0,
|
||||
body.show_in_toolbar ? 1 : 0,
|
||||
body.toolbar_icon || null,
|
||||
body.toolbar_order ?? 99
|
||||
)
|
||||
return Response.json({ success: true, id }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Operaciones sobre un canvas específico
|
||||
if (url.pathname.startsWith('/api/canvas/') && !url.pathname.includes('/components')) {
|
||||
const pathParts = url.pathname.split('/')
|
||||
const id = pathParts[3]
|
||||
const action = pathParts[4]
|
||||
|
||||
// POST /api/canvas/:id/clone - Clonar canvas
|
||||
if (action === 'clone' && req.method === 'POST') {
|
||||
const original = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!original) {
|
||||
return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const newId = `canvas-${Date.now()}`
|
||||
const newName = body.name || `${original.name} (copia)`
|
||||
|
||||
// Clonar el canvas
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO project_canvas
|
||||
(id, name, description, type, theme_id, config, tools, is_default, is_system)
|
||||
VALUES (?, ?, ?, 'project', ?, ?, ?, 0, 0)
|
||||
`)
|
||||
stmt.run(newId, newName, original.description, original.theme_id, original.config, original.tools)
|
||||
|
||||
// Clonar los componentes del canvas
|
||||
const components = db.query('SELECT * FROM canvas_components WHERE canvas_id = ?').all(id) as any[]
|
||||
if (components.length > 0) {
|
||||
const compStmt = db.prepare(`
|
||||
INSERT INTO canvas_components (canvas_id, component_id, position, props, layout, is_visible)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const comp of components) {
|
||||
compStmt.run(newId, comp.component_id, comp.position, comp.props, comp.layout, comp.is_visible)
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ success: true, id: newId }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// GET /api/canvas/:id - Obtener canvas
|
||||
if (req.method === 'GET' && !action) {
|
||||
const row = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
return Response.json({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
show_in_toolbar: !!row.show_in_toolbar,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : []
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// PUT /api/canvas/:id - Actualizar canvas
|
||||
if (req.method === 'PUT' && !action) {
|
||||
const canvas = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!canvas) {
|
||||
return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
// System canvas solo puede modificar toolbar settings y is_default
|
||||
if (canvas.is_system) {
|
||||
if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) }
|
||||
if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) }
|
||||
if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) }
|
||||
if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) }
|
||||
} else {
|
||||
// Non-system canvas puede modificar todo
|
||||
if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name) }
|
||||
if (body.description !== undefined) { updates.push('description = ?'); values.push(body.description) }
|
||||
if (body.theme_id !== undefined) { updates.push('theme_id = ?'); values.push(body.theme_id) }
|
||||
if (body.config !== undefined) { updates.push('config = ?'); values.push(JSON.stringify(body.config)) }
|
||||
if (body.tools !== undefined) { updates.push('tools = ?'); values.push(JSON.stringify(body.tools)) }
|
||||
if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) }
|
||||
if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) }
|
||||
if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) }
|
||||
if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) }
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP')
|
||||
values.push(id)
|
||||
const sql = `UPDATE project_canvas SET ${updates.join(', ')} WHERE id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return Response.json({ success: true, id }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// DELETE /api/canvas/:id - Eliminar canvas
|
||||
if (req.method === 'DELETE' && !action) {
|
||||
const canvas = db.query('SELECT is_system FROM project_canvas WHERE id = ?').get(id) as { is_system: number } | null
|
||||
if (canvas?.is_system) {
|
||||
return Response.json({ error: 'Cannot delete system canvas' }, { status: 403, headers: corsHeaders })
|
||||
}
|
||||
// canvas_components se eliminan automáticamente por CASCADE
|
||||
db.run('DELETE FROM project_canvas WHERE id = ?', [id])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// API de Canvas Components
|
||||
// =====================
|
||||
|
||||
// GET /api/canvas/:id/components - Lista componentes del canvas
|
||||
// POST /api/canvas/:id/components - Agrega componente al canvas
|
||||
const canvasComponentsMatch = url.pathname.match(/^\/api\/canvas\/([^/]+)\/components\/?$/)
|
||||
if (canvasComponentsMatch) {
|
||||
const canvasId = canvasComponentsMatch[1]
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query(`
|
||||
SELECT cc.*, vc.name, vc.template, vc.setup, vc.style, vc.props as component_props, vc.imports
|
||||
FROM canvas_components cc
|
||||
JOIN vue_components vc ON cc.component_id = vc.id
|
||||
WHERE cc.canvas_id = ?
|
||||
ORDER BY cc.position ASC
|
||||
`).all(canvasId) as any[]
|
||||
|
||||
const components = rows.map(row => ({
|
||||
id: row.id,
|
||||
canvasId: row.canvas_id,
|
||||
componentId: row.component_id,
|
||||
position: row.position,
|
||||
props: row.props ? JSON.parse(row.props) : {},
|
||||
layout: row.layout ? JSON.parse(row.layout) : null,
|
||||
isVisible: !!row.is_visible,
|
||||
createdAt: row.created_at,
|
||||
component: {
|
||||
id: row.component_id,
|
||||
name: row.name,
|
||||
template: row.template,
|
||||
setup: row.setup,
|
||||
style: row.style,
|
||||
props: row.component_props ? JSON.parse(row.component_props) : [],
|
||||
imports: row.imports ? JSON.parse(row.imports) : []
|
||||
}
|
||||
}))
|
||||
|
||||
return Response.json(components, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
|
||||
// Verificar que el componente existe
|
||||
const component = db.query('SELECT id FROM vue_components WHERE id = ?').get(body.component_id)
|
||||
if (!component) {
|
||||
return Response.json({ error: 'Component not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Obtener la siguiente posición
|
||||
const maxPos = db.query('SELECT MAX(position) as max FROM canvas_components WHERE canvas_id = ?').get(canvasId) as { max: number | null }
|
||||
const position = body.position ?? ((maxPos?.max ?? -1) + 1)
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO canvas_components
|
||||
(canvas_id, component_id, position, props, layout, is_visible)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(
|
||||
canvasId,
|
||||
body.component_id,
|
||||
position,
|
||||
JSON.stringify(body.props || {}),
|
||||
body.layout ? JSON.stringify(body.layout) : null,
|
||||
body.is_visible !== false ? 1 : 0
|
||||
)
|
||||
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT/DELETE /api/canvas/:canvasId/components/:componentId
|
||||
const canvasComponentMatch = url.pathname.match(/^\/api\/canvas\/([^/]+)\/components\/([^/]+)$/)
|
||||
if (canvasComponentMatch) {
|
||||
const canvasId = canvasComponentMatch[1]
|
||||
const componentId = canvasComponentMatch[2]
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const body = await req.json()
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (body.position !== undefined) { updates.push('position = ?'); values.push(body.position) }
|
||||
if (body.props !== undefined) { updates.push('props = ?'); values.push(JSON.stringify(body.props)) }
|
||||
if (body.layout !== undefined) { updates.push('layout = ?'); values.push(JSON.stringify(body.layout)) }
|
||||
if (body.is_visible !== undefined) { updates.push('is_visible = ?'); values.push(body.is_visible ? 1 : 0) }
|
||||
|
||||
if (updates.length > 0) {
|
||||
values.push(canvasId, componentId)
|
||||
const sql = `UPDATE canvas_components SET ${updates.join(', ')} WHERE canvas_id = ? AND component_id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM canvas_components WHERE canvas_id = ? AND component_id = ?', [canvasId, componentId])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/components/:id/usage - Canvas que usan el componente
|
||||
const componentUsageMatch = url.pathname.match(/^\/api\/components\/([^/]+)\/usage$/)
|
||||
if (componentUsageMatch && req.method === 'GET') {
|
||||
const componentId = componentUsageMatch[1]
|
||||
const usage = db.query(`
|
||||
SELECT pc.id, pc.name, pc.type
|
||||
FROM canvas_components cc
|
||||
JOIN project_canvas pc ON cc.canvas_id = pc.id
|
||||
WHERE cc.component_id = ?
|
||||
`).all(componentId) as { id: string; name: string; type: string }[]
|
||||
|
||||
return Response.json({
|
||||
componentId,
|
||||
usedBy: usage,
|
||||
canDelete: usage.length === 0
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user