Files
agent-ui/frontend/src/pages/ThemesPage.vue
josedario87 2e64dceb1e 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
2026-02-13 05:50:13 -06:00

1028 lines
24 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useThemeStore, type ThemeVariables } from '../stores/theme'
import { THEME_CATEGORIES, downloadTheme, readThemeFile, type ThemeCategory } from '../services/themeService'
import ThemeListItem from '../components/themes/ThemeListItem.vue'
import VariableEditor from '../components/themes/VariableEditor.vue'
import ThemePreview from '../components/themes/ThemePreview.vue'
const store = useThemeStore()
const activeCategory = ref<ThemeCategory>('colors')
const variablesCollapsed = ref(false)
const mobileDropdownOpen = ref(false)
const newThemeName = ref('')
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(() => {
const vars = store.currentVariables
if (!vars) return []
const categoryVars = vars[activeCategory.value as keyof ThemeVariables]
if (!categoryVars) return []
return Object.entries(categoryVars)
})
const editingThemeName = computed(() => {
return store.activeTheme?.name || 'No theme selected'
})
function handleSelectTheme(theme: any) {
store.selectTheme(theme)
}
function handleDeleteTheme(id: string) {
if (confirm('Delete this theme?')) {
store.deleteTheme(id)
}
}
function handleCloneTheme(id: string) {
const theme = store.themes.find(t => t.id === id)
if (theme) {
cloneSourceId.value = id
cloneName.value = `${theme.name} Copy`
showCloneModal.value = true
}
}
async function confirmClone() {
if (cloneSourceId.value && cloneName.value.trim()) {
await store.cloneTheme(cloneSourceId.value, cloneName.value.trim())
showCloneModal.value = false
cloneSourceId.value = null
cloneName.value = ''
}
}
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)
}
function handleUpdateVariable(key: string, value: string) {
store.updateVariable(activeCategory.value as keyof ThemeVariables, key, value)
}
async function handleSave() {
if (store.activeTheme?.is_system) {
newThemeName.value = `${store.activeTheme.name} Custom`
showNewThemeModal.value = true
} else {
await store.commitPreview()
}
}
async function handleSaveAsNew() {
if (newThemeName.value.trim()) {
await store.commitPreview(newThemeName.value.trim())
showNewThemeModal.value = false
newThemeName.value = ''
}
}
function handleReset() {
store.resetPreview()
}
function handleExport() {
if (store.activeTheme) {
downloadTheme(store.activeTheme)
}
}
function handleImportClick() {
fileInput.value?.click()
}
async function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
try {
const themeData = await readThemeFile(file)
await store.saveTheme({
name: themeData.name || 'Imported Theme',
description: themeData.description,
variables: themeData.variables!,
metadata: themeData.metadata
})
} catch (err) {
alert('Failed to import theme: ' + (err as Error).message)
}
}
input.value = ''
}
function createNewTheme() {
newThemeName.value = 'New Theme'
showNewThemeModal.value = true
}
async function confirmCreateTheme() {
if (newThemeName.value.trim() && store.activeTheme) {
await store.saveTheme({
name: newThemeName.value.trim(),
variables: JSON.parse(JSON.stringify(store.activeTheme.variables))
})
showNewThemeModal.value = false
newThemeName.value = ''
}
}
onMounted(() => {
store.fetchThemes()
})
</script>
<template>
<div class="themes-page">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<h2>Themes</h2>
<button class="btn-icon desktop-only" @click="createNewTheme" title="New theme">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<!-- Mobile: theme dropdown + actions -->
<div class="mobile-editor-controls">
<div class="mobile-theme-dropdown">
<button class="mobile-dropdown-trigger" @click="mobileDropdownOpen = !mobileDropdownOpen">
<div class="dropdown-color" :style="{ background: store.activeTheme?.variables?.accent?.accent || '#6366f1' }"></div>
<span>{{ editingThemeName }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ rotated: mobileDropdownOpen }">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div v-if="mobileDropdownOpen" class="mobile-dropdown-menu" @click.stop>
<div class="dropdown-section">
<span class="dropdown-label">System</span>
<ThemeListItem
v-for="theme in store.systemThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="(t) => { handleSelectTheme(t); mobileDropdownOpen = false }"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
@edit="handleEditTheme"
/>
</div>
<div v-if="store.userThemes.length > 0" class="dropdown-section">
<span class="dropdown-label">Custom</span>
<ThemeListItem
v-for="theme in store.userThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="(t) => { handleSelectTheme(t); mobileDropdownOpen = false }"
@delete="handleDeleteTheme"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
@edit="handleEditTheme"
/>
</div>
</div>
</div>
<div class="mobile-actions">
<button class="btn-sm" @click="handleReset" :disabled="!store.hasUnsavedChanges">Reset</button>
<button class="btn-sm primary" @click="handleSave" :disabled="!store.hasUnsavedChanges">Save</button>
<button class="btn-sm" @click="handleExport">Export</button>
</div>
</div>
</div>
<div class="theme-groups">
<section class="theme-group">
<h3>System</h3>
<ThemeListItem
v-for="theme in store.systemThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="handleSelectTheme"
@delete="handleDeleteTheme"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
@edit="handleEditTheme"
/>
</section>
<section v-if="store.userThemes.length > 0" class="theme-group">
<h3>Custom</h3>
<ThemeListItem
v-for="theme in store.userThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="handleSelectTheme"
@delete="handleDeleteTheme"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
@edit="handleEditTheme"
/>
</section>
</div>
<div class="sidebar-footer">
<button class="btn-secondary" @click="handleImportClick">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import
</button>
<input
ref="fileInput"
type="file"
accept=".json"
@change="handleFileChange"
hidden
/>
</div>
</aside>
<!-- Editor -->
<main class="editor">
<header class="editor-header">
<div class="editor-title">
<h2>{{ editingThemeName }}</h2>
<span v-if="store.hasUnsavedChanges" class="unsaved-badge">Unsaved changes</span>
</div>
<div class="editor-actions">
<button
class="btn-secondary"
@click="handleReset"
:disabled="!store.hasUnsavedChanges"
>
Reset
</button>
<button
class="btn-primary"
@click="handleSave"
:disabled="!store.hasUnsavedChanges"
>
Save
</button>
<button class="btn-secondary" @click="handleExport">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export
</button>
</div>
</header>
<!-- Category Tabs -->
<nav class="category-tabs">
<button
v-for="cat in THEME_CATEGORIES"
:key="cat.key"
:class="{ active: activeCategory === cat.key }"
@click="activeCategory = cat.key"
>
{{ cat.label }}
</button>
</nav>
<!-- Variables Grid -->
<section class="variables-section" :class="{ collapsed: variablesCollapsed }">
<div class="variables-header" @click="variablesCollapsed = !variablesCollapsed">
<span>Variables</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="collapse-icon"
:class="{ rotated: variablesCollapsed }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="variables-content">
<div class="variables-grid">
<VariableEditor
v-for="[key, value] in currentCategoryVariables"
:key="key"
:name="key"
:value="value"
@update="handleUpdateVariable(key, $event)"
/>
</div>
</div>
</section>
<!-- Preview -->
<section class="preview-section">
<h3>Preview</h3>
<ThemePreview :variables="store.currentVariables" />
</section>
</main>
<!-- New Theme Modal -->
<div v-if="showNewThemeModal" class="modal-overlay" @click="showNewThemeModal = false">
<div class="modal" @click.stop>
<h3>{{ store.activeTheme?.is_system ? 'Save as New Theme' : 'Create Theme' }}</h3>
<input
v-model="newThemeName"
type="text"
placeholder="Theme name"
@keyup.enter="store.activeTheme?.is_system ? handleSaveAsNew() : confirmCreateTheme()"
/>
<div class="modal-actions">
<button class="btn-secondary" @click="showNewThemeModal = false">Cancel</button>
<button
class="btn-primary"
@click="store.activeTheme?.is_system ? handleSaveAsNew() : confirmCreateTheme()"
>
Create
</button>
</div>
</div>
</div>
<!-- Clone Modal -->
<div v-if="showCloneModal" class="modal-overlay" @click="showCloneModal = false">
<div class="modal" @click.stop>
<h3>Clone Theme</h3>
<input
v-model="cloneName"
type="text"
placeholder="New theme name"
@keyup.enter="confirmClone"
/>
<div class="modal-actions">
<button class="btn-secondary" @click="showCloneModal = false">Cancel</button>
<button class="btn-primary" @click="confirmClone">Clone</button>
</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>
<style scoped>
.themes-page {
display: flex;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
}
/* Sidebar */
.sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
gap: 1rem;
}
.sidebar-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar-header h2 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.mobile-editor-controls {
display: none;
}
.mobile-theme-dropdown {
position: relative;
}
.mobile-dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s;
}
.mobile-dropdown-trigger:hover {
border-color: var(--accent);
}
.mobile-dropdown-trigger .dropdown-color {
width: 18px;
height: 18px;
border-radius: 4px;
flex-shrink: 0;
}
.mobile-dropdown-trigger svg {
transition: transform 0.2s;
color: var(--text-muted);
}
.mobile-dropdown-trigger svg.rotated {
transform: rotate(180deg);
}
.mobile-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 240px;
max-height: 300px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
padding: 0.5rem;
}
.dropdown-section {
margin-bottom: 0.5rem;
}
.dropdown-section:last-child {
margin-bottom: 0;
}
.dropdown-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 0.25rem 0.5rem;
margin-bottom: 0.25rem;
}
.dropdown-section :deep(.theme-item) {
margin-bottom: 0.375rem;
}
.desktop-only {
display: flex;
}
.mobile-actions {
display: flex;
gap: 0.375rem;
}
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-hover);
color: var(--text-primary);
cursor: pointer;
}
.btn-sm.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent);
}
.theme-groups {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.theme-group {
margin-bottom: 1.5rem;
}
.theme-group h3 {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
}
.theme-group > :deep(.theme-item) {
margin-bottom: 0.5rem;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
/* Editor */
.editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.editor-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.editor-title h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.unsaved-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
background: var(--warning-bg);
color: var(--warning);
border-radius: 9999px;
}
.editor-actions {
display: flex;
gap: 0.5rem;
}
/* Category Tabs */
.category-tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.category-tabs button {
padding: 0.5rem 1rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.category-tabs button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.category-tabs button.active {
background: var(--accent-muted);
color: var(--accent);
}
/* Variables */
.variables-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: flex 0.3s ease;
}
.variables-section.collapsed {
flex: 0 0 auto;
}
.variables-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-tertiary);
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--border-color);
}
.variables-header:hover {
background: var(--bg-hover);
}
.variables-header span {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.collapse-icon {
color: var(--text-muted);
transition: transform 0.3s ease;
}
.collapse-icon.rotated {
transform: rotate(-180deg);
}
.variables-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.variables-section.collapsed .variables-content {
max-height: 0;
padding: 0 1.5rem;
opacity: 0;
overflow: hidden;
}
.variables-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
padding-bottom: 1rem;
}
/* Preview */
.preview-section {
flex-shrink: 0;
max-height: 400px;
overflow-y: auto;
padding: 2rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.variables-section.collapsed ~ .preview-section {
max-height: none;
flex: 1;
}
.preview-section h3 {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 1rem 0;
}
/* Buttons */
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
width: 100%;
max-width: 360px;
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.modal input {
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;
margin-bottom: 1rem;
}
.modal input:focus {
outline: none;
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;
justify-content: flex-end;
}
/* Mobile responsive */
@media (max-width: 900px) {
.themes-page {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: none;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.sidebar-title {
flex: 1;
}
.mobile-editor-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: flex-end;
}
.desktop-only {
display: none !important;
}
.theme-groups {
display: none;
}
.sidebar-footer {
display: none;
}
.editor-header {
display: none;
}
.editor-actions {
width: 100%;
justify-content: flex-end;
}
.category-tabs {
padding: 0.5rem 1rem;
gap: 0.125rem;
}
.category-tabs button {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.variables-content {
padding: 1rem;
}
.variables-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.preview-section {
padding: 1rem;
}
}
@media (max-width: 600px) {
.sidebar-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem;
}
.sidebar-title h2 {
font-size: 0.9rem;
}
.mobile-editor-controls {
flex-wrap: wrap;
gap: 0.375rem;
}
.mobile-theme-select {
flex: 1;
min-width: 100px;
}
.mobile-actions {
flex-wrap: wrap;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
}
.variables-grid {
grid-template-columns: 1fr;
}
.modal {
margin: 1rem;
max-width: none;
}
}
</style>