This commit is contained in:
Thomas Peterson 2026-01-26 18:04:07 +01:00
parent 2ae3335031
commit e7f3bb6b12
21 changed files with 765 additions and 1119 deletions

View File

@ -19,6 +19,8 @@ void WidgetConfig::serialize(uint8_t* buf) const {
memcpy(&buf[pos], text, MAX_TEXT_LEN); pos += MAX_TEXT_LEN;
buf[pos++] = knxAddress & 0xFF; buf[pos++] = (knxAddress >> 8) & 0xFF;
buf[pos++] = fontSize;
buf[pos++] = textAlign;
buf[pos++] = isContainer ? 1 : 0;
buf[pos++] = textColor.r; buf[pos++] = textColor.g; buf[pos++] = textColor.b;
buf[pos++] = bgColor.r; buf[pos++] = bgColor.g; buf[pos++] = bgColor.b;
@ -66,6 +68,8 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
text[MAX_TEXT_LEN - 1] = '\0';
knxAddress = buf[pos] | (buf[pos+1] << 8); pos += 2;
fontSize = buf[pos++];
textAlign = buf[pos++];
isContainer = buf[pos++] != 0;
textColor.r = buf[pos++]; textColor.g = buf[pos++]; textColor.b = buf[pos++];
bgColor.r = buf[pos++]; bgColor.g = buf[pos++]; bgColor.b = buf[pos++];
@ -109,6 +113,8 @@ WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const c
cfg.textSource = TextSource::STATIC;
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
cfg.fontSize = 1; // 18pt
cfg.textAlign = static_cast<uint8_t>(TextAlign::LEFT);
cfg.isContainer = false;
cfg.textColor = {255, 255, 255};
cfg.bgColor = {0, 0, 0};
cfg.bgOpacity = 0;
@ -144,6 +150,8 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
cfg.textSource = TextSource::STATIC;
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
cfg.fontSize = 1;
cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
cfg.isContainer = true;
cfg.textColor = {255, 255, 255};
cfg.bgColor = {33, 150, 243}; // Blue
cfg.bgOpacity = 255;

View File

@ -46,6 +46,12 @@ enum class TextSource : uint8_t {
KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000)
};
enum class TextAlign : uint8_t {
LEFT = 0,
CENTER = 1,
RIGHT = 2,
};
// Color as RGB888
struct Color {
uint8_t r, g, b;
@ -87,6 +93,8 @@ struct WidgetConfig {
char text[MAX_TEXT_LEN]; // Static text or format string
uint16_t knxAddress; // KNX group address (if textSource != STATIC)
uint8_t fontSize; // Font size index (0=14, 1=18, 2=22, 3=28, 4=36, 5=48)
uint8_t textAlign; // TextAlign: 0=left, 1=center, 2=right
bool isContainer; // For buttons: use as container (no internal label/icon)
// Colors
Color textColor;
@ -113,7 +121,7 @@ struct WidgetConfig {
int8_t parentId; // ID of parent widget (-1 = root/screen)
// Serialization size (fixed for NVS storage)
static constexpr size_t SERIALIZED_SIZE = 77;
static constexpr size_t SERIALIZED_SIZE = 78;
void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf);

View File

@ -69,6 +69,61 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds
dst[di] = '\0';
}
static WidgetConfig makeButtonLabelChild(const WidgetConfig& button) {
WidgetConfig label = WidgetConfig::createLabel(0, 0, 0, button.text);
label.parentId = button.id;
if (button.width > 0) label.width = button.width;
if (button.height > 0) label.height = button.height;
label.fontSize = button.fontSize;
label.textAlign = button.textAlign;
label.textColor = button.textColor;
label.textSource = TextSource::STATIC;
label.bgOpacity = 0;
label.borderRadius = 0;
label.shadow.enabled = false;
// Preserve existing icon config if any
label.iconCodepoint = button.iconCodepoint;
label.iconPosition = button.iconPosition;
label.iconSize = button.iconSize;
label.iconGap = button.iconGap;
if (label.text[0] == '\0') {
strncpy(label.text, "Button", MAX_TEXT_LEN - 1);
label.text[MAX_TEXT_LEN - 1] = '\0';
}
return label;
}
static void ensureButtonLabels(ScreenConfig& screen) {
bool hasLabelChild[MAX_WIDGETS] = {};
for (uint8_t i = 0; i < screen.widgetCount; i++) {
const WidgetConfig& w = screen.widgets[i];
if (w.type == WidgetType::LABEL && w.parentId >= 0 && w.parentId < MAX_WIDGETS) {
hasLabelChild[w.parentId] = true;
}
}
const uint8_t initialCount = screen.widgetCount;
for (uint8_t i = 0; i < initialCount; i++) {
WidgetConfig& w = screen.widgets[i];
if (w.type != WidgetType::BUTTON) continue;
w.isContainer = true;
if (w.id < MAX_WIDGETS && hasLabelChild[w.id]) continue;
WidgetConfig label = makeButtonLabelChild(w);
int newId = screen.addWidget(label);
if (newId < 0) {
ESP_LOGW(TAG, "No space to add label child for button %d", w.id);
w.isContainer = false;
continue;
}
if (w.id < MAX_WIDGETS) {
hasLabelChild[w.id] = true;
}
}
}
// WidgetManager implementation
WidgetManager& WidgetManager::instance() {
static WidgetManager inst;
@ -103,6 +158,8 @@ void WidgetManager::createDefaultConfig() {
progBtn.bgColor = {200, 50, 50}; // Red
screen.addWidget(progBtn);
ensureButtonLabels(screen);
config_.startScreenId = screen.id;
config_.standbyEnabled = false;
config_.standbyScreenId = 0xFF;
@ -836,6 +893,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddStringToObject(widget, "text", w.text);
cJSON_AddNumberToObject(widget, "knxAddr", w.knxAddress);
cJSON_AddNumberToObject(widget, "fontSize", w.fontSize);
cJSON_AddNumberToObject(widget, "textAlign", w.textAlign);
cJSON_AddBoolToObject(widget, "isContainer", w.isContainer);
char textColorStr[8];
snprintf(textColorStr, sizeof(textColorStr), "#%02X%02X%02X",
@ -920,6 +979,8 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.visible = true;
w.action = ButtonAction::KNX;
w.targetScreen = 0;
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
w.isContainer = false;
cJSON* id = cJSON_GetObjectItem(widget, "id");
if (cJSON_IsNumber(id)) w.id = id->valueint;
@ -957,6 +1018,12 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON* fontSize = cJSON_GetObjectItem(widget, "fontSize");
if (cJSON_IsNumber(fontSize)) w.fontSize = fontSize->valueint;
cJSON* textAlign = cJSON_GetObjectItem(widget, "textAlign");
if (cJSON_IsNumber(textAlign)) w.textAlign = textAlign->valueint;
cJSON* isContainer = cJSON_GetObjectItem(widget, "isContainer");
if (cJSON_IsBool(isContainer)) w.isContainer = cJSON_IsTrue(isContainer);
cJSON* textColor = cJSON_GetObjectItem(widget, "textColor");
if (cJSON_IsString(textColor)) {
w.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
@ -1096,6 +1163,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
if (!parseWidgets(widgets, screen)) {
screen.widgetCount = 0;
}
ensureButtonLabels(screen);
newConfig->screenCount++;
}
@ -1114,6 +1182,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
cJSON_Delete(root);
return false;
}
ensureButtonLabels(screen);
}
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");

View File

@ -5,6 +5,18 @@
static const char* TAG = "ButtonWidget";
static lv_text_align_t toLvTextAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_TEXT_ALIGN_LEFT;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_TEXT_ALIGN_RIGHT;
return LV_TEXT_ALIGN_CENTER;
}
static lv_flex_align_t toFlexAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_FLEX_ALIGN_START;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END;
return LV_FLEX_ALIGN_CENTER;
}
ButtonWidget::ButtonWidget(const WidgetConfig& config)
: Widget(config)
, contentContainer_(nullptr)
@ -73,14 +85,30 @@ void ButtonWidget::setupFlexLayout() {
lv_obj_set_flex_flow(contentContainer_,
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(contentContainer_,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER;
lv_flex_align_t crossAlign = LV_FLEX_ALIGN_CENTER;
lv_flex_align_t contentAlign = toFlexAlign(config_.textAlign);
if (contentAlign != LV_FLEX_ALIGN_CENTER) {
if (isVertical) {
crossAlign = contentAlign;
} else {
mainAlign = contentAlign;
}
}
lv_obj_set_flex_align(contentContainer_, mainAlign, crossAlign, LV_FLEX_ALIGN_CENTER);
// Set gap between icon and text
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
lv_obj_set_style_pad_gap(contentContainer_, gap, 0);
}
void ButtonWidget::applyTextAlignment() {
if (label_ == nullptr) return;
lv_obj_set_width(label_, LV_PCT(100));
lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0);
lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0);
}
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
obj_ = lv_btn_create(parent);
lv_obj_set_pos(obj_, config_.x, config_.y);
@ -89,51 +117,54 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this);
bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
bool hasIcon = !config_.isContainer &&
config_.iconCodepoint > 0 && Fonts::hasIconFont();
if (hasIcon) {
// Create container for flex layout
contentContainer_ = lv_obj_create(obj_);
if (contentContainer_ == nullptr) {
return obj_; // Continue without icon container
if (!config_.isContainer) {
if (hasIcon) {
// Create container for flex layout
contentContainer_ = lv_obj_create(obj_);
if (contentContainer_ == nullptr) {
return obj_; // Continue without icon container
}
lv_obj_remove_style_all(contentContainer_);
lv_obj_set_size(contentContainer_, LV_PCT(100), LV_PCT(100));
lv_obj_center(contentContainer_);
lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent
// Create icon label
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
if (iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
// Create text label
label_ = lv_label_create(contentContainer_);
lv_label_set_text(label_, config_.text);
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
if (!iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
setupFlexLayout();
} else {
// Simple button without icon
label_ = lv_label_create(obj_);
lv_label_set_text(label_, config_.text);
applyTextAlignment();
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_remove_style_all(contentContainer_);
lv_obj_set_size(contentContainer_, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_center(contentContainer_);
lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent
// Create icon label
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
if (iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
// Create text label
label_ = lv_label_create(contentContainer_);
lv_label_set_text(label_, config_.text);
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
if (!iconFirst) {
iconLabel_ = lv_label_create(contentContainer_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
setupFlexLayout();
} else {
// Simple button without icon
label_ = lv_label_create(obj_);
lv_label_set_text(label_, config_.text);
lv_obj_center(label_);
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
}
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",

View File

@ -19,6 +19,7 @@ private:
lv_obj_t* iconLabel_ = nullptr;
void setupFlexLayout();
void applyTextAlignment();
static int encodeUtf8(uint32_t codepoint, char* buf);
static void clickCallback(lv_event_t* e);
};

View File

@ -10,6 +10,18 @@ LabelWidget::LabelWidget(const WidgetConfig& config)
{
}
static lv_text_align_t toLvTextAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_TEXT_ALIGN_LEFT;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_TEXT_ALIGN_RIGHT;
return LV_TEXT_ALIGN_CENTER;
}
static lv_flex_align_t toFlexAlign(uint8_t align) {
if (align == static_cast<uint8_t>(TextAlign::LEFT)) return LV_FLEX_ALIGN_START;
if (align == static_cast<uint8_t>(TextAlign::RIGHT)) return LV_FLEX_ALIGN_END;
return LV_FLEX_ALIGN_CENTER;
}
int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) {
if (codepoint < 0x80) {
buf[0] = static_cast<char>(codepoint);
@ -47,8 +59,17 @@ void LabelWidget::setupFlexLayout() {
lv_obj_set_flex_flow(container_,
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(container_,
LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER;
lv_flex_align_t crossAlign = LV_FLEX_ALIGN_CENTER;
lv_flex_align_t contentAlign = toFlexAlign(config_.textAlign);
if (contentAlign != LV_FLEX_ALIGN_CENTER) {
if (isVertical) {
crossAlign = contentAlign;
} else {
mainAlign = contentAlign;
}
}
lv_obj_set_flex_align(container_, mainAlign, crossAlign, LV_FLEX_ALIGN_CENTER);
// Set gap between icon and text
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
@ -85,16 +106,19 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
textLabel_ = lv_label_create(container_);
lv_label_set_text(textLabel_, config_.text);
lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE);
if (!iconFirst) {
iconLabel_ = lv_label_create(container_);
char iconText[5];
encodeUtf8(config_.iconCodepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
}
setupFlexLayout();
@ -103,6 +127,7 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
textLabel_ = lv_label_create(container_);
lv_label_set_text(textLabel_, config_.text);
lv_obj_center(textLabel_);
lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE);
}
} else {
// Simple label without container
@ -115,6 +140,9 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
}
}
if (obj_ != nullptr) {
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
}
return obj_;
}
@ -138,6 +166,7 @@ void LabelWidget::applyStyle() {
lv_obj_set_style_text_color(textLabel_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
lv_obj_set_style_text_font(textLabel_, Fonts::bySizeIndex(config_.fontSize), 0);
lv_obj_set_style_text_align(textLabel_, toLvTextAlign(config_.textAlign), 0);
}
// Apply icon style

View File

@ -1,24 +1,32 @@
<template>
<TopBar />
<div class="workspace">
<SidebarLeft />
<CanvasArea />
<SidebarRight />
<div class="min-h-screen flex flex-col">
<TopBar @open-settings="showSettings = true" />
<div class="flex-1 min-h-0 grid grid-cols-[300px_1fr_320px] max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[auto_auto_auto]">
<SidebarLeft />
<CanvasArea @open-screen-settings="showScreenSettings = true" />
<SidebarRight />
</div>
<SettingsModal v-if="showSettings" @close="showSettings = false" />
<ScreenSettingsModal v-if="showScreenSettings" @close="showScreenSettings = false" />
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import TopBar from './components/TopBar.vue';
import SidebarLeft from './components/SidebarLeft.vue';
import SidebarRight from './components/SidebarRight.vue';
import CanvasArea from './components/CanvasArea.vue';
import SettingsModal from './components/SettingsModal.vue';
import ScreenSettingsModal from './components/ScreenSettingsModal.vue';
import { useEditorStore } from './stores/editor';
const store = useEditorStore();
const showSettings = ref(false);
const showScreenSettings = ref(false);
onMounted(() => {
store.loadConfig();
store.loadKnxAddresses();
});
</script>
</script>

View File

@ -1,13 +1,46 @@
<template>
<main class="canvas-area">
<div class="canvas-shell">
<main class="p-5 overflow-auto flex flex-col items-center gap-4 min-h-0">
<div class="w-full">
<div class="bg-panel border border-border rounded-[14px] shadow-[0_8px_18px_var(--shadow)] w-full pt-3 px-3.5" :class="screensOpen ? 'pb-3' : 'pb-2'">
<div class="flex flex-wrap items-center justify-between gap-2.5" :class="screensOpen ? 'mb-3' : 'mb-0'">
<div class="flex items-center gap-2">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Bildschirme</h3>
<span class="text-[11px] text-muted">{{ store.config.screens.length }}</span>
</div>
<div class="flex items-center gap-2">
<button class="border border-border bg-transparent text-text px-2.5 py-1.5 rounded-[10px] text-[12px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5" @click="emit('open-screen-settings')">Einstellungen</button>
<button class="w-7 h-7 rounded-lg border border-border bg-panel-2 text-text grid place-items-center cursor-pointer hover:border-accent" @click="store.addScreen">+</button>
<button class="w-7 h-7 rounded-lg border border-border bg-panel-2 text-text grid place-items-center cursor-pointer hover:border-accent" @click="screensOpen = !screensOpen">
<span class="material-symbols-outlined text-[18px]">{{ screensOpen ? 'expand_less' : 'expand_more' }}</span>
</button>
</div>
</div>
<div v-if="screensOpen" class="flex flex-wrap gap-2">
<button
v-for="screen in store.config.screens"
:key="screen.id"
class="bg-panel-2 border border-border rounded-full px-2.5 py-1.5 text-left text-text text-[12px] inline-flex items-center gap-2 cursor-pointer transition hover:border-accent hover:-translate-y-0.5"
:class="screen.id === store.activeScreenId ? 'border-accent-2 shadow-[0_0_0_2px_rgba(90,147,218,0.2)]' : ''"
@click="selectScreen(screen.id)"
>
<span class="max-w-[160px] overflow-hidden text-ellipsis whitespace-nowrap">{{ screen.name }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-accent-2/15 text-accent-2 uppercase tracking-[0.06em]">{{ screen.mode === 1 ? 'Modal' : 'Fullscreen' }}</span>
</button>
</div>
</div>
</div>
<div class="p-[18px] rounded-[18px] border border-border bg-white shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<div
class="canvas"
class="relative border border-border overflow-hidden"
id="canvas"
:class="{ 'grid-off': !store.showGrid }"
:style="canvasStyle"
@click.self="deselect"
>
<div
class="pointer-events-none absolute inset-0 z-0 transition-opacity"
:class="store.showGrid ? 'opacity-50' : 'opacity-0'"
:style="gridStyle"
></div>
<WidgetElement
v-for="widget in rootWidgets"
:key="widget.id"
@ -24,13 +57,15 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import WidgetElement from './WidgetElement.vue';
import { DISPLAY_W, DISPLAY_H, WIDGET_TYPES } from '../constants';
import { clamp, minSizeFor } from '../utils';
const emit = defineEmits(['open-screen-settings']);
const store = useEditorStore();
const screensOpen = ref(true);
const rootWidgets = computed(() => {
return store.activeScreen?.widgets.filter(w => w.parentId === -1) || [];
@ -53,14 +88,23 @@ const canvasH = computed(() => {
const canvasStyle = computed(() => ({
width: `${canvasW.value * store.canvasScale}px`,
height: `${canvasH.value * store.canvasScale}px`,
'--canvas-bg': store.activeScreen?.bgColor || '#1A1A2E',
'--grid-size': `${store.gridSize * store.canvasScale}px`
backgroundColor: store.activeScreen?.bgColor || '#1A1A2E'
}));
const gridStyle = computed(() => ({
backgroundImage: 'linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px)',
backgroundSize: `${store.gridSize * store.canvasScale}px ${store.gridSize * store.canvasScale}px`
}));
function deselect() {
store.selectedWidgetId = null;
}
function selectScreen(id) {
store.activeScreenId = id;
store.selectedWidgetId = null;
}
function snap(val) {
if (!store.snapToGrid) return val;
const s = store.gridSize;
@ -72,7 +116,7 @@ let resizeState = null;
function startDrag(payload) {
const { id, event: e } = payload;
if (e.target.closest('.resize-handle')) return;
if (e.target.closest('[data-resize-handle]')) return;
e.preventDefault();
store.selectedWidgetId = id;

View File

@ -9,35 +9,30 @@ const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<h1 class="text-2xl font-semibold">{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
<div class="mt-4 border border-border rounded-lg p-4 bg-panel">
<button class="border border-border bg-panel-2 text-text px-3 py-2 rounded-lg text-sm font-semibold" type="button" @click="count++">count is {{ count }}</button>
<p class="mt-2 text-sm text-muted">
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
<p class="mt-4 text-sm">
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
<a class="text-accent underline" href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
<p class="mt-2 text-sm">
Learn more about IDE Support for Vue in the
<a
class="text-accent underline"
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
<p class="mt-2 text-sm text-muted">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -3,7 +3,7 @@
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
@click.self="$emit('close')"
>
<div class="border border-border rounded-2xl w-[90%] max-w-2xl max-h-[80vh] flex flex-col shadow-2xl" style="background: var(--panel)">
<div class="border border-border rounded-2xl w-[90%] max-w-2xl max-h-[80vh] flex flex-col shadow-2xl bg-panel">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 class="text-base font-semibold">Icon auswaehlen</h3>
@ -33,7 +33,7 @@
<span class="material-symbols-outlined text-2xl text-accent">{{ String.fromCodePoint(modelValue) }}</span>
<span class="flex-1 text-sm">{{ currentIconName }}</span>
<button
class="bg-transparent border border-red-400/40 text-red-200 px-3 py-1.5 rounded-md text-xs cursor-pointer hover:bg-red-500/20"
class="bg-transparent border border-red-300 text-red-600 px-3 py-1.5 rounded-md text-xs cursor-pointer hover:bg-red-50"
@click="selectIcon(0)"
>
Entfernen
@ -44,7 +44,7 @@
<div class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
<button
v-for="icon in filteredIcons"
:key="icon.codepoint"
:key="icon.name"
class="flex flex-col items-center justify-center gap-2 p-3 bg-panel-2 border rounded-xl cursor-pointer transition-all hover:border-accent hover:-translate-y-0.5 min-h-[80px]"
:class="icon.codepoint === modelValue ? 'border-accent-2 bg-accent-2/10' : 'border-border'"
@click="selectIcon(icon.codepoint)"

View File

@ -0,0 +1,68 @@
<template>
<div class="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50" @click.self="emit('close')">
<div class="w-[min(720px,96vw)] max-h-[85vh] bg-panel border border-border rounded-2xl shadow-[0_20px_40px_var(--shadow)] flex flex-col">
<div class="flex items-center justify-between gap-3 px-[18px] py-4 border-b border-border">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Bildschirm</h3>
<button class="w-7 h-7 rounded-lg border border-border bg-panel-2 text-text grid place-items-center cursor-pointer hover:border-accent" @click="emit('close')">x</button>
</div>
<div class="px-[18px] py-4 overflow-y-auto flex flex-col gap-3.5" v-if="screen">
<div class="flex flex-col gap-2.5">
<div class="text-[12px] uppercase tracking-[0.08em] text-muted">Allgemein</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Name</label>
<input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="text" v-model="screen.name">
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Hintergrund</label>
<input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="screen.bgColor">
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Modus</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="screen.mode">
<option :value="0">Fullscreen</option>
<option :value="1">Modal</option>
</select>
</div>
</div>
<div class="flex flex-col gap-2.5" v-if="screen.mode === 1">
<div class="text-[12px] uppercase tracking-[0.08em] text-muted">Modal</div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">X</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.x"></div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">Y</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.y"></div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">Breite</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.w"></div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">Hoehe</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.h"></div>
<div class="flex items-center justify-between gap-2.5"><label class="text-[12px] text-muted">Radius</label><input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" v-model.number="screen.modal.radius"></div>
<label class="flex items-center gap-2 text-[12px] text-muted">
<input class="accent-[var(--accent)]" type="checkbox" v-model="screen.modal.dim">
<span>Hintergrund dimmen</span>
</label>
</div>
</div>
<div class="px-[18px] py-4 overflow-y-auto flex flex-col gap-3.5" v-else>
<div class="text-[12px] text-muted">Kein Bildschirm ausgewaehlt.</div>
</div>
<div class="px-[18px] py-3 border-t border-border flex justify-end gap-2.5">
<button class="border border-border bg-transparent text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5" @click="emit('close')">Schliessen</button>
<button class="border border-red-300 bg-transparent text-red-600 px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-red-50 active:translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!canDelete" @click="handleDelete">Screen loeschen</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useEditorStore } from '../stores/editor';
const emit = defineEmits(['close']);
const store = useEditorStore();
const screen = computed(() => store.activeScreen);
const canDelete = computed(() => store.config.screens.length > 1);
function handleDelete() {
if (!canDelete.value) return;
if (!confirm('Wirklich loeschen?')) return;
store.deleteScreen();
emit('close');
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<div class="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50" @click.self="emit('close')">
<div class="w-[min(720px,96vw)] max-h-[85vh] bg-panel border border-border rounded-2xl shadow-[0_20px_40px_var(--shadow)] flex flex-col">
<div class="flex items-center justify-between gap-3 px-[18px] py-4 border-b border-border">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Einstellungen</h3>
<button class="w-7 h-7 rounded-lg border border-border bg-panel-2 text-text grid place-items-center cursor-pointer hover:border-accent" @click="emit('close')">x</button>
</div>
<div class="px-[18px] py-4 overflow-y-auto flex flex-col gap-3.5">
<div class="flex flex-col gap-2.5">
<div class="text-[12px] uppercase tracking-[0.08em] text-muted">Allgemein</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Startscreen</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.startScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-[12px] text-muted">
<input class="accent-[var(--accent)]" type="checkbox" v-model="store.config.standby.enabled">
<span>Standby aktiv</span>
</label>
<div class="flex items-center justify-between gap-2.5 mt-2">
<label class="text-[12px] text-muted">Standby</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.standby.screen">
<option :value="-1">-- Kein --</option>
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Minuten</label>
<input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" min="0" v-model.number="store.config.standby.minutes">
</div>
</div>
</div>
<div class="px-[18px] py-3 border-t border-border flex justify-end gap-2.5">
<button class="border border-border bg-transparent text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5" @click="emit('close')">Schliessen</button>
</div>
</div>
</div>
</template>
<script setup>
import { useEditorStore } from '../stores/editor';
const emit = defineEmits(['close']);
const store = useEditorStore();
</script>

View File

@ -1,89 +1,43 @@
<template>
<aside class="sidebar left">
<section class="panel">
<div class="panel-header">
<h3>Elemente</h3>
<span class="panel-hint">Klick zum Hinzufuegen</span>
<aside class="h-full overflow-y-auto p-[18px] flex flex-col gap-4 border-r border-border max-[1100px]:border-r-0 max-[1100px]:border-b">
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
<div class="flex items-center justify-between gap-2.5 mb-3">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Elemente</h3>
<span class="text-[11px] text-muted">Klick zum Hinzufuegen</span>
</div>
<div class="element-grid">
<button class="element-btn" @click="store.addWidget('label')">
<span class="element-title">Label</span>
<span class="element-sub">Text</span>
<div class="grid grid-cols-2 gap-2.5">
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('label')">
<span class="text-[13px] font-semibold">Label</span>
<span class="text-[11px] text-muted mt-0.5 block">Text</span>
</button>
<button class="element-btn" @click="store.addWidget('button')">
<span class="element-title">Button</span>
<span class="element-sub">Aktion</span>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('button')">
<span class="text-[13px] font-semibold">Button</span>
<span class="text-[11px] text-muted mt-0.5 block">Aktion</span>
</button>
<button class="element-btn" @click="store.addWidget('led')">
<span class="element-title">LED</span>
<span class="element-sub">Status</span>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('led')">
<span class="text-[13px] font-semibold">LED</span>
<span class="text-[11px] text-muted mt-0.5 block">Status</span>
</button>
<button class="element-btn" @click="store.addWidget('icon')">
<span class="element-title">Icon</span>
<span class="element-sub">Symbol</span>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('icon')">
<span class="text-[13px] font-semibold">Icon</span>
<span class="text-[11px] text-muted mt-0.5 block">Symbol</span>
</button>
<button class="element-btn" @click="store.addWidget('tabview')">
<span class="element-title">Tabs</span>
<span class="element-sub">Container</span>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('tabview')">
<span class="text-[13px] font-semibold">Tabs</span>
<span class="text-[11px] text-muted mt-0.5 block">Container</span>
</button>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Bildschirme</h3>
<button class="icon-btn" @click="store.addScreen">+</button>
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
<div class="flex items-center justify-between gap-2.5 mb-3">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Baum</h3>
<span class="text-[11px] text-muted">{{ store.activeScreen?.widgets.length || 0 }}</span>
</div>
<div class="screen-list">
<div
v-for="screen in store.config.screens"
:key="screen.id"
class="screen-item"
:class="{ active: screen.id === store.activeScreenId }"
@click="store.activeScreenId = screen.id; store.selectedWidgetId = null;"
>
<span class="screen-tag">{{ screen.mode === 1 ? 'Modal' : 'Fullscreen' }}</span>
<span class="screen-name">{{ screen.name }}</span>
</div>
</div>
<div class="screen-settings" v-if="store.activeScreen">
<div class="control-row">
<label>Name</label>
<input type="text" v-model="store.activeScreen.name">
</div>
<div class="control-row">
<label>Modus</label>
<select v-model.number="store.activeScreen.mode">
<option :value="0">Fullscreen</option>
<option :value="1">Modal</option>
</select>
</div>
<template v-if="store.activeScreen.mode === 1">
<div class="control-row"><label>X</label><input type="number" v-model.number="store.activeScreen.modal.x"></div>
<div class="control-row"><label>Y</label><input type="number" v-model.number="store.activeScreen.modal.y"></div>
<div class="control-row"><label>Breite</label><input type="number" v-model.number="store.activeScreen.modal.w"></div>
<div class="control-row"><label>Hoehe</label><input type="number" v-model.number="store.activeScreen.modal.h"></div>
<div class="control-row"><label>Radius</label><input type="number" v-model.number="store.activeScreen.modal.radius"></div>
<label class="toggle">
<input type="checkbox" v-model="store.activeScreen.modal.dim">
<span>Hintergrund dimmen</span>
</label>
</template>
<button class="btn ghost danger small" @click="store.deleteScreen">Screen loeschen</button>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Baum</h3>
<span class="panel-hint">{{ store.activeScreen?.widgets.length || 0 }}</span>
</div>
<div class="tree">
<div class="tree-root">{{ store.activeScreen?.name }}</div>
<div class="tree-list">
<div v-if="!store.widgetTree.length" class="tree-empty">Keine Widgets</div>
<div class="flex flex-col gap-2">
<div class="text-[12px] text-muted mb-1">{{ store.activeScreen?.name }}</div>
<div class="flex flex-col gap-1.5">
<div v-if="!store.widgetTree.length" class="text-[12px] text-muted py-1.5">Keine Widgets</div>
<TreeItem
v-else
v-for="node in store.widgetTree"
@ -94,70 +48,36 @@
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Canvas</h3>
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
<div class="flex items-center justify-between gap-2.5 mb-3">
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Canvas</h3>
</div>
<div class="control-row">
<label>Hintergrund</label>
<input type="color" v-model="store.activeScreen.bgColor" v-if="store.activeScreen">
<div class="flex items-center justify-between gap-2.5 mb-2.5">
<label class="text-[12px] text-muted">Zoom</label>
<input class="flex-1 accent-[var(--accent)]" type="range" min="0.3" max="1" step="0.05" v-model.number="store.canvasScale">
</div>
<div class="control-row">
<label>Zoom</label>
<input type="range" min="0.3" max="1" step="0.05" v-model.number="store.canvasScale">
</div>
<div class="control-meta">
<div class="flex items-center justify-between text-[11px] text-muted mb-2">
<span>{{ Math.round(store.canvasScale * 100) }}%</span>
<span>1280x800</span>
</div>
<label class="toggle">
<input type="checkbox" v-model="store.showGrid">
<label class="flex items-center gap-2 text-[12px] text-muted">
<input class="accent-[var(--accent)]" type="checkbox" v-model="store.showGrid">
<span>Grid anzeigen</span>
</label>
<div class="control-row" style="margin-top: 8px;">
<label>Grid Gr.</label>
<input type="number" min="5" max="100" v-model.number="store.gridSize">
<div class="flex items-center justify-between gap-2.5 mt-2 mb-2.5">
<label class="text-[12px] text-muted">Grid Gr.</label>
<input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" min="5" max="100" v-model.number="store.gridSize">
</div>
<label class="toggle">
<input type="checkbox" v-model="store.snapToGrid">
<label class="flex items-center gap-2 text-[12px] text-muted">
<input class="accent-[var(--accent)]" type="checkbox" v-model="store.snapToGrid">
<span>Am Grid ausrichten</span>
</label>
</section>
<section class="panel">
<div class="panel-header">
<h3>Navigation</h3>
</div>
<div class="control-row">
<label>Startscreen</label>
<select v-model.number="store.config.startScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<label class="toggle">
<input type="checkbox" v-model="store.config.standby.enabled">
<span>Standby aktiv</span>
</label>
<div class="control-row" style="margin-top:8px;">
<label>Standby</label>
<select v-model.number="store.config.standby.screen">
<option :value="-1">-- Kein --</option>
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div class="control-row">
<label>Minuten</label>
<input type="number" min="0" v-model.number="store.config.standby.minutes">
</div>
</section>
</aside>
</template>
<script setup>
import { useEditorStore } from '../stores/editor';
import TreeItem from './TreeItem.vue';
import { typeKeyFor } from '../utils';
import { TYPE_LABELS } from '../constants';
const store = useEditorStore();
</script>

View File

@ -1,34 +1,34 @@
<template>
<aside class="sidebar right">
<section class="panel properties" id="properties">
<div v-if="!w" class="no-selection">
<aside class="h-full overflow-y-auto p-[18px] flex flex-col gap-4 border-l border-border max-[1100px]:border-l-0 max-[1100px]:border-t">
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]" id="properties">
<div v-if="!w" class="text-muted text-center py-5 text-[13px]">
Kein Widget ausgewaehlt.<br><br>
Waehle ein Widget im Canvas oder im Baum.
</div>
<div v-else>
<!-- Layout -->
<h4>Layout</h4>
<div class="prop-row"><label>X</label><input type="number" v-model.number="w.x"></div>
<div class="prop-row"><label>Y</label><input type="number" v-model.number="w.y"></div>
<div class="prop-row"><label>Breite</label><input type="number" v-model.number="w.w"></div>
<div class="prop-row"><label>Hoehe</label><input type="number" v-model.number="w.h"></div>
<div class="prop-row"><label>Sichtbar</label><input type="checkbox" v-model="w.visible"></div>
<h4 :class="headingClass">Layout</h4>
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="w.x"></div>
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="w.y"></div>
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" v-model.number="w.w"></div>
<div :class="rowClass"><label :class="labelClass">Hoehe</label><input :class="inputClass" type="number" v-model.number="w.h"></div>
<div :class="rowClass"><label :class="labelClass">Sichtbar</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.visible"></div>
<!-- Content -->
<template v-if="key === 'label'">
<h4>Inhalt</h4>
<div class="prop-row"><label>Quelle</label>
<select v-model.number="w.textSrc" @change="handleTextSrcChange">
<h4 :class="headingClass">Inhalt</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<option v-for="opt in sourceOptions.label" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 0" class="prop-row">
<label>Text</label><input type="text" v-model="w.text">
<div v-if="w.textSrc === 0" :class="rowClass">
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="w.text">
</div>
<template v-else>
<div class="prop-row"><label>Format</label><input type="text" v-model="w.text"></div>
<div class="prop-row"><label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="w.text"></div>
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
@ -39,20 +39,20 @@
</template>
<template v-if="key === 'button'">
<h4>Text</h4>
<div class="prop-row"><label>Text</label><input type="text" v-model="w.text"></div>
<h4 :class="headingClass">Button</h4>
<div :class="rowClass"><label :class="labelClass">Hinweis</label><span :class="noteClass">Text als Label-Child anlegen.</span></div>
</template>
<template v-if="key === 'led'">
<h4>LED</h4>
<div class="prop-row"><label>Modus</label>
<select v-model.number="w.textSrc">
<h4 :class="headingClass">LED</h4>
<div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.led" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 2" class="prop-row">
<label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<div v-if="w.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
@ -63,28 +63,28 @@
<!-- Icon Widget -->
<template v-if="key === 'icon'">
<h4>Icon</h4>
<div class="prop-row">
<label>Icon</label>
<button class="icon-select-btn" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Auswaehlen</span>
</button>
</div>
<div class="prop-row">
<label>Groesse</label>
<select v-model.number="w.iconSize">
<div :class="rowClass">
<label :class="labelClass">Groesse</label>
<select :class="inputClass" v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div class="prop-row"><label>Modus</label>
<select v-model.number="w.textSrc">
<div :class="rowClass"><label :class="labelClass">Modus</label>
<select :class="inputClass" v-model.number="w.textSrc">
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</select>
</div>
<div v-if="w.textSrc === 2" class="prop-row">
<label>KNX Objekt</label>
<select v-model.number="w.knxAddr">
<div v-if="w.textSrc === 2" :class="rowClass">
<label :class="labelClass">KNX Objekt</label>
<select :class="inputClass" v-model.number="w.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
@ -94,121 +94,127 @@
</template>
<template v-if="key === 'tabview'">
<h4>Tabs</h4>
<div class="prop-row"><label>Position</label>
<select v-model.number="w.iconPosition">
<h4 :class="headingClass">Tabs</h4>
<div :class="rowClass"><label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="w.iconPosition">
<option :value="0">Oben</option>
<option :value="1">Unten</option>
<option :value="2">Links</option>
<option :value="3">Rechts</option>
</select>
</div>
<div class="prop-row"><label>Tab Hoehe</label>
<input type="number" v-model.number="w.iconSize" min="1" max="20">
<span style="font-size:10px; color:var(--muted); margin-left:4px">x10px</span>
<div :class="rowClass"><label :class="labelClass">Tab Hoehe</label>
<input :class="inputClass" type="number" v-model.number="w.iconSize" min="1" max="20">
<span class="text-[10px] text-muted ml-1">x10px</span>
</div>
</template>
<template v-if="key === 'tabpage'">
<h4>Tab Seite</h4>
<div class="prop-row"><label>Titel</label><input type="text" v-model="w.text"></div>
<h4 :class="headingClass">Tab Seite</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
</template>
<!-- Typography -->
<template v-if="key === 'label' || key === 'button'">
<h4>Typo</h4>
<div class="prop-row"><label>Schriftgr.</label>
<select v-model.number="w.fontSize">
<template v-if="key === 'label'">
<h4 :class="headingClass">Typo</h4>
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
<select :class="inputClass" v-model.number="w.fontSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
</template>
<!-- Icon for Label/Button -->
<template v-if="key === 'label' || key === 'button'">
<h4>Icon</h4>
<div class="prop-row">
<label>Icon</label>
<button class="icon-select-btn" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<template v-if="key === 'label'">
<h4 :class="headingClass">Icon</h4>
<div :class="rowClass">
<label :class="labelClass">Icon</label>
<button :class="iconSelectClass" @click="showIconPicker = true">
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="w.iconCodepoint" class="icon-remove-btn" @click="w.iconCodepoint = 0">x</button>
<button v-if="w.iconCodepoint" :class="iconRemoveClass" @click="w.iconCodepoint = 0">x</button>
</div>
<template v-if="w.iconCodepoint">
<div class="prop-row">
<label>Position</label>
<select v-model.number="w.iconPosition">
<div :class="rowClass">
<label :class="labelClass">Position</label>
<select :class="inputClass" v-model.number="w.iconPosition">
<option :value="0">Links</option>
<option :value="1">Rechts</option>
<option :value="2">Oben</option>
<option :value="3">Unten</option>
</select>
</div>
<div class="prop-row">
<label>Icon-Gr.</label>
<select v-model.number="w.iconSize">
<div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="w.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
</select>
</div>
<div class="prop-row">
<label>Abstand</label>
<input type="number" v-model.number="w.iconGap" min="0" max="50">
<div :class="rowClass">
<label :class="labelClass">Abstand</label>
<input :class="inputClass" type="number" v-model.number="w.iconGap" min="0" max="50">
</div>
</template>
</template>
<!-- Style -->
<template v-if="key === 'led'">
<h4>Stil</h4>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Helligkeit</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template>
<template v-else-if="key === 'icon'">
<h4>Stil</h4>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.textColor"></div>
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div class="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<template v-else-if="key === 'button'">
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<template v-else>
<h4>Stil</h4>
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></div>
<div class="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div class="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
<h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<!-- Shadow/Glow -->
<template v-if="key !== 'icon'">
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
<div class="prop-row" v-if="key !== 'led'"><label>X</label><input type="number" v-model.number="w.shadow.x"></div>
<div class="prop-row" v-if="key !== 'led'"><label>Y</label><input type="number" v-model.number="w.shadow.y"></div>
<div class="prop-row"><label>Blur</label><input type="number" v-model.number="w.shadow.blur"></div>
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.shadow.color"></div>
<h4 :class="headingClass">{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.shadow.enabled"></div>
<div :class="rowClass" v-if="key !== 'led'"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="w.shadow.x"></div>
<div :class="rowClass" v-if="key !== 'led'"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="w.shadow.y"></div>
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="w.shadow.blur"></div>
<div :class="rowClass"><label :class="labelClass">Farbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.shadow.color"></div>
</template>
<!-- Button Actions -->
<template v-if="key === 'button'">
<h4>Aktion</h4>
<div class="prop-row"><label>Typ</label>
<select v-model.number="w.action">
<h4 :class="headingClass">Aktion</h4>
<div :class="rowClass"><label :class="labelClass">Typ</label>
<select :class="inputClass" v-model.number="w.action">
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
</select>
</div>
<div v-if="w.action === BUTTON_ACTIONS.JUMP" class="prop-row">
<label>Ziel</label>
<select v-model.number="w.targetScreen">
<div v-if="w.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
<label :class="labelClass">Ziel</label>
<select :class="inputClass" v-model.number="w.targetScreen">
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<template v-if="w.action === BUTTON_ACTIONS.KNX">
<div class="prop-row"><label>Toggle</label><input type="checkbox" v-model="w.isToggle"></div>
<div class="prop-row"><label>KNX Schreib</label>
<select v-model.number="w.knxAddrWrite">
<div :class="rowClass"><label :class="labelClass">Toggle</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.isToggle"></div>
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
<select :class="inputClass" v-model.number="w.knxAddrWrite">
<option :value="0">-- Keine --</option>
<option v-for="addr in writeableAddresses" :key="addr.index" :value="addr.index">
GO{{ addr.index }} ({{ addr.addrStr }})
@ -218,8 +224,8 @@
</template>
</template>
<div class="prop-actions">
<button class="btn ghost danger" @click="store.deleteWidget">Widget loeschen</button>
<div class="mt-4">
<button class="border border-red-300 bg-transparent text-red-600 px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-red-50 active:translate-y-0.5" @click="store.deleteWidget">Widget loeschen</button>
</div>
</div>
</section>
@ -246,6 +252,13 @@ const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
const showIconPicker = ref(false);
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
const rowClass = 'flex items-center gap-2.5 mb-2';
const labelClass = 'w-[90px] text-[12px] text-muted';
const inputClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent';
const headingClass = 'mt-4 mb-2.5 text-[12px] uppercase tracking-[0.08em] text-muted';
const noteClass = 'text-[11px] text-muted leading-tight';
const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:border-accent';
const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-300 bg-transparent text-red-600 grid place-items-center text-[12px] cursor-pointer hover:bg-red-50';
function handleTextSrcChange() {
if (!w.value) return;
@ -255,41 +268,3 @@ function handleTextSrcChange() {
}
}
</script>
<style scoped>
.icon-select-btn {
flex: 1;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
}
.icon-select-btn:hover {
border-color: var(--accent);
}
.icon-select-btn .material-symbols-outlined {
font-size: 20px;
}
.icon-remove-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid rgba(255, 107, 107, 0.4);
background: transparent;
color: #ffd1d1;
cursor: pointer;
display: grid;
place-items: center;
font-size: 12px;
}
</style>

View File

@ -1,16 +1,17 @@
<template>
<header class="topbar">
<div class="brand">
<div class="brand-mark">LV</div>
<div class="brand-text">
<div class="title">GUI Designer</div>
<div class="subtitle">KNX Display</div>
<header class="flex items-center justify-between gap-4 px-5 py-3.5 bg-[#f4f7fa] border-b border-border">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-[10px] bg-gradient-to-br from-accent to-[#86b7e6] text-white grid place-items-center font-bold tracking-[0.04em] shadow-[0_6px_16px_rgba(90,147,218,0.35)]">LV</div>
<div class="flex flex-col">
<div class="text-[18px] font-semibold">GUI Designer</div>
<div class="text-[12px] text-muted">KNX Display</div>
</div>
</div>
<div class="top-actions">
<button class="btn ghost" @click="enableUsbMode">USB-Modus</button>
<button class="btn ghost danger" @click="handleReset">Zuruecksetzen</button>
<button class="btn primary" @click="handleSave">Speichern & Anwenden</button>
<div class="flex items-center gap-2.5 flex-wrap justify-end">
<button class="border border-border bg-transparent text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5" @click="emit('open-settings')">Einstellungen</button>
<button class="border border-border bg-transparent text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5" @click="enableUsbMode">USB-Modus</button>
<button class="border border-red-300 bg-transparent text-red-600 px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-red-50 active:translate-y-0.5" @click="handleReset">Zuruecksetzen</button>
<button class="border border-transparent bg-gradient-to-br from-accent to-[#86b7e6] text-white px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 active:translate-y-0.5 shadow-[0_8px_18px_rgba(90,147,218,0.35)]" @click="handleSave">Speichern & Anwenden</button>
</div>
</header>
</template>
@ -19,6 +20,7 @@
import { useEditorStore } from '../stores/editor';
const store = useEditorStore();
const emit = defineEmits(['open-settings']);
async function handleSave() {
try {

View File

@ -1,11 +1,11 @@
<template>
<div class="tree-node">
<div class="relative">
<div
class="tree-item"
class="flex items-center gap-1.5 border border-transparent rounded-[4px] px-1.5 py-1 text-text cursor-pointer mb-px select-none hover:bg-panel-2"
:class="{
active: store.selectedWidgetId === node.id,
hidden: !node.visible,
'drag-over': isDragOver
'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id,
'opacity-50': !node.visible,
'bg-accent/20 border-dashed border-accent': isDragOver
}"
@click.stop="store.selectedWidgetId = node.id"
:style="{ paddingLeft: `${level * 16 + 8}px` }"
@ -16,23 +16,23 @@
@drop.stop="onDrop($event, node)"
>
<span
class="tree-expander"
class="w-4 h-4 flex items-center justify-center text-[8px] text-muted cursor-pointer hover:text-text"
@click.stop="toggleExpand"
:style="{ visibility: node.children.length > 0 ? 'visible' : 'hidden' }"
>
{{ expanded ? '▼' : '▶' }}
</span>
<span class="tree-icon-type material-symbols-outlined">{{ getIconForType(node.type) }}</span>
<span class="material-symbols-outlined text-[16px] text-accent opacity-80">{{ getIconForType(node.type) }}</span>
<div class="tree-content">
<span class="tree-name">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="tree-type">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
<div class="flex flex-col overflow-hidden">
<span class="text-[12px] truncate">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
</div>
</div>
<div class="tree-children" v-if="node.children.length > 0 && expanded">
<div class="tree-guide" :style="{ left: `${level * 16 + 15}px` }"></div>
<div class="relative" v-if="node.children.length > 0 && expanded">
<div class="absolute top-0 bottom-0 w-px bg-white/5" :style="{ left: `${level * 16 + 15}px` }"></div>
<TreeItem
v-for="child in node.children"
:key="child.id"
@ -115,92 +115,3 @@ function onDrop(e, targetNode) {
}
}
</script>
<style scoped>
.tree-node {
position: relative;
}
.tree-item {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 6px;
color: var(--text);
cursor: pointer;
margin-bottom: 1px;
user-select: none;
}
.tree-item:hover {
background: var(--panel-2);
}
.tree-item.active {
background: rgba(125, 211, 176, 0.15);
border-color: rgba(125, 211, 176, 0.3);
}
.tree-item.drag-over {
background: rgba(246, 193, 119, 0.2);
border: 1px dashed var(--accent);
}
.tree-item.hidden {
opacity: 0.5;
}
.tree-expander {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
color: var(--muted);
cursor: pointer;
}
.tree-expander:hover {
color: var(--text);
}
.tree-icon-type {
font-size: 16px;
color: var(--accent);
opacity: 0.8;
}
.tree-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tree-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-type {
font-size: 9px;
color: var(--muted);
}
.tree-children {
position: relative;
}
.tree-guide {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.05);
}
</style>

View File

@ -1,13 +1,10 @@
<template>
<div
class="widget"
:class="{
selected: selected,
'widget-label': isLabel,
'widget-button': isButton,
'widget-led': isLed,
'widget-icon': isIcon
}"
class="z-[1] select-none touch-none"
:class="[
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
]"
:style="computedStyle"
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
@ -16,19 +13,18 @@
<!-- Recursive Children -->
<!-- Special handling for TabView to render structure -->
<template v-if="isTabView">
<div class="tabview-container" :class="'pos-'+tabPosition" :style="tabViewStyle">
<div class="tab-buttons" :style="tabBtnsStyle">
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
<div
v-for="(child, idx) in children"
:key="child.id"
class="tab-btn"
:class="{ active: activePageId === child.id }"
:class="tabBtnClass(activePageId === child.id)"
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
>
{{ child.text }}
</div>
</div>
<div class="tab-content">
<div class="flex-1 relative overflow-hidden">
<WidgetElement
v-for="child in children"
:key="child.id"
@ -60,23 +56,23 @@
<!-- Icon-only Widget -->
<template v-if="isIcon">
<span class="material-symbols-outlined icon-display" :style="iconOnlyStyle">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
{{ iconChar }}
</span>
</template>
<!-- Label/Button with Icon -->
<template v-else-if="hasIcon">
<div class="widget-content" :style="contentStyle">
<div :style="contentStyle">
<span
v-if="iconPosition === 0 || iconPosition === 2"
class="material-symbols-outlined widget-icon"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
<span class="widget-text">{{ widget.text }}</span>
<span class="flex-shrink-0">{{ widget.text }}</span>
<span
v-if="iconPosition === 1 || iconPosition === 3"
class="material-symbols-outlined widget-icon"
class="material-symbols-outlined flex-shrink-0"
:style="iconStyle"
>{{ iconChar }}</span>
</div>
@ -84,16 +80,19 @@
<!-- Standard (no icon) -->
<template v-else>
<span v-if="!isTabView && !isTabPage">{{ widget.text }}</span>
<span v-if="showDefaultText">{{ widget.text }}</span>
</template>
<!-- Resize Handle (at end to be on top) -->
<div
v-if="selected"
class="resize-handle"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
></div>
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
@ -124,6 +123,7 @@ const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
const isButtonContainer = computed(() => isButton.value && props.widget.isContainer);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
@ -155,6 +155,7 @@ const activePageId = computed(() => {
});
const hasIcon = computed(() => {
if (isButtonContainer.value) return false;
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
});
@ -164,6 +165,30 @@ const iconChar = computed(() => {
});
const iconPosition = computed(() => props.widget.iconPosition || 0);
const textAlign = computed(() => props.widget.textAlign ?? 1);
const showDefaultText = computed(() => {
if (isTabView.value || isTabPage.value) return false;
if (isButtonContainer.value) return false;
return true;
});
const justifyForAlign = (align) => {
if (align === 0) return 'flex-start';
if (align === 2) return 'flex-end';
return 'center';
};
const textAlignCss = (align) => {
if (align === 0) return 'left';
if (align === 2) return 'right';
return 'center';
};
const contentJustify = computed(() => {
if (isButton.value || isLabel.value) return justifyForAlign(textAlign.value);
return 'center';
});
const isVerticalLayout = computed(() => {
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
@ -176,7 +201,7 @@ const contentStyle = computed(() => {
display: 'flex',
flexDirection: isVerticalLayout.value ? 'column' : 'row',
alignItems: 'center',
justifyContent: 'center',
justifyContent: contentJustify.value,
gap: `${gap}px`,
width: '100%',
height: '100%'
@ -238,16 +263,18 @@ const computedStyle = computed(() => {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (hasIcon.value) {
style.display = 'flex';
style.alignItems = 'center';
style.display = 'flex';
style.alignItems = 'center';
if (!hasIcon.value) {
style.justifyContent = justifyForAlign(textAlign.value);
style.textAlign = textAlignCss(textAlign.value);
}
} else if (isButton.value) {
style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`;
style.display = 'flex';
style.alignItems = 'center';
style.justifyContent = 'center';
style.justifyContent = contentJustify.value;
style.fontWeight = '600';
if (w.shadow && w.shadow.enabled) {
@ -295,10 +322,7 @@ const computedStyle = computed(() => {
const tabViewStyle = computed(() => {
return {
display: 'flex',
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row',
height: '100%',
width: '100%'
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
};
});
@ -310,71 +334,17 @@ const tabBtnsStyle = computed(() => {
return {
[isVert ? 'height' : 'width']: `${h}px`,
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
display: 'flex',
flexDirection: isVert ? 'row' : 'column',
background: 'rgba(0,0,0,0.2)',
overflow: 'hidden'
flexDirection: isVert ? 'row' : 'column'
};
});
const tabBtnClass = (isActive) => {
const isVerticalTabs = tabPosition.value === 2 || tabPosition.value === 3;
const base = 'flex-1 flex items-center justify-center px-1 py-1 text-[12px] cursor-pointer select-none';
const border = isVerticalTabs ? 'border-b border-white/10' : 'border-b border-r border-white/10';
const active = isVerticalTabs
? 'bg-white/10 font-bold border-b-0 border-r-2 border-accent'
: 'bg-white/10 font-bold border-b-2 border-accent';
return `${base} ${border}${isActive ? ` ${active}` : ''}`;
};
</script>
<style scoped>
.widget-content {
display: flex;
align-items: center;
justify-content: center;
}
.widget-icon {
flex-shrink: 0;
}
.widget-text {
flex-shrink: 0;
}
.icon-display {
display: flex;
align-items: center;
justify-content: center;
}
/* TabView Styles */
.tabview-container {
overflow: hidden;
}
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
font-size: 12px;
border-right: 1px solid rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255,255,255,0.1);
user-select: none;
}
.tab-btn.active {
background: rgba(255,255,255,0.1);
font-weight: bold;
border-bottom: 2px solid var(--accent);
}
.pos-2 .tab-btn, .pos-3 .tab-btn {
border-right: none;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.pos-2 .tab-btn.active, .pos-3 .tab-btn.active {
border-bottom: none;
border-right: 2px solid var(--accent);
}
</style>

View File

@ -24,6 +24,12 @@ export const BUTTON_ACTIONS = {
BACK: 2
};
export const TEXT_ALIGNS = {
LEFT: 0,
CENTER: 1,
RIGHT: 2
};
export const TYPE_KEYS = {
0: 'label',
1: 'button',
@ -81,6 +87,7 @@ export const WIDGET_DEFAULTS = {
text: 'Neues Label',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.LEFT,
textColor: '#FFFFFF',
bgColor: '#0E1217',
bgOpacity: 0,
@ -102,6 +109,8 @@ export const WIDGET_DEFAULTS = {
text: 'Button',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
isContainer: true,
textColor: '#FFFFFF',
bgColor: '#2E7DD1',
bgOpacity: 255,
@ -123,6 +132,7 @@ export const WIDGET_DEFAULTS = {
text: '',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#F6C177',
bgOpacity: 255,
@ -144,6 +154,7 @@ export const WIDGET_DEFAULTS = {
text: '',
textSrc: 0,
fontSize: 3,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#0E1217',
bgOpacity: 0,
@ -165,6 +176,7 @@ export const WIDGET_DEFAULTS = {
text: '',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#2a3543',
bgOpacity: 255,
@ -186,6 +198,7 @@ export const WIDGET_DEFAULTS = {
text: 'Tab',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#1A1A2E',
bgOpacity: 0, // Transparent by default

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { ref, computed, reactive } from 'vue';
import { normalizeScreen, normalizeWidget } from '../utils';
import { MAX_SCREENS, WIDGET_DEFAULTS, WIDGET_TYPES } from '../constants';
import { MAX_SCREENS, WIDGET_DEFAULTS, WIDGET_TYPES, TEXT_ALIGNS } from '../constants';
export const useEditorStore = defineStore('editor', () => {
const config = reactive({
@ -51,6 +51,58 @@ export const useEditorStore = defineStore('editor', () => {
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
});
function ensureButtonLabels(screen) {
if (!screen || !Array.isArray(screen.widgets)) return;
const hasLabelChild = new Set();
screen.widgets.forEach((w) => {
if (w.type === WIDGET_TYPES.LABEL && w.parentId !== -1) {
hasLabelChild.add(w.parentId);
}
});
screen.widgets.forEach((w) => {
if (w.type !== WIDGET_TYPES.BUTTON) return;
w.isContainer = true;
if (hasLabelChild.has(w.id)) return;
const defaults = WIDGET_DEFAULTS.label;
const labelText = w.text || WIDGET_DEFAULTS.button.text;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: defaults.textSrc,
text: labelText,
knxAddr: defaults.knxAddr,
fontSize: w.fontSize ?? defaults.fontSize,
textAlign: w.textAlign ?? TEXT_ALIGNS.CENTER,
textColor: w.textColor ?? defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...defaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition ?? defaults.iconPosition,
iconSize: w.iconSize ?? defaults.iconSize,
iconGap: w.iconGap ?? defaults.iconGap
};
screen.widgets.push(label);
hasLabelChild.add(w.id);
});
}
async function loadKnxAddresses() {
try {
// Mock or Real API
@ -111,6 +163,7 @@ export const useEditorStore = defineStore('editor', () => {
config.screens.forEach((screen) => {
normalizeScreen(screen, nextScreenId, nextWidgetId);
ensureButtonLabels(screen);
// Also update max widget id
screen.widgets.forEach(w => {
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
@ -238,6 +291,8 @@ export const useEditorStore = defineStore('editor', () => {
text: defaults.text,
knxAddr: defaults.knxAddr,
fontSize: defaults.fontSize,
textAlign: defaults.textAlign,
isContainer: defaults.isContainer,
textColor: defaults.textColor,
bgColor: defaults.bgColor,
bgOpacity: defaults.bgOpacity,
@ -256,6 +311,39 @@ export const useEditorStore = defineStore('editor', () => {
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;
if (typeStr === 'button') {
const labelDefaults = WIDGET_DEFAULTS.label;
const label = {
id: nextWidgetId.value++,
parentId: w.id,
type: WIDGET_TYPES.LABEL,
x: 0,
y: 0,
w: w.w,
h: w.h,
visible: true,
textSrc: labelDefaults.textSrc,
text: w.text,
knxAddr: labelDefaults.knxAddr,
fontSize: w.fontSize,
textAlign: TEXT_ALIGNS.CENTER,
textColor: w.textColor,
bgColor: labelDefaults.bgColor,
bgOpacity: 0,
radius: 0,
shadow: { ...labelDefaults.shadow, enabled: false },
isToggle: false,
knxAddrWrite: 0,
action: 0,
targetScreen: 0,
iconCodepoint: w.iconCodepoint || 0,
iconPosition: w.iconPosition || 0,
iconSize: w.iconSize || 1,
iconGap: w.iconGap || 8
};
activeScreen.value.widgets.push(label);
}
// Auto-create pages for new TabView
if (typeStr === 'tabview') {
// Deselect to reset parent logic for recursive calls, or explicitly pass parent

View File

@ -2,28 +2,29 @@
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
:root {
--bg: #0f1419;
--bg-2: #141c24;
--panel: #18212b;
--panel-2: #1d2732;
--border: #2a3543;
--text: #e7edf3;
--muted: #9aa8b4;
--accent: #f6c177;
--accent-2: #7dd3b0;
--danger: #ff6b6b;
--canvas-bg: #1a1a2e;
--shadow: rgba(0, 0, 0, 0.35);
--bg: #eef2f6;
--bg-2: #e6edf3;
--panel: #ffffff;
--panel-2: #f3f6f9;
--border: #d5dce3;
--text: #1f2a33;
--muted: #6b7885;
--accent: #5a93da;
--accent-2: #86b7e6;
--danger: #d9534f;
--canvas-bg: #f7f9fb;
--shadow: rgba(15, 23, 42, 0.12);
}
body {
font-family: "Space Grotesk", "Fira Sans", "Segoe UI", sans-serif;
background:
radial-gradient(1200px 800px at 15% 10%, #1b2530 0%, #0f1419 55%),
linear-gradient(135deg, #0f1419, #141c24);
radial-gradient(900px 650px at 12% 10%, rgba(90, 147, 218, 0.18) 0%, rgba(238, 242, 246, 0) 60%),
radial-gradient(900px 650px at 90% 5%, rgba(134, 183, 230, 0.18) 0%, rgba(238, 242, 246, 0) 60%),
radial-gradient(1200px 800px at 40% 90%, rgba(120, 160, 200, 0.12) 0%, rgba(238, 242, 246, 0) 55%),
linear-gradient(135deg, #f7f9fb, #eef2f6);
color: var(--text);
min-height: 100vh;
/* display: flex; flex-direction: column; -- Handled by Vue App layout */
}
/* Custom scrollbar */
@ -42,566 +43,21 @@ body {
background: var(--muted);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
background: rgba(15, 20, 25, 0.85);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #f0b365);
color: #1b1308;
display: grid;
place-items: center;
font-weight: 700;
letter-spacing: 0.04em;
box-shadow: 0 6px 16px rgba(246, 193, 119, 0.35);
}
.brand-text .title {
font-size: 18px;
font-weight: 600;
}
.brand-text .subtitle {
font-size: 12px;
color: var(--muted);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
border: 1px solid transparent;
background: var(--panel-2);
color: var(--text);
padding: 8px 14px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn.small {
padding: 6px 10px;
font-size: 12px;
}
.btn.primary {
background: linear-gradient(135deg, var(--accent), #f0b365);
color: #1b1308;
box-shadow: 0 8px 18px rgba(246, 193, 119, 0.35);
}
.btn.ghost {
background: transparent;
border-color: var(--border);
}
.btn.danger {
border-color: rgba(255, 107, 107, 0.4);
color: #ffd1d1;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(1px); }
.icon-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
display: grid;
place-items: center;
cursor: pointer;
}
.workspace {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 300px 1fr 320px;
}
.sidebar {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
height: calc(100vh - 66px); /* Adjust based on topbar height */
}
.sidebar.left {
border-right: 1px solid var(--border);
}
.sidebar.right {
border-left: 1px solid var(--border);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 8px 18px var(--shadow);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.panel-header h3 {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.panel-hint {
font-size: 11px;
color: var(--muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.element-btn {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.element-btn:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.element-title {
font-size: 13px;
font-weight: 600;
}
.element-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
display: block;
}
.screen-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.screen-item {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
color: var(--text);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.screen-item.active {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
}
.screen-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(125, 211, 176, 0.15);
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.screen-name {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screen-settings {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tree {
display: flex;
flex-direction: column;
gap: 8px;
}
.tree-root {
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tree-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 10px;
color: var(--text);
cursor: pointer;
transition: border-color 0.2s ease;
}
.tree-item.active {
border-color: var(--accent-2);
box-shadow: 0 0 0 2px rgba(125, 211, 176, 0.2);
}
.tree-item.hidden {
opacity: 0.5;
}
.tree-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(125, 211, 176, 0.15);
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tree-name {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-id {
font-size: 11px;
color: var(--muted);
}
.tree-empty {
font-size: 12px;
color: var(--muted);
padding: 6px 0;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.control-row label {
font-size: 12px;
color: var(--muted);
}
.control-row input[type="color"] {
width: 44px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
}
.control-row input[type="range"] {
flex: 1;
}
.control-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.canvas-area {
padding: 20px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-shell {
padding: 18px;
border-radius: 18px;
border: 1px solid var(--border);
background: rgba(20, 28, 36, 0.7);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 20px 40px rgba(0, 0, 0, 0.4);
}
.canvas {
position: relative;
background: var(--canvas-bg);
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.canvas::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
background-size: var(--grid-size, 32px) var(--grid-size, 32px);
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
.canvas.grid-off::before {
opacity: 0;
}
.widget {
/* Absolute positioning handled by component inline styles */
position: absolute;
cursor: move;
user-select: none;
touch-action: none;
z-index: 1;
}
.widget.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.resize-handle {
position: absolute;
right: -6px;
bottom: -6px;
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--accent);
border: 2px solid #1b1308;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
cursor: se-resize;
z-index: 2;
}
.resize-handle::before {
content: "";
position: absolute;
right: 2px;
bottom: 2px;
width: 6px;
height: 6px;
border-right: 2px solid rgba(27, 19, 8, 0.65);
border-bottom: 2px solid rgba(27, 19, 8, 0.65);
border-radius: 2px;
}
.widget-label {
padding: 4px 6px;
border-radius: 6px;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.widget-button {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.widget-led {
border-radius: 999px;
}
.properties h4 {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 16px 0 10px;
}
.prop-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.prop-row label {
width: 90px;
font-size: 12px;
color: var(--muted);
}
.prop-row input,
.prop-row select {
flex: 1;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 8px;
color: var(--text);
font-size: 12px;
}
.prop-row input[type="color"] {
padding: 2px;
height: 30px;
cursor: pointer;
}
.prop-row input[type="checkbox"] {
width: auto;
flex: none;
}
.prop-actions {
margin-top: 16px;
}
.no-selection {
color: var(--muted);
text-align: center;
padding: 20px 10px;
font-size: 13px;
}
.status {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: #1b1308;
padding: 10px 18px;
border-radius: 10px;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.status.show { opacity: 1; }
.status.error {
background: var(--danger);
color: #1b0f0f;
}
@media (max-width: 1100px) {
.workspace {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.sidebar.left {
order: 1;
border-right: none;
border-bottom: 1px solid var(--border);
}
.canvas-area {
order: 2;
}
.sidebar.right {
order: 3;
border-left: none;
border-top: 1px solid var(--border);
}
}
/* Material Symbols Icon Font */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}
.widget-icon {
display: flex;
align-items: center;
justify-content: center;
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}

View File

@ -64,6 +64,10 @@ export function normalizeWidget(w, nextWidgetIdRef) {
// Hierarchy
if (w.parentId === undefined) w.parentId = -1;
if (key === 'button') {
w.isContainer = true;
}
}
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {