feat: Add theme system with visual editor
- Backend: themes table and API endpoints (CRUD, export, design-tokens) - Theme store with preview, apply, and persistence - ThemesPage with collapsible variables editor and live preview - Components: ColorPicker (HSL), VariableEditor, ThemePreview, ThemeListItem - Integration: $theme helper for dynamic components, get_design_tokens MCP tool - Navigation: /themes route with palette icon in toolbar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
662
frontend/src/pages/ThemesPage.vue
Normal file
662
frontend/src/pages/ThemesPage.vue
Normal file
@@ -0,0 +1,662 @@
|
||||
<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 newThemeName = ref('')
|
||||
const showNewThemeModal = ref(false)
|
||||
const showCloneModal = ref(false)
|
||||
const cloneSourceId = ref<string | null>(null)
|
||||
const cloneName = 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 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">
|
||||
<h2>Themes</h2>
|
||||
<button class="btn-icon" @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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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>
|
||||
</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);
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user