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:
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>
|
||||
Reference in New Issue
Block a user