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:
296
frontend/src/stores/theme.ts
Normal file
296
frontend/src/stores/theme.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// =====================
|
||||
// Types
|
||||
// =====================
|
||||
|
||||
export interface ThemeVariables {
|
||||
colors: Record<string, string>
|
||||
text: Record<string, string>
|
||||
accent: Record<string, string>
|
||||
semantic: Record<string, string>
|
||||
spacing: Record<string, string>
|
||||
typography: Record<string, string>
|
||||
effects: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ThemeMetadata {
|
||||
author?: string
|
||||
version?: string
|
||||
tags?: string[]
|
||||
base?: string | null
|
||||
exported_at?: string | null
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
is_default: boolean
|
||||
is_system: boolean
|
||||
variables: ThemeVariables
|
||||
metadata?: ThemeMetadata
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface DesignTokens {
|
||||
version: string
|
||||
description: string
|
||||
usage: string
|
||||
tokens: ThemeVariables
|
||||
guidelines: Record<string, string>
|
||||
examples: Record<string, string>
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:4101'
|
||||
|
||||
// =====================
|
||||
// Store
|
||||
// =====================
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// State
|
||||
const themes = ref<Theme[]>([])
|
||||
const activeTheme = ref<Theme | null>(null)
|
||||
const previewTheme = ref<ThemeVariables | null>(null)
|
||||
const designTokens = ref<DesignTokens | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const currentVariables = computed(() => {
|
||||
if (previewTheme.value) return previewTheme.value
|
||||
return activeTheme.value?.variables || null
|
||||
})
|
||||
|
||||
const systemThemes = computed(() =>
|
||||
themes.value.filter(t => t.is_system)
|
||||
)
|
||||
|
||||
const userThemes = computed(() =>
|
||||
themes.value.filter(t => !t.is_system)
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = computed(() => previewTheme.value !== null)
|
||||
|
||||
const flattenedVariables = computed(() => {
|
||||
const vars = currentVariables.value
|
||||
if (!vars) return {}
|
||||
|
||||
const flat: Record<string, string> = {}
|
||||
for (const category of Object.keys(vars)) {
|
||||
for (const [key, value] of Object.entries(vars[category as keyof ThemeVariables])) {
|
||||
flat[`--${key}`] = value
|
||||
}
|
||||
}
|
||||
return flat
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function fetchThemes() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/themes`)
|
||||
themes.value = await res.json()
|
||||
|
||||
const defaultTheme = themes.value.find(t => t.is_default)
|
||||
if (defaultTheme) {
|
||||
activeTheme.value = defaultTheme
|
||||
applyTheme(defaultTheme.variables)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Error loading themes'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDesignTokens() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/design-tokens`)
|
||||
designTokens.value = await res.json()
|
||||
} catch (e) {
|
||||
console.error('Error fetching design tokens:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTheme(theme: Partial<Theme> & { name: string; variables: ThemeVariables }) {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/themes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(theme)
|
||||
})
|
||||
const result = await res.json()
|
||||
await fetchThemes()
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = 'Error saving theme'
|
||||
throw e
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTheme(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
error.value = result.error
|
||||
return false
|
||||
}
|
||||
await fetchThemes()
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = 'Error deleting theme'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultTheme(id: string) {
|
||||
try {
|
||||
await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
|
||||
await fetchThemes()
|
||||
} catch (e) {
|
||||
error.value = 'Error setting default theme'
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneTheme(id: string, newName: string) {
|
||||
const original = themes.value.find(t => t.id === id)
|
||||
if (!original) return null
|
||||
|
||||
return saveTheme({
|
||||
name: newName,
|
||||
description: `Cloned from ${original.name}`,
|
||||
variables: JSON.parse(JSON.stringify(original.variables)),
|
||||
metadata: { base: id }
|
||||
})
|
||||
}
|
||||
|
||||
function applyTheme(variables: ThemeVariables) {
|
||||
const root = document.documentElement
|
||||
|
||||
for (const category of Object.keys(variables)) {
|
||||
for (const [key, value] of Object.entries(variables[category as keyof ThemeVariables])) {
|
||||
root.style.setProperty(`--${key}`, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectTheme(theme: Theme) {
|
||||
activeTheme.value = theme
|
||||
previewTheme.value = null
|
||||
applyTheme(theme.variables)
|
||||
}
|
||||
|
||||
function setPreview(variables: ThemeVariables | null) {
|
||||
previewTheme.value = variables
|
||||
if (variables) {
|
||||
applyTheme(variables)
|
||||
} else if (activeTheme.value) {
|
||||
applyTheme(activeTheme.value.variables)
|
||||
}
|
||||
}
|
||||
|
||||
function updateVariable(category: keyof ThemeVariables, key: string, value: string) {
|
||||
if (!previewTheme.value && activeTheme.value) {
|
||||
previewTheme.value = JSON.parse(JSON.stringify(activeTheme.value.variables))
|
||||
}
|
||||
|
||||
if (previewTheme.value && previewTheme.value[category]) {
|
||||
previewTheme.value[category][key] = value
|
||||
document.documentElement.style.setProperty(`--${key}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
function exportTheme(theme: Theme): string {
|
||||
return JSON.stringify({
|
||||
name: theme.name,
|
||||
description: theme.description,
|
||||
variables: theme.variables,
|
||||
metadata: {
|
||||
...theme.metadata,
|
||||
exported_at: new Date().toISOString()
|
||||
}
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
async function importTheme(jsonString: string) {
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
if (!data.name || !data.variables) {
|
||||
throw new Error('Invalid theme format')
|
||||
}
|
||||
return saveTheme({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
variables: data.variables,
|
||||
metadata: data.metadata
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = 'Invalid theme file'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function resetPreview() {
|
||||
previewTheme.value = null
|
||||
if (activeTheme.value) {
|
||||
applyTheme(activeTheme.value.variables)
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPreview(name?: string) {
|
||||
if (!previewTheme.value) return
|
||||
|
||||
const themeName = name || activeTheme.value?.name || 'Custom Theme'
|
||||
const themeId = activeTheme.value?.is_system ? undefined : activeTheme.value?.id
|
||||
|
||||
await saveTheme({
|
||||
id: themeId,
|
||||
name: themeName,
|
||||
variables: previewTheme.value
|
||||
})
|
||||
previewTheme.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
themes,
|
||||
activeTheme,
|
||||
previewTheme,
|
||||
designTokens,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
// Getters
|
||||
currentVariables,
|
||||
systemThemes,
|
||||
userThemes,
|
||||
hasUnsavedChanges,
|
||||
flattenedVariables,
|
||||
// Actions
|
||||
fetchThemes,
|
||||
fetchDesignTokens,
|
||||
saveTheme,
|
||||
deleteTheme,
|
||||
setDefaultTheme,
|
||||
cloneTheme,
|
||||
applyTheme,
|
||||
selectTheme,
|
||||
setPreview,
|
||||
updateVariable,
|
||||
exportTheme,
|
||||
importTheme,
|
||||
resetPreview,
|
||||
commitPreview
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user