feat: Add update_theme functionality with UI support
Backend: - Add PUT /api/themes/:id endpoint for updating existing themes Store: - Add updateTheme method to theme store MCP: - Add update_theme tool to modify name, description, or save current variables UI: - Add edit button to ThemeListItem (custom themes only) - Add edit modal with name and description fields - Support editing from both desktop sidebar and mobile dropdown
This commit is contained in:
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Agent UI - Claude Code Guidelines
|
||||
|
||||
## Commits
|
||||
- NO incluir "Co-Authored-By: Claude" en los commits
|
||||
- Commits concisos y descriptivos en inglés
|
||||
|
||||
## Proyecto
|
||||
- Frontend: Vue 3 + TypeScript + Pinia
|
||||
- Backend: Bun + SQLite
|
||||
- MCP: @nucleoriofrio/webmcp
|
||||
|
||||
## Herramientas MCP dinámicas
|
||||
Las herramientas MCP cambian según la página activa:
|
||||
- `/canvas` - render_html, render_vue_component, save/load components
|
||||
- `/themes` - get_design_tokens, set_theme_variable, save_theme, update_theme, etc.
|
||||
- `/components` - gestión de componentes Vue
|
||||
|
||||
## Archivos clave
|
||||
- `frontend/src/services/tools/` - Herramientas MCP por página
|
||||
- `frontend/src/stores/theme.ts` - Store de temas
|
||||
- `server/index.ts` - API HTTP y SQLite
|
||||
@@ -11,6 +11,7 @@ const emit = defineEmits<{
|
||||
delete: [id: string]
|
||||
clone: [id: string]
|
||||
setDefault: [id: string]
|
||||
edit: [theme: Theme]
|
||||
}>()
|
||||
|
||||
function getAccentColor(): string {
|
||||
@@ -48,6 +49,18 @@ function getAccentColor(): string {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!theme.is_system"
|
||||
class="action-btn"
|
||||
@click="emit('edit', theme)"
|
||||
title="Edit theme"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="emit('clone', theme.id)"
|
||||
|
||||
@@ -16,6 +16,10 @@ const showNewThemeModal = ref(false)
|
||||
const showCloneModal = ref(false)
|
||||
const cloneSourceId = ref<string | null>(null)
|
||||
const cloneName = ref('')
|
||||
const showEditModal = ref(false)
|
||||
const editingTheme = ref<any>(null)
|
||||
const editName = ref('')
|
||||
const editDescription = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const currentCategoryVariables = computed(() => {
|
||||
@@ -58,6 +62,26 @@ async function confirmClone() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditTheme(theme: any) {
|
||||
editingTheme.value = theme
|
||||
editName.value = theme.name
|
||||
editDescription.value = theme.description || ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function confirmEdit() {
|
||||
if (editingTheme.value && editName.value.trim()) {
|
||||
await store.updateTheme(editingTheme.value.id, {
|
||||
name: editName.value.trim(),
|
||||
description: editDescription.value.trim() || undefined
|
||||
})
|
||||
showEditModal.value = false
|
||||
editingTheme.value = null
|
||||
editName.value = ''
|
||||
editDescription.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetDefault(id: string) {
|
||||
store.setDefaultTheme(id)
|
||||
}
|
||||
@@ -171,6 +195,7 @@ onMounted(() => {
|
||||
@select="(t) => { handleSelectTheme(t); mobileDropdownOpen = false }"
|
||||
@clone="handleCloneTheme"
|
||||
@setDefault="handleSetDefault"
|
||||
@edit="handleEditTheme"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="store.userThemes.length > 0" class="dropdown-section">
|
||||
@@ -184,6 +209,7 @@ onMounted(() => {
|
||||
@delete="handleDeleteTheme"
|
||||
@clone="handleCloneTheme"
|
||||
@setDefault="handleSetDefault"
|
||||
@edit="handleEditTheme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,6 +234,7 @@ onMounted(() => {
|
||||
@delete="handleDeleteTheme"
|
||||
@clone="handleCloneTheme"
|
||||
@setDefault="handleSetDefault"
|
||||
@edit="handleEditTheme"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -222,6 +249,7 @@ onMounted(() => {
|
||||
@delete="handleDeleteTheme"
|
||||
@clone="handleCloneTheme"
|
||||
@setDefault="handleSetDefault"
|
||||
@edit="handleEditTheme"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -366,6 +394,33 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div v-if="showEditModal" class="modal-overlay" @click="showEditModal = false">
|
||||
<div class="modal" @click.stop>
|
||||
<h3>Edit Theme</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
v-model="editName"
|
||||
type="text"
|
||||
placeholder="Theme name"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
placeholder="Theme description (optional)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="showEditModal = false">Cancel</button>
|
||||
<button class="btn-primary" @click="confirmEdit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -820,6 +875,35 @@ onMounted(() => {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.modal textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const THEME_TOOLS = [
|
||||
'get_active_theme',
|
||||
'set_theme_variable',
|
||||
'save_theme',
|
||||
'update_theme',
|
||||
'list_themes',
|
||||
'switch_theme',
|
||||
'set_default_theme',
|
||||
@@ -212,6 +213,85 @@ export function registerThemeTools() {
|
||||
}
|
||||
)
|
||||
|
||||
// update_theme
|
||||
registerTool(
|
||||
'update_theme',
|
||||
'Actualiza un tema existente (nombre, descripción o variables)',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: {
|
||||
type: 'string',
|
||||
description: 'Nombre o ID del tema a actualizar'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Nuevo nombre para el tema (opcional)'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Nueva descripción para el tema (opcional)'
|
||||
},
|
||||
saveCurrentVariables: {
|
||||
type: 'boolean',
|
||||
description: 'Si es true, guarda las variables actuales (con los cambios de set_theme_variable) en este tema'
|
||||
}
|
||||
},
|
||||
required: ['theme']
|
||||
},
|
||||
async (args: { theme: string; name?: string; description?: string; saveCurrentVariables?: boolean }) => {
|
||||
try {
|
||||
const themeStore = useThemeStore()
|
||||
await themeStore.fetchThemes()
|
||||
|
||||
// Find theme by ID or name
|
||||
const theme = themeStore.themes.find(t =>
|
||||
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
|
||||
)
|
||||
|
||||
if (!theme) {
|
||||
const available = themeStore.themes.map(t => t.name).join(', ')
|
||||
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
|
||||
}
|
||||
|
||||
if (theme.is_system) {
|
||||
return `No se puede modificar "${theme.name}" porque es un tema del sistema. Usa save_theme para crear una copia.`
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: { name?: string; description?: string; variables?: any } = {}
|
||||
|
||||
if (args.name) {
|
||||
updateData.name = args.name
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
updateData.description = args.description
|
||||
}
|
||||
if (args.saveCurrentVariables) {
|
||||
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
|
||||
if (variablesToSave) {
|
||||
updateData.variables = variablesToSave
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return 'No se especificaron cambios. Usa name, description o saveCurrentVariables.'
|
||||
}
|
||||
|
||||
await themeStore.updateTheme(theme.id, updateData)
|
||||
|
||||
const changes = []
|
||||
if (args.name) changes.push(`nombre: "${args.name}"`)
|
||||
if (args.description !== undefined) changes.push('descripción actualizada')
|
||||
if (args.saveCurrentVariables) changes.push('variables guardadas')
|
||||
|
||||
return `Tema "${theme.name}" actualizado:\n ${changes.join('\n ')}`
|
||||
} catch (e: any) {
|
||||
return `Error al actualizar tema: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// list_themes
|
||||
registerTool(
|
||||
'list_themes',
|
||||
|
||||
@@ -138,6 +138,29 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTheme(id: string, data: { name?: string; description?: string; variables?: ThemeVariables; metadata?: ThemeMetadata }) {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
error.value = result.error
|
||||
return null
|
||||
}
|
||||
await fetchThemes()
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = 'Error updating theme'
|
||||
throw e
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTheme(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
|
||||
@@ -281,6 +304,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
fetchThemes,
|
||||
fetchDesignTokens,
|
||||
saveTheme,
|
||||
updateTheme,
|
||||
deleteTheme,
|
||||
setDefaultTheme,
|
||||
cloneTheme,
|
||||
|
||||
@@ -393,6 +393,47 @@ Bun.serve({
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// PUT /api/themes/:id - Actualizar un tema existente
|
||||
if (req.method === 'PUT' && !action) {
|
||||
const theme = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!theme) {
|
||||
return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// Build update query dynamically based on provided fields
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updates.push('name = ?')
|
||||
values.push(body.name)
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updates.push('description = ?')
|
||||
values.push(body.description)
|
||||
}
|
||||
if (body.variables !== undefined) {
|
||||
updates.push('variables = ?')
|
||||
values.push(JSON.stringify(body.variables))
|
||||
}
|
||||
if (body.metadata !== undefined) {
|
||||
updates.push('metadata = ?')
|
||||
values.push(JSON.stringify(body.metadata))
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP')
|
||||
values.push(id)
|
||||
|
||||
const sql = `UPDATE themes SET ${updates.join(', ')} WHERE id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return Response.json({ success: true, id }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// GET /api/themes/:id - Obtener un tema
|
||||
if (req.method === 'GET' && !action) {
|
||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
|
||||
Reference in New Issue
Block a user