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:
2026-02-13 06:32:46 -06:00
parent 2e64dceb1e
commit 8a017db777
13 changed files with 2016 additions and 13 deletions

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useProjectCanvasStore } from '../stores/projectCanvas'
import Canvas from '../components/Canvas.vue'
import ProjectCanvasPage from './ProjectCanvasPage.vue'
const projectCanvasStore = useProjectCanvasStore()
const loading = ref(true)
const showDefaultCanvas = ref(false)
const defaultCanvasId = ref<string | null>(null)
onMounted(async () => {
const defaultCanvas = await projectCanvasStore.fetchDefaultCanvas()
if (defaultCanvas) {
showDefaultCanvas.value = true
defaultCanvasId.value = defaultCanvas.id
} else {
showDefaultCanvas.value = false
}
loading.value = false
})
</script>
<template>
<div v-if="loading" class="home-loading">
<div class="spinner"></div>
</div>
<ProjectCanvasPage
v-else-if="showDefaultCanvas && defaultCanvasId"
:id="defaultCanvasId"
/>
<Canvas v-else />
</template>
<style scoped>
.home-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,314 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { renderInlineComponent, type VueComponentDefinition } from '../services/dynamicComponents'
import { useProjectCanvasStore } from '../stores/projectCanvas'
import { useCanvasStore } from '../stores/canvas'
import { useThemeStore } from '../stores/theme'
const props = defineProps<{
id: string
}>()
const router = useRouter()
const projectCanvasStore = useProjectCanvasStore()
const canvasStore = useCanvasStore()
const themeStore = useThemeStore()
const loading = ref(true)
const error = ref<string | null>(null)
async function loadCanvas() {
loading.value = true
error.value = null
const success = await projectCanvasStore.activateCanvas(props.id)
if (!success) {
error.value = 'Canvas no encontrado'
loading.value = false
return
}
// Apply canvas theme if set
const canvas = projectCanvasStore.activeCanvas
if (canvas?.theme_id) {
const theme = themeStore.themes.find(t => t.id === canvas.theme_id)
if (theme) {
themeStore.applyTheme(theme.variables)
}
}
// Set loading to false first, then render after DOM updates
loading.value = false
// Wait for DOM to update with canvas-content element
await nextTick()
// Render all components
const container = document.getElementById('canvas-content')
if (container && projectCanvasStore.activeCanvasComponents.length > 0) {
container.innerHTML = ''
for (const canvasComp of projectCanvasStore.activeCanvasComponents) {
if (!canvasComp.isVisible || !canvasComp.component) continue
const definition: VueComponentDefinition = {
id: canvasComp.component.id,
name: canvasComp.component.name,
template: canvasComp.component.template,
setup: canvasComp.component.setup,
style: canvasComp.component.style,
props: canvasComp.component.props,
imports: canvasComp.component.imports || ['ref', 'reactive', 'computed']
}
const componentProps = { ...canvasComp.props }
renderInlineComponent(definition, container, componentProps, true)
}
}
}
function handleLoadComponent(e: Event) {
const detail = (e as CustomEvent).detail
if (!detail) return
const container = document.getElementById('canvas-content')
if (!container) return
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
const definition: VueComponentDefinition = {
id: detail.id,
name: detail.name,
template: detail.template,
setup: detail.setup,
style: detail.style,
props: detail.props,
imports: detail.imports || ['ref', 'reactive', 'computed']
}
const result = renderInlineComponent(definition, container, {}, true)
;(window as any).__vueComponentUnmount = result.unmount
window.dispatchEvent(new CustomEvent('vue-component-rendered', { detail }))
canvasStore.addToHistory({ tool: 'load_vue_component', args: detail, timestamp: Date.now() })
}
function goBack() {
router.push('/projects')
}
onMounted(() => {
loadCanvas()
window.addEventListener('load-vue-component', handleLoadComponent)
})
onUnmounted(() => {
window.removeEventListener('load-vue-component', handleLoadComponent)
projectCanvasStore.clearActiveCanvas()
})
watch(() => props.id, () => {
loadCanvas()
})
</script>
<template>
<div class="project-canvas-page">
<header class="canvas-header">
<button class="back-btn" @click="goBack" title="Volver a proyectos">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
</svg>
</button>
<div class="canvas-info" v-if="projectCanvasStore.activeCanvas">
<h1>{{ projectCanvasStore.activeCanvas.name }}</h1>
<span v-if="projectCanvasStore.activeCanvas.description">
{{ projectCanvasStore.activeCanvas.description }}
</span>
</div>
<div class="canvas-badge" v-if="projectCanvasStore.activeCanvas?.is_system">
Sistema
</div>
</header>
<div class="canvas-container">
<div v-if="loading" class="canvas-loading">
<div class="spinner"></div>
<p>Cargando canvas...</p>
</div>
<div v-else-if="error" class="canvas-error">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4"/>
<path d="M12 16h.01"/>
</svg>
<p>{{ error }}</p>
<button @click="goBack">Volver a proyectos</button>
</div>
<div v-else id="canvas-content" class="canvas-content">
<div v-if="projectCanvasStore.activeCanvasComponents.length === 0" class="canvas-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
<p>Canvas vacio</p>
<span>Usa las herramientas MCP para agregar componentes</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.project-canvas-page {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: hidden;
}
.canvas-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.back-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.canvas-info {
flex: 1;
}
.canvas-info h1 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.canvas-info span {
font-size: 0.75rem;
color: var(--text-muted);
}
.canvas-badge {
padding: 0.25rem 0.5rem;
background: var(--accent-muted);
color: var(--accent);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-full);
}
.canvas-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.canvas-content {
flex: 1;
padding: 1.5rem;
min-height: 100%;
}
.canvas-loading,
.canvas-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
color: var(--text-muted);
text-align: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.canvas-error svg {
color: var(--error);
opacity: 0.7;
}
.canvas-error button {
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.15s ease;
}
.canvas-error button:hover {
background: var(--accent-hover);
}
.canvas-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
color: var(--text-muted);
text-align: center;
}
.canvas-placeholder svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.canvas-placeholder p {
font-size: 1.25rem;
margin: 0 0 0.5rem 0;
}
.canvas-placeholder span {
font-size: 0.875rem;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,504 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectCanvasStore } from '../stores/projectCanvas'
import type { ProjectCanvas } from '../types/canvas'
const router = useRouter()
const store = useProjectCanvasStore()
const showCreateModal = ref(false)
const newCanvasName = ref('')
const newCanvasDescription = ref('')
const selectedCloneSource = ref<string | null>(null)
const systemCanvases = computed(() => store.systemCanvases)
const projectCanvases = computed(() => store.projectCanvases)
async function createCanvas() {
if (!newCanvasName.value.trim()) return
let id: string | null = null
if (selectedCloneSource.value) {
id = await store.cloneCanvas(selectedCloneSource.value, newCanvasName.value)
} else {
id = await store.createCanvas({
name: newCanvasName.value,
description: newCanvasDescription.value,
type: 'project'
})
}
if (id) {
closeModal()
router.push(`/canvas/${id}`)
}
}
function openCanvas(canvas: ProjectCanvas) {
router.push(`/canvas/${canvas.id}`)
}
function openCreateModal(cloneSource?: string) {
selectedCloneSource.value = cloneSource || null
newCanvasName.value = ''
newCanvasDescription.value = ''
showCreateModal.value = true
}
function closeModal() {
showCreateModal.value = false
newCanvasName.value = ''
newCanvasDescription.value = ''
selectedCloneSource.value = null
}
async function deleteCanvas(canvas: ProjectCanvas) {
if (canvas.is_system) return
if (confirm(`¿Eliminar el canvas "${canvas.name}"?`)) {
await store.deleteCanvas(canvas.id)
}
}
onMounted(() => {
store.fetchCanvases()
})
</script>
<template>
<div class="projects-page">
<header class="page-header">
<h1>Proyectos</h1>
<button class="create-btn" @click="openCreateModal()">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
Nuevo Canvas
</button>
</header>
<div class="content">
<!-- System Templates -->
<section v-if="systemCanvases.length > 0" class="canvas-section">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Templates del Sistema
</h2>
<div class="canvas-grid">
<div
v-for="canvas in systemCanvases"
:key="canvas.id"
class="canvas-card system"
>
<div class="card-content" @click="openCanvas(canvas)">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
</div>
<h3>{{ canvas.name }}</h3>
<p v-if="canvas.description">{{ canvas.description }}</p>
</div>
<div class="card-actions">
<button @click.stop="openCreateModal(canvas.id)" title="Clonar template">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</div>
</div>
</section>
<!-- Project Canvases -->
<section class="canvas-section">
<h2>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Mis Proyectos
</h2>
<div v-if="projectCanvases.length === 0" class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<p>No tienes proyectos aun</p>
<span>Crea un nuevo canvas o clona un template del sistema</span>
</div>
<div v-else class="canvas-grid">
<div
v-for="canvas in projectCanvases"
:key="canvas.id"
class="canvas-card"
>
<div class="card-content" @click="openCanvas(canvas)">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
</div>
<h3>{{ canvas.name }}</h3>
<p v-if="canvas.description">{{ canvas.description }}</p>
</div>
<div class="card-actions">
<button @click.stop="openCreateModal(canvas.id)" title="Clonar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button @click.stop="deleteCanvas(canvas)" class="delete-btn" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
</section>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ selectedCloneSource ? 'Clonar Canvas' : 'Nuevo Canvas' }}</h2>
<div class="form-group">
<label for="canvas-name">Nombre</label>
<input
id="canvas-name"
v-model="newCanvasName"
type="text"
placeholder="Mi proyecto"
@keyup.enter="createCanvas"
/>
</div>
<div class="form-group">
<label for="canvas-desc">Descripcion (opcional)</label>
<textarea
id="canvas-desc"
v-model="newCanvasDescription"
placeholder="Descripcion del canvas..."
rows="3"
></textarea>
</div>
<div class="modal-actions">
<button class="cancel-btn" @click="closeModal">Cancelar</button>
<button class="submit-btn" @click="createCanvas" :disabled="!newCanvasName.trim()">
{{ selectedCloneSource ? 'Clonar' : 'Crear' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.projects-page {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.create-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.create-btn:hover {
background: var(--accent-hover);
}
.content {
flex: 1;
padding: 1.5rem;
}
.canvas-section {
margin-bottom: 2rem;
}
.canvas-section h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.canvas-section h2 svg {
color: var(--text-muted);
}
.canvas-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.canvas-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.15s ease;
}
.canvas-card:hover {
border-color: var(--border-hover);
box-shadow: var(--shadow-md);
}
.canvas-card.system {
border-color: var(--accent-muted);
}
.canvas-card.system:hover {
border-color: var(--accent);
}
.card-content {
padding: 1.25rem;
cursor: pointer;
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--bg-hover);
border-radius: var(--radius-md);
margin-bottom: 1rem;
color: var(--text-muted);
}
.canvas-card.system .card-icon {
background: var(--accent-muted);
color: var(--accent);
}
.card-content h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.card-content p {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.card-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.card-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.card-actions .delete-btn:hover {
background: var(--error-bg);
color: var(--error);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.empty-state svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
font-size: 1rem;
margin: 0 0 0.25rem 0;
}
.empty-state span {
font-size: 0.875rem;
opacity: 0.7;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-lg);
}
.modal h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1.5rem 0;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.cancel-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.cancel-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.submit-btn {
padding: 0.5rem 1rem;
background: var(--accent);
border: none;
border-radius: var(--radius-md);
color: var(--accent-text);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>