backup
This commit is contained in:
parent
2ae3335031
commit
e7f3bb6b12
@ -19,6 +19,8 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
|||||||
memcpy(&buf[pos], text, MAX_TEXT_LEN); pos += MAX_TEXT_LEN;
|
memcpy(&buf[pos], text, MAX_TEXT_LEN); pos += MAX_TEXT_LEN;
|
||||||
buf[pos++] = knxAddress & 0xFF; buf[pos++] = (knxAddress >> 8) & 0xFF;
|
buf[pos++] = knxAddress & 0xFF; buf[pos++] = (knxAddress >> 8) & 0xFF;
|
||||||
buf[pos++] = fontSize;
|
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++] = textColor.r; buf[pos++] = textColor.g; buf[pos++] = textColor.b;
|
||||||
buf[pos++] = bgColor.r; buf[pos++] = bgColor.g; buf[pos++] = bgColor.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';
|
text[MAX_TEXT_LEN - 1] = '\0';
|
||||||
knxAddress = buf[pos] | (buf[pos+1] << 8); pos += 2;
|
knxAddress = buf[pos] | (buf[pos+1] << 8); pos += 2;
|
||||||
fontSize = buf[pos++];
|
fontSize = buf[pos++];
|
||||||
|
textAlign = buf[pos++];
|
||||||
|
isContainer = buf[pos++] != 0;
|
||||||
|
|
||||||
textColor.r = buf[pos++]; textColor.g = buf[pos++]; textColor.b = buf[pos++];
|
textColor.r = buf[pos++]; textColor.g = buf[pos++]; textColor.b = buf[pos++];
|
||||||
bgColor.r = buf[pos++]; bgColor.g = buf[pos++]; bgColor.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;
|
cfg.textSource = TextSource::STATIC;
|
||||||
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
|
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
|
||||||
cfg.fontSize = 1; // 18pt
|
cfg.fontSize = 1; // 18pt
|
||||||
|
cfg.textAlign = static_cast<uint8_t>(TextAlign::LEFT);
|
||||||
|
cfg.isContainer = false;
|
||||||
cfg.textColor = {255, 255, 255};
|
cfg.textColor = {255, 255, 255};
|
||||||
cfg.bgColor = {0, 0, 0};
|
cfg.bgColor = {0, 0, 0};
|
||||||
cfg.bgOpacity = 0;
|
cfg.bgOpacity = 0;
|
||||||
@ -144,6 +150,8 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y,
|
|||||||
cfg.textSource = TextSource::STATIC;
|
cfg.textSource = TextSource::STATIC;
|
||||||
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
|
strncpy(cfg.text, labelText, MAX_TEXT_LEN - 1);
|
||||||
cfg.fontSize = 1;
|
cfg.fontSize = 1;
|
||||||
|
cfg.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
||||||
|
cfg.isContainer = true;
|
||||||
cfg.textColor = {255, 255, 255};
|
cfg.textColor = {255, 255, 255};
|
||||||
cfg.bgColor = {33, 150, 243}; // Blue
|
cfg.bgColor = {33, 150, 243}; // Blue
|
||||||
cfg.bgOpacity = 255;
|
cfg.bgOpacity = 255;
|
||||||
|
|||||||
@ -46,6 +46,12 @@ enum class TextSource : uint8_t {
|
|||||||
KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000)
|
KNX_DPT_TEXT = 4, // KNX Text (DPT 16.000)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class TextAlign : uint8_t {
|
||||||
|
LEFT = 0,
|
||||||
|
CENTER = 1,
|
||||||
|
RIGHT = 2,
|
||||||
|
};
|
||||||
|
|
||||||
// Color as RGB888
|
// Color as RGB888
|
||||||
struct Color {
|
struct Color {
|
||||||
uint8_t r, g, b;
|
uint8_t r, g, b;
|
||||||
@ -87,6 +93,8 @@ struct WidgetConfig {
|
|||||||
char text[MAX_TEXT_LEN]; // Static text or format string
|
char text[MAX_TEXT_LEN]; // Static text or format string
|
||||||
uint16_t knxAddress; // KNX group address (if textSource != STATIC)
|
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 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
|
// Colors
|
||||||
Color textColor;
|
Color textColor;
|
||||||
@ -113,7 +121,7 @@ struct WidgetConfig {
|
|||||||
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
||||||
|
|
||||||
// Serialization size (fixed for NVS storage)
|
// 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 serialize(uint8_t* buf) const;
|
||||||
void deserialize(const uint8_t* buf);
|
void deserialize(const uint8_t* buf);
|
||||||
|
|||||||
@ -69,6 +69,61 @@ static void latin1_to_utf8(const char* src, size_t src_len, char* dst, size_t ds
|
|||||||
dst[di] = '\0';
|
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 implementation
|
||||||
WidgetManager& WidgetManager::instance() {
|
WidgetManager& WidgetManager::instance() {
|
||||||
static WidgetManager inst;
|
static WidgetManager inst;
|
||||||
@ -103,6 +158,8 @@ void WidgetManager::createDefaultConfig() {
|
|||||||
progBtn.bgColor = {200, 50, 50}; // Red
|
progBtn.bgColor = {200, 50, 50}; // Red
|
||||||
screen.addWidget(progBtn);
|
screen.addWidget(progBtn);
|
||||||
|
|
||||||
|
ensureButtonLabels(screen);
|
||||||
|
|
||||||
config_.startScreenId = screen.id;
|
config_.startScreenId = screen.id;
|
||||||
config_.standbyEnabled = false;
|
config_.standbyEnabled = false;
|
||||||
config_.standbyScreenId = 0xFF;
|
config_.standbyScreenId = 0xFF;
|
||||||
@ -836,6 +893,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
|||||||
cJSON_AddStringToObject(widget, "text", w.text);
|
cJSON_AddStringToObject(widget, "text", w.text);
|
||||||
cJSON_AddNumberToObject(widget, "knxAddr", w.knxAddress);
|
cJSON_AddNumberToObject(widget, "knxAddr", w.knxAddress);
|
||||||
cJSON_AddNumberToObject(widget, "fontSize", w.fontSize);
|
cJSON_AddNumberToObject(widget, "fontSize", w.fontSize);
|
||||||
|
cJSON_AddNumberToObject(widget, "textAlign", w.textAlign);
|
||||||
|
cJSON_AddBoolToObject(widget, "isContainer", w.isContainer);
|
||||||
|
|
||||||
char textColorStr[8];
|
char textColorStr[8];
|
||||||
snprintf(textColorStr, sizeof(textColorStr), "#%02X%02X%02X",
|
snprintf(textColorStr, sizeof(textColorStr), "#%02X%02X%02X",
|
||||||
@ -920,6 +979,8 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
w.visible = true;
|
w.visible = true;
|
||||||
w.action = ButtonAction::KNX;
|
w.action = ButtonAction::KNX;
|
||||||
w.targetScreen = 0;
|
w.targetScreen = 0;
|
||||||
|
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
||||||
|
w.isContainer = false;
|
||||||
|
|
||||||
cJSON* id = cJSON_GetObjectItem(widget, "id");
|
cJSON* id = cJSON_GetObjectItem(widget, "id");
|
||||||
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
||||||
@ -957,6 +1018,12 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
cJSON* fontSize = cJSON_GetObjectItem(widget, "fontSize");
|
cJSON* fontSize = cJSON_GetObjectItem(widget, "fontSize");
|
||||||
if (cJSON_IsNumber(fontSize)) w.fontSize = fontSize->valueint;
|
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");
|
cJSON* textColor = cJSON_GetObjectItem(widget, "textColor");
|
||||||
if (cJSON_IsString(textColor)) {
|
if (cJSON_IsString(textColor)) {
|
||||||
w.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
|
w.textColor = Color::fromHex(parseHexColor(textColor->valuestring));
|
||||||
@ -1096,6 +1163,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
if (!parseWidgets(widgets, screen)) {
|
if (!parseWidgets(widgets, screen)) {
|
||||||
screen.widgetCount = 0;
|
screen.widgetCount = 0;
|
||||||
}
|
}
|
||||||
|
ensureButtonLabels(screen);
|
||||||
|
|
||||||
newConfig->screenCount++;
|
newConfig->screenCount++;
|
||||||
}
|
}
|
||||||
@ -1114,6 +1182,7 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
cJSON_Delete(root);
|
cJSON_Delete(root);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
ensureButtonLabels(screen);
|
||||||
}
|
}
|
||||||
|
|
||||||
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");
|
cJSON* startScreen = cJSON_GetObjectItem(root, "startScreen");
|
||||||
|
|||||||
@ -5,6 +5,18 @@
|
|||||||
|
|
||||||
static const char* TAG = "ButtonWidget";
|
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)
|
ButtonWidget::ButtonWidget(const WidgetConfig& config)
|
||||||
: Widget(config)
|
: Widget(config)
|
||||||
, contentContainer_(nullptr)
|
, contentContainer_(nullptr)
|
||||||
@ -73,14 +85,30 @@ void ButtonWidget::setupFlexLayout() {
|
|||||||
|
|
||||||
lv_obj_set_flex_flow(contentContainer_,
|
lv_obj_set_flex_flow(contentContainer_,
|
||||||
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
||||||
lv_obj_set_flex_align(contentContainer_,
|
lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER;
|
||||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, 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
|
// Set gap between icon and text
|
||||||
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
||||||
lv_obj_set_style_pad_gap(contentContainer_, gap, 0);
|
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) {
|
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
|
||||||
obj_ = lv_btn_create(parent);
|
obj_ = lv_btn_create(parent);
|
||||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
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);
|
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) {
|
if (!config_.isContainer) {
|
||||||
// Create container for flex layout
|
if (hasIcon) {
|
||||||
contentContainer_ = lv_obj_create(obj_);
|
// Create container for flex layout
|
||||||
if (contentContainer_ == nullptr) {
|
contentContainer_ = lv_obj_create(obj_);
|
||||||
return obj_; // Continue without icon container
|
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)",
|
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ private:
|
|||||||
lv_obj_t* iconLabel_ = nullptr;
|
lv_obj_t* iconLabel_ = nullptr;
|
||||||
|
|
||||||
void setupFlexLayout();
|
void setupFlexLayout();
|
||||||
|
void applyTextAlignment();
|
||||||
static int encodeUtf8(uint32_t codepoint, char* buf);
|
static int encodeUtf8(uint32_t codepoint, char* buf);
|
||||||
static void clickCallback(lv_event_t* e);
|
static void clickCallback(lv_event_t* e);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) {
|
int LabelWidget::encodeUtf8(uint32_t codepoint, char* buf) {
|
||||||
if (codepoint < 0x80) {
|
if (codepoint < 0x80) {
|
||||||
buf[0] = static_cast<char>(codepoint);
|
buf[0] = static_cast<char>(codepoint);
|
||||||
@ -47,8 +59,17 @@ void LabelWidget::setupFlexLayout() {
|
|||||||
|
|
||||||
lv_obj_set_flex_flow(container_,
|
lv_obj_set_flex_flow(container_,
|
||||||
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
isVertical ? LV_FLEX_FLOW_COLUMN : LV_FLEX_FLOW_ROW);
|
||||||
lv_obj_set_flex_align(container_,
|
lv_flex_align_t mainAlign = LV_FLEX_ALIGN_CENTER;
|
||||||
LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, 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
|
// Set gap between icon and text
|
||||||
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
int gap = config_.iconGap > 0 ? config_.iconGap : 8;
|
||||||
@ -85,16 +106,19 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
|
|||||||
char iconText[5];
|
char iconText[5];
|
||||||
encodeUtf8(config_.iconCodepoint, iconText);
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
lv_label_set_text(iconLabel_, iconText);
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
textLabel_ = lv_label_create(container_);
|
textLabel_ = lv_label_create(container_);
|
||||||
lv_label_set_text(textLabel_, config_.text);
|
lv_label_set_text(textLabel_, config_.text);
|
||||||
|
lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
|
||||||
if (!iconFirst) {
|
if (!iconFirst) {
|
||||||
iconLabel_ = lv_label_create(container_);
|
iconLabel_ = lv_label_create(container_);
|
||||||
char iconText[5];
|
char iconText[5];
|
||||||
encodeUtf8(config_.iconCodepoint, iconText);
|
encodeUtf8(config_.iconCodepoint, iconText);
|
||||||
lv_label_set_text(iconLabel_, iconText);
|
lv_label_set_text(iconLabel_, iconText);
|
||||||
|
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFlexLayout();
|
setupFlexLayout();
|
||||||
@ -103,6 +127,7 @@ lv_obj_t* LabelWidget::create(lv_obj_t* parent) {
|
|||||||
textLabel_ = lv_label_create(container_);
|
textLabel_ = lv_label_create(container_);
|
||||||
lv_label_set_text(textLabel_, config_.text);
|
lv_label_set_text(textLabel_, config_.text);
|
||||||
lv_obj_center(textLabel_);
|
lv_obj_center(textLabel_);
|
||||||
|
lv_obj_clear_flag(textLabel_, LV_OBJ_FLAG_CLICKABLE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simple label without container
|
// 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_;
|
return obj_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +166,7 @@ void LabelWidget::applyStyle() {
|
|||||||
lv_obj_set_style_text_color(textLabel_, lv_color_make(
|
lv_obj_set_style_text_color(textLabel_, lv_color_make(
|
||||||
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
|
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_font(textLabel_, Fonts::bySizeIndex(config_.fontSize), 0);
|
||||||
|
lv_obj_set_style_text_align(textLabel_, toLvTextAlign(config_.textAlign), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply icon style
|
// Apply icon style
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<TopBar />
|
<div class="min-h-screen flex flex-col">
|
||||||
<div class="workspace">
|
<TopBar @open-settings="showSettings = true" />
|
||||||
<SidebarLeft />
|
<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]">
|
||||||
<CanvasArea />
|
<SidebarLeft />
|
||||||
<SidebarRight />
|
<CanvasArea @open-screen-settings="showScreenSettings = true" />
|
||||||
|
<SidebarRight />
|
||||||
|
</div>
|
||||||
|
<SettingsModal v-if="showSettings" @close="showSettings = false" />
|
||||||
|
<ScreenSettingsModal v-if="showScreenSettings" @close="showScreenSettings = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import TopBar from './components/TopBar.vue';
|
import TopBar from './components/TopBar.vue';
|
||||||
import SidebarLeft from './components/SidebarLeft.vue';
|
import SidebarLeft from './components/SidebarLeft.vue';
|
||||||
import SidebarRight from './components/SidebarRight.vue';
|
import SidebarRight from './components/SidebarRight.vue';
|
||||||
import CanvasArea from './components/CanvasArea.vue';
|
import CanvasArea from './components/CanvasArea.vue';
|
||||||
|
import SettingsModal from './components/SettingsModal.vue';
|
||||||
|
import ScreenSettingsModal from './components/ScreenSettingsModal.vue';
|
||||||
import { useEditorStore } from './stores/editor';
|
import { useEditorStore } from './stores/editor';
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
|
const showSettings = ref(false);
|
||||||
|
const showScreenSettings = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadConfig();
|
store.loadConfig();
|
||||||
|
|||||||
@ -1,13 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="canvas-area">
|
<main class="p-5 overflow-auto flex flex-col items-center gap-4 min-h-0">
|
||||||
<div class="canvas-shell">
|
<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
|
<div
|
||||||
class="canvas"
|
class="relative border border-border overflow-hidden"
|
||||||
id="canvas"
|
id="canvas"
|
||||||
:class="{ 'grid-off': !store.showGrid }"
|
|
||||||
:style="canvasStyle"
|
:style="canvasStyle"
|
||||||
@click.self="deselect"
|
@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
|
<WidgetElement
|
||||||
v-for="widget in rootWidgets"
|
v-for="widget in rootWidgets"
|
||||||
:key="widget.id"
|
:key="widget.id"
|
||||||
@ -24,13 +57,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
import WidgetElement from './WidgetElement.vue';
|
import WidgetElement from './WidgetElement.vue';
|
||||||
import { DISPLAY_W, DISPLAY_H, WIDGET_TYPES } from '../constants';
|
import { DISPLAY_W, DISPLAY_H, WIDGET_TYPES } from '../constants';
|
||||||
import { clamp, minSizeFor } from '../utils';
|
import { clamp, minSizeFor } from '../utils';
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-screen-settings']);
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
|
const screensOpen = ref(true);
|
||||||
|
|
||||||
const rootWidgets = computed(() => {
|
const rootWidgets = computed(() => {
|
||||||
return store.activeScreen?.widgets.filter(w => w.parentId === -1) || [];
|
return store.activeScreen?.widgets.filter(w => w.parentId === -1) || [];
|
||||||
@ -53,14 +88,23 @@ const canvasH = computed(() => {
|
|||||||
const canvasStyle = computed(() => ({
|
const canvasStyle = computed(() => ({
|
||||||
width: `${canvasW.value * store.canvasScale}px`,
|
width: `${canvasW.value * store.canvasScale}px`,
|
||||||
height: `${canvasH.value * store.canvasScale}px`,
|
height: `${canvasH.value * store.canvasScale}px`,
|
||||||
'--canvas-bg': store.activeScreen?.bgColor || '#1A1A2E',
|
backgroundColor: store.activeScreen?.bgColor || '#1A1A2E'
|
||||||
'--grid-size': `${store.gridSize * store.canvasScale}px`
|
}));
|
||||||
|
|
||||||
|
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() {
|
function deselect() {
|
||||||
store.selectedWidgetId = null;
|
store.selectedWidgetId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectScreen(id) {
|
||||||
|
store.activeScreenId = id;
|
||||||
|
store.selectedWidgetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
function snap(val) {
|
function snap(val) {
|
||||||
if (!store.snapToGrid) return val;
|
if (!store.snapToGrid) return val;
|
||||||
const s = store.gridSize;
|
const s = store.gridSize;
|
||||||
@ -72,7 +116,7 @@ let resizeState = null;
|
|||||||
|
|
||||||
function startDrag(payload) {
|
function startDrag(payload) {
|
||||||
const { id, event: e } = payload;
|
const { id, event: e } = payload;
|
||||||
if (e.target.closest('.resize-handle')) return;
|
if (e.target.closest('[data-resize-handle]')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
store.selectedWidgetId = id;
|
store.selectedWidgetId = id;
|
||||||
|
|
||||||
|
|||||||
@ -9,35 +9,30 @@ const count = ref(0)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>{{ msg }}</h1>
|
<h1 class="text-2xl font-semibold">{{ msg }}</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="mt-4 border border-border rounded-lg p-4 bg-panel">
|
||||||
<button type="button" @click="count++">count is {{ count }}</button>
|
<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>
|
<p class="mt-2 text-sm text-muted">
|
||||||
Edit
|
Edit
|
||||||
<code>components/HelloWorld.vue</code> to test HMR
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p class="mt-4 text-sm">
|
||||||
Check out
|
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
|
>create-vue</a
|
||||||
>, the official Vue + Vite starter
|
>, the official Vue + Vite starter
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p class="mt-2 text-sm">
|
||||||
Learn more about IDE Support for Vue in the
|
Learn more about IDE Support for Vue in the
|
||||||
<a
|
<a
|
||||||
|
class="text-accent underline"
|
||||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>Vue Docs Scaling up Guide</a
|
>Vue Docs Scaling up Guide</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||||
@click.self="$emit('close')"
|
@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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||||
<h3 class="text-base font-semibold">Icon auswaehlen</h3>
|
<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="material-symbols-outlined text-2xl text-accent">{{ String.fromCodePoint(modelValue) }}</span>
|
||||||
<span class="flex-1 text-sm">{{ currentIconName }}</span>
|
<span class="flex-1 text-sm">{{ currentIconName }}</span>
|
||||||
<button
|
<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)"
|
@click="selectIcon(0)"
|
||||||
>
|
>
|
||||||
Entfernen
|
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">
|
<div class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="icon in filteredIcons"
|
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="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'"
|
:class="icon.codepoint === modelValue ? 'border-accent-2 bg-accent-2/10' : 'border-border'"
|
||||||
@click="selectIcon(icon.codepoint)"
|
@click="selectIcon(icon.codepoint)"
|
||||||
|
|||||||
68
web-interface/src/components/ScreenSettingsModal.vue
Normal file
68
web-interface/src/components/ScreenSettingsModal.vue
Normal 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>
|
||||||
46
web-interface/src/components/SettingsModal.vue
Normal file
46
web-interface/src/components/SettingsModal.vue
Normal 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>
|
||||||
@ -1,89 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar left">
|
<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="panel">
|
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
|
||||||
<div class="panel-header">
|
<div class="flex items-center justify-between gap-2.5 mb-3">
|
||||||
<h3>Elemente</h3>
|
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Elemente</h3>
|
||||||
<span class="panel-hint">Klick zum Hinzufuegen</span>
|
<span class="text-[11px] text-muted">Klick zum Hinzufuegen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="element-grid">
|
<div class="grid grid-cols-2 gap-2.5">
|
||||||
<button class="element-btn" @click="store.addWidget('label')">
|
<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="element-title">Label</span>
|
<span class="text-[13px] font-semibold">Label</span>
|
||||||
<span class="element-sub">Text</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Text</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="element-btn" @click="store.addWidget('button')">
|
<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="element-title">Button</span>
|
<span class="text-[13px] font-semibold">Button</span>
|
||||||
<span class="element-sub">Aktion</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Aktion</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="element-btn" @click="store.addWidget('led')">
|
<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="element-title">LED</span>
|
<span class="text-[13px] font-semibold">LED</span>
|
||||||
<span class="element-sub">Status</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Status</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="element-btn" @click="store.addWidget('icon')">
|
<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="element-title">Icon</span>
|
<span class="text-[13px] font-semibold">Icon</span>
|
||||||
<span class="element-sub">Symbol</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Symbol</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="element-btn" @click="store.addWidget('tabview')">
|
<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="element-title">Tabs</span>
|
<span class="text-[13px] font-semibold">Tabs</span>
|
||||||
<span class="element-sub">Container</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Container</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
|
||||||
<div class="panel-header">
|
<div class="flex items-center justify-between gap-2.5 mb-3">
|
||||||
<h3>Bildschirme</h3>
|
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Baum</h3>
|
||||||
<button class="icon-btn" @click="store.addScreen">+</button>
|
<span class="text-[11px] text-muted">{{ store.activeScreen?.widgets.length || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="screen-list">
|
<div class="flex flex-col gap-2">
|
||||||
<div
|
<div class="text-[12px] text-muted mb-1">{{ store.activeScreen?.name }}</div>
|
||||||
v-for="screen in store.config.screens"
|
<div class="flex flex-col gap-1.5">
|
||||||
:key="screen.id"
|
<div v-if="!store.widgetTree.length" class="text-[12px] text-muted py-1.5">Keine Widgets</div>
|
||||||
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>
|
|
||||||
<TreeItem
|
<TreeItem
|
||||||
v-else
|
v-else
|
||||||
v-for="node in store.widgetTree"
|
v-for="node in store.widgetTree"
|
||||||
@ -94,70 +48,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="bg-panel border border-border rounded-[14px] p-3.5 shadow-[0_8px_18px_var(--shadow)]">
|
||||||
<div class="panel-header">
|
<div class="flex items-center justify-between gap-2.5 mb-3">
|
||||||
<h3>Canvas</h3>
|
<h3 class="text-[12px] uppercase tracking-[0.08em] text-muted">Canvas</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-row">
|
<div class="flex items-center justify-between gap-2.5 mb-2.5">
|
||||||
<label>Hintergrund</label>
|
<label class="text-[12px] text-muted">Zoom</label>
|
||||||
<input type="color" v-model="store.activeScreen.bgColor" v-if="store.activeScreen">
|
<input class="flex-1 accent-[var(--accent)]" type="range" min="0.3" max="1" step="0.05" v-model.number="store.canvasScale">
|
||||||
</div>
|
</div>
|
||||||
<div class="control-row">
|
<div class="flex items-center justify-between text-[11px] text-muted mb-2">
|
||||||
<label>Zoom</label>
|
|
||||||
<input type="range" min="0.3" max="1" step="0.05" v-model.number="store.canvasScale">
|
|
||||||
</div>
|
|
||||||
<div class="control-meta">
|
|
||||||
<span>{{ Math.round(store.canvasScale * 100) }}%</span>
|
<span>{{ Math.round(store.canvasScale * 100) }}%</span>
|
||||||
<span>1280x800</span>
|
<span>1280x800</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle">
|
<label class="flex items-center gap-2 text-[12px] text-muted">
|
||||||
<input type="checkbox" v-model="store.showGrid">
|
<input class="accent-[var(--accent)]" type="checkbox" v-model="store.showGrid">
|
||||||
<span>Grid anzeigen</span>
|
<span>Grid anzeigen</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="control-row" style="margin-top: 8px;">
|
<div class="flex items-center justify-between gap-2.5 mt-2 mb-2.5">
|
||||||
<label>Grid Gr.</label>
|
<label class="text-[12px] text-muted">Grid Gr.</label>
|
||||||
<input type="number" min="5" max="100" v-model.number="store.gridSize">
|
<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>
|
</div>
|
||||||
<label class="toggle">
|
<label class="flex items-center gap-2 text-[12px] text-muted">
|
||||||
<input type="checkbox" v-model="store.snapToGrid">
|
<input class="accent-[var(--accent)]" type="checkbox" v-model="store.snapToGrid">
|
||||||
<span>Am Grid ausrichten</span>
|
<span>Am Grid ausrichten</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
import TreeItem from './TreeItem.vue';
|
import TreeItem from './TreeItem.vue';
|
||||||
import { typeKeyFor } from '../utils';
|
|
||||||
import { TYPE_LABELS } from '../constants';
|
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar right">
|
<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="panel properties" id="properties">
|
<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="no-selection">
|
<div v-if="!w" class="text-muted text-center py-5 text-[13px]">
|
||||||
Kein Widget ausgewaehlt.<br><br>
|
Kein Widget ausgewaehlt.<br><br>
|
||||||
Waehle ein Widget im Canvas oder im Baum.
|
Waehle ein Widget im Canvas oder im Baum.
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Layout -->
|
<!-- Layout -->
|
||||||
<h4>Layout</h4>
|
<h4 :class="headingClass">Layout</h4>
|
||||||
<div class="prop-row"><label>X</label><input type="number" v-model.number="w.x"></div>
|
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" 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="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" 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="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" 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="rowClass"><label :class="labelClass">Hoehe</label><input :class="inputClass" type="number" v-model.number="w.h"></div>
|
||||||
<div class="prop-row"><label>Sichtbar</label><input type="checkbox" v-model="w.visible"></div>
|
<div :class="rowClass"><label :class="labelClass">Sichtbar</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.visible"></div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<template v-if="key === 'label'">
|
<template v-if="key === 'label'">
|
||||||
<h4>Inhalt</h4>
|
<h4 :class="headingClass">Inhalt</h4>
|
||||||
<div class="prop-row"><label>Quelle</label>
|
<div :class="rowClass"><label :class="labelClass">Quelle</label>
|
||||||
<select v-model.number="w.textSrc" @change="handleTextSrcChange">
|
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
|
||||||
<option v-for="opt in sourceOptions.label" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
<option v-for="opt in sourceOptions.label" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="w.textSrc === 0" class="prop-row">
|
<div v-if="w.textSrc === 0" :class="rowClass">
|
||||||
<label>Text</label><input type="text" v-model="w.text">
|
<label :class="labelClass">Text</label><input :class="inputClass" type="text" v-model="w.text">
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="prop-row"><label>Format</label><input type="text" v-model="w.text"></div>
|
<div :class="rowClass"><label :class="labelClass">Format</label><input :class="inputClass" type="text" v-model="w.text"></div>
|
||||||
<div class="prop-row"><label>KNX Objekt</label>
|
<div :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
||||||
<select v-model.number="w.knxAddr">
|
<select :class="inputClass" v-model.number="w.knxAddr">
|
||||||
<option :value="0">-- Waehlen --</option>
|
<option :value="0">-- Waehlen --</option>
|
||||||
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
||||||
GO{{ addr.index }} ({{ addr.addrStr }})
|
GO{{ addr.index }} ({{ addr.addrStr }})
|
||||||
@ -39,20 +39,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="key === 'button'">
|
<template v-if="key === 'button'">
|
||||||
<h4>Text</h4>
|
<h4 :class="headingClass">Button</h4>
|
||||||
<div class="prop-row"><label>Text</label><input type="text" v-model="w.text"></div>
|
<div :class="rowClass"><label :class="labelClass">Hinweis</label><span :class="noteClass">Text als Label-Child anlegen.</span></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="key === 'led'">
|
<template v-if="key === 'led'">
|
||||||
<h4>LED</h4>
|
<h4 :class="headingClass">LED</h4>
|
||||||
<div class="prop-row"><label>Modus</label>
|
<div :class="rowClass"><label :class="labelClass">Modus</label>
|
||||||
<select v-model.number="w.textSrc">
|
<select :class="inputClass" v-model.number="w.textSrc">
|
||||||
<option v-for="opt in sourceOptions.led" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
<option v-for="opt in sourceOptions.led" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="w.textSrc === 2" class="prop-row">
|
<div v-if="w.textSrc === 2" :class="rowClass">
|
||||||
<label>KNX Objekt</label>
|
<label :class="labelClass">KNX Objekt</label>
|
||||||
<select v-model.number="w.knxAddr">
|
<select :class="inputClass" v-model.number="w.knxAddr">
|
||||||
<option :value="0">-- Waehlen --</option>
|
<option :value="0">-- Waehlen --</option>
|
||||||
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
||||||
GO{{ addr.index }} ({{ addr.addrStr }})
|
GO{{ addr.index }} ({{ addr.addrStr }})
|
||||||
@ -63,28 +63,28 @@
|
|||||||
|
|
||||||
<!-- Icon Widget -->
|
<!-- Icon Widget -->
|
||||||
<template v-if="key === 'icon'">
|
<template v-if="key === 'icon'">
|
||||||
<h4>Icon</h4>
|
<h4 :class="headingClass">Icon</h4>
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Icon</label>
|
<label :class="labelClass">Icon</label>
|
||||||
<button class="icon-select-btn" @click="showIconPicker = true">
|
<button :class="iconSelectClass" @click="showIconPicker = true">
|
||||||
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
<span v-else>Auswaehlen</span>
|
<span v-else>Auswaehlen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Groesse</label>
|
<label :class="labelClass">Groesse</label>
|
||||||
<select v-model.number="w.iconSize">
|
<select :class="inputClass" v-model.number="w.iconSize">
|
||||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row"><label>Modus</label>
|
<div :class="rowClass"><label :class="labelClass">Modus</label>
|
||||||
<select v-model.number="w.textSrc">
|
<select :class="inputClass" v-model.number="w.textSrc">
|
||||||
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
<option v-for="opt in sourceOptions.icon" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="w.textSrc === 2" class="prop-row">
|
<div v-if="w.textSrc === 2" :class="rowClass">
|
||||||
<label>KNX Objekt</label>
|
<label :class="labelClass">KNX Objekt</label>
|
||||||
<select v-model.number="w.knxAddr">
|
<select :class="inputClass" v-model.number="w.knxAddr">
|
||||||
<option :value="0">-- Waehlen --</option>
|
<option :value="0">-- Waehlen --</option>
|
||||||
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
<option v-for="addr in store.knxAddresses" :key="addr.index" :value="addr.index">
|
||||||
GO{{ addr.index }} ({{ addr.addrStr }})
|
GO{{ addr.index }} ({{ addr.addrStr }})
|
||||||
@ -94,121 +94,127 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="key === 'tabview'">
|
<template v-if="key === 'tabview'">
|
||||||
<h4>Tabs</h4>
|
<h4 :class="headingClass">Tabs</h4>
|
||||||
<div class="prop-row"><label>Position</label>
|
<div :class="rowClass"><label :class="labelClass">Position</label>
|
||||||
<select v-model.number="w.iconPosition">
|
<select :class="inputClass" v-model.number="w.iconPosition">
|
||||||
<option :value="0">Oben</option>
|
<option :value="0">Oben</option>
|
||||||
<option :value="1">Unten</option>
|
<option :value="1">Unten</option>
|
||||||
<option :value="2">Links</option>
|
<option :value="2">Links</option>
|
||||||
<option :value="3">Rechts</option>
|
<option :value="3">Rechts</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row"><label>Tab Hoehe</label>
|
<div :class="rowClass"><label :class="labelClass">Tab Hoehe</label>
|
||||||
<input type="number" v-model.number="w.iconSize" min="1" max="20">
|
<input :class="inputClass" type="number" v-model.number="w.iconSize" min="1" max="20">
|
||||||
<span style="font-size:10px; color:var(--muted); margin-left:4px">x10px</span>
|
<span class="text-[10px] text-muted ml-1">x10px</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="key === 'tabpage'">
|
<template v-if="key === 'tabpage'">
|
||||||
<h4>Tab Seite</h4>
|
<h4 :class="headingClass">Tab Seite</h4>
|
||||||
<div class="prop-row"><label>Titel</label><input type="text" v-model="w.text"></div>
|
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Typography -->
|
<!-- Typography -->
|
||||||
<template v-if="key === 'label' || key === 'button'">
|
<template v-if="key === 'label'">
|
||||||
<h4>Typo</h4>
|
<h4 :class="headingClass">Typo</h4>
|
||||||
<div class="prop-row"><label>Schriftgr.</label>
|
<div :class="rowClass"><label :class="labelClass">Schriftgr.</label>
|
||||||
<select v-model.number="w.fontSize">
|
<select :class="inputClass" v-model.number="w.fontSize">
|
||||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Icon for Label/Button -->
|
<!-- Icon for Label/Button -->
|
||||||
<template v-if="key === 'label' || key === 'button'">
|
<template v-if="key === 'label'">
|
||||||
<h4>Icon</h4>
|
<h4 :class="headingClass">Icon</h4>
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Icon</label>
|
<label :class="labelClass">Icon</label>
|
||||||
<button class="icon-select-btn" @click="showIconPicker = true">
|
<button :class="iconSelectClass" @click="showIconPicker = true">
|
||||||
<span v-if="w.iconCodepoint" class="material-symbols-outlined">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
<span v-if="w.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(w.iconCodepoint) }}</span>
|
||||||
<span v-else>Kein Icon</span>
|
<span v-else>Kein Icon</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<template v-if="w.iconCodepoint">
|
<template v-if="w.iconCodepoint">
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Position</label>
|
<label :class="labelClass">Position</label>
|
||||||
<select v-model.number="w.iconPosition">
|
<select :class="inputClass" v-model.number="w.iconPosition">
|
||||||
<option :value="0">Links</option>
|
<option :value="0">Links</option>
|
||||||
<option :value="1">Rechts</option>
|
<option :value="1">Rechts</option>
|
||||||
<option :value="2">Oben</option>
|
<option :value="2">Oben</option>
|
||||||
<option :value="3">Unten</option>
|
<option :value="3">Unten</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Icon-Gr.</label>
|
<label :class="labelClass">Icon-Gr.</label>
|
||||||
<select v-model.number="w.iconSize">
|
<select :class="inputClass" v-model.number="w.iconSize">
|
||||||
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div :class="rowClass">
|
||||||
<label>Abstand</label>
|
<label :class="labelClass">Abstand</label>
|
||||||
<input type="number" v-model.number="w.iconGap" min="0" max="50">
|
<input :class="inputClass" type="number" v-model.number="w.iconGap" min="0" max="50">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Style -->
|
<!-- Style -->
|
||||||
<template v-if="key === 'led'">
|
<template v-if="key === 'led'">
|
||||||
<h4>Stil</h4>
|
<h4 :class="headingClass">Stil</h4>
|
||||||
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.bgColor"></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.bgColor"></div>
|
||||||
<div class="prop-row"><label>Helligkeit</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></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>
|
||||||
<template v-else-if="key === 'icon'">
|
<template v-else-if="key === 'icon'">
|
||||||
<h4>Stil</h4>
|
<h4 :class="headingClass">Stil</h4>
|
||||||
<div class="prop-row"><label>Farbe</label><input type="color" v-model="w.textColor"></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.textColor"></div>
|
||||||
<div class="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></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="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></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="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h4>Stil</h4>
|
<h4 :class="headingClass">Stil</h4>
|
||||||
<div class="prop-row"><label>Textfarbe</label><input type="color" v-model="w.textColor"></div>
|
<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="prop-row"><label>Hintergrund</label><input type="color" v-model="w.bgColor"></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="prop-row"><label>Deckkraft</label><input type="number" min="0" max="255" v-model.number="w.bgOpacity"></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="prop-row"><label>Radius</label><input type="number" v-model.number="w.radius"></div>
|
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Shadow/Glow -->
|
<!-- Shadow/Glow -->
|
||||||
<template v-if="key !== 'icon'">
|
<template v-if="key !== 'icon'">
|
||||||
<h4>{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
|
<h4 :class="headingClass">{{ key === 'led' ? 'Glow' : 'Schatten' }}</h4>
|
||||||
<div class="prop-row"><label>Aktiv</label><input type="checkbox" v-model="w.shadow.enabled"></div>
|
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" 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="rowClass" v-if="key !== 'led'"><label :class="labelClass">X</label><input :class="inputClass" 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="rowClass" v-if="key !== 'led'"><label :class="labelClass">Y</label><input :class="inputClass" 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="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" 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>
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- Button Actions -->
|
<!-- Button Actions -->
|
||||||
<template v-if="key === 'button'">
|
<template v-if="key === 'button'">
|
||||||
<h4>Aktion</h4>
|
<h4 :class="headingClass">Aktion</h4>
|
||||||
<div class="prop-row"><label>Typ</label>
|
<div :class="rowClass"><label :class="labelClass">Typ</label>
|
||||||
<select v-model.number="w.action">
|
<select :class="inputClass" v-model.number="w.action">
|
||||||
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
|
<option :value="BUTTON_ACTIONS.KNX">KNX</option>
|
||||||
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
|
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
|
||||||
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
|
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="w.action === BUTTON_ACTIONS.JUMP" class="prop-row">
|
<div v-if="w.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
|
||||||
<label>Ziel</label>
|
<label :class="labelClass">Ziel</label>
|
||||||
<select v-model.number="w.targetScreen">
|
<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>
|
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="w.action === BUTTON_ACTIONS.KNX">
|
<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="rowClass"><label :class="labelClass">Toggle</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.isToggle"></div>
|
||||||
<div class="prop-row"><label>KNX Schreib</label>
|
<div :class="rowClass"><label :class="labelClass">KNX Schreib</label>
|
||||||
<select v-model.number="w.knxAddrWrite">
|
<select :class="inputClass" v-model.number="w.knxAddrWrite">
|
||||||
<option :value="0">-- Keine --</option>
|
<option :value="0">-- Keine --</option>
|
||||||
<option v-for="addr in writeableAddresses" :key="addr.index" :value="addr.index">
|
<option v-for="addr in writeableAddresses" :key="addr.index" :value="addr.index">
|
||||||
GO{{ addr.index }} ({{ addr.addrStr }})
|
GO{{ addr.index }} ({{ addr.addrStr }})
|
||||||
@ -218,8 +224,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="prop-actions">
|
<div class="mt-4">
|
||||||
<button class="btn ghost danger" @click="store.deleteWidget">Widget loeschen</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="store.deleteWidget">Widget loeschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -246,6 +252,13 @@ const key = computed(() => w.value ? typeKeyFor(w.value.type) : 'label');
|
|||||||
const showIconPicker = ref(false);
|
const showIconPicker = ref(false);
|
||||||
|
|
||||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
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() {
|
function handleTextSrcChange() {
|
||||||
if (!w.value) return;
|
if (!w.value) return;
|
||||||
@ -255,41 +268,3 @@ function handleTextSrcChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="topbar">
|
<header class="flex items-center justify-between gap-4 px-5 py-3.5 bg-[#f4f7fa] border-b border-border">
|
||||||
<div class="brand">
|
<div class="flex items-center gap-3">
|
||||||
<div class="brand-mark">LV</div>
|
<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="brand-text">
|
<div class="flex flex-col">
|
||||||
<div class="title">GUI Designer</div>
|
<div class="text-[18px] font-semibold">GUI Designer</div>
|
||||||
<div class="subtitle">KNX Display</div>
|
<div class="text-[12px] text-muted">KNX Display</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="flex items-center gap-2.5 flex-wrap justify-end">
|
||||||
<button class="btn ghost" @click="enableUsbMode">USB-Modus</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="emit('open-settings')">Einstellungen</button>
|
||||||
<button class="btn ghost danger" @click="handleReset">Zuruecksetzen</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="btn primary" @click="handleSave">Speichern & Anwenden</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@ -19,6 +20,7 @@
|
|||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
|
|
||||||
const store = useEditorStore();
|
const store = useEditorStore();
|
||||||
|
const emit = defineEmits(['open-settings']);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tree-node">
|
<div class="relative">
|
||||||
<div
|
<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="{
|
:class="{
|
||||||
active: store.selectedWidgetId === node.id,
|
'bg-accent-2/15 border-accent-2/30': store.selectedWidgetId === node.id,
|
||||||
hidden: !node.visible,
|
'opacity-50': !node.visible,
|
||||||
'drag-over': isDragOver
|
'bg-accent/20 border-dashed border-accent': isDragOver
|
||||||
}"
|
}"
|
||||||
@click.stop="store.selectedWidgetId = node.id"
|
@click.stop="store.selectedWidgetId = node.id"
|
||||||
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
||||||
@ -16,23 +16,23 @@
|
|||||||
@drop.stop="onDrop($event, node)"
|
@drop.stop="onDrop($event, node)"
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
@click.stop="toggleExpand"
|
||||||
:style="{ visibility: node.children.length > 0 ? 'visible' : 'hidden' }"
|
:style="{ visibility: node.children.length > 0 ? 'visible' : 'hidden' }"
|
||||||
>
|
>
|
||||||
{{ expanded ? '▼' : '▶' }}
|
{{ expanded ? '▼' : '▶' }}
|
||||||
</span>
|
</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">
|
<div class="flex flex-col overflow-hidden">
|
||||||
<span class="tree-name">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
|
<span class="text-[12px] truncate">{{ node.text || TYPE_LABELS[typeKeyFor(node.type)] }}</span>
|
||||||
<span class="tree-type">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
|
<span class="text-[9px] text-muted">{{ TYPE_LABELS[typeKeyFor(node.type)] }} #{{ node.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tree-children" v-if="node.children.length > 0 && expanded">
|
<div class="relative" v-if="node.children.length > 0 && expanded">
|
||||||
<div class="tree-guide" :style="{ left: `${level * 16 + 15}px` }"></div>
|
<div class="absolute top-0 bottom-0 w-px bg-white/5" :style="{ left: `${level * 16 + 15}px` }"></div>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
v-for="child in node.children"
|
v-for="child in node.children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
@ -115,92 +115,3 @@ function onDrop(e, targetNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
@ -1,13 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="widget"
|
class="z-[1] select-none touch-none"
|
||||||
:class="{
|
:class="[
|
||||||
selected: selected,
|
selected ? 'outline outline-2 outline-accent outline-offset-2' : '',
|
||||||
'widget-label': isLabel,
|
isLabel ? 'px-1.5 py-1 rounded-md overflow-hidden whitespace-nowrap' : ''
|
||||||
'widget-button': isButton,
|
]"
|
||||||
'widget-led': isLed,
|
|
||||||
'widget-icon': isIcon
|
|
||||||
}"
|
|
||||||
:style="computedStyle"
|
:style="computedStyle"
|
||||||
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
|
@mousedown.stop="!isTabPage && $emit('drag-start', { id: widget.id, event: $event })"
|
||||||
@touchstart.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 -->
|
<!-- Recursive Children -->
|
||||||
<!-- Special handling for TabView to render structure -->
|
<!-- Special handling for TabView to render structure -->
|
||||||
<template v-if="isTabView">
|
<template v-if="isTabView">
|
||||||
<div class="tabview-container" :class="'pos-'+tabPosition" :style="tabViewStyle">
|
<div class="flex w-full h-full overflow-hidden" :style="tabViewStyle">
|
||||||
<div class="tab-buttons" :style="tabBtnsStyle">
|
<div class="flex overflow-hidden bg-black/20" :style="tabBtnsStyle">
|
||||||
<div
|
<div
|
||||||
v-for="(child, idx) in children"
|
v-for="(child, idx) in children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
class="tab-btn"
|
:class="tabBtnClass(activePageId === child.id)"
|
||||||
:class="{ active: activePageId === child.id }"
|
|
||||||
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
|
@click.stop="activeTabIndex = idx; store.selectedWidgetId = child.id"
|
||||||
>
|
>
|
||||||
{{ child.text }}
|
{{ child.text }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="flex-1 relative overflow-hidden">
|
||||||
<WidgetElement
|
<WidgetElement
|
||||||
v-for="child in children"
|
v-for="child in children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
@ -60,23 +56,23 @@
|
|||||||
|
|
||||||
<!-- Icon-only Widget -->
|
<!-- Icon-only Widget -->
|
||||||
<template v-if="isIcon">
|
<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 }}
|
{{ iconChar }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Label/Button with Icon -->
|
<!-- Label/Button with Icon -->
|
||||||
<template v-else-if="hasIcon">
|
<template v-else-if="hasIcon">
|
||||||
<div class="widget-content" :style="contentStyle">
|
<div :style="contentStyle">
|
||||||
<span
|
<span
|
||||||
v-if="iconPosition === 0 || iconPosition === 2"
|
v-if="iconPosition === 0 || iconPosition === 2"
|
||||||
class="material-symbols-outlined widget-icon"
|
class="material-symbols-outlined flex-shrink-0"
|
||||||
:style="iconStyle"
|
:style="iconStyle"
|
||||||
>{{ iconChar }}</span>
|
>{{ iconChar }}</span>
|
||||||
<span class="widget-text">{{ widget.text }}</span>
|
<span class="flex-shrink-0">{{ widget.text }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="iconPosition === 1 || iconPosition === 3"
|
v-if="iconPosition === 1 || iconPosition === 3"
|
||||||
class="material-symbols-outlined widget-icon"
|
class="material-symbols-outlined flex-shrink-0"
|
||||||
:style="iconStyle"
|
:style="iconStyle"
|
||||||
>{{ iconChar }}</span>
|
>{{ iconChar }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -84,16 +80,19 @@
|
|||||||
|
|
||||||
<!-- Standard (no icon) -->
|
<!-- Standard (no icon) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="!isTabView && !isTabPage">{{ widget.text }}</span>
|
<span v-if="showDefaultText">{{ widget.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Resize Handle (at end to be on top) -->
|
<!-- Resize Handle (at end to be on top) -->
|
||||||
<div
|
<div
|
||||||
v-if="selected"
|
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 })"
|
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
|
||||||
@touchstart.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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -124,6 +123,7 @@ const isLed = computed(() => props.widget.type === WIDGET_TYPES.LED);
|
|||||||
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
|
const isIcon = computed(() => props.widget.type === WIDGET_TYPES.ICON);
|
||||||
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
|
const isTabView = computed(() => props.widget.type === WIDGET_TYPES.TABVIEW);
|
||||||
const isTabPage = computed(() => props.widget.type === WIDGET_TYPES.TABPAGE);
|
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 tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
||||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||||
@ -155,6 +155,7 @@ const activePageId = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasIcon = computed(() => {
|
const hasIcon = computed(() => {
|
||||||
|
if (isButtonContainer.value) return false;
|
||||||
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
|
return (isLabel.value || isButton.value) && props.widget.iconCodepoint > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -164,6 +165,30 @@ const iconChar = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const iconPosition = computed(() => props.widget.iconPosition || 0);
|
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(() => {
|
const isVerticalLayout = computed(() => {
|
||||||
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
return iconPosition.value === ICON_POSITIONS.TOP || iconPosition.value === ICON_POSITIONS.BOTTOM;
|
||||||
@ -176,7 +201,7 @@ const contentStyle = computed(() => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
flexDirection: isVerticalLayout.value ? 'column' : 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: contentJustify.value,
|
||||||
gap: `${gap}px`,
|
gap: `${gap}px`,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%'
|
height: '100%'
|
||||||
@ -238,16 +263,18 @@ const computedStyle = computed(() => {
|
|||||||
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
|
||||||
style.background = hexToRgba(w.bgColor, alpha);
|
style.background = hexToRgba(w.bgColor, alpha);
|
||||||
}
|
}
|
||||||
if (hasIcon.value) {
|
style.display = 'flex';
|
||||||
style.display = 'flex';
|
style.alignItems = 'center';
|
||||||
style.alignItems = 'center';
|
if (!hasIcon.value) {
|
||||||
|
style.justifyContent = justifyForAlign(textAlign.value);
|
||||||
|
style.textAlign = textAlignCss(textAlign.value);
|
||||||
}
|
}
|
||||||
} else if (isButton.value) {
|
} else if (isButton.value) {
|
||||||
style.background = w.bgColor;
|
style.background = w.bgColor;
|
||||||
style.borderRadius = `${w.radius * s}px`;
|
style.borderRadius = `${w.radius * s}px`;
|
||||||
style.display = 'flex';
|
style.display = 'flex';
|
||||||
style.alignItems = 'center';
|
style.alignItems = 'center';
|
||||||
style.justifyContent = 'center';
|
style.justifyContent = contentJustify.value;
|
||||||
style.fontWeight = '600';
|
style.fontWeight = '600';
|
||||||
|
|
||||||
if (w.shadow && w.shadow.enabled) {
|
if (w.shadow && w.shadow.enabled) {
|
||||||
@ -295,10 +322,7 @@ const computedStyle = computed(() => {
|
|||||||
|
|
||||||
const tabViewStyle = computed(() => {
|
const tabViewStyle = computed(() => {
|
||||||
return {
|
return {
|
||||||
display: 'flex',
|
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row'
|
||||||
flexDirection: tabPosition.value === 0 || tabPosition.value === 1 ? 'column' : 'row',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -310,71 +334,17 @@ const tabBtnsStyle = computed(() => {
|
|||||||
return {
|
return {
|
||||||
[isVert ? 'height' : 'width']: `${h}px`,
|
[isVert ? 'height' : 'width']: `${h}px`,
|
||||||
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
|
order: tabPosition.value === 1 || tabPosition.value === 3 ? 2 : 0, // Bottom/Right
|
||||||
display: 'flex',
|
flexDirection: isVert ? 'row' : 'column'
|
||||||
flexDirection: isVert ? 'row' : 'column',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
overflow: 'hidden'
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>
|
</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>
|
|
||||||
|
|||||||
@ -24,6 +24,12 @@ export const BUTTON_ACTIONS = {
|
|||||||
BACK: 2
|
BACK: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TEXT_ALIGNS = {
|
||||||
|
LEFT: 0,
|
||||||
|
CENTER: 1,
|
||||||
|
RIGHT: 2
|
||||||
|
};
|
||||||
|
|
||||||
export const TYPE_KEYS = {
|
export const TYPE_KEYS = {
|
||||||
0: 'label',
|
0: 'label',
|
||||||
1: 'button',
|
1: 'button',
|
||||||
@ -81,6 +87,7 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: 'Neues Label',
|
text: 'Neues Label',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 1,
|
fontSize: 1,
|
||||||
|
textAlign: TEXT_ALIGNS.LEFT,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#0E1217',
|
bgColor: '#0E1217',
|
||||||
bgOpacity: 0,
|
bgOpacity: 0,
|
||||||
@ -102,6 +109,8 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: 'Button',
|
text: 'Button',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 1,
|
fontSize: 1,
|
||||||
|
textAlign: TEXT_ALIGNS.CENTER,
|
||||||
|
isContainer: true,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#2E7DD1',
|
bgColor: '#2E7DD1',
|
||||||
bgOpacity: 255,
|
bgOpacity: 255,
|
||||||
@ -123,6 +132,7 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: '',
|
text: '',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 1,
|
fontSize: 1,
|
||||||
|
textAlign: TEXT_ALIGNS.CENTER,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#F6C177',
|
bgColor: '#F6C177',
|
||||||
bgOpacity: 255,
|
bgOpacity: 255,
|
||||||
@ -144,6 +154,7 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: '',
|
text: '',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 3,
|
fontSize: 3,
|
||||||
|
textAlign: TEXT_ALIGNS.CENTER,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#0E1217',
|
bgColor: '#0E1217',
|
||||||
bgOpacity: 0,
|
bgOpacity: 0,
|
||||||
@ -165,6 +176,7 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: '',
|
text: '',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 1,
|
fontSize: 1,
|
||||||
|
textAlign: TEXT_ALIGNS.CENTER,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#2a3543',
|
bgColor: '#2a3543',
|
||||||
bgOpacity: 255,
|
bgOpacity: 255,
|
||||||
@ -186,6 +198,7 @@ export const WIDGET_DEFAULTS = {
|
|||||||
text: 'Tab',
|
text: 'Tab',
|
||||||
textSrc: 0,
|
textSrc: 0,
|
||||||
fontSize: 1,
|
fontSize: 1,
|
||||||
|
textAlign: TEXT_ALIGNS.CENTER,
|
||||||
textColor: '#FFFFFF',
|
textColor: '#FFFFFF',
|
||||||
bgColor: '#1A1A2E',
|
bgColor: '#1A1A2E',
|
||||||
bgOpacity: 0, // Transparent by default
|
bgOpacity: 0, // Transparent by default
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed, reactive } from 'vue';
|
import { ref, computed, reactive } from 'vue';
|
||||||
import { normalizeScreen, normalizeWidget } from '../utils';
|
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', () => {
|
export const useEditorStore = defineStore('editor', () => {
|
||||||
const config = reactive({
|
const config = reactive({
|
||||||
@ -51,6 +51,58 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
return activeScreen.value.widgets.find(w => w.id === selectedWidgetId.value);
|
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() {
|
async function loadKnxAddresses() {
|
||||||
try {
|
try {
|
||||||
// Mock or Real API
|
// Mock or Real API
|
||||||
@ -111,6 +163,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
config.screens.forEach((screen) => {
|
config.screens.forEach((screen) => {
|
||||||
normalizeScreen(screen, nextScreenId, nextWidgetId);
|
normalizeScreen(screen, nextScreenId, nextWidgetId);
|
||||||
|
ensureButtonLabels(screen);
|
||||||
// Also update max widget id
|
// Also update max widget id
|
||||||
screen.widgets.forEach(w => {
|
screen.widgets.forEach(w => {
|
||||||
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
|
nextWidgetId.value = Math.max(nextWidgetId.value, w.id + 1);
|
||||||
@ -238,6 +291,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
text: defaults.text,
|
text: defaults.text,
|
||||||
knxAddr: defaults.knxAddr,
|
knxAddr: defaults.knxAddr,
|
||||||
fontSize: defaults.fontSize,
|
fontSize: defaults.fontSize,
|
||||||
|
textAlign: defaults.textAlign,
|
||||||
|
isContainer: defaults.isContainer,
|
||||||
textColor: defaults.textColor,
|
textColor: defaults.textColor,
|
||||||
bgColor: defaults.bgColor,
|
bgColor: defaults.bgColor,
|
||||||
bgOpacity: defaults.bgOpacity,
|
bgOpacity: defaults.bgOpacity,
|
||||||
@ -256,6 +311,39 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
activeScreen.value.widgets.push(w);
|
activeScreen.value.widgets.push(w);
|
||||||
selectedWidgetId.value = w.id;
|
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
|
// Auto-create pages for new TabView
|
||||||
if (typeStr === 'tabview') {
|
if (typeStr === 'tabview') {
|
||||||
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
|
// Deselect to reset parent logic for recursive calls, or explicitly pass parent
|
||||||
|
|||||||
@ -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');
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1419;
|
--bg: #eef2f6;
|
||||||
--bg-2: #141c24;
|
--bg-2: #e6edf3;
|
||||||
--panel: #18212b;
|
--panel: #ffffff;
|
||||||
--panel-2: #1d2732;
|
--panel-2: #f3f6f9;
|
||||||
--border: #2a3543;
|
--border: #d5dce3;
|
||||||
--text: #e7edf3;
|
--text: #1f2a33;
|
||||||
--muted: #9aa8b4;
|
--muted: #6b7885;
|
||||||
--accent: #f6c177;
|
--accent: #5a93da;
|
||||||
--accent-2: #7dd3b0;
|
--accent-2: #86b7e6;
|
||||||
--danger: #ff6b6b;
|
--danger: #d9534f;
|
||||||
--canvas-bg: #1a1a2e;
|
--canvas-bg: #f7f9fb;
|
||||||
--shadow: rgba(0, 0, 0, 0.35);
|
--shadow: rgba(15, 23, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Space Grotesk", "Fira Sans", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Fira Sans", "Segoe UI", sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 800px at 15% 10%, #1b2530 0%, #0f1419 55%),
|
radial-gradient(900px 650px at 12% 10%, rgba(90, 147, 218, 0.18) 0%, rgba(238, 242, 246, 0) 60%),
|
||||||
linear-gradient(135deg, #0f1419, #141c24);
|
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);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
/* display: flex; flex-direction: column; -- Handled by Vue App layout */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
@ -42,566 +43,21 @@ body {
|
|||||||
background: var(--muted);
|
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 Icon Font */
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-family: 'Material Symbols Outlined';
|
font-family: 'Material Symbols Outlined';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-feature-settings: 'liga';
|
font-feature-settings: 'liga';
|
||||||
}
|
|
||||||
|
|
||||||
.widget-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,10 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
|||||||
|
|
||||||
// Hierarchy
|
// Hierarchy
|
||||||
if (w.parentId === undefined) w.parentId = -1;
|
if (w.parentId === undefined) w.parentId = -1;
|
||||||
|
|
||||||
|
if (key === 'button') {
|
||||||
|
w.isContainer = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
export function normalizeScreen(screen, nextScreenIdRef, nextWidgetIdRef) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user