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:
@@ -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>
|
||||
Reference in New Issue
Block a user