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:
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@@ -11,7 +11,8 @@
|
||||
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||
"pinia": "^3.0.4",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vue": "^3.5.25"
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
@@ -5330,6 +5331,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.2.4",
|
||||
"dev": true,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||
"pinia": "^3.0.4",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vue": "^3.5.25"
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import Canvas from './components/Canvas.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import StatusBar from './components/StatusBar.vue'
|
||||
import Toolbar from './components/Toolbar.vue'
|
||||
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||
@@ -9,14 +9,18 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1>Agent UI</h1>
|
||||
<h1 class="logo">Agent UI</h1>
|
||||
<ComponentsDropdown />
|
||||
</div>
|
||||
<StatusBar />
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<Toolbar />
|
||||
<Canvas />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,7 +48,7 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -56,4 +60,20 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Page transitions */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
const route = useRoute()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function clearCanvas() {
|
||||
@@ -8,13 +10,13 @@ function clearCanvas() {
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="canvas-placeholder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 21V9"/>
|
||||
</svg>
|
||||
<p>Canvas listo</p>
|
||||
<span>Claude Code puede renderizar contenido aquí usando las herramientas MCP</span>
|
||||
<span>Claude Code puede renderizar contenido aquí</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -27,9 +29,42 @@ function toggleHistory() {
|
||||
|
||||
<template>
|
||||
<aside class="toolbar">
|
||||
<!-- Navegación -->
|
||||
<div class="toolbar-section nav-section">
|
||||
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Canvas">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 21V9"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/themes" class="toolbar-btn" :class="{ active: route.path === '/themes' }" title="Temas">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="13.5" cy="6.5" r="2.5"/>
|
||||
<circle cx="19" cy="11.5" r="2.5"/>
|
||||
<circle cx="17" cy="18.5" r="2.5"/>
|
||||
<circle cx="8.5" cy="17.5" r="2.5"/>
|
||||
<circle cx="5" cy="10.5" r="2.5"/>
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.82-.13 2.67-.36"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="toolbar-section">
|
||||
<button class="toolbar-btn" @click="clearCanvas" title="Limpiar canvas">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||
@@ -37,7 +72,7 @@ function toggleHistory() {
|
||||
</button>
|
||||
|
||||
<button class="toolbar-btn" @click="toggleHistory" title="Historial">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 8v4l3 3"/>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
@@ -54,6 +89,7 @@ function toggleHistory() {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
@@ -62,6 +98,12 @@ function toggleHistory() {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -74,6 +116,7 @@ function toggleHistory() {
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
@@ -84,4 +127,9 @@ function toggleHistory() {
|
||||
.toolbar-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #6366f1;
|
||||
}
|
||||
</style>
|
||||
|
||||
280
frontend/src/components/themes/ColorPicker.vue
Normal file
280
frontend/src/components/themes/ColorPicker.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { hexToHSL, hslToHex } from '../../services/themeService'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const hexInput = ref(props.modelValue)
|
||||
|
||||
const hsl = computed(() => {
|
||||
try {
|
||||
return hexToHSL(props.modelValue)
|
||||
} catch {
|
||||
return { h: 0, s: 50, l: 50 }
|
||||
}
|
||||
})
|
||||
|
||||
const hue = ref(hsl.value.h)
|
||||
const saturation = ref(hsl.value.s)
|
||||
const lightness = ref(hsl.value.l)
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
hexInput.value = newVal
|
||||
const newHsl = hexToHSL(newVal)
|
||||
hue.value = newHsl.h
|
||||
saturation.value = newHsl.s
|
||||
lightness.value = newHsl.l
|
||||
})
|
||||
|
||||
function updateFromHSL() {
|
||||
const hex = hslToHex(hue.value, saturation.value, lightness.value)
|
||||
hexInput.value = hex
|
||||
emit('update:modelValue', hex)
|
||||
}
|
||||
|
||||
function updateFromHex() {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(hexInput.value)) {
|
||||
emit('update:modelValue', hexInput.value)
|
||||
const newHsl = hexToHSL(hexInput.value)
|
||||
hue.value = newHsl.h
|
||||
saturation.value = newHsl.s
|
||||
lightness.value = newHsl.l
|
||||
}
|
||||
}
|
||||
|
||||
// Close picker on outside click
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.color-picker')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Register click outside listener
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
setTimeout(() => document.addEventListener('click', handleClickOutside), 0)
|
||||
} else {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="color-picker" @click.stop>
|
||||
<div class="color-preview" @click="isOpen = !isOpen">
|
||||
<div class="color-swatch" :style="{ background: modelValue }"></div>
|
||||
<span class="color-value">{{ modelValue }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isOpen" class="color-dropdown" @click.stop>
|
||||
<div class="color-sliders">
|
||||
<div class="slider-group">
|
||||
<label>Hue</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="hue"
|
||||
min="0"
|
||||
max="360"
|
||||
class="hue-slider"
|
||||
@input="updateFromHSL"
|
||||
/>
|
||||
<span>{{ hue }}°</span>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label>Saturation</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="saturation"
|
||||
min="0"
|
||||
max="100"
|
||||
class="saturation-slider"
|
||||
:style="{
|
||||
'--sat-left': hslToHex(hue, 0, lightness),
|
||||
'--sat-right': hslToHex(hue, 100, lightness)
|
||||
}"
|
||||
@input="updateFromHSL"
|
||||
/>
|
||||
<span>{{ saturation }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label>Lightness</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="lightness"
|
||||
min="0"
|
||||
max="100"
|
||||
class="lightness-slider"
|
||||
:style="{
|
||||
'--light-mid': hslToHex(hue, saturation, 50)
|
||||
}"
|
||||
@input="updateFromHSL"
|
||||
/>
|
||||
<span>{{ lightness }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hex-input-group">
|
||||
<label>Hex</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="hexInput"
|
||||
@blur="updateFromHex"
|
||||
@keyup.enter="updateFromHex"
|
||||
maxlength="7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="large-preview" :style="{ background: modelValue }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.color-preview:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.color-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
width: 240px;
|
||||
padding: 1rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.color-sliders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slider-group label {
|
||||
width: 70px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-group span {
|
||||
width: 40px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000
|
||||
);
|
||||
}
|
||||
|
||||
.saturation-slider {
|
||||
background: linear-gradient(to right, var(--sat-left), var(--sat-right));
|
||||
}
|
||||
|
||||
.lightness-slider {
|
||||
background: linear-gradient(to right, #000000, var(--light-mid), #ffffff);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-primary);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hex-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hex-input-group label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hex-input-group input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.large-preview {
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
168
frontend/src/components/themes/ThemeListItem.vue
Normal file
168
frontend/src/components/themes/ThemeListItem.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import type { Theme } from '../../stores/theme'
|
||||
|
||||
const props = defineProps<{
|
||||
theme: Theme
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [theme: Theme]
|
||||
delete: [id: string]
|
||||
clone: [id: string]
|
||||
setDefault: [id: string]
|
||||
}>()
|
||||
|
||||
function getAccentColor(): string {
|
||||
return props.theme.variables?.accent?.accent || '#6366f1'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="theme-item"
|
||||
:class="{ active, 'is-default': theme.is_default }"
|
||||
@click="emit('select', theme)"
|
||||
>
|
||||
<div class="theme-color" :style="{ background: getAccentColor() }"></div>
|
||||
|
||||
<div class="theme-info">
|
||||
<span class="theme-name">
|
||||
{{ theme.name }}
|
||||
<span v-if="theme.is_default" class="default-badge">Default</span>
|
||||
</span>
|
||||
<span class="theme-meta">
|
||||
{{ theme.is_system ? 'System' : 'Custom' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-actions" @click.stop>
|
||||
<button
|
||||
v-if="!theme.is_default"
|
||||
class="action-btn"
|
||||
@click="emit('setDefault', theme.id)"
|
||||
title="Set as default"
|
||||
>
|
||||
<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="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="emit('clone', theme.id)"
|
||||
title="Clone 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">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!theme.is_system"
|
||||
class="action-btn delete"
|
||||
@click="emit('delete', theme.id)"
|
||||
title="Delete 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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.theme-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.theme-item.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.theme-color {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.theme-item:hover .theme-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
252
frontend/src/components/themes/ThemePreview.vue
Normal file
252
frontend/src/components/themes/ThemePreview.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeVariables } from '../../stores/theme'
|
||||
|
||||
defineProps<{
|
||||
variables: ThemeVariables | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-preview">
|
||||
<div class="preview-section">
|
||||
<h4>Colors</h4>
|
||||
<div class="color-swatches">
|
||||
<div class="swatch" style="background: var(--bg-primary)">
|
||||
<span>Primary</span>
|
||||
</div>
|
||||
<div class="swatch" style="background: var(--bg-secondary)">
|
||||
<span>Secondary</span>
|
||||
</div>
|
||||
<div class="swatch" style="background: var(--bg-hover)">
|
||||
<span>Hover</span>
|
||||
</div>
|
||||
<div class="swatch" style="background: var(--bg-tertiary)">
|
||||
<span>Tertiary</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>Text</h4>
|
||||
<div class="text-samples">
|
||||
<p style="color: var(--text-primary)">Primary Text</p>
|
||||
<p style="color: var(--text-secondary)">Secondary Text</p>
|
||||
<p style="color: var(--text-muted)">Muted Text</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>Components</h4>
|
||||
<div class="component-samples">
|
||||
<button class="sample-btn primary">Primary Button</button>
|
||||
<button class="sample-btn secondary">Secondary</button>
|
||||
<button class="sample-btn danger">Danger</button>
|
||||
</div>
|
||||
|
||||
<div class="sample-card">
|
||||
<h5>Sample Card</h5>
|
||||
<p>This is a preview of how cards will look with the current theme.</p>
|
||||
<div class="card-actions">
|
||||
<button class="sample-btn primary small">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sample-input-group">
|
||||
<input type="text" placeholder="Sample input..." class="sample-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>Status</h4>
|
||||
<div class="status-badges">
|
||||
<span class="badge success">Success</span>
|
||||
<span class="badge warning">Warning</span>
|
||||
<span class="badge error">Error</span>
|
||||
<span class="badge info">Info</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-preview {
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-section h4 {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.color-swatches {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.swatch span {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
|
||||
.text-samples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.text-samples p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.component-samples {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sample-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sample-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.sample-btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sample-btn.secondary {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sample-btn.danger {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.sample-btn.small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sample-card {
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sample-card h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sample-card p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sample-input-group {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.sample-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sample-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.sample-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.badge.info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
</style>
|
||||
172
frontend/src/components/themes/VariableEditor.vue
Normal file
172
frontend/src/components/themes/VariableEditor.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
import { isColorVariable, isSizeVariable, isShadowVariable, isTransitionVariable, isFontVariable } from '../../services/themeService'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
value: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [value: string]
|
||||
}>()
|
||||
|
||||
const variableType = computed(() => {
|
||||
if (isColorVariable(props.name)) return 'color'
|
||||
if (isSizeVariable(props.name)) return 'size'
|
||||
if (isShadowVariable(props.name)) return 'shadow'
|
||||
if (isTransitionVariable(props.name)) return 'transition'
|
||||
if (isFontVariable(props.name)) return 'font'
|
||||
return 'text'
|
||||
})
|
||||
|
||||
const isRadiusVariable = computed(() => props.name.toLowerCase().includes('radius'))
|
||||
|
||||
const displayName = computed(() => {
|
||||
return props.name
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
function handleColorChange(newValue: string) {
|
||||
emit('update', newValue)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
emit('update', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="variable-editor" :class="variableType">
|
||||
<div class="variable-header">
|
||||
<span class="variable-name">{{ displayName }}</span>
|
||||
<code class="variable-key">--{{ name }}</code>
|
||||
</div>
|
||||
|
||||
<div class="variable-input">
|
||||
<!-- Color input -->
|
||||
<ColorPicker
|
||||
v-if="variableType === 'color'"
|
||||
:modelValue="value"
|
||||
@update:modelValue="handleColorChange"
|
||||
/>
|
||||
|
||||
<!-- Size input -->
|
||||
<div v-else-if="variableType === 'size'" class="size-input">
|
||||
<input
|
||||
type="text"
|
||||
:value="value"
|
||||
@input="handleInputChange"
|
||||
placeholder="8px"
|
||||
/>
|
||||
<div
|
||||
v-if="isRadiusVariable"
|
||||
class="radius-preview"
|
||||
:style="{ borderRadius: value }"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="size-preview"
|
||||
:style="{ width: value, height: value }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Text/Other input -->
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:value="value"
|
||||
@input="handleInputChange"
|
||||
class="text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.variable-editor {
|
||||
padding: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.variable-editor:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.variable-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.variable-key {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.variable-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.size-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.size-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.size-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radius-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Exponer contexto global para componentes dinámicos
|
||||
;(window as any).__vueApp = app
|
||||
;(window as any).__pinia = pinia
|
||||
|
||||
app.mount('#app')
|
||||
// Inicializar tema antes de montar la app
|
||||
import { useThemeStore } from './stores/theme'
|
||||
const themeStore = useThemeStore(pinia)
|
||||
themeStore.fetchThemes().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
7
frontend/src/pages/CanvasPage.vue
Normal file
7
frontend/src/pages/CanvasPage.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Canvas from '../components/Canvas.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Canvas />
|
||||
</template>
|
||||
716
frontend/src/pages/ComponentsPage.vue
Normal file
716
frontend/src/pages/ComponentsPage.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useComponentsStore, type ComponentState } from '../stores/components'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const store = useComponentsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedComponent = ref<ComponentState | null>(null)
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const componentToDelete = ref<string | null>(null)
|
||||
|
||||
const filteredComponents = computed(() => {
|
||||
if (!searchQuery.value) return store.savedComponents
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return store.savedComponents.filter(c =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.id.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
function selectComponent(comp: ComponentState) {
|
||||
selectedComponent.value = comp
|
||||
}
|
||||
|
||||
function loadInCanvas(comp: ComponentState) {
|
||||
window.dispatchEvent(new CustomEvent('load-vue-component', {
|
||||
detail: {
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
template: comp.template,
|
||||
setup: comp.setup,
|
||||
style: comp.style,
|
||||
props: typeof comp.props === 'string' ? JSON.parse(comp.props) : comp.props || [],
|
||||
imports: typeof comp.imports === 'string' ? JSON.parse(comp.imports) : comp.imports || []
|
||||
}
|
||||
}))
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function confirmDelete(id: string) {
|
||||
componentToDelete.value = id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function deleteComponent() {
|
||||
if (componentToDelete.value) {
|
||||
await store.deleteComponent(componentToDelete.value)
|
||||
if (selectedComponent.value?.id === componentToDelete.value) {
|
||||
selectedComponent.value = null
|
||||
}
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
componentToDelete.value = null
|
||||
}
|
||||
|
||||
async function deleteAll() {
|
||||
if (confirm('¿Eliminar TODOS los componentes? Esta acción no se puede deshacer.')) {
|
||||
await store.deleteAllComponents()
|
||||
selectedComponent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchComponents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="components-page">
|
||||
<!-- Sidebar con lista -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Componentes</h2>
|
||||
<span class="count">{{ store.savedCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
<input v-model="searchQuery" placeholder="Buscar componentes..." />
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'" title="Vista grid">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'" title="Vista lista">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="components-list" :class="viewMode">
|
||||
<div v-if="store.loading" class="loading">Cargando...</div>
|
||||
<div v-else-if="filteredComponents.length === 0" class="empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
</svg>
|
||||
<p>No hay componentes</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="comp in filteredComponents"
|
||||
:key="comp.id"
|
||||
class="component-card"
|
||||
:class="{ selected: selectedComponent?.id === comp.id }"
|
||||
@click="selectComponent(comp)"
|
||||
>
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">{{ comp.name }}</span>
|
||||
<span class="card-id">{{ comp.id }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="action-btn load" @click.stop="loadInCanvas(comp)" title="Cargar en canvas">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn delete" @click.stop="confirmDelete(comp.id)" title="Eliminar">
|
||||
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-danger" @click="deleteAll" :disabled="store.savedCount === 0">
|
||||
Eliminar todos
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Panel de detalles -->
|
||||
<main class="details-panel">
|
||||
<div v-if="!selectedComponent" class="no-selection">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<h3>Selecciona un componente</h3>
|
||||
<p>Haz clic en un componente de la lista para ver sus detalles</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="details-header">
|
||||
<div class="details-title">
|
||||
<h2>{{ selectedComponent.name }}</h2>
|
||||
<code>{{ selectedComponent.id }}</code>
|
||||
</div>
|
||||
<div class="details-actions">
|
||||
<button class="btn-primary" @click="loadInCanvas(selectedComponent)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
Cargar en Canvas
|
||||
</button>
|
||||
<button class="btn-danger" @click="confirmDelete(selectedComponent.id)">
|
||||
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
</svg>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Creado</span>
|
||||
<span class="meta-value">{{ formatDate(selectedComponent.created_at) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Actualizado</span>
|
||||
<span class="meta-value">{{ formatDate(selectedComponent.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="meta-item" v-if="selectedComponent.imports">
|
||||
<span class="meta-label">Imports</span>
|
||||
<span class="meta-value">{{ Array.isArray(selectedComponent.imports) ? selectedComponent.imports.join(', ') : selectedComponent.imports }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-sections">
|
||||
<div class="code-section">
|
||||
<div class="code-header">
|
||||
<span>Template</span>
|
||||
<button @click="copyToClipboard(selectedComponent.template)" title="Copiar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{{ selectedComponent.template }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="code-section" v-if="selectedComponent.setup">
|
||||
<div class="code-header">
|
||||
<span>Setup</span>
|
||||
<button @click="copyToClipboard(selectedComponent.setup)" title="Copiar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{{ selectedComponent.setup }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="code-section" v-if="selectedComponent.style">
|
||||
<div class="code-header">
|
||||
<span>Style</span>
|
||||
<button @click="copyToClipboard(selectedComponent.style)" title="Copiar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{{ selectedComponent.style }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<!-- Modal de confirmación -->
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
||||
<div class="modal" @click.stop>
|
||||
<h3>¿Eliminar componente?</h3>
|
||||
<p>Esta acción no se puede deshacer.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="showDeleteConfirm = false">Cancelar</button>
|
||||
<button class="btn-danger" @click="deleteComponent">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.components-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
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: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.count {
|
||||
background: var(--bg-hover);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search-box svg {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.view-toggle button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--bg-hover);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.components-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.components-list.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.components-list.grid .component-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.components-list.grid .card-icon {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.components-list.grid .card-actions {
|
||||
margin-top: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.component-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.component-card:hover {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.component-card.selected {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-btn.load:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Details Panel */
|
||||
.details-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-selection svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-selection h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.details-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.details-title h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.details-title code {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.details-meta {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.code-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.code-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--bg-hover);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.code-header button {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.code-header button:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.code-section pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.code-section code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger: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 {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
min-width: 300px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
24
frontend/src/router/index.ts
Normal file
24
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'canvas',
|
||||
component: () => import('../pages/CanvasPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/components',
|
||||
name: 'components',
|
||||
component: () => import('../pages/ComponentsPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/themes',
|
||||
name: 'themes',
|
||||
component: () => import('../pages/ThemesPage.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import { registerTool } from './webmcp'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
@@ -254,4 +255,62 @@ export function registerCanvasTools() {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// get_design_tokens
|
||||
registerTool(
|
||||
'get_design_tokens',
|
||||
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
|
||||
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
|
||||
}
|
||||
}
|
||||
},
|
||||
async (args: { category?: string }) => {
|
||||
try {
|
||||
const themeStore = useThemeStore()
|
||||
const theme = themeStore.activeTheme
|
||||
|
||||
if (!theme) {
|
||||
return 'No hay tema activo. Usa las variables CSS por defecto.'
|
||||
}
|
||||
|
||||
const category = args.category || 'all'
|
||||
const variables = theme.variables
|
||||
|
||||
if (category !== 'all' && variables[category as keyof typeof variables]) {
|
||||
const categoryVars = variables[category as keyof typeof variables]
|
||||
const tokenList = Object.entries(categoryVars)
|
||||
.map(([name, value]) => `--${name}: ${value}`)
|
||||
.join('\n')
|
||||
|
||||
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
|
||||
}
|
||||
|
||||
// Return all tokens organized by category
|
||||
const allTokens = Object.entries(variables)
|
||||
.map(([cat, vars]) => {
|
||||
const tokenList = Object.entries(vars as Record<string, string>)
|
||||
.map(([name, value]) => ` --${name}: ${value}`)
|
||||
.join('\n')
|
||||
return `[${cat.toUpperCase()}]\n${tokenList}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
|
||||
`GUÍA DE USO:\n` +
|
||||
`- Usa var(--nombre-variable) en CSS\n` +
|
||||
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
|
||||
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
|
||||
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
|
||||
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'vue'
|
||||
import { setActivePinia, type Pinia } from 'pinia'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
const API_URL = 'http://localhost:4101'
|
||||
|
||||
@@ -171,6 +172,12 @@ function getCanvasStore() {
|
||||
return useCanvasStore()
|
||||
}
|
||||
|
||||
function getThemeStore() {
|
||||
const globalPinia = (window as any).__pinia as Pinia | undefined
|
||||
if (globalPinia) setActivePinia(globalPinia)
|
||||
return useThemeStore()
|
||||
}
|
||||
|
||||
const dynamicHelpers = {
|
||||
$emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
|
||||
$on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
|
||||
@@ -182,7 +189,15 @@ const dynamicHelpers = {
|
||||
list: () => componentsApi.getAll(),
|
||||
save: (comp: VueComponentDefinition) => componentsApi.save(comp),
|
||||
},
|
||||
$theme: {
|
||||
getVariable: (name: string) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim(),
|
||||
setVariable: (name: string, value: string) => document.documentElement.style.setProperty(`--${name}`, value),
|
||||
getTokens: () => getThemeStore().designTokens,
|
||||
getActiveTheme: () => getThemeStore().activeTheme,
|
||||
getVariables: () => getThemeStore().currentVariables,
|
||||
},
|
||||
useCanvasStore: getCanvasStore,
|
||||
useThemeStore: getThemeStore,
|
||||
$nextTick: nextTick,
|
||||
}
|
||||
|
||||
|
||||
302
frontend/src/services/themeService.ts
Normal file
302
frontend/src/services/themeService.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { ThemeVariables, Theme, DesignTokens } from '../stores/theme'
|
||||
|
||||
const API_URL = 'http://localhost:4101'
|
||||
|
||||
// =====================
|
||||
// API Client
|
||||
// =====================
|
||||
|
||||
export const themesApi = {
|
||||
async getAll(): Promise<Theme[]> {
|
||||
const res = await fetch(`${API_URL}/api/themes`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Theme | null> {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async getActive(): Promise<Theme | null> {
|
||||
const res = await fetch(`${API_URL}/api/themes/active`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async save(theme: Partial<Theme>): Promise<{ success: boolean; id: string }> {
|
||||
const res = await fetch(`${API_URL}/api/themes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(theme)
|
||||
})
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async setDefault(id: string): Promise<{ success: boolean }> {
|
||||
const res = await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async getDesignTokens(): Promise<DesignTokens> {
|
||||
const res = await fetch(`${API_URL}/api/design-tokens`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
getExportUrl(id: string): string {
|
||||
return `${API_URL}/api/themes/export/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// CSS Utilities
|
||||
// =====================
|
||||
|
||||
export function variablesToCSS(variables: ThemeVariables): string {
|
||||
const lines: string[] = [':root {']
|
||||
|
||||
for (const [category, vars] of Object.entries(variables)) {
|
||||
lines.push(` /* ${category} */`)
|
||||
for (const [key, value] of Object.entries(vars as Record<string, string>)) {
|
||||
lines.push(` --${key}: ${value};`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function parseColorValue(value: string): { hex: string; alpha: number } {
|
||||
// Handle rgba
|
||||
const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
|
||||
if (rgbaMatch) {
|
||||
const r = rgbaMatch[1] || '0'
|
||||
const g = rgbaMatch[2] || '0'
|
||||
const b = rgbaMatch[3] || '0'
|
||||
const a = rgbaMatch[4]
|
||||
const hex = `#${[r, g, b].map(x => parseInt(x).toString(16).padStart(2, '0')).join('')}`
|
||||
return { hex, alpha: a ? parseFloat(a) : 1 }
|
||||
}
|
||||
|
||||
// Handle hex with alpha
|
||||
if (value.length === 9 && value.startsWith('#')) {
|
||||
const alpha = parseInt(value.slice(7), 16) / 255
|
||||
return { hex: value.slice(0, 7), alpha }
|
||||
}
|
||||
|
||||
return { hex: value, alpha: 1 }
|
||||
}
|
||||
|
||||
export function isColorVariable(key: string): boolean {
|
||||
const colorKeywords = ['bg', 'text', 'color', 'accent', 'success', 'warning', 'error', 'info', 'border']
|
||||
return colorKeywords.some(kw => key.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
export function isSizeVariable(key: string): boolean {
|
||||
const sizeKeywords = ['radius', 'size', 'spacing', 'width', 'height']
|
||||
return sizeKeywords.some(kw => key.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
export function isTransitionVariable(key: string): boolean {
|
||||
return key.toLowerCase().includes('transition')
|
||||
}
|
||||
|
||||
export function isShadowVariable(key: string): boolean {
|
||||
return key.toLowerCase().includes('shadow')
|
||||
}
|
||||
|
||||
export function isFontVariable(key: string): boolean {
|
||||
return key.toLowerCase().includes('font')
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Color Manipulation
|
||||
// =====================
|
||||
|
||||
export function hexToRGB(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
if (!result) return { r: 0, g: 0, b: 0 }
|
||||
return {
|
||||
r: parseInt(result[1] || '0', 16),
|
||||
g: parseInt(result[2] || '0', 16),
|
||||
b: parseInt(result[3] || '0', 16)
|
||||
}
|
||||
}
|
||||
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(x => Math.round(x).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
const { r, g, b } = hexToRGB(hex)
|
||||
const rNorm = r / 255
|
||||
const gNorm = g / 255
|
||||
const bNorm = b / 255
|
||||
|
||||
const max = Math.max(rNorm, gNorm, bNorm)
|
||||
const min = Math.min(rNorm, gNorm, bNorm)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case rNorm:
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
||||
break
|
||||
case gNorm:
|
||||
h = ((bNorm - rNorm) / d + 2) / 6
|
||||
break
|
||||
case bNorm:
|
||||
h = ((rNorm - gNorm) / d + 4) / 6
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100)
|
||||
}
|
||||
}
|
||||
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100
|
||||
l /= 100
|
||||
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||
return Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`
|
||||
}
|
||||
|
||||
export function lighten(hex: string, amount: number): string {
|
||||
const { h, s, l } = hexToHSL(hex)
|
||||
return hslToHex(h, s, Math.min(100, l + amount))
|
||||
}
|
||||
|
||||
export function darken(hex: string, amount: number): string {
|
||||
const { h, s, l } = hexToHSL(hex)
|
||||
return hslToHex(h, s, Math.max(0, l - amount))
|
||||
}
|
||||
|
||||
export function generateColorScale(baseColor: string, steps = 10): string[] {
|
||||
const { h, s } = hexToHSL(baseColor)
|
||||
const scale: string[] = []
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const l = 95 - i * 9 // From 95% to 5% lightness
|
||||
scale.push(hslToHex(h, s, Math.max(5, Math.min(95, l))))
|
||||
}
|
||||
|
||||
return scale
|
||||
}
|
||||
|
||||
export function getContrastColor(hex: string): string {
|
||||
const { r, g, b } = hexToRGB(hex)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Theme Categories
|
||||
// =====================
|
||||
|
||||
export const THEME_CATEGORIES = [
|
||||
{ key: 'colors', label: 'Backgrounds', icon: 'square' },
|
||||
{ key: 'text', label: 'Text', icon: 'type' },
|
||||
{ key: 'accent', label: 'Accent', icon: 'zap' },
|
||||
{ key: 'semantic', label: 'Semantic', icon: 'alert-circle' },
|
||||
{ key: 'spacing', label: 'Spacing', icon: 'maximize' },
|
||||
{ key: 'typography', label: 'Typography', icon: 'text' },
|
||||
{ key: 'effects', label: 'Effects', icon: 'layers' }
|
||||
] as const
|
||||
|
||||
export type ThemeCategory = (typeof THEME_CATEGORIES)[number]['key']
|
||||
|
||||
// =====================
|
||||
// Validation
|
||||
// =====================
|
||||
|
||||
export function validateTheme(theme: Partial<Theme>): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!theme.name?.trim()) {
|
||||
errors.push('Theme name is required')
|
||||
}
|
||||
|
||||
if (!theme.variables) {
|
||||
errors.push('Theme variables are required')
|
||||
} else {
|
||||
const requiredCategories = ['colors', 'text', 'accent']
|
||||
for (const cat of requiredCategories) {
|
||||
if (!(cat in theme.variables)) {
|
||||
errors.push(`Missing required category: ${cat}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
// =====================
|
||||
// File Operations
|
||||
// =====================
|
||||
|
||||
export function downloadTheme(theme: Theme): void {
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
name: theme.name,
|
||||
description: theme.description,
|
||||
variables: theme.variables,
|
||||
metadata: {
|
||||
...theme.metadata,
|
||||
exported_at: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${theme.name.toLowerCase().replace(/\s+/g, '-')}-theme.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function readThemeFile(file: File): Promise<Partial<Theme>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(reader.result as string)
|
||||
if (!data.name || !data.variables) {
|
||||
reject(new Error('Invalid theme file format'))
|
||||
}
|
||||
resolve(data)
|
||||
} catch {
|
||||
reject(new Error('Failed to parse theme file'))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
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