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:
@@ -10,6 +10,8 @@ import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './servi
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Initialize WebMCP connection
|
// Initialize WebMCP connection
|
||||||
await initWebMCP()
|
await initWebMCP()
|
||||||
@@ -19,13 +21,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Initialize tools for current page (handles refresh)
|
// Initialize tools for current page (handles refresh)
|
||||||
const currentPage = (route.name as string) || 'canvas'
|
const currentPage = (route.name as string) || 'canvas'
|
||||||
initToolsOnRefresh(currentPage as 'canvas' | 'components' | 'themes')
|
initToolsOnRefresh(currentPage as PageName)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for route changes and update tools
|
// Watch for route changes and update tools
|
||||||
watch(() => route.name, (newPage) => {
|
watch(() => route.name, (newPage) => {
|
||||||
if (newPage) {
|
if (newPage) {
|
||||||
activatePageTools(newPage as 'canvas' | 'components' | 'themes')
|
activatePageTools(newPage as PageName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { useCanvasStore } from '../stores/canvas'
|
import { useCanvasStore } from '../stores/canvas'
|
||||||
|
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const projectCanvasStore = useProjectCanvasStore()
|
||||||
|
|
||||||
function clearCanvas() {
|
function clearCanvas() {
|
||||||
const container = document.getElementById('canvas-content')
|
const container = document.getElementById('canvas-content')
|
||||||
@@ -25,19 +28,54 @@ function clearCanvas() {
|
|||||||
function toggleHistory() {
|
function toggleHistory() {
|
||||||
canvasStore.toggleHistoryPanel()
|
canvasStore.toggleHistoryPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCanvasActive(canvasId: string) {
|
||||||
|
return route.path === `/canvas/${canvasId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
projectCanvasStore.fetchToolbarCanvases()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="toolbar">
|
<aside class="toolbar">
|
||||||
<!-- Navegación -->
|
<!-- Navegacion principal -->
|
||||||
<div class="toolbar-section nav-section">
|
<div class="toolbar-section nav-section">
|
||||||
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Canvas">
|
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Canvas dinamicos de la toolbar -->
|
||||||
|
<RouterLink
|
||||||
|
v-for="canvas in projectCanvasStore.toolbarCanvases"
|
||||||
|
:key="canvas.id"
|
||||||
|
:to="`/canvas/${canvas.id}`"
|
||||||
|
class="toolbar-btn"
|
||||||
|
:class="{ active: isCanvasActive(canvas.id) }"
|
||||||
|
:title="canvas.name"
|
||||||
|
>
|
||||||
|
<span v-if="canvas.toolbar_icon" class="toolbar-emoji">{{ canvas.toolbar_icon }}</span>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
<path d="M3 9h18"/>
|
<path d="M3 9h18"/>
|
||||||
<path d="M9 21V9"/>
|
<path d="M9 21V9"/>
|
||||||
</svg>
|
</svg>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
|
|
||||||
|
<!-- Gestion -->
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<RouterLink to="/projects" class="toolbar-btn" :class="{ active: route.path === '/projects' }" title="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="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>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
|
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -132,4 +170,9 @@ function toggleHistory() {
|
|||||||
background: rgba(99, 102, 241, 0.15);
|
background: rgba(99, 102, 241, 0.15);
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-emoji {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
60
frontend/src/pages/HomePage.vue
Normal file
60
frontend/src/pages/HomePage.vue
Normal 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>
|
||||||
314
frontend/src/pages/ProjectCanvasPage.vue
Normal file
314
frontend/src/pages/ProjectCanvasPage.vue
Normal 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>
|
||||||
504
frontend/src/pages/ProjectsPage.vue
Normal file
504
frontend/src/pages/ProjectsPage.vue
Normal 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>
|
||||||
@@ -5,9 +5,25 @@ const router = createRouter({
|
|||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('../pages/HomePage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dynamic-canvas',
|
||||||
name: 'canvas',
|
name: 'canvas',
|
||||||
component: () => import('../pages/CanvasPage.vue')
|
component: () => import('../pages/CanvasPage.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/canvas/:id',
|
||||||
|
name: 'project-canvas',
|
||||||
|
component: () => import('../pages/ProjectCanvasPage.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
name: 'projects',
|
||||||
|
component: () => import('../pages/ProjectsPage.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/components',
|
path: '/components',
|
||||||
name: 'components',
|
name: 'components',
|
||||||
|
|||||||
@@ -146,9 +146,13 @@ export const componentsApi = {
|
|||||||
return res.json()
|
return res.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<{ success: boolean }> {
|
async delete(id: string): Promise<{ success: boolean; error?: string; usedBy?: { id: string; name: string }[] }> {
|
||||||
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
||||||
return res.json()
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
return { success: false, error: data.error || data.message, usedBy: data.usedBy }
|
||||||
|
}
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteAll(): Promise<{ success: boolean }> {
|
async deleteAll(): Promise<{ success: boolean }> {
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import {
|
|||||||
setRouter,
|
setRouter,
|
||||||
GLOBAL_TOOLS
|
GLOBAL_TOOLS
|
||||||
} from './tools/globalTools'
|
} from './tools/globalTools'
|
||||||
|
import {
|
||||||
|
registerProjectCanvasTools,
|
||||||
|
unregisterProjectCanvasTools,
|
||||||
|
PROJECT_CANVAS_TOOLS
|
||||||
|
} from './tools/projectCanvasTools'
|
||||||
|
|
||||||
type PageName = 'canvas' | 'components' | 'themes'
|
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
|
||||||
|
|
||||||
interface PageToolSet {
|
interface PageToolSet {
|
||||||
register: () => void
|
register: () => void
|
||||||
@@ -29,10 +34,23 @@ interface PageToolSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageTools: Record<PageName, PageToolSet> = {
|
const pageTools: Record<PageName, PageToolSet> = {
|
||||||
|
home: {
|
||||||
|
register: () => {
|
||||||
|
registerCanvasTools()
|
||||||
|
registerComponentTools()
|
||||||
|
registerProjectCanvasTools()
|
||||||
|
},
|
||||||
|
unregister: () => {
|
||||||
|
unregisterCanvasTools()
|
||||||
|
unregisterComponentTools()
|
||||||
|
unregisterProjectCanvasTools()
|
||||||
|
},
|
||||||
|
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
|
||||||
|
},
|
||||||
canvas: {
|
canvas: {
|
||||||
register: () => {
|
register: () => {
|
||||||
registerCanvasTools()
|
registerCanvasTools()
|
||||||
registerComponentTools() // Canvas también puede guardar/cargar
|
registerComponentTools()
|
||||||
},
|
},
|
||||||
unregister: () => {
|
unregister: () => {
|
||||||
unregisterCanvasTools()
|
unregisterCanvasTools()
|
||||||
@@ -40,6 +58,24 @@ const pageTools: Record<PageName, PageToolSet> = {
|
|||||||
},
|
},
|
||||||
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS]
|
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS]
|
||||||
},
|
},
|
||||||
|
'project-canvas': {
|
||||||
|
register: () => {
|
||||||
|
registerCanvasTools()
|
||||||
|
registerComponentTools()
|
||||||
|
registerProjectCanvasTools()
|
||||||
|
},
|
||||||
|
unregister: () => {
|
||||||
|
unregisterCanvasTools()
|
||||||
|
unregisterComponentTools()
|
||||||
|
unregisterProjectCanvasTools()
|
||||||
|
},
|
||||||
|
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
register: registerProjectCanvasTools,
|
||||||
|
unregister: unregisterProjectCanvasTools,
|
||||||
|
toolNames: PROJECT_CANVAS_TOOLS
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
register: registerComponentTools,
|
register: registerComponentTools,
|
||||||
unregister: unregisterComponentTools,
|
unregister: unregisterComponentTools,
|
||||||
|
|||||||
246
frontend/src/services/tools/projectCanvasTools.ts
Normal file
246
frontend/src/services/tools/projectCanvasTools.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { registerTool, unregisterTools } from '../webmcp'
|
||||||
|
import { useProjectCanvasStore } from '../../stores/projectCanvas'
|
||||||
|
|
||||||
|
export const PROJECT_CANVAS_TOOLS = [
|
||||||
|
'list_canvases',
|
||||||
|
'create_canvas',
|
||||||
|
'get_canvas',
|
||||||
|
'update_canvas',
|
||||||
|
'delete_canvas',
|
||||||
|
'clone_canvas',
|
||||||
|
'add_component_to_canvas',
|
||||||
|
'remove_component_from_canvas',
|
||||||
|
'get_canvas_components'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function registerProjectCanvasTools() {
|
||||||
|
const store = useProjectCanvasStore()
|
||||||
|
|
||||||
|
// list_canvases
|
||||||
|
registerTool(
|
||||||
|
'list_canvases',
|
||||||
|
'Lista todos los canvas disponibles (proyectos, sistema)',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['all', 'project', 'system'],
|
||||||
|
description: 'Filtrar por tipo de canvas'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args: { type?: string }) => {
|
||||||
|
await store.fetchCanvases()
|
||||||
|
let canvases = store.canvases
|
||||||
|
|
||||||
|
if (args.type === 'project') {
|
||||||
|
canvases = store.projectCanvases
|
||||||
|
} else if (args.type === 'system') {
|
||||||
|
canvases = store.systemCanvases
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(canvases.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
description: c.description,
|
||||||
|
is_system: c.is_system
|
||||||
|
})), null, 2)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// create_canvas
|
||||||
|
registerTool(
|
||||||
|
'create_canvas',
|
||||||
|
'Crea un nuevo project canvas',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Nombre del canvas' },
|
||||||
|
description: { type: 'string', description: 'Descripcion del canvas' },
|
||||||
|
theme_id: { type: 'string', description: 'ID del tema a usar (opcional)' },
|
||||||
|
config: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Configuracion del canvas (layout, settings, permissions)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['name']
|
||||||
|
},
|
||||||
|
async (args: { name: string; description?: string; theme_id?: string; config?: object }) => {
|
||||||
|
const id = await store.createCanvas({
|
||||||
|
name: args.name,
|
||||||
|
description: args.description,
|
||||||
|
theme_id: args.theme_id,
|
||||||
|
config: args.config as any,
|
||||||
|
type: 'project'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
return `Canvas "${args.name}" creado con ID: ${id}`
|
||||||
|
}
|
||||||
|
return `Error al crear canvas: ${store.error}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_canvas
|
||||||
|
registerTool(
|
||||||
|
'get_canvas',
|
||||||
|
'Obtiene los detalles de un canvas por ID',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'ID del canvas' }
|
||||||
|
},
|
||||||
|
required: ['id']
|
||||||
|
},
|
||||||
|
async (args: { id: string }) => {
|
||||||
|
const canvas = await store.fetchCanvasById(args.id)
|
||||||
|
if (!canvas) {
|
||||||
|
return `Canvas con ID "${args.id}" no encontrado`
|
||||||
|
}
|
||||||
|
return JSON.stringify(canvas, null, 2)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// update_canvas
|
||||||
|
registerTool(
|
||||||
|
'update_canvas',
|
||||||
|
'Actualiza un canvas existente',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'ID del canvas a actualizar' },
|
||||||
|
name: { type: 'string', description: 'Nuevo nombre' },
|
||||||
|
description: { type: 'string', description: 'Nueva descripcion' },
|
||||||
|
theme_id: { type: 'string', description: 'Nuevo tema' },
|
||||||
|
config: { type: 'object', description: 'Nueva configuracion' }
|
||||||
|
},
|
||||||
|
required: ['id']
|
||||||
|
},
|
||||||
|
async (args: { id: string; name?: string; description?: string; theme_id?: string; config?: object }) => {
|
||||||
|
const { id, ...data } = args
|
||||||
|
const success = await store.updateCanvas(id, data as any)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return `Canvas "${id}" actualizado`
|
||||||
|
}
|
||||||
|
return `Error al actualizar canvas: ${store.error}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// delete_canvas
|
||||||
|
registerTool(
|
||||||
|
'delete_canvas',
|
||||||
|
'Elimina un canvas (no se pueden eliminar canvas del sistema)',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'ID del canvas a eliminar' }
|
||||||
|
},
|
||||||
|
required: ['id']
|
||||||
|
},
|
||||||
|
async (args: { id: string }) => {
|
||||||
|
const success = await store.deleteCanvas(args.id)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return `Canvas "${args.id}" eliminado`
|
||||||
|
}
|
||||||
|
return `Error al eliminar canvas: ${store.error}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// clone_canvas
|
||||||
|
registerTool(
|
||||||
|
'clone_canvas',
|
||||||
|
'Clona un canvas existente (incluyendo sus componentes)',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'ID del canvas a clonar' },
|
||||||
|
name: { type: 'string', description: 'Nombre para el nuevo canvas (opcional)' }
|
||||||
|
},
|
||||||
|
required: ['id']
|
||||||
|
},
|
||||||
|
async (args: { id: string; name?: string }) => {
|
||||||
|
const newId = await store.cloneCanvas(args.id, args.name)
|
||||||
|
|
||||||
|
if (newId) {
|
||||||
|
return `Canvas clonado con nuevo ID: ${newId}`
|
||||||
|
}
|
||||||
|
return `Error al clonar canvas: ${store.error}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// add_component_to_canvas
|
||||||
|
registerTool(
|
||||||
|
'add_component_to_canvas',
|
||||||
|
'Agrega un componente guardado a un canvas',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
canvas_id: { type: 'string', description: 'ID del canvas' },
|
||||||
|
component_id: { type: 'string', description: 'ID del componente a agregar' },
|
||||||
|
props: { type: 'object', description: 'Props para el componente en este canvas' },
|
||||||
|
position: { type: 'number', description: 'Posicion del componente (orden de renderizado)' }
|
||||||
|
},
|
||||||
|
required: ['canvas_id', 'component_id']
|
||||||
|
},
|
||||||
|
async (args: { canvas_id: string; component_id: string; props?: object; position?: number }) => {
|
||||||
|
const success = await store.addComponentToCanvas(
|
||||||
|
args.canvas_id,
|
||||||
|
args.component_id,
|
||||||
|
args.props as Record<string, any>,
|
||||||
|
args.position
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return `Componente "${args.component_id}" agregado al canvas "${args.canvas_id}"`
|
||||||
|
}
|
||||||
|
return 'Error al agregar componente al canvas'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// remove_component_from_canvas
|
||||||
|
registerTool(
|
||||||
|
'remove_component_from_canvas',
|
||||||
|
'Remueve un componente de un canvas',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
canvas_id: { type: 'string', description: 'ID del canvas' },
|
||||||
|
component_id: { type: 'string', description: 'ID del componente a remover' }
|
||||||
|
},
|
||||||
|
required: ['canvas_id', 'component_id']
|
||||||
|
},
|
||||||
|
async (args: { canvas_id: string; component_id: string }) => {
|
||||||
|
const success = await store.removeComponentFromCanvas(args.canvas_id, args.component_id)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return `Componente "${args.component_id}" removido del canvas "${args.canvas_id}"`
|
||||||
|
}
|
||||||
|
return 'Error al remover componente del canvas'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// get_canvas_components
|
||||||
|
registerTool(
|
||||||
|
'get_canvas_components',
|
||||||
|
'Obtiene los componentes de un canvas con sus definiciones',
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
canvas_id: { type: 'string', description: 'ID del canvas' }
|
||||||
|
},
|
||||||
|
required: ['canvas_id']
|
||||||
|
},
|
||||||
|
async (args: { canvas_id: string }) => {
|
||||||
|
await store.fetchCanvasComponents(args.canvas_id)
|
||||||
|
return JSON.stringify(store.activeCanvasComponents, null, 2)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterProjectCanvasTools() {
|
||||||
|
unregisterTools(PROJECT_CANVAS_TOOLS)
|
||||||
|
}
|
||||||
@@ -57,14 +57,21 @@ export const useComponentsStore = defineStore('components', () => {
|
|||||||
return saveComponent(currentComponent.value)
|
return saveComponent(currentComponent.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComponent(id: string) {
|
async function deleteComponent(id: string): Promise<{ success: boolean; error?: string; usedBy?: { id: string; name: string }[] }> {
|
||||||
try {
|
try {
|
||||||
await componentsApi.delete(id)
|
const result = await componentsApi.delete(id)
|
||||||
|
if (!result.success && result.usedBy) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Component in use',
|
||||||
|
usedBy: result.usedBy
|
||||||
|
}
|
||||||
|
}
|
||||||
await fetchComponents()
|
await fetchComponents()
|
||||||
return true
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error deleting component:', e)
|
console.error('Error deleting component:', e)
|
||||||
return false
|
return { success: false, error: e instanceof Error ? e.message : 'Unknown error' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
327
frontend/src/stores/projectCanvas.ts
Normal file
327
frontend/src/stores/projectCanvas.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { ProjectCanvas, CanvasComponent, ComponentUsage } from '../types/canvas'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:4101'
|
||||||
|
|
||||||
|
export const useProjectCanvasStore = defineStore('projectCanvas', () => {
|
||||||
|
// State
|
||||||
|
const canvases = ref<ProjectCanvas[]>([])
|
||||||
|
const activeCanvas = ref<ProjectCanvas | null>(null)
|
||||||
|
const activeCanvasComponents = ref<CanvasComponent[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// State adicional
|
||||||
|
const toolbarCanvases = ref<ProjectCanvas[]>([])
|
||||||
|
const defaultCanvas = ref<ProjectCanvas | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const projectCanvases = computed(() =>
|
||||||
|
canvases.value.filter(c => c.type === 'project')
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemCanvases = computed(() =>
|
||||||
|
canvases.value.filter(c => c.type === 'system')
|
||||||
|
)
|
||||||
|
|
||||||
|
const dynamicCanvas = computed(() =>
|
||||||
|
canvases.value.find(c => c.type === 'dynamic')
|
||||||
|
)
|
||||||
|
|
||||||
|
const canvasCount = computed(() => canvases.value.length)
|
||||||
|
|
||||||
|
const hasActiveCanvas = computed(() => activeCanvas.value !== null)
|
||||||
|
|
||||||
|
const hasDefaultCanvas = computed(() => defaultCanvas.value !== null)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchCanvases() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch canvases')
|
||||||
|
canvases.value = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
console.error('Error fetching canvases:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchToolbarCanvases() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/toolbar`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch toolbar canvases')
|
||||||
|
toolbarCanvases.value = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching toolbar canvases:', e)
|
||||||
|
toolbarCanvases.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDefaultCanvas(): Promise<ProjectCanvas | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/default`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch default canvas')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.hasDefault) {
|
||||||
|
defaultCanvas.value = data.canvas
|
||||||
|
return data.canvas
|
||||||
|
}
|
||||||
|
defaultCanvas.value = null
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching default canvas:', e)
|
||||||
|
defaultCanvas.value = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCanvasById(id: string): Promise<ProjectCanvas | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return null
|
||||||
|
throw new Error('Failed to fetch canvas')
|
||||||
|
}
|
||||||
|
return await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching canvas:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCanvas(data: Partial<ProjectCanvas>): Promise<string | null> {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to create canvas')
|
||||||
|
const result = await res.json()
|
||||||
|
await fetchCanvases()
|
||||||
|
return result.id
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
console.error('Error creating canvas:', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCanvas(id: string, data: Partial<ProjectCanvas>): Promise<boolean> {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to update canvas')
|
||||||
|
}
|
||||||
|
await fetchCanvases()
|
||||||
|
// Update active canvas if it's the one being updated
|
||||||
|
if (activeCanvas.value?.id === id) {
|
||||||
|
activeCanvas.value = await fetchCanvasById(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
console.error('Error updating canvas:', e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCanvas(id: string): Promise<boolean> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to delete canvas')
|
||||||
|
}
|
||||||
|
await fetchCanvases()
|
||||||
|
if (activeCanvas.value?.id === id) {
|
||||||
|
clearActiveCanvas()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
console.error('Error deleting canvas:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneCanvas(id: string, newName?: string): Promise<string | null> {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${id}/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName })
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to clone canvas')
|
||||||
|
const result = await res.json()
|
||||||
|
await fetchCanvases()
|
||||||
|
return result.id
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
console.error('Error cloning canvas:', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas Components
|
||||||
|
async function fetchCanvasComponents(canvasId: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch canvas components')
|
||||||
|
activeCanvasComponents.value = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching canvas components:', e)
|
||||||
|
activeCanvasComponents.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComponentToCanvas(
|
||||||
|
canvasId: string,
|
||||||
|
componentId: string,
|
||||||
|
props?: Record<string, any>,
|
||||||
|
position?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ component_id: componentId, props, position })
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to add component to canvas')
|
||||||
|
await fetchCanvasComponents(canvasId)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding component to canvas:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeComponentFromCanvas(canvasId: string, componentId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to remove component from canvas')
|
||||||
|
await fetchCanvasComponents(canvasId)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error removing component from canvas:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCanvasComponent(
|
||||||
|
canvasId: string,
|
||||||
|
componentId: string,
|
||||||
|
data: { position?: number; props?: Record<string, any>; layout?: any; is_visible?: boolean }
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to update canvas component')
|
||||||
|
await fetchCanvasComponents(canvasId)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating canvas component:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component Usage
|
||||||
|
async function getComponentUsage(componentId: string): Promise<ComponentUsage | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/components/${componentId}/usage`)
|
||||||
|
if (!res.ok) throw new Error('Failed to get component usage')
|
||||||
|
return await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error getting component usage:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas Activation
|
||||||
|
function setActiveCanvas(canvas: ProjectCanvas) {
|
||||||
|
activeCanvas.value = canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveCanvas() {
|
||||||
|
activeCanvas.value = null
|
||||||
|
activeCanvasComponents.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateCanvas(id: string): Promise<boolean> {
|
||||||
|
const canvas = await fetchCanvasById(id)
|
||||||
|
if (!canvas) return false
|
||||||
|
setActiveCanvas(canvas)
|
||||||
|
await fetchCanvasComponents(id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
canvases,
|
||||||
|
activeCanvas,
|
||||||
|
activeCanvasComponents,
|
||||||
|
toolbarCanvases,
|
||||||
|
defaultCanvas,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
// Getters
|
||||||
|
projectCanvases,
|
||||||
|
systemCanvases,
|
||||||
|
dynamicCanvas,
|
||||||
|
canvasCount,
|
||||||
|
hasActiveCanvas,
|
||||||
|
hasDefaultCanvas,
|
||||||
|
// Actions
|
||||||
|
fetchCanvases,
|
||||||
|
fetchToolbarCanvases,
|
||||||
|
fetchDefaultCanvas,
|
||||||
|
fetchCanvasById,
|
||||||
|
createCanvas,
|
||||||
|
updateCanvas,
|
||||||
|
deleteCanvas,
|
||||||
|
cloneCanvas,
|
||||||
|
fetchCanvasComponents,
|
||||||
|
addComponentToCanvas,
|
||||||
|
removeComponentFromCanvas,
|
||||||
|
updateCanvasComponent,
|
||||||
|
getComponentUsage,
|
||||||
|
setActiveCanvas,
|
||||||
|
clearActiveCanvas,
|
||||||
|
activateCanvas
|
||||||
|
}
|
||||||
|
})
|
||||||
71
frontend/src/types/canvas.ts
Normal file
71
frontend/src/types/canvas.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export type CanvasType = 'dynamic' | 'project' | 'system' | 'external'
|
||||||
|
|
||||||
|
export interface CanvasConfig {
|
||||||
|
layout?: 'free' | 'grid' | 'stack'
|
||||||
|
settings?: {
|
||||||
|
backgroundColor?: string
|
||||||
|
padding?: string
|
||||||
|
gridColumns?: number
|
||||||
|
gridGap?: string
|
||||||
|
}
|
||||||
|
permissions?: {
|
||||||
|
canAddComponents?: boolean
|
||||||
|
canRemoveComponents?: boolean
|
||||||
|
canEditTools?: boolean
|
||||||
|
canChangeTheme?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCanvas {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
type: CanvasType
|
||||||
|
theme_id?: string | null
|
||||||
|
config: CanvasConfig | null
|
||||||
|
tools: string[]
|
||||||
|
is_default: boolean
|
||||||
|
is_system: boolean
|
||||||
|
show_in_toolbar: boolean
|
||||||
|
toolbar_icon?: string | null
|
||||||
|
toolbar_order: number
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasComponentLayout {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasComponent {
|
||||||
|
id: number
|
||||||
|
canvasId: string
|
||||||
|
componentId: string
|
||||||
|
position: number
|
||||||
|
props: Record<string, any>
|
||||||
|
layout: CanvasComponentLayout | null
|
||||||
|
isVisible: boolean
|
||||||
|
createdAt?: string
|
||||||
|
component?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
template: string
|
||||||
|
setup?: string
|
||||||
|
style?: string
|
||||||
|
props?: string[]
|
||||||
|
imports?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentUsage {
|
||||||
|
componentId: string
|
||||||
|
usedBy: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: CanvasType
|
||||||
|
}>
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
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
|
// 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 }
|
const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number }
|
||||||
if (existingThemes.count === 0) {
|
if (existingThemes.count === 0) {
|
||||||
@@ -277,7 +321,7 @@ Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Obtener componente por ID
|
// 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()
|
const id = url.pathname.split('/').pop()
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
@@ -289,6 +333,22 @@ Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
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])
|
db.run('DELETE FROM vue_components WHERE id = ?', [id])
|
||||||
return Response.json({ success: true }, { headers: corsHeaders })
|
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 })
|
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user