344 lines
16 KiB
Vue
344 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Room Card -->
|
|
<h4 :class="headingClass">Room Card</h4>
|
|
<div :class="rowClass"><label :class="labelClass">Raumname</label><input :class="inputClass" type="text" v-model="roomCardName"></div>
|
|
<!-- Bubble style: Temperature via primary textSrc/knxAddr -->
|
|
<template v-if="widget.cardStyle !== 1">
|
|
<div :class="rowClass"><label :class="labelClass">Temp. Format</label><input :class="inputClass" type="text" v-model="roomCardFormat"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Temp. Quelle</label>
|
|
<select :class="inputClass" v-model.number="widget.textSrc">
|
|
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
|
|
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
<div v-if="widget.textSrc > 0" :class="rowClass"><label :class="labelClass">KNX Objekt</label>
|
|
<select :class="inputClass" v-model.number="widget.knxAddr">
|
|
<option :value="0">-- Waehlen --</option>
|
|
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
|
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</template>
|
|
<div :class="rowClass"><label :class="labelClass">Klick-Aktion</label>
|
|
<select :class="inputClass" v-model.number="widget.action">
|
|
<option :value="BUTTON_ACTIONS.JUMP">Sprung</option>
|
|
<option :value="BUTTON_ACTIONS.BACK">Zurueck</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="widget.action === BUTTON_ACTIONS.JUMP" :class="rowClass">
|
|
<label :class="labelClass">Ziel Screen</label>
|
|
<select :class="inputClass" v-model.number="widget.targetScreen">
|
|
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Variable Text Lines (Tile style only) -->
|
|
<template v-if="widget.cardStyle === 1">
|
|
<h4 :class="headingClass">Textzeilen</h4>
|
|
<div :class="rowClass">
|
|
<label :class="labelClass">Anzahl</label>
|
|
<select :class="inputClass" v-model.number="textLineCount">
|
|
<option :value="0">Keine</option>
|
|
<option :value="1">1</option>
|
|
<option :value="2">2</option>
|
|
<option :value="3">3</option>
|
|
<option :value="4">4</option>
|
|
<option :value="5">5</option>
|
|
</select>
|
|
</div>
|
|
<div v-for="(line, idx) in textLines" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Zeile {{ idx + 1 }}</label>
|
|
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-textline-icon-picker', idx)">
|
|
<span v-if="line.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(line.icon) }}</span>
|
|
<span v-else>Kein Icon</span>
|
|
</button>
|
|
<select class="w-[70px] bg-white border border-border rounded-md px-1 py-1 text-[11px]" v-model.number="line.fontSize">
|
|
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}px</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Quelle</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.textSrc">
|
|
<option :value="0">Statisch</option>
|
|
<optgroup v-for="group in groupedSources(sourceOptions.roomcard)" :key="group.label" :label="group.label">
|
|
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
<div v-if="line.textSrc === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Text</label>
|
|
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. 21.5°C">
|
|
</div>
|
|
<template v-else>
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Format</label>
|
|
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="text" v-model="line.text" placeholder="z.B. %.1f°C">
|
|
</div>
|
|
<div class="flex items-center gap-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">KNX</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="line.knxAddr">
|
|
<option :value="0">-- Waehlen --</option>
|
|
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
|
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Icon -->
|
|
<h4 :class="headingClass">Icon</h4>
|
|
<div :class="rowClass">
|
|
<label :class="labelClass">Icon</label>
|
|
<button :class="iconSelectClass" @click="$emit('open-icon-picker')">
|
|
<span v-if="widget.iconCodepoint" class="material-symbols-outlined text-[20px]">{{ String.fromCodePoint(widget.iconCodepoint) }}</span>
|
|
<span v-else>Auswaehlen</span>
|
|
</button>
|
|
<button v-if="widget.iconCodepoint" :class="iconRemoveClass" @click="widget.iconCodepoint = 0">x</button>
|
|
</div>
|
|
<template v-if="widget.iconCodepoint">
|
|
<div :class="rowClass">
|
|
<label :class="labelClass">Icon-Gr.</label>
|
|
<select :class="inputClass" v-model.number="widget.iconSize">
|
|
<option v-for="(size, idx) in iconFontSizes" :key="idx" :value="idx">{{ size }}px</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="widget.cardStyle === 1" :class="rowClass">
|
|
<label :class="labelClass">Icon X</label>
|
|
<input :class="inputClass" type="number" v-model.number="widget.iconPositionX" min="-100" max="400">
|
|
<span class="text-[10px] text-muted">px</span>
|
|
</div>
|
|
<div v-if="widget.cardStyle === 1" :class="rowClass">
|
|
<label :class="labelClass">Icon Y</label>
|
|
<input :class="inputClass" type="number" v-model.number="widget.iconPositionY" min="-100" max="400">
|
|
<span class="text-[10px] text-muted">px</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Card Style -->
|
|
<h4 :class="headingClass">Karten-Stil</h4>
|
|
<div :class="rowClass">
|
|
<label :class="labelClass">Layout</label>
|
|
<select :class="inputClass" v-model.number="widget.cardStyle">
|
|
<option :value="0">Bubble (rund)</option>
|
|
<option :value="1">Tile (rechteckig)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Sub-Buttons -->
|
|
<h4 :class="headingClass">Sub-Buttons</h4>
|
|
<div :class="rowClass">
|
|
<label :class="labelClass">Anzahl</label>
|
|
<select :class="inputClass" v-model.number="subButtonCount">
|
|
<option :value="0">Keine</option>
|
|
<option :value="1">1</option>
|
|
<option :value="2">2</option>
|
|
<option :value="3">3</option>
|
|
<option :value="4">4</option>
|
|
<option :value="5">5</option>
|
|
<option :value="6">6</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="subButtonCount > 0" :class="rowClass">
|
|
<label :class="labelClass">Button-Gr.</label>
|
|
<input :class="inputClass" type="number" min="30" max="80" v-model.number="widget.subButtonSize">
|
|
<span class="text-[10px] text-muted">px</span>
|
|
</div>
|
|
<div v-if="subButtonCount > 0" :class="rowClass">
|
|
<label :class="labelClass">Abstand</label>
|
|
<input :class="inputClass" type="number" min="40" max="200" v-model.number="widget.subButtonDistance">
|
|
<span class="text-[10px] text-muted">px</span>
|
|
</div>
|
|
<div v-if="subButtonCount > 0" :class="rowClass">
|
|
<label :class="labelClass">Btn Opacity</label>
|
|
<input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.subButtonOpacity">
|
|
</div>
|
|
<div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Btn {{ idx + 1 }}</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.pos">
|
|
<option v-for="(label, pos) in SUBBUTTON_POSITION_LABELS" :key="pos" :value="Number(pos)">{{ label }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Icon</label>
|
|
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-subbutton-icon-picker', idx)">
|
|
<span v-if="sb.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(sb.icon) }}</span>
|
|
<span v-else>Kein Icon</span>
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Aktion</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.action">
|
|
<option :value="0">KNX Toggle</option>
|
|
<option :value="1">Navigation</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">KNX R</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxRead">
|
|
<option :value="0">-- Keine --</option>
|
|
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
|
GA {{ addr.addrStr }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="sb.action === 0" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">KNX W</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.knxWrite">
|
|
<option :value="0">-- Keine --</option>
|
|
<option v-for="addr in writeableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
|
GA {{ addr.addrStr }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="sb.action === 1" class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Ziel</label>
|
|
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="sb.target">
|
|
<option v-for="s in store.config.screens" :key="s.id" :value="s.id">{{ s.name }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-[11px] text-muted">
|
|
<label class="w-[50px]">Farben</label>
|
|
<span class="text-[10px]">An:</span>
|
|
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOn">
|
|
<span class="text-[10px]">Aus:</span>
|
|
<input class="h-[22px] w-[28px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="sb.colorOff">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Style -->
|
|
<h4 :class="headingClass">Stil</h4>
|
|
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input :class="colorInputClass" type="color" v-model="widget.textColor"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input :class="colorInputClass" type="color" v-model="widget.bgColor"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.bgOpacity"></div>
|
|
<h4 :class="headingClass">Rand</h4>
|
|
<div :class="rowClass"><label :class="labelClass">Breite</label><input :class="inputClass" type="number" min="0" max="32" v-model.number="widget.borderWidth"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.borderColor"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="widget.borderOpacity"></div>
|
|
|
|
<!-- Shadow -->
|
|
<h4 :class="headingClass">Schatten</h4>
|
|
<div :class="rowClass"><label :class="labelClass">Aktiv</label><input class="accent-[var(--accent)]" type="checkbox" v-model="widget.shadow.enabled"></div>
|
|
<div :class="rowClass"><label :class="labelClass">X</label><input :class="inputClass" type="number" v-model.number="widget.shadow.x"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Y</label><input :class="inputClass" type="number" v-model.number="widget.shadow.y"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Blur</label><input :class="inputClass" type="number" v-model.number="widget.shadow.blur"></div>
|
|
<div :class="rowClass"><label :class="labelClass">Farbe</label><input :class="colorInputClass" type="color" v-model="widget.shadow.color"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue';
|
|
import { useEditorStore } from '../../../stores/editor';
|
|
import { sourceOptions, textSources, textSourceGroups, fontSizes, iconFontSizes, BUTTON_ACTIONS, SUBBUTTON_POSITION_LABELS } from '../../../constants';
|
|
import { rowClass, labelClass, inputClass, headingClass, subHeadingClass, iconSelectClass, iconRemoveClass, colorInputClass } from '../shared/styles';
|
|
|
|
const props = defineProps({
|
|
widget: { type: Object, required: true }
|
|
});
|
|
|
|
defineEmits(['open-icon-picker', 'open-subbutton-icon-picker', 'open-textline-icon-picker']);
|
|
|
|
const store = useEditorStore();
|
|
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
|
|
|
const roomCardName = computed({
|
|
get() {
|
|
if (!props.widget?.text) return '';
|
|
const parts = props.widget.text.split('\n');
|
|
return parts[0] || '';
|
|
},
|
|
set(value) {
|
|
if (!props.widget) return;
|
|
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
|
|
parts[0] = value;
|
|
props.widget.text = parts.join('\n');
|
|
}
|
|
});
|
|
|
|
const roomCardFormat = computed({
|
|
get() {
|
|
if (!props.widget?.text) return '';
|
|
const parts = props.widget.text.split('\n');
|
|
return parts[1] || '';
|
|
},
|
|
set(value) {
|
|
if (!props.widget) return;
|
|
const parts = props.widget.text ? props.widget.text.split('\n') : ['', ''];
|
|
parts[1] = value;
|
|
props.widget.text = parts.join('\n');
|
|
}
|
|
});
|
|
|
|
const subButtons = computed(() => props.widget?.subButtons ?? []);
|
|
const textLines = computed(() => props.widget?.textLines ?? []);
|
|
|
|
const textLineCount = computed({
|
|
get() {
|
|
return textLines.value.length || 0;
|
|
},
|
|
set(value) {
|
|
if (!props.widget) return;
|
|
const target = Math.max(0, Math.min(value, 5));
|
|
if (!Array.isArray(props.widget.textLines)) {
|
|
props.widget.textLines = [];
|
|
}
|
|
while (props.widget.textLines.length < target) {
|
|
props.widget.textLines.push({
|
|
text: '',
|
|
textSrc: 0,
|
|
knxAddr: 0,
|
|
icon: 0,
|
|
fontSize: 1 // Default 18px
|
|
});
|
|
}
|
|
if (props.widget.textLines.length > target) {
|
|
props.widget.textLines = props.widget.textLines.slice(0, target);
|
|
}
|
|
}
|
|
});
|
|
|
|
const subButtonCount = computed({
|
|
get() {
|
|
return subButtons.value.length || 0;
|
|
},
|
|
set(value) {
|
|
if (!props.widget) return;
|
|
const target = Math.max(0, Math.min(value, 6));
|
|
if (!Array.isArray(props.widget.subButtons)) {
|
|
props.widget.subButtons = [];
|
|
}
|
|
while (props.widget.subButtons.length < target) {
|
|
props.widget.subButtons.push({
|
|
pos: props.widget.subButtons.length,
|
|
icon: 0,
|
|
knxRead: 0,
|
|
knxWrite: 0,
|
|
action: 0,
|
|
target: 0,
|
|
colorOn: '#FFCC00',
|
|
colorOff: '#666666'
|
|
});
|
|
}
|
|
if (props.widget.subButtons.length > target) {
|
|
props.widget.subButtons = props.widget.subButtons.slice(0, target);
|
|
}
|
|
}
|
|
});
|
|
|
|
function groupedSources(options) {
|
|
const allowed = new Set(options || []);
|
|
return textSourceGroups
|
|
.map((group) => ({
|
|
label: group.label,
|
|
values: group.values.filter((value) => allowed.has(value))
|
|
}))
|
|
.filter((group) => group.values.length > 0);
|
|
}
|
|
</script>
|