feat: Auto-save components, soft delete, tags, compact WCO header

- Auto-save rendered Vue components to DB on render_vue_component
- Soft delete (archive) instead of hard delete for components
- Tags support for component categorization
- Gallery limited to 10 most recent items per section
- Upsert with ON CONFLICT for component saves
- PUT endpoint for partial component updates
- Collapsible toolbar with animated toggle button
- Window Controls Overlay support for PWA titlebar
- Compact header mode (32px) with hidden dot toggle
- Dynamic theme-color meta sync for Windows titlebar
This commit is contained in:
2026-02-15 02:54:27 -06:00
parent 8154bac63f
commit 9f9f335439
10 changed files with 401 additions and 84 deletions

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#16161d" />
<meta name="theme-color" content="#0f0f14" />
<meta name="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />

View File

@@ -6,6 +6,7 @@ import TorchButton from './components/TorchButton.vue'
import FloatingTerminal from './components/FloatingTerminal.vue'
import FloatingResponse from './components/FloatingResponse.vue'
import FloatingVoice from './components/FloatingVoice.vue'
import AgentBar from './components/AgentBar.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue'
import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch'
@@ -21,6 +22,8 @@ const router = useRouter()
const showTerminal = ref(false)
const showVoice = ref(false)
const showDebugConsole = ref(false)
const toolbarVisible = ref(true)
const forceWco = ref(false)
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
// Intercept console.log for debug panel
@@ -242,7 +245,17 @@ function triggerToolFlash() {
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'agents'
function syncThemeColor() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
if (bg) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg)
}
}
onMounted(async () => {
// Sync Windows titlebar color with CSS variable
syncThemeColor()
// Connect to WebSocket for Claude status updates
connectStatusWs()
@@ -348,10 +361,20 @@ watch(() => route.name, (newPage) => {
</script>
<template>
<div class="app-container">
<header class="app-header">
<div class="app-container" :class="{ wco: forceWco }">
<header class="app-header" :class="{ 'wco-header': forceWco }">
<div class="header-left">
<h1 class="logo">Agent UI</h1>
<button
class="toolbar-toggle"
:class="{ collapsed: !toolbarVisible }"
@click="toolbarVisible = !toolbarVisible"
:title="toolbarVisible ? 'Ocultar toolbar' : 'Mostrar toolbar'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</button>
<template v-if="projectCanvasStore.activeCanvas && route.name === 'project-canvas'">
<span class="header-sep">/</span>
<span class="header-canvas-name">{{ projectCanvasStore.activeCanvas.name }}</span>
@@ -376,11 +399,12 @@ watch(() => route.name, (newPage) => {
<path d="M21 3v5h-5"/>
</svg>
</button>
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
<TorchButton />
</div>
</header>
<main class="app-main">
<Toolbar />
<Toolbar :collapsed="!toolbarVisible" />
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
@@ -490,6 +514,9 @@ watch(() => route.name, (newPage) => {
</svg>
</button>
<!-- Agent Bar (bottom pills) -->
<AgentBar />
<!-- Floating Terminal -->
<FloatingTerminal ref="terminalRef" v-model="showTerminal" />
@@ -544,51 +571,200 @@ watch(() => route.name, (newPage) => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
padding-left: calc(1.5rem + env(safe-area-inset-left, 0px));
padding-right: calc(1.5rem + env(safe-area-inset-right, 0px));
background: var(--bg-secondary);
padding: 0.5rem 1rem;
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
min-height: 40px;
-webkit-app-region: drag;
app-region: drag;
}
.header-left {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 0.75rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.logo {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
/* ── Compact header (WCO + manual toggle) ── */
.wco-header,
.wco-header.app-header {
height: 32px;
min-height: 32px;
max-height: 32px;
padding: 0 0.5rem;
border-bottom: none;
overflow: visible;
}
.wco-header .header-left { gap: 0.35rem; overflow: visible; }
.wco-header .header-right { gap: 0.2rem; }
.wco-header .toolbar-toggle {
width: 22px;
height: 22px;
border-radius: 4px;
}
.wco-header .toolbar-toggle svg { width: 12px; height: 12px; }
.wco-header .header-sep { font-size: 0.65rem; }
.wco-header .header-canvas-name { font-size: 0.65rem; max-width: 100px; }
.wco-header .header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
.wco-header :deep(.pwa-banner) { display: none; }
.wco-header .debug-btn {
padding: 1px 4px;
font-size: 8px;
border-radius: 3px;
gap: 2px;
}
.wco-header .debug-btn svg { width: 10px; height: 10px; }
.wco-header .log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
.wco-header .refresh-btn {
width: 22px;
height: 22px;
border-radius: 4px;
}
.wco-header .refresh-btn svg { width: 12px; height: 12px; }
/* TorchButton compact via :deep */
.wco-header :deep(.trigger-split) { border-radius: 4px; font-size: 0.65rem; }
.wco-header :deep(.trigger-main) { padding: 0.1rem 0.25rem 0.1rem 0.35rem; gap: 0.25rem; }
.wco-header :deep(.trigger-chevron) { padding: 0.1rem 0.25rem; }
.wco-header :deep(.status-dot) { width: 5px; height: 5px; }
.wco-header :deep(.chevron) { width: 10px; height: 10px; }
.wco-header :deep(.trigger-name) { max-width: 60px; font-size: 0.65rem; }
/* Window Controls Overlay — real PWA titlebar */
@media (display-mode: window-controls-overlay) {
.app-header {
position: fixed;
top: env(titlebar-area-y, 0);
left: env(titlebar-area-x, 0);
width: env(titlebar-area-width, 100%);
height: env(titlebar-area-height, 32px);
min-height: unset;
max-height: env(titlebar-area-height, 32px);
padding: 0 0.5rem;
z-index: 10000;
border-bottom: none;
box-sizing: border-box;
background: var(--bg-primary);
}
.app-container {
padding-top: env(titlebar-area-height, 32px);
}
/* All the same compacting as .wco-header */
.header-left { gap: 0.35rem; }
.header-right { gap: 0.2rem; }
.toolbar-toggle { width: 22px; height: 22px; border-radius: 4px; }
.toolbar-toggle svg { width: 12px; height: 12px; }
.header-sep { font-size: 0.65rem; }
.header-canvas-name { font-size: 0.65rem; max-width: 100px; }
.header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
.debug-btn { padding: 1px 4px; font-size: 8px; border-radius: 3px; gap: 2px; }
.debug-btn svg { width: 10px; height: 10px; }
.log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
.refresh-btn { width: 22px; height: 22px; border-radius: 4px; }
.refresh-btn svg { width: 12px; height: 12px; }
}
/* Tiny hidden toggle dot */
.wco-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border-color);
opacity: 0.18;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
}
.wco-dot:hover {
opacity: 0.5;
transform: scale(1.8);
}
.wco-dot.on {
background: #6366f1;
opacity: 0.6;
box-shadow: 0 0 4px rgba(99, 102, 241, 0.5);
}
.toolbar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
}
.toolbar-toggle:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
}
.toolbar-toggle svg {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.toolbar-toggle.collapsed svg {
transform: scaleX(-1);
}
.toolbar-toggle.collapsed {
color: var(--accent, #6366f1);
border-color: rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.08);
}
.header-sep {
color: var(--text-muted);
font-size: 1.1rem;
font-size: 0.85rem;
opacity: 0.4;
}
.header-canvas-name {
font-size: 0.9rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.header-canvas-badge {
padding: 0.125rem 0.5rem;
padding: 0.0625rem 0.375rem;
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
font-size: 0.6875rem;
font-size: 0.625rem;
font-weight: 500;
border-radius: 999px;
}
@@ -597,12 +773,12 @@ watch(() => route.name, (newPage) => {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
@@ -1115,13 +1291,13 @@ watch(() => route.name, (newPage) => {
.debug-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
gap: 3px;
padding: 3px 6px;
background: rgba(100, 100, 100, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
border-radius: 5px;
color: #888;
font-size: 11px;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
}
@@ -1140,11 +1316,12 @@ watch(() => route.name, (newPage) => {
.log-count {
background: #ef4444;
color: white;
font-size: 9px;
padding: 1px 5px;
font-size: 8px;
padding: 1px 4px;
border-radius: 10px;
min-width: 16px;
min-width: 14px;
text-align: center;
line-height: 1.2;
}
/* Debug Console Panel */

View File

@@ -38,13 +38,15 @@ const editIcon = ref('')
const editOrder = ref(99)
const filteredCanvases = computed(() => {
const list = showArchived.value ? store.canvases : store.activeCanvasesList
if (!searchQuery.value) return list
const q = searchQuery.value.toLowerCase()
return list.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.description && c.description.toLowerCase().includes(q))
)
let list = showArchived.value ? store.canvases : store.activeCanvasesList
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
list = list.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.description && c.description.toLowerCase().includes(q))
)
}
return list.slice(0, 10)
})
const filteredSnapshots = computed(() => {
@@ -168,7 +170,7 @@ async function loadComponent(comp: VueComponentDefinition) {
async function fetchComponents() {
try {
savedComponents.value = await componentsApi.getAll()
savedComponents.value = await componentsApi.getAll({ limit: 10 })
} catch {
savedComponents.value = []
}
@@ -432,10 +434,14 @@ onMounted(() => {
<div class="card-content">
<div class="card-name">{{ comp.name }}</div>
<div class="card-desc card-id">{{ comp.id }}</div>
<div v-if="comp.tags?.length" class="card-tags">
<span v-for="tag in comp.tags" :key="tag" class="tag-pill">{{ tag }}</span>
</div>
</div>
<div class="card-meta">
<span class="card-badge component">componente</span>
<span v-if="comp.status === 'archived'" class="card-badge archived-badge">Archivado</span>
</div>
<!-- Loading overlay -->
@@ -955,4 +961,20 @@ onMounted(() => {
.new-btn.cancel:hover {
background: var(--border-color);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
.tag-pill {
padding: 0.0625rem 0.375rem;
background: rgba(99, 102, 241, 0.1);
color: #818cf8;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 500;
}
</style>

View File

@@ -4,6 +4,10 @@ import { RouterLink, useRoute } from 'vue-router'
import { useCanvasStore } from '../stores/canvas'
import { useProjectCanvasStore } from '../stores/projectCanvas'
defineProps<{
collapsed?: boolean
}>()
const route = useRoute()
const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
@@ -26,7 +30,7 @@ onMounted(() => {
</script>
<template>
<aside class="toolbar">
<aside class="toolbar" :class="{ collapsed }">
<!-- Navegacion principal -->
<div class="toolbar-section nav-section">
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
@@ -121,6 +125,12 @@ onMounted(() => {
<path d="M18 9a9 9 0 0 1-9 9"/>
</svg>
</RouterLink>
<RouterLink to="/agents" class="toolbar-btn" :class="{ active: route.path === '/agents' }" title="Agents">
<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 2L9.5 7.5 4 8.5l4 4-1 5.5L12 15l5 3-1-5.5 4-4-5.5-1z"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>
@@ -154,6 +164,19 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
flex-shrink: 0;
}
.toolbar.collapsed {
width: 0;
padding: 0;
border-right-color: transparent;
opacity: 0;
pointer-events: none;
}
.toolbar-section {

View File

@@ -34,6 +34,10 @@ export interface VueComponentDefinition {
style?: string
props?: string[]
imports?: string[]
tags?: string[]
status?: 'active' | 'archived'
created_at?: string
updated_at?: string
}
// ============================================
@@ -119,13 +123,18 @@ function injectScopedStyle(css: string, scopeId: string, componentId: string): v
// ============================================
export const componentsApi = {
async getAll(): Promise<VueComponentDefinition[]> {
const res = await fetch(`${API_URL}/api/components`)
async getAll(opts?: { includeArchived?: boolean; limit?: number }): Promise<VueComponentDefinition[]> {
const params = new URLSearchParams()
if (opts?.includeArchived) params.set('include_archived', 'true')
if (opts?.limit) params.set('limit', String(opts.limit))
const qs = params.toString()
const res = await fetch(`${API_URL}/api/components${qs ? '?' + qs : ''}`)
const data = await res.json()
return data.map((row: any) => ({
...row,
props: JSON.parse(row.props || '[]'),
imports: JSON.parse(row.imports || '[]')
imports: JSON.parse(row.imports || '[]'),
tags: JSON.parse(row.tags || '[]')
}))
},
@@ -136,7 +145,8 @@ export const componentsApi = {
return {
...row,
props: JSON.parse(row.props || '[]'),
imports: JSON.parse(row.imports || '[]')
imports: JSON.parse(row.imports || '[]'),
tags: JSON.parse(row.tags || '[]')
}
},
@@ -149,16 +159,25 @@ export const componentsApi = {
return res.json()
},
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 data = await res.json()
if (!res.ok) {
return { success: false, error: data.error || data.message, usedBy: data.usedBy }
}
return data
async update(id: string, data: Partial<VueComponentDefinition>): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
return res.json()
},
async deleteAll(): Promise<{ success: boolean }> {
async restore(id: string): Promise<{ success: boolean }> {
return this.update(id, { status: 'active' })
},
async delete(id: string): Promise<{ success: boolean; warning?: string }> {
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
return res.json()
},
async archiveAll(): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
return res.json()
}

View File

@@ -3,6 +3,7 @@ import { useCanvasStore } from '../../../stores/canvas'
import { useWindowsStore } from '../../../stores/windows'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
@@ -144,7 +145,7 @@ export function createCanvasHandlers(): ToolConfig[] {
},
required: ['id', 'name', 'template']
},
handler: (args: {
handler: async (args: {
id: string
name: string
template: string
@@ -195,6 +196,13 @@ export function createCanvasHandlers(): ToolConfig[] {
emitComponentRendered(args)
// Auto-save component to database
try {
await componentsApi.save(definition)
} catch (e) {
console.warn('[auto-save] Failed to save component:', e)
}
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado en ventana flotante`

View File

@@ -34,11 +34,12 @@ export function createComponentHandlers(): ToolConfig[] {
setup: { type: 'string', description: 'Codigo de setup' },
style: { type: 'string', description: 'CSS' },
props: { type: 'array', items: { type: 'string' }, description: 'Props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Imports de Vue' }
imports: { type: 'array', items: { type: 'string' }, description: 'Imports de Vue' },
tags: { type: 'array', items: { type: 'string' }, description: 'Etiquetas para categorizar el componente' }
},
required: ['name', 'template']
},
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string; tags?: string[] }) => {
try {
const result = await componentsApi.save({
id: args.id || `comp-${Date.now()}`,
@@ -47,7 +48,8 @@ export function createComponentHandlers(): ToolConfig[] {
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
imports: args.imports,
tags: args.tags
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
@@ -105,20 +107,26 @@ export function createComponentHandlers(): ToolConfig[] {
},
{
name: 'list_vue_components',
description: 'Lista todos los componentes guardados',
description: 'Lista todos los componentes guardados (activos)',
category: 'component',
schema: {
type: 'object',
properties: {}
properties: {
include_archived: { type: 'boolean', description: 'Incluir componentes archivados' }
}
},
handler: async () => {
handler: async (args: { include_archived?: boolean }) => {
try {
const components = await componentsApi.getAll()
const components = await componentsApi.getAll({ includeArchived: args.include_archived })
if (components.length === 0) {
return 'No hay componentes guardados'
}
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
return `Componentes guardados:\n${list}`
const list = components.map(c => {
const tags = c.tags?.length ? ` [${c.tags.join(', ')}]` : ''
const status = c.status === 'archived' ? ' (archivado)' : ''
return `- ${c.id}: ${c.name}${tags}${status}`
}).join('\n')
return `Componentes guardados (${components.length}):\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
@@ -126,7 +134,7 @@ export function createComponentHandlers(): ToolConfig[] {
},
{
name: 'delete_vue_component',
description: 'Elimina un componente de la base de datos',
description: 'Archiva un componente (soft delete). El componente no se elimina, solo se oculta.',
category: 'component',
schema: {
type: 'object',
@@ -137,8 +145,9 @@ export function createComponentHandlers(): ToolConfig[] {
},
handler: async (args: { id: string }) => {
try {
await componentsApi.delete(args.id)
return `Componente "${args.id}" eliminado`
const result = await componentsApi.delete(args.id)
const warning = result.warning ? ` (Nota: ${result.warning})` : ''
return `Componente "${args.id}" archivado${warning}`
} catch (e: any) {
return `Error: ${e.message}`
}

View File

@@ -34,9 +34,10 @@ export default defineConfig({
name: 'Agent UI',
short_name: 'AgentUI',
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
theme_color: '#16161d',
theme_color: '#0f0f14',
background_color: '#0f0f14',
display: 'standalone',
display_override: ['window-controls-overlay'],
orientation: 'any',
start_url: '/',
scope: '/',

View File

@@ -120,7 +120,9 @@ function runColumnMigrations(db: Database) {
'ALTER TABLE project_canvas ADD COLUMN show_in_toolbar INTEGER DEFAULT 0',
'ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT',
'ALTER TABLE project_canvas ADD COLUMN toolbar_order INTEGER DEFAULT 99',
'ALTER TABLE project_canvas ADD COLUMN status TEXT DEFAULT \'active\''
'ALTER TABLE project_canvas ADD COLUMN status TEXT DEFAULT \'active\'',
'ALTER TABLE vue_components ADD COLUMN tags TEXT',
'ALTER TABLE vue_components ADD COLUMN status TEXT DEFAULT \'active\''
]
for (const sql of alterStatements) {

View File

@@ -2,18 +2,47 @@ import { db } from '../db'
import { jsonResponse, errorResponse } from '../utils/cors'
export async function handleComponents(req: Request) {
const url = new URL(req.url)
if (req.method === 'GET') {
const rows = db.query('SELECT * FROM vue_components ORDER BY updated_at DESC').all()
const includeArchived = url.searchParams.get('include_archived') === 'true'
const limit = parseInt(url.searchParams.get('limit') || '0') || 0
let sql = 'SELECT * FROM vue_components'
const params: any[] = []
if (!includeArchived) {
sql += " WHERE (status = 'active' OR status IS NULL)"
}
sql += ' ORDER BY updated_at DESC'
if (limit > 0) {
sql += ' LIMIT ?'
params.push(limit)
}
const rows = db.query(sql).all(...params)
return jsonResponse(rows)
}
if (req.method === 'POST') {
const body = await req.json()
const id = body.id || `comp-${Date.now()}`
const tags = body.tags ? JSON.stringify(body.tags) : null
const stmt = db.prepare(`
INSERT OR REPLACE INTO vue_components
(id, name, template, setup, style, props, imports, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO vue_components (id, name, template, setup, style, props, imports, tags, status, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
template = excluded.template,
setup = excluded.setup,
style = excluded.style,
props = excluded.props,
imports = excluded.imports,
tags = COALESCE(excluded.tags, tags),
updated_at = CURRENT_TIMESTAMP
`)
stmt.run(
id,
@@ -22,13 +51,14 @@ export async function handleComponents(req: Request) {
body.setup || '',
body.style || '',
JSON.stringify(body.props || []),
JSON.stringify(body.imports || [])
JSON.stringify(body.imports || []),
tags
)
return jsonResponse({ success: true, id })
}
if (req.method === 'DELETE') {
db.run('DELETE FROM vue_components')
db.run("UPDATE vue_components SET status = 'archived', updated_at = CURRENT_TIMESTAMP")
return jsonResponse({ success: true })
}
@@ -44,8 +74,37 @@ export async function handleComponentById(req: Request, id: string) {
return jsonResponse(row)
}
if (req.method === 'PUT') {
const body = await req.json()
const fields: string[] = []
const params: any[] = []
const allowedFields = ['name', 'template', 'setup', 'style', 'props', 'imports', 'tags', 'status']
for (const field of allowedFields) {
if (body[field] !== undefined) {
if (field === 'props' || field === 'imports' || field === 'tags') {
fields.push(`${field} = ?`)
params.push(JSON.stringify(body[field]))
} else {
fields.push(`${field} = ?`)
params.push(body[field])
}
}
}
if (fields.length === 0) {
return errorResponse('No fields to update', 400)
}
fields.push('updated_at = CURRENT_TIMESTAMP')
params.push(id)
db.run(`UPDATE vue_components SET ${fields.join(', ')} WHERE id = ?`, params)
return jsonResponse({ success: true })
}
if (req.method === 'DELETE') {
// Check if component is in use by any canvas
// Check if component is in use by any canvas (warn only)
const usage = db.query(`
SELECT pc.id, pc.name
FROM canvas_components cc
@@ -53,16 +112,13 @@ export async function handleComponentById(req: Request, id: string) {
WHERE cc.component_id = ?
`).all(id) as { id: string; name: string }[]
if (usage.length > 0) {
return jsonResponse({
error: 'Component in use',
message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`,
usedBy: usage
}, 409)
}
db.run('DELETE FROM vue_components WHERE id = ?', [id])
return jsonResponse({ success: true })
db.run("UPDATE vue_components SET status = 'archived', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [id])
return jsonResponse({
success: true,
warning: usage.length > 0
? `Component is used by: ${usage.map(u => u.name).join(', ')}`
: undefined
})
}
return null