feat: Replace DB component tools with filesystem-based user-components/
Components are now .vue files in user-components/<folder>/ parsed at runtime. Replaces 6 DB MCP tools with 2 (list_fs_components, load_fs_component). Adds vue-parser, fs-components API, and file watcher for live reload.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
createGitHandlers,
|
createGitHandlers,
|
||||||
createTorchHandlers,
|
createTorchHandlers,
|
||||||
createSnapshotHandlers,
|
createSnapshotHandlers,
|
||||||
|
createFsComponentHandlers,
|
||||||
type ToolConfig
|
type ToolConfig
|
||||||
} from './tools/handlers'
|
} from './tools/handlers'
|
||||||
import { setRouter } from './tools/handlers/globalHandlers'
|
import { setRouter } from './tools/handlers/globalHandlers'
|
||||||
@@ -134,7 +135,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
|||||||
...createResponseHandlers(),
|
...createResponseHandlers(),
|
||||||
...createGitHandlers(),
|
...createGitHandlers(),
|
||||||
...createTorchHandlers(),
|
...createTorchHandlers(),
|
||||||
...createSnapshotHandlers()
|
...createSnapshotHandlers(),
|
||||||
|
...createFsComponentHandlers()
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const config of allHandlers) {
|
for (const config of allHandlers) {
|
||||||
@@ -148,7 +150,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
|||||||
const categoryTools: Record<ToolCategory, string[]> = {
|
const categoryTools: Record<ToolCategory, string[]> = {
|
||||||
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
|
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
|
||||||
canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css', 'save_canvas_snapshot', 'load_canvas_snapshot', 'list_canvas_snapshots', 'delete_canvas_snapshot'],
|
canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css', 'save_canvas_snapshot', 'load_canvas_snapshot', 'list_canvas_snapshots', 'delete_canvas_snapshot'],
|
||||||
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component', 'read_component', 'edit_component'],
|
component: ['list_fs_components', 'load_fs_component'],
|
||||||
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
|
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
|
||||||
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
|
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
|
||||||
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
|
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useCanvasStore } from '../../../stores/canvas'
|
|||||||
import { useWindowsStore } from '../../../stores/windows'
|
import { useWindowsStore } from '../../../stores/windows'
|
||||||
import {
|
import {
|
||||||
renderInlineComponent,
|
renderInlineComponent,
|
||||||
componentsApi,
|
|
||||||
type VueComponentDefinition
|
type VueComponentDefinition
|
||||||
} from '../../dynamicComponents'
|
} from '../../dynamicComponents'
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ export function clearScriptLog(): void {
|
|||||||
// WINDOW DEFINITIONS — tracks component definition per rendered window
|
// WINDOW DEFINITIONS — tracks component definition per rendered window
|
||||||
// ============================================
|
// ============================================
|
||||||
export interface WindowDefinitionEntry {
|
export interface WindowDefinitionEntry {
|
||||||
source: 'inline' | 'db'
|
source: 'inline' | 'db' | 'fs'
|
||||||
componentId?: string
|
componentId?: string
|
||||||
definition: VueComponentDefinition
|
definition: VueComponentDefinition
|
||||||
componentProps: Record<string, any>
|
componentProps: Record<string, any>
|
||||||
@@ -196,13 +195,6 @@ export function createCanvasHandlers(): ToolConfig[] {
|
|||||||
|
|
||||||
emitComponentRendered(args)
|
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()
|
const canvasStore = useCanvasStore()
|
||||||
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
|
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
|
||||||
return `Componente Vue "${args.name}" renderizado en ventana flotante`
|
return `Componente Vue "${args.name}" renderizado en ventana flotante`
|
||||||
|
|||||||
113
frontend/src/services/tools/handlers/fsComponentHandlers.ts
Normal file
113
frontend/src/services/tools/handlers/fsComponentHandlers.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ToolConfig } from './index'
|
||||||
|
import { useCanvasStore } from '../../../stores/canvas'
|
||||||
|
import {
|
||||||
|
renderInlineComponent,
|
||||||
|
type VueComponentDefinition
|
||||||
|
} from '../../dynamicComponents'
|
||||||
|
import { getWindowDefinitions } from './canvasHandlers'
|
||||||
|
|
||||||
|
const API_URL = ''
|
||||||
|
|
||||||
|
export const fsComponentsApi = {
|
||||||
|
async list(): Promise<VueComponentDefinition[]> {
|
||||||
|
const res = await fetch(`${API_URL}/api/fs-components`)
|
||||||
|
if (!res.ok) throw new Error(`Failed to list fs-components: ${res.statusText}`)
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByFolder(folder: string): Promise<VueComponentDefinition> {
|
||||||
|
const res = await fetch(`${API_URL}/api/fs-components/${encodeURIComponent(folder)}`)
|
||||||
|
if (!res.ok) throw new Error(`Component "${folder}" not found`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasContainer() {
|
||||||
|
return document.getElementById('canvas-content')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePlaceholder(container: HTMLElement) {
|
||||||
|
const placeholder = container.querySelector('.canvas-placeholder')
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.remove()
|
||||||
|
window.dispatchEvent(new CustomEvent('canvas-content-rendered'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFsComponentHandlers(): ToolConfig[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'list_fs_components',
|
||||||
|
description: 'Lista componentes Vue del filesystem (user-components/)',
|
||||||
|
category: 'component',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
try {
|
||||||
|
const components = await fsComponentsApi.list()
|
||||||
|
if (components.length === 0) {
|
||||||
|
return 'No hay componentes en user-components/. Crea una carpeta con un archivo .vue para empezar.'
|
||||||
|
}
|
||||||
|
const list = components.map(c => {
|
||||||
|
const tags = c.tags?.length ? ` [${c.tags.join(', ')}]` : ''
|
||||||
|
return `- ${c.folder}: ${c.name}${tags}`
|
||||||
|
}).join('\n')
|
||||||
|
return `Componentes en filesystem (${components.length}):\n${list}`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'load_fs_component',
|
||||||
|
description: 'Carga y renderiza un componente desde user-components/',
|
||||||
|
category: 'component',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
folder: { type: 'string', description: 'Nombre de la carpeta del componente' },
|
||||||
|
componentProps: { type: 'object', description: 'Props para el componente' },
|
||||||
|
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' },
|
||||||
|
x: { type: 'number', description: 'Posicion X inicial' },
|
||||||
|
y: { type: 'number', description: 'Posicion Y inicial' },
|
||||||
|
width: { type: 'number', description: 'Ancho inicial' },
|
||||||
|
height: { type: 'number', description: 'Alto inicial' }
|
||||||
|
},
|
||||||
|
required: ['folder']
|
||||||
|
},
|
||||||
|
handler: async (args: { folder: string; componentProps?: Record<string, any>; mode?: string; x?: number; y?: number; width?: number; height?: number }) => {
|
||||||
|
try {
|
||||||
|
const definition = await fsComponentsApi.getByFolder(args.folder)
|
||||||
|
|
||||||
|
const container = getCanvasContainer()
|
||||||
|
if (!container) return 'Error: canvas no encontrado'
|
||||||
|
|
||||||
|
removePlaceholder(container)
|
||||||
|
|
||||||
|
const isAppend = args.mode === 'append'
|
||||||
|
const layout = { x: args.x, y: args.y, width: args.width, height: args.height }
|
||||||
|
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
|
||||||
|
|
||||||
|
// Track definition for snapshot capture
|
||||||
|
getWindowDefinitions().set(definition.id, {
|
||||||
|
source: 'fs',
|
||||||
|
componentId: args.folder,
|
||||||
|
definition,
|
||||||
|
componentProps: args.componentProps || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
;(window as any).__vueComponentUnmount = result.unmount
|
||||||
|
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
canvasStore.addToHistory({ tool: 'load_fs_component', args, timestamp: Date.now() })
|
||||||
|
return `Componente "${definition.name}" cargado desde user-components/${args.folder}/`
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type { ResponseControls } from './responseHandlers'
|
|||||||
export { createGitHandlers } from './gitHandlers'
|
export { createGitHandlers } from './gitHandlers'
|
||||||
export { createTorchHandlers } from './torchHandlers'
|
export { createTorchHandlers } from './torchHandlers'
|
||||||
export { createSnapshotHandlers } from './snapshotHandlers'
|
export { createSnapshotHandlers } from './snapshotHandlers'
|
||||||
|
export { createFsComponentHandlers } from './fsComponentHandlers'
|
||||||
|
|
||||||
export type ToolHandler = (args: any) => string | Promise<string>
|
export type ToolHandler = (args: any) => string | Promise<string>
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,9 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
|
|||||||
{ name: 'list_canvas_snapshots', description: 'Lista snapshots del canvas guardados', category: 'canvas' },
|
{ name: 'list_canvas_snapshots', description: 'Lista snapshots del canvas guardados', category: 'canvas' },
|
||||||
{ name: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' },
|
{ name: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' },
|
||||||
|
|
||||||
// Component tools
|
// Component tools (filesystem-based)
|
||||||
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
|
{ name: 'list_fs_components', description: 'Lista componentes Vue del filesystem', category: 'component' },
|
||||||
{ name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', category: 'component' },
|
{ name: 'load_fs_component', description: 'Carga y renderiza un componente desde user-components/', category: 'component' },
|
||||||
{ name: 'list_vue_components', description: 'Lista componentes guardados', category: 'component' },
|
|
||||||
{ name: 'delete_vue_component', description: 'Elimina un componente', category: 'component' },
|
|
||||||
{ name: 'read_component', description: 'Lee campos especificos de un componente guardado', category: 'component' },
|
|
||||||
{ name: 'edit_component', description: 'Edita un campo de componente con reemplazo de strings (requiere lectura previa)', category: 'component' },
|
|
||||||
|
|
||||||
// Theme tools
|
// Theme tools
|
||||||
{ name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' },
|
{ name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' },
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ export const DB_PATH = 'agent-ui.db'
|
|||||||
|
|
||||||
// Recordings
|
// Recordings
|
||||||
export const RECORDINGS_DIR = 'recordings'
|
export const RECORDINGS_DIR = 'recordings'
|
||||||
|
|
||||||
|
// User components
|
||||||
|
export const USER_COMPONENTS_DIR = 'user-components'
|
||||||
|
|||||||
24
server/routes/fs-components.ts
Normal file
24
server/routes/fs-components.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
|
import { USER_COMPONENTS_DIR, WORKING_DIR } from '../config'
|
||||||
|
import { listAllComponents, parseComponentFolder } from '../services/vue-parser'
|
||||||
|
|
||||||
|
const baseDir = join(WORKING_DIR, USER_COMPONENTS_DIR)
|
||||||
|
|
||||||
|
export function handleFsComponents(req: Request) {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
|
||||||
|
const components = listAllComponents(baseDir)
|
||||||
|
return jsonResponse(components)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFsComponentByName(req: Request, folderName: string) {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
|
||||||
|
const component = parseComponentFolder(baseDir, folderName)
|
||||||
|
if (!component) {
|
||||||
|
return errorResponse(`Component folder "${folderName}" not found or has no .vue file`, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(component)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { handleHistory } from './history'
|
|||||||
import { handleConfig, handleHealth } from './config'
|
import { handleConfig, handleHealth } from './config'
|
||||||
import { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp'
|
import { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp'
|
||||||
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
|
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
|
||||||
|
import { handleFsComponents, handleFsComponentByName } from './fs-components'
|
||||||
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
|
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
|
||||||
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
|
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
|
||||||
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
|
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
|
||||||
@@ -107,6 +108,18 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
if (res) return res
|
if (res) return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filesystem components
|
||||||
|
if (path === '/api/fs-components') {
|
||||||
|
const res = handleFsComponents(req)
|
||||||
|
if (res) return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/api/fs-components/')) {
|
||||||
|
const folderName = path.split('/').pop()!
|
||||||
|
const res = handleFsComponentByName(req, folderName)
|
||||||
|
if (res) return res
|
||||||
|
}
|
||||||
|
|
||||||
// Themes
|
// Themes
|
||||||
if (path === '/api/themes') {
|
if (path === '/api/themes') {
|
||||||
const res = await handleThemes(req)
|
const res = await handleThemes(req)
|
||||||
|
|||||||
67
server/services/handlers/components-handler.ts
Normal file
67
server/services/handlers/components-handler.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Components Handler
|
||||||
|
* Watches user-components/ for .vue and .json file changes
|
||||||
|
* and broadcasts notifications via the sync server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { watch, mkdirSync, existsSync, type FSWatcher } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { USER_COMPONENTS_DIR } from '../../config'
|
||||||
|
|
||||||
|
let componentsWatcher: FSWatcher | null = null
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const DEBOUNCE_MS = 400
|
||||||
|
|
||||||
|
export function setupComponentsWatcher(workingDir: string, broadcast: (message: string, filter?: (ws: any) => boolean) => void) {
|
||||||
|
const componentsDir = join(workingDir, USER_COMPONENTS_DIR)
|
||||||
|
|
||||||
|
// Auto-create directory if it doesn't exist
|
||||||
|
if (!existsSync(componentsDir)) {
|
||||||
|
try {
|
||||||
|
mkdirSync(componentsDir, { recursive: true })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[Components] Failed to create ${componentsDir}: ${e.message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
componentsWatcher = watch(componentsDir, { recursive: true }, (_, filename) => {
|
||||||
|
if (!filename) return
|
||||||
|
|
||||||
|
// Only watch .vue and .json files
|
||||||
|
if (!filename.endsWith('.vue') && !filename.endsWith('.json')) return
|
||||||
|
|
||||||
|
// Debounce
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
// Extract folder name from path (first segment)
|
||||||
|
const folder = filename.split(/[/\\]/)[0] || ''
|
||||||
|
const file = filename.split(/[/\\]/).pop() || filename
|
||||||
|
|
||||||
|
console.log(`[Components] Change: ${filename}`)
|
||||||
|
broadcast(JSON.stringify({
|
||||||
|
type: 'component-change',
|
||||||
|
folder,
|
||||||
|
file,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Components] Watching ${componentsDir}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[Components] Watch failed: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupComponentsWatcher() {
|
||||||
|
if (componentsWatcher) {
|
||||||
|
componentsWatcher.close()
|
||||||
|
componentsWatcher = null
|
||||||
|
}
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { PORT_GIT, WORKING_DIR } from '../config'
|
import { PORT_GIT, WORKING_DIR } from '../config'
|
||||||
import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
|
import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
|
||||||
|
import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler'
|
||||||
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
|
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
|
||||||
|
|
||||||
// Connected clients
|
// Connected clients
|
||||||
@@ -96,14 +97,16 @@ export function startSyncServer() {
|
|||||||
|
|
||||||
console.log(`[Sync] WebSocket server on port ${PORT_GIT}`)
|
console.log(`[Sync] WebSocket server on port ${PORT_GIT}`)
|
||||||
|
|
||||||
// Start git file watcher
|
// Start file watchers
|
||||||
setupGitWatcher(WORKING_DIR, broadcast)
|
setupGitWatcher(WORKING_DIR, broadcast)
|
||||||
|
setupComponentsWatcher(WORKING_DIR, broadcast)
|
||||||
|
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSyncServer() {
|
export function stopSyncServer() {
|
||||||
cleanupGitWatcher()
|
cleanupGitWatcher()
|
||||||
|
cleanupComponentsWatcher()
|
||||||
cleanupTorchHandler()
|
cleanupTorchHandler()
|
||||||
clients.clear()
|
clients.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
117
server/services/vue-parser.ts
Normal file
117
server/services/vue-parser.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Vue File Parser
|
||||||
|
* Extracts <template>, <script setup>, <style> from .vue files
|
||||||
|
* and converts them into VueComponentDefinition objects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readdirSync, readFileSync, existsSync, statSync } from 'fs'
|
||||||
|
|
||||||
|
export interface VueComponentDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
template: string
|
||||||
|
setup?: string
|
||||||
|
style?: string
|
||||||
|
props?: string[]
|
||||||
|
imports?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
folder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentMeta {
|
||||||
|
name?: string
|
||||||
|
tags?: string[]
|
||||||
|
props?: string[]
|
||||||
|
imports?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a .vue file content into template, setup, style sections
|
||||||
|
*/
|
||||||
|
export function parseVueFile(content: string): { template: string; setup: string; style: string } {
|
||||||
|
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/)
|
||||||
|
const scriptMatch = content.match(/<script\s+setup[^>]*>([\s\S]*?)<\/script>/)
|
||||||
|
const styleMatch = content.match(/<style[^>]*>([\s\S]*?)<\/style>/)
|
||||||
|
|
||||||
|
return {
|
||||||
|
template: templateMatch?.[1]?.trim() || '',
|
||||||
|
setup: scriptMatch?.[1]?.trim() || '',
|
||||||
|
style: styleMatch?.[1]?.trim() || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read optional meta.json from a component folder
|
||||||
|
*/
|
||||||
|
export function readMetaJson(folderPath: string): ComponentMeta | null {
|
||||||
|
const metaPath = join(folderPath, 'meta.json')
|
||||||
|
if (!existsSync(metaPath)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(metaPath, 'utf-8')
|
||||||
|
return JSON.parse(raw) as ComponentMeta
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a component folder into a VueComponentDefinition
|
||||||
|
* Convention: user-components/counter-widget/CounterWidget.vue + optional meta.json
|
||||||
|
*/
|
||||||
|
export function parseComponentFolder(baseDir: string, folderName: string): VueComponentDefinition | null {
|
||||||
|
const folderPath = join(baseDir, folderName)
|
||||||
|
|
||||||
|
if (!existsSync(folderPath) || !statSync(folderPath).isDirectory()) return null
|
||||||
|
|
||||||
|
// Find the .vue file in the folder
|
||||||
|
const files = readdirSync(folderPath)
|
||||||
|
const vueFile = files.find(f => f.endsWith('.vue'))
|
||||||
|
if (!vueFile) return null
|
||||||
|
|
||||||
|
const vueContent = readFileSync(join(folderPath, vueFile), 'utf-8')
|
||||||
|
const { template, setup, style } = parseVueFile(vueContent)
|
||||||
|
|
||||||
|
if (!template) return null
|
||||||
|
|
||||||
|
const meta = readMetaJson(folderPath)
|
||||||
|
|
||||||
|
// Derive name from meta or filename (CounterWidget.vue -> CounterWidget)
|
||||||
|
const nameFromFile = vueFile.replace('.vue', '')
|
||||||
|
const name = meta?.name || nameFromFile
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: folderName,
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
setup: setup || undefined,
|
||||||
|
style: style || undefined,
|
||||||
|
props: meta?.props,
|
||||||
|
imports: meta?.imports,
|
||||||
|
tags: meta?.tags,
|
||||||
|
folder: folderName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all component subdirectories and parse them
|
||||||
|
*/
|
||||||
|
export function listAllComponents(baseDir: string): VueComponentDefinition[] {
|
||||||
|
if (!existsSync(baseDir)) return []
|
||||||
|
|
||||||
|
const entries = readdirSync(baseDir)
|
||||||
|
const components: VueComponentDefinition[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(baseDir, entry)
|
||||||
|
if (!statSync(fullPath).isDirectory()) continue
|
||||||
|
|
||||||
|
const component = parseComponentFolder(baseDir, entry)
|
||||||
|
if (component) {
|
||||||
|
components.push(component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return components
|
||||||
|
}
|
||||||
0
user-components/.gitkeep
Normal file
0
user-components/.gitkeep
Normal file
247
user-components/test-counter/TestCounter.vue
Normal file
247
user-components/test-counter/TestCounter.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<div class="counter" ref="counterEl">
|
||||||
|
<div class="fx-layer" ref="fxLayer"></div>
|
||||||
|
<h2 class="title">{{ title }}</h2>
|
||||||
|
<p class="count-display" :class="pulseClass" ref="countEl">{{ count }}</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn btn-minus" @click="decrement">-1</button>
|
||||||
|
<button class="btn btn-plus" @click="increment">+1</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const count = ref(0)
|
||||||
|
const title = 'Test Counter'
|
||||||
|
const pulseClass = ref('')
|
||||||
|
const countEl = ref(null)
|
||||||
|
const counterEl = ref(null)
|
||||||
|
const fxLayer = ref(null)
|
||||||
|
|
||||||
|
const fxStyles = [
|
||||||
|
{ pos: 'sparkle', neg: 'ember' },
|
||||||
|
{ pos: 'bubble', neg: 'drip' },
|
||||||
|
{ pos: 'star', neg: 'crack' },
|
||||||
|
{ pos: 'ring', neg: 'smoke' }
|
||||||
|
]
|
||||||
|
let styleIdx = 0
|
||||||
|
|
||||||
|
// Inject stylesheet once
|
||||||
|
if (!document.getElementById('counter-fx-css')) {
|
||||||
|
const s = document.createElement('style')
|
||||||
|
s.id = 'counter-fx-css'
|
||||||
|
s.textContent = `
|
||||||
|
.ctr-fx {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--sz);
|
||||||
|
height: var(--sz);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
animation-delay: var(--del);
|
||||||
|
animation-duration: var(--dur);
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
animation-timing-function: cubic-bezier(.25,.46,.45,.94);
|
||||||
|
}
|
||||||
|
.ctr-fx-sparkle {
|
||||||
|
background: radial-gradient(circle, #fde047, #f59e0b);
|
||||||
|
box-shadow: 0 0 8px #fbbf24;
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation-name: ctr-sparkle;
|
||||||
|
}
|
||||||
|
@keyframes ctr-sparkle {
|
||||||
|
0% { opacity:1; transform: translate(0,0) rotate(45deg) scale(1); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx),var(--dy)) rotate(200deg) scale(0); }
|
||||||
|
}
|
||||||
|
.ctr-fx-ember {
|
||||||
|
background: radial-gradient(circle, #fca5a5, #dc2626);
|
||||||
|
box-shadow: 0 0 10px #ef4444;
|
||||||
|
animation-name: ctr-ember;
|
||||||
|
}
|
||||||
|
@keyframes ctr-ember {
|
||||||
|
0% { opacity:1; transform: translate(0,0) scale(1); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) + 80px)) scale(0.2); }
|
||||||
|
}
|
||||||
|
.ctr-fx-bubble {
|
||||||
|
background: radial-gradient(circle at 30% 30%, rgba(16,185,129,0.7), rgba(5,150,105,0.2));
|
||||||
|
border: 1.5px solid rgba(16,185,129,0.5);
|
||||||
|
animation-name: ctr-bubble;
|
||||||
|
}
|
||||||
|
@keyframes ctr-bubble {
|
||||||
|
0% { opacity:0.9; transform: translate(0,0) scale(0.5); }
|
||||||
|
50% { opacity:0.7; transform: translate(calc(var(--dx)*0.5), calc(var(--dy)*0.5)) scale(1.3); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) - 40px)) scale(1.6); }
|
||||||
|
}
|
||||||
|
.ctr-fx-drip {
|
||||||
|
background: radial-gradient(circle, #c084fc, #7c3aed);
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
animation-name: ctr-drip;
|
||||||
|
}
|
||||||
|
@keyframes ctr-drip {
|
||||||
|
0% { opacity:1; transform: translate(0,0) rotate(45deg) scale(1); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx), var(--dy)) rotate(45deg) scaleY(1.8) scale(0.3); }
|
||||||
|
}
|
||||||
|
.ctr-fx-star {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0 10px #22d3ee, 0 0 20px rgba(34,211,238,0.4);
|
||||||
|
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
|
||||||
|
border-radius: 0;
|
||||||
|
animation-name: ctr-star;
|
||||||
|
}
|
||||||
|
@keyframes ctr-star {
|
||||||
|
0% { opacity:1; transform: translate(0,0) scale(0.3) rotate(0); }
|
||||||
|
50% { opacity:1; transform: translate(calc(var(--dx)*0.6),calc(var(--dy)*0.6)) scale(1.3) rotate(90deg); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(0) rotate(180deg); }
|
||||||
|
}
|
||||||
|
.ctr-fx-crack {
|
||||||
|
background: linear-gradient(135deg, #f97316, #dc2626);
|
||||||
|
clip-path: polygon(20% 0%,80% 0%,100% 60%,60% 100%,0% 80%);
|
||||||
|
border-radius: 0;
|
||||||
|
animation-name: ctr-crack;
|
||||||
|
}
|
||||||
|
@keyframes ctr-crack {
|
||||||
|
0% { opacity:1; transform: translate(0,0) scale(1) rotate(0); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(0.2) rotate(270deg); }
|
||||||
|
}
|
||||||
|
.ctr-fx-ring {
|
||||||
|
background: transparent;
|
||||||
|
border: 2.5px solid #34d399;
|
||||||
|
box-shadow: 0 0 8px rgba(52,211,153,0.6);
|
||||||
|
animation-name: ctr-ring;
|
||||||
|
}
|
||||||
|
@keyframes ctr-ring {
|
||||||
|
0% { opacity:1; transform: translate(0,0) scale(0.3); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(2.5); }
|
||||||
|
}
|
||||||
|
.ctr-fx-smoke {
|
||||||
|
background: radial-gradient(circle, rgba(120,113,108,0.6), rgba(68,64,60,0.1));
|
||||||
|
filter: blur(3px);
|
||||||
|
animation-name: ctr-smoke;
|
||||||
|
}
|
||||||
|
@keyframes ctr-smoke {
|
||||||
|
0% { opacity:0.8; transform: translate(0,0) scale(0.8); }
|
||||||
|
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) - 50px)) scale(3); }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnParticles(type) {
|
||||||
|
const layer = fxLayer.value
|
||||||
|
const numEl = countEl.value
|
||||||
|
if (!layer || !numEl) return
|
||||||
|
|
||||||
|
const layerRect = layer.getBoundingClientRect()
|
||||||
|
const numRect = numEl.getBoundingClientRect()
|
||||||
|
const cx = numRect.left + numRect.width / 2 - layerRect.left
|
||||||
|
const cy = numRect.top + numRect.height / 2 - layerRect.top
|
||||||
|
|
||||||
|
const n = 14 + Math.floor(Math.random() * 8)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'ctr-fx ctr-fx-' + type
|
||||||
|
const angle = (Math.PI * 2 * i) / n + (Math.random() - 0.5) * 0.7
|
||||||
|
const dist = 60 + Math.random() * 120
|
||||||
|
const dx = Math.cos(angle) * dist
|
||||||
|
const rawDy = Math.sin(angle) * dist
|
||||||
|
const dy = type === 'drip' ? Math.abs(rawDy) + 50 : rawDy
|
||||||
|
const size = 6 + Math.random() * 16
|
||||||
|
el.style.cssText = [
|
||||||
|
'left:' + cx + 'px',
|
||||||
|
'top:' + cy + 'px',
|
||||||
|
'--dx:' + dx + 'px',
|
||||||
|
'--dy:' + dy + 'px',
|
||||||
|
'--sz:' + size + 'px',
|
||||||
|
'--del:' + (i * 20) + 'ms',
|
||||||
|
'--dur:' + (500 + Math.random() * 500) + 'ms'
|
||||||
|
].join(';')
|
||||||
|
layer.appendChild(el)
|
||||||
|
setTimeout(() => el.remove(), 1300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFx(isPositive) {
|
||||||
|
const pair = fxStyles[styleIdx % fxStyles.length]
|
||||||
|
spawnParticles(isPositive ? pair.pos : pair.neg)
|
||||||
|
styleIdx++
|
||||||
|
pulseClass.value = isPositive ? 'pulse-up' : 'pulse-down'
|
||||||
|
setTimeout(() => { pulseClass.value = '' }, 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
triggerFx(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
count.value--
|
||||||
|
triggerFx(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, title, pulseClass, countEl, counterEl, fxLayer, increment, decrement }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.counter {
|
||||||
|
position: relative;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.fx-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.count-display {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
transition: transform 0.2s cubic-bezier(.34,1.56,.64,1), color 0.3s;
|
||||||
|
}
|
||||||
|
.pulse-up { transform: scale(1.15); color: #10b981; }
|
||||||
|
.pulse-down { transform: scale(0.88); color: #ef4444; }
|
||||||
|
.buttons {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.9); }
|
||||||
|
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 14px rgba(0,0,0,0.25); }
|
||||||
|
.btn-plus { background: #10b981; color: white; }
|
||||||
|
.btn-plus:hover { background: #059669; }
|
||||||
|
.btn-minus { background: #ef4444; color: white; }
|
||||||
|
.btn-minus:hover { background: #dc2626; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user