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]
|
delete: [id: string]
|
||||||
clone: [id: string]
|
clone: [id: string]
|
||||||
setDefault: [id: string]
|
setDefault: [id: string]
|
||||||
|
edit: [theme: Theme]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function getAccentColor(): string {
|
function getAccentColor(): string {
|
||||||
@@ -48,6 +49,18 @@ function getAccentColor(): string {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="emit('clone', theme.id)"
|
@click="emit('clone', theme.id)"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const showNewThemeModal = ref(false)
|
|||||||
const showCloneModal = ref(false)
|
const showCloneModal = ref(false)
|
||||||
const cloneSourceId = ref<string | null>(null)
|
const cloneSourceId = ref<string | null>(null)
|
||||||
const cloneName = ref('')
|
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 fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const currentCategoryVariables = computed(() => {
|
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) {
|
function handleSetDefault(id: string) {
|
||||||
store.setDefaultTheme(id)
|
store.setDefaultTheme(id)
|
||||||
}
|
}
|
||||||
@@ -171,6 +195,7 @@ onMounted(() => {
|
|||||||
@select="(t) => { handleSelectTheme(t); mobileDropdownOpen = false }"
|
@select="(t) => { handleSelectTheme(t); mobileDropdownOpen = false }"
|
||||||
@clone="handleCloneTheme"
|
@clone="handleCloneTheme"
|
||||||
@setDefault="handleSetDefault"
|
@setDefault="handleSetDefault"
|
||||||
|
@edit="handleEditTheme"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.userThemes.length > 0" class="dropdown-section">
|
<div v-if="store.userThemes.length > 0" class="dropdown-section">
|
||||||
@@ -184,6 +209,7 @@ onMounted(() => {
|
|||||||
@delete="handleDeleteTheme"
|
@delete="handleDeleteTheme"
|
||||||
@clone="handleCloneTheme"
|
@clone="handleCloneTheme"
|
||||||
@setDefault="handleSetDefault"
|
@setDefault="handleSetDefault"
|
||||||
|
@edit="handleEditTheme"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +234,7 @@ onMounted(() => {
|
|||||||
@delete="handleDeleteTheme"
|
@delete="handleDeleteTheme"
|
||||||
@clone="handleCloneTheme"
|
@clone="handleCloneTheme"
|
||||||
@setDefault="handleSetDefault"
|
@setDefault="handleSetDefault"
|
||||||
|
@edit="handleEditTheme"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -222,6 +249,7 @@ onMounted(() => {
|
|||||||
@delete="handleDeleteTheme"
|
@delete="handleDeleteTheme"
|
||||||
@clone="handleCloneTheme"
|
@clone="handleCloneTheme"
|
||||||
@setDefault="handleSetDefault"
|
@setDefault="handleSetDefault"
|
||||||
|
@edit="handleEditTheme"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,6 +394,33 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -820,6 +875,35 @@ onMounted(() => {
|
|||||||
border-color: var(--accent);
|
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 {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const THEME_TOOLS = [
|
|||||||
'get_active_theme',
|
'get_active_theme',
|
||||||
'set_theme_variable',
|
'set_theme_variable',
|
||||||
'save_theme',
|
'save_theme',
|
||||||
|
'update_theme',
|
||||||
'list_themes',
|
'list_themes',
|
||||||
'switch_theme',
|
'switch_theme',
|
||||||
'set_default_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
|
// list_themes
|
||||||
registerTool(
|
registerTool(
|
||||||
'list_themes',
|
'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) {
|
async function deleteTheme(id: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
|
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
|
||||||
@@ -281,6 +304,7 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
fetchThemes,
|
fetchThemes,
|
||||||
fetchDesignTokens,
|
fetchDesignTokens,
|
||||||
saveTheme,
|
saveTheme,
|
||||||
|
updateTheme,
|
||||||
deleteTheme,
|
deleteTheme,
|
||||||
setDefaultTheme,
|
setDefaultTheme,
|
||||||
cloneTheme,
|
cloneTheme,
|
||||||
|
|||||||
@@ -393,6 +393,47 @@ Bun.serve({
|
|||||||
return Response.json({ success: true }, { headers: corsHeaders })
|
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
|
// GET /api/themes/:id - Obtener un tema
|
||||||
if (req.method === 'GET' && !action) {
|
if (req.method === 'GET' && !action) {
|
||||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||||
|
|||||||
Reference in New Issue
Block a user