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:
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- 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="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TorchButton from './components/TorchButton.vue'
|
|||||||
import FloatingTerminal from './components/FloatingTerminal.vue'
|
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||||
import FloatingResponse from './components/FloatingResponse.vue'
|
import FloatingResponse from './components/FloatingResponse.vue'
|
||||||
import FloatingVoice from './components/FloatingVoice.vue'
|
import FloatingVoice from './components/FloatingVoice.vue'
|
||||||
|
import AgentBar from './components/AgentBar.vue'
|
||||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||||
import { initTorch, destroyTorch } from './services/torch'
|
import { initTorch, destroyTorch } from './services/torch'
|
||||||
@@ -21,6 +22,8 @@ const router = useRouter()
|
|||||||
const showTerminal = ref(false)
|
const showTerminal = ref(false)
|
||||||
const showVoice = ref(false)
|
const showVoice = ref(false)
|
||||||
const showDebugConsole = ref(false)
|
const showDebugConsole = ref(false)
|
||||||
|
const toolbarVisible = ref(true)
|
||||||
|
const forceWco = ref(false)
|
||||||
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
||||||
|
|
||||||
// Intercept console.log for debug panel
|
// 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'
|
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 () => {
|
onMounted(async () => {
|
||||||
|
// Sync Windows titlebar color with CSS variable
|
||||||
|
syncThemeColor()
|
||||||
|
|
||||||
// Connect to WebSocket for Claude status updates
|
// Connect to WebSocket for Claude status updates
|
||||||
connectStatusWs()
|
connectStatusWs()
|
||||||
|
|
||||||
@@ -348,10 +361,20 @@ watch(() => route.name, (newPage) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container" :class="{ wco: forceWco }">
|
||||||
<header class="app-header">
|
<header class="app-header" :class="{ 'wco-header': forceWco }">
|
||||||
<div class="header-left">
|
<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'">
|
<template v-if="projectCanvasStore.activeCanvas && route.name === 'project-canvas'">
|
||||||
<span class="header-sep">/</span>
|
<span class="header-sep">/</span>
|
||||||
<span class="header-canvas-name">{{ projectCanvasStore.activeCanvas.name }}</span>
|
<span class="header-canvas-name">{{ projectCanvasStore.activeCanvas.name }}</span>
|
||||||
@@ -376,11 +399,12 @@ watch(() => route.name, (newPage) => {
|
|||||||
<path d="M21 3v5h-5"/>
|
<path d="M21 3v5h-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
|
||||||
<TorchButton />
|
<TorchButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<Toolbar />
|
<Toolbar :collapsed="!toolbarVisible" />
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<Transition name="page" mode="out-in">
|
<Transition name="page" mode="out-in">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
@@ -490,6 +514,9 @@ watch(() => route.name, (newPage) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Agent Bar (bottom pills) -->
|
||||||
|
<AgentBar />
|
||||||
|
|
||||||
<!-- Floating Terminal -->
|
<!-- Floating Terminal -->
|
||||||
<FloatingTerminal ref="terminalRef" v-model="showTerminal" />
|
<FloatingTerminal ref="terminalRef" v-model="showTerminal" />
|
||||||
|
|
||||||
@@ -544,51 +571,200 @@ watch(() => route.name, (newPage) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.5rem 1rem;
|
||||||
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
|
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
|
||||||
padding-left: calc(1.5rem + env(safe-area-inset-left, 0px));
|
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
|
||||||
padding-right: calc(1.5rem + env(safe-area-inset-right, 0px));
|
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 0.75rem;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
/* ── Compact header (WCO + manual toggle) ── */
|
||||||
font-size: 1.25rem;
|
.wco-header,
|
||||||
font-weight: 600;
|
.wco-header.app-header {
|
||||||
color: var(--text-primary);
|
height: 32px;
|
||||||
margin: 0;
|
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 {
|
.header-sep {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.1rem;
|
font-size: 0.85rem;
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-canvas-name {
|
.header-canvas-name {
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-canvas-badge {
|
.header-canvas-badge {
|
||||||
padding: 0.125rem 0.5rem;
|
padding: 0.0625rem 0.375rem;
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: rgba(99, 102, 241, 0.15);
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
@@ -597,12 +773,12 @@ watch(() => route.name, (newPage) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 5px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -1115,13 +1291,13 @@ watch(() => route.name, (newPage) => {
|
|||||||
.debug-btn {
|
.debug-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
padding: 4px 8px;
|
padding: 3px 6px;
|
||||||
background: rgba(100, 100, 100, 0.2);
|
background: rgba(100, 100, 100, 0.2);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 6px;
|
border-radius: 5px;
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
@@ -1140,11 +1316,12 @@ watch(() => route.name, (newPage) => {
|
|||||||
.log-count {
|
.log-count {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
padding: 1px 5px;
|
padding: 1px 4px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
min-width: 16px;
|
min-width: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Debug Console Panel */
|
/* Debug Console Panel */
|
||||||
|
|||||||
@@ -38,13 +38,15 @@ const editIcon = ref('')
|
|||||||
const editOrder = ref(99)
|
const editOrder = ref(99)
|
||||||
|
|
||||||
const filteredCanvases = computed(() => {
|
const filteredCanvases = computed(() => {
|
||||||
const list = showArchived.value ? store.canvases : store.activeCanvasesList
|
let list = showArchived.value ? store.canvases : store.activeCanvasesList
|
||||||
if (!searchQuery.value) return list
|
if (searchQuery.value) {
|
||||||
const q = searchQuery.value.toLowerCase()
|
const q = searchQuery.value.toLowerCase()
|
||||||
return list.filter(c =>
|
list = list.filter(c =>
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
(c.description && c.description.toLowerCase().includes(q))
|
(c.description && c.description.toLowerCase().includes(q))
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
return list.slice(0, 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredSnapshots = computed(() => {
|
const filteredSnapshots = computed(() => {
|
||||||
@@ -168,7 +170,7 @@ async function loadComponent(comp: VueComponentDefinition) {
|
|||||||
|
|
||||||
async function fetchComponents() {
|
async function fetchComponents() {
|
||||||
try {
|
try {
|
||||||
savedComponents.value = await componentsApi.getAll()
|
savedComponents.value = await componentsApi.getAll({ limit: 10 })
|
||||||
} catch {
|
} catch {
|
||||||
savedComponents.value = []
|
savedComponents.value = []
|
||||||
}
|
}
|
||||||
@@ -432,10 +434,14 @@ onMounted(() => {
|
|||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-name">{{ comp.name }}</div>
|
<div class="card-name">{{ comp.name }}</div>
|
||||||
<div class="card-desc card-id">{{ comp.id }}</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>
|
||||||
|
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span class="card-badge component">componente</span>
|
<span class="card-badge component">componente</span>
|
||||||
|
<span v-if="comp.status === 'archived'" class="card-badge archived-badge">Archivado</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
@@ -955,4 +961,20 @@ onMounted(() => {
|
|||||||
.new-btn.cancel:hover {
|
.new-btn.cancel:hover {
|
||||||
background: var(--border-color);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { RouterLink, useRoute } from 'vue-router'
|
|||||||
import { useCanvasStore } from '../stores/canvas'
|
import { useCanvasStore } from '../stores/canvas'
|
||||||
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
collapsed?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const projectCanvasStore = useProjectCanvasStore()
|
const projectCanvasStore = useProjectCanvasStore()
|
||||||
@@ -26,7 +30,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="toolbar">
|
<aside class="toolbar" :class="{ collapsed }">
|
||||||
<!-- Navegacion principal -->
|
<!-- Navegacion principal -->
|
||||||
<div class="toolbar-section nav-section">
|
<div class="toolbar-section nav-section">
|
||||||
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
|
<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"/>
|
<path d="M18 9a9 9 0 0 1-9 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
</RouterLink>
|
</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>
|
||||||
|
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
@@ -154,6 +164,19 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
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 {
|
.toolbar-section {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface VueComponentDefinition {
|
|||||||
style?: string
|
style?: string
|
||||||
props?: string[]
|
props?: string[]
|
||||||
imports?: 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 = {
|
export const componentsApi = {
|
||||||
async getAll(): Promise<VueComponentDefinition[]> {
|
async getAll(opts?: { includeArchived?: boolean; limit?: number }): Promise<VueComponentDefinition[]> {
|
||||||
const res = await fetch(`${API_URL}/api/components`)
|
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()
|
const data = await res.json()
|
||||||
return data.map((row: any) => ({
|
return data.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
props: JSON.parse(row.props || '[]'),
|
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 {
|
return {
|
||||||
...row,
|
...row,
|
||||||
props: JSON.parse(row.props || '[]'),
|
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()
|
return res.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<{ success: boolean; error?: string; usedBy?: { id: string; name: string }[] }> {
|
async update(id: string, data: Partial<VueComponentDefinition>): Promise<{ success: boolean }> {
|
||||||
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
const res = await fetch(`${API_URL}/api/components/${id}`, {
|
||||||
const data = await res.json()
|
method: 'PUT',
|
||||||
if (!res.ok) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
return { success: false, error: data.error || data.message, usedBy: data.usedBy }
|
body: JSON.stringify(data)
|
||||||
}
|
})
|
||||||
return 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' })
|
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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'
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ export function createCanvasHandlers(): ToolConfig[] {
|
|||||||
},
|
},
|
||||||
required: ['id', 'name', 'template']
|
required: ['id', 'name', 'template']
|
||||||
},
|
},
|
||||||
handler: (args: {
|
handler: async (args: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
template: string
|
template: string
|
||||||
@@ -195,6 +196,13 @@ 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`
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
setup: { type: 'string', description: 'Codigo de setup' },
|
setup: { type: 'string', description: 'Codigo de setup' },
|
||||||
style: { type: 'string', description: 'CSS' },
|
style: { type: 'string', description: 'CSS' },
|
||||||
props: { type: 'array', items: { type: 'string' }, description: 'Props' },
|
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']
|
required: ['name', 'template']
|
||||||
},
|
},
|
||||||
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
|
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string; tags?: string[] }) => {
|
||||||
try {
|
try {
|
||||||
const result = await componentsApi.save({
|
const result = await componentsApi.save({
|
||||||
id: args.id || `comp-${Date.now()}`,
|
id: args.id || `comp-${Date.now()}`,
|
||||||
@@ -47,7 +48,8 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
setup: args.setup,
|
setup: args.setup,
|
||||||
style: args.style,
|
style: args.style,
|
||||||
props: args.props,
|
props: args.props,
|
||||||
imports: args.imports
|
imports: args.imports,
|
||||||
|
tags: args.tags
|
||||||
})
|
})
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
|
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
|
||||||
@@ -105,20 +107,26 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'list_vue_components',
|
name: 'list_vue_components',
|
||||||
description: 'Lista todos los componentes guardados',
|
description: 'Lista todos los componentes guardados (activos)',
|
||||||
category: 'component',
|
category: 'component',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {}
|
properties: {
|
||||||
|
include_archived: { type: 'boolean', description: 'Incluir componentes archivados' }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handler: async () => {
|
handler: async (args: { include_archived?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const components = await componentsApi.getAll()
|
const components = await componentsApi.getAll({ includeArchived: args.include_archived })
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
return 'No hay componentes guardados'
|
return 'No hay componentes guardados'
|
||||||
}
|
}
|
||||||
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
|
const list = components.map(c => {
|
||||||
return `Componentes guardados:\n${list}`
|
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) {
|
} catch (e: any) {
|
||||||
return `Error: ${e.message}`
|
return `Error: ${e.message}`
|
||||||
}
|
}
|
||||||
@@ -126,7 +134,7 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete_vue_component',
|
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',
|
category: 'component',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -137,8 +145,9 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
},
|
},
|
||||||
handler: async (args: { id: string }) => {
|
handler: async (args: { id: string }) => {
|
||||||
try {
|
try {
|
||||||
await componentsApi.delete(args.id)
|
const result = await componentsApi.delete(args.id)
|
||||||
return `Componente "${args.id}" eliminado`
|
const warning = result.warning ? ` (Nota: ${result.warning})` : ''
|
||||||
|
return `Componente "${args.id}" archivado${warning}`
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return `Error: ${e.message}`
|
return `Error: ${e.message}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ export default defineConfig({
|
|||||||
name: 'Agent UI',
|
name: 'Agent UI',
|
||||||
short_name: 'AgentUI',
|
short_name: 'AgentUI',
|
||||||
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
|
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
|
||||||
theme_color: '#16161d',
|
theme_color: '#0f0f14',
|
||||||
background_color: '#0f0f14',
|
background_color: '#0f0f14',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
display_override: ['window-controls-overlay'],
|
||||||
orientation: 'any',
|
orientation: 'any',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
scope: '/',
|
scope: '/',
|
||||||
|
|||||||
@@ -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 show_in_toolbar INTEGER DEFAULT 0',
|
||||||
'ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT',
|
'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 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) {
|
for (const sql of alterStatements) {
|
||||||
|
|||||||
@@ -2,18 +2,47 @@ import { db } from '../db'
|
|||||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
|
|
||||||
export async function handleComponents(req: Request) {
|
export async function handleComponents(req: Request) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
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)
|
return jsonResponse(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const id = body.id || `comp-${Date.now()}`
|
const id = body.id || `comp-${Date.now()}`
|
||||||
|
const tags = body.tags ? JSON.stringify(body.tags) : null
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT OR REPLACE INTO vue_components
|
INSERT INTO vue_components (id, name, template, setup, style, props, imports, tags, status, updated_at)
|
||||||
(id, name, template, setup, style, props, imports, updated_at)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', CURRENT_TIMESTAMP)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 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(
|
stmt.run(
|
||||||
id,
|
id,
|
||||||
@@ -22,13 +51,14 @@ export async function handleComponents(req: Request) {
|
|||||||
body.setup || '',
|
body.setup || '',
|
||||||
body.style || '',
|
body.style || '',
|
||||||
JSON.stringify(body.props || []),
|
JSON.stringify(body.props || []),
|
||||||
JSON.stringify(body.imports || [])
|
JSON.stringify(body.imports || []),
|
||||||
|
tags
|
||||||
)
|
)
|
||||||
return jsonResponse({ success: true, id })
|
return jsonResponse({ success: true, id })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
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 })
|
return jsonResponse({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +74,37 @@ export async function handleComponentById(req: Request, id: string) {
|
|||||||
return jsonResponse(row)
|
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') {
|
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(`
|
const usage = db.query(`
|
||||||
SELECT pc.id, pc.name
|
SELECT pc.id, pc.name
|
||||||
FROM canvas_components cc
|
FROM canvas_components cc
|
||||||
@@ -53,16 +112,13 @@ export async function handleComponentById(req: Request, id: string) {
|
|||||||
WHERE cc.component_id = ?
|
WHERE cc.component_id = ?
|
||||||
`).all(id) as { id: string; name: string }[]
|
`).all(id) as { id: string; name: string }[]
|
||||||
|
|
||||||
if (usage.length > 0) {
|
db.run("UPDATE vue_components SET status = 'archived', updated_at = CURRENT_TIMESTAMP WHERE id = ?", [id])
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
error: 'Component in use',
|
success: true,
|
||||||
message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`,
|
warning: usage.length > 0
|
||||||
usedBy: usage
|
? `Component is used by: ${usage.map(u => u.name).join(', ')}`
|
||||||
}, 409)
|
: undefined
|
||||||
}
|
})
|
||||||
|
|
||||||
db.run('DELETE FROM vue_components WHERE id = ?', [id])
|
|
||||||
return jsonResponse({ success: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user