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,
|
||||
createTorchHandlers,
|
||||
createSnapshotHandlers,
|
||||
createFsComponentHandlers,
|
||||
type ToolConfig
|
||||
} from './tools/handlers'
|
||||
import { setRouter } from './tools/handlers/globalHandlers'
|
||||
@@ -134,7 +135,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
||||
...createResponseHandlers(),
|
||||
...createGitHandlers(),
|
||||
...createTorchHandlers(),
|
||||
...createSnapshotHandlers()
|
||||
...createSnapshotHandlers(),
|
||||
...createFsComponentHandlers()
|
||||
]
|
||||
|
||||
for (const config of allHandlers) {
|
||||
@@ -148,7 +150,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
||||
const categoryTools: Record<ToolCategory, string[]> = {
|
||||
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'],
|
||||
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'],
|
||||
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'],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCanvasStore } from '../../../stores/canvas'
|
||||
import { useWindowsStore } from '../../../stores/windows'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
componentsApi,
|
||||
type VueComponentDefinition
|
||||
} from '../../dynamicComponents'
|
||||
|
||||
@@ -24,7 +23,7 @@ export function clearScriptLog(): void {
|
||||
// WINDOW DEFINITIONS — tracks component definition per rendered window
|
||||
// ============================================
|
||||
export interface WindowDefinitionEntry {
|
||||
source: 'inline' | 'db'
|
||||
source: 'inline' | 'db' | 'fs'
|
||||
componentId?: string
|
||||
definition: VueComponentDefinition
|
||||
componentProps: Record<string, any>
|
||||
@@ -196,13 +195,6 @@ 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`
|
||||
|
||||
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 { createTorchHandlers } from './torchHandlers'
|
||||
export { createSnapshotHandlers } from './snapshotHandlers'
|
||||
export { createFsComponentHandlers } from './fsComponentHandlers'
|
||||
|
||||
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: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' },
|
||||
|
||||
// Component tools
|
||||
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
|
||||
{ name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', 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' },
|
||||
// Component tools (filesystem-based)
|
||||
{ name: 'list_fs_components', description: 'Lista componentes Vue del filesystem', category: 'component' },
|
||||
{ name: 'load_fs_component', description: 'Carga y renderiza un componente desde user-components/', category: 'component' },
|
||||
|
||||
// Theme tools
|
||||
{ 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
|
||||
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 { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp'
|
||||
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
|
||||
import { handleFsComponents, handleFsComponentByName } from './fs-components'
|
||||
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
|
||||
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
|
||||
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
|
||||
@@ -107,6 +108,18 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
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
|
||||
if (path === '/api/themes') {
|
||||
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 { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
|
||||
import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler'
|
||||
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
|
||||
|
||||
// Connected clients
|
||||
@@ -96,14 +97,16 @@ export function startSyncServer() {
|
||||
|
||||
console.log(`[Sync] WebSocket server on port ${PORT_GIT}`)
|
||||
|
||||
// Start git file watcher
|
||||
// Start file watchers
|
||||
setupGitWatcher(WORKING_DIR, broadcast)
|
||||
setupComponentsWatcher(WORKING_DIR, broadcast)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export function stopSyncServer() {
|
||||
cleanupGitWatcher()
|
||||
cleanupComponentsWatcher()
|
||||
cleanupTorchHandler()
|
||||
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