knxdisplay/web-interface/src/components/widgets/elements/RoomCardElement.vue
2026-02-04 21:11:04 +01:00

289 lines
9.8 KiB
Vue

<template>
<div
class="z-[1] select-none touch-none"
:class="selected ? 'outline outline-2 outline-accent outline-offset-2' : ''"
:style="computedStyle"
@mousedown.stop="$emit('drag-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('drag-start', { id: widget.id, event: $event })"
@click.stop="$emit('select')"
>
<!-- Bubble Style -->
<template v-if="widget.cardStyle !== 1">
<!-- Central Bubble -->
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
{{ iconChar }}
</span>
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
</div>
<!-- Sub-Buttons (circular orbit) -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyle(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Tile Style -->
<template v-else>
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
<!-- Room name (top-left) -->
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
{{ roomCardParts.name }}
</div>
<!-- Text Lines -->
<div class="absolute flex flex-col gap-1" :style="textLinesContainerStyle">
<span v-for="(line, idx) in textLines" :key="idx" class="flex items-center gap-1" :style="getTextLineStyle(line)">
<span v-if="line.icon" class="material-symbols-outlined" :style="{ fontSize: getTextLineIconSize(line) + 'px' }">{{ String.fromCodePoint(line.icon) }}</span>
<span>{{ line.text || '--' }}</span>
</span>
</div>
<!-- Large decorative icon (configurable position) -->
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="decorIconStyle">
{{ iconChar }}
</span>
</div>
<!-- Sub-Buttons (right side, vertical) -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyleTile(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Resize Handle -->
<div
v-if="selected"
class="absolute -right-1.5 -bottom-1.5 w-3.5 h-3.5 rounded-[4px] bg-accent border-[2px] border-[#1b1308] shadow-[0_4px_12px_rgba(0,0,0,0.35)] cursor-se-resize z-10"
data-resize-handle
@mousedown.stop="$emit('resize-start', { id: widget.id, event: $event })"
@touchstart.stop="$emit('resize-start', { id: widget.id, event: $event })"
>
<span class="absolute right-[2px] bottom-[2px] w-[6px] h-[6px] border-r-2 border-b-2 border-[#1b1308a6] rounded-[2px]"></span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { fontSizes, iconFontSizes } from '../../../constants';
import { getBaseStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
const props = defineProps({
widget: { type: Object, required: true },
scale: { type: Number, default: 1 },
selected: { type: Boolean, default: false }
});
defineEmits(['select', 'drag-start', 'resize-start']);
const iconChar = computed(() => {
const cp = props.widget.iconCodepoint || 0xe88a;
return String.fromCodePoint(cp);
});
const roomCardParts = computed(() => {
if (!props.widget.text) return { name: '', format: '' };
const parts = props.widget.text.split('\n');
return {
name: parts[0] || '',
format: props.widget.cardStyle === 1 ? '' : (parts[1] || '')
};
});
const roomCardSubButtons = computed(() => props.widget.subButtons || []);
const textLines = computed(() => props.widget.textLines || []);
const textLinesContainerStyle = computed(() => {
const s = props.scale;
// Calculate top based on room name position + font size + padding
const roomNameTop = 12;
const roomNameFontSize = fontSizes[props.widget.fontSize || 2] || 22;
const padding = 10;
const top = (roomNameTop + roomNameFontSize + padding) * s;
return {
left: (16 * s) + 'px',
top: top + 'px',
color: props.widget.textColor
};
});
function getTextLineStyle(line) {
const s = props.scale;
const sizeIdx = line.fontSize ?? 1;
return {
fontSize: (fontSizes[sizeIdx] * s) + 'px'
};
}
function getTextLineIconSize(line) {
const s = props.scale;
const sizeIdx = line.fontSize ?? 1;
return fontSizes[sizeIdx] * s;
}
const roomCardTileContainerStyle = computed(() => {
const alpha = (props.widget.bgOpacity ?? 255) / 255;
return {
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
borderRadius: (props.widget.radius || 16) + 'px',
};
});
const decorIconStyle = computed(() => {
const s = props.scale;
const w = props.widget;
const x = (w.iconPositionX !== undefined && w.iconPositionX !== 0) ? w.iconPositionX * s : -20 * s;
const y = (w.iconPositionY !== undefined && w.iconPositionY !== 0) ? w.iconPositionY * s : (w.h - 120) * s;
const sizeIdx = w.iconSize >= 6 ? w.iconSize : 8;
const fontSize = iconFontSizes[sizeIdx] || 96;
return {
left: x + 'px',
top: y + 'px',
fontSize: (fontSize * s) + 'px',
color: w.textColor,
};
});
const getSubButtonStyleTile = (sb, idx) => {
const s = props.scale;
const btnSize = (props.widget.subButtonSize || 40) * s;
const gap = 10 * s;
const padding = 12 * s;
return {
width: btnSize + 'px',
height: btnSize + 'px',
right: padding + 'px',
top: (padding + idx * (btnSize + gap)) + 'px',
backgroundColor: sb.colorOff || '#666666',
};
};
const roomCardBubbleStyle = computed(() => {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const padding = subBtnSize * 0.6;
const bubbleSize = Math.min(w, h) * s - padding * 2;
const left = (w * s - bubbleSize) / 2;
const top = (h * s - bubbleSize) / 2;
const alpha = clamp((props.widget.bgOpacity ?? 255) / 255, 0, 1);
return {
width: `${bubbleSize}px`,
height: `${bubbleSize}px`,
left: `${left}px`,
top: `${top}px`,
backgroundColor: hexToRgba(props.widget.bgColor, alpha),
color: props.widget.textColor,
border: ((props.widget.borderWidth || 0) > 0 && (props.widget.borderOpacity ?? 0) > 0)
? `${(props.widget.borderWidth || 0) * s}px solid ${hexToRgba(props.widget.borderColor || '#ffffff', clamp((props.widget.borderOpacity ?? 0) / 255, 0, 1))}`
: 'none',
boxShadow: props.widget.shadow?.enabled
? `${(props.widget.shadow.x || 0) * s}px ${(props.widget.shadow.y || 0) * s}px ${(props.widget.shadow.blur || 0) * s}px ${hexToRgba(props.widget.shadow.color || '#000000', 0.3)}`
: '0 4px 12px rgba(0,0,0,0.15)'
};
});
const roomCardIconStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.iconSize ?? 3;
const size = fontSizes[sizeIdx] || 28;
return {
fontSize: `${size * s}px`,
color: props.widget.textColor
};
});
const roomCardNameStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.7}px`,
color: props.widget.textColor
};
});
const roomCardTempStyle = computed(() => {
const s = props.scale;
const sizeIdx = props.widget.fontSize ?? 2;
const size = fontSizes[sizeIdx] || 22;
return {
fontSize: `${size * s * 0.55}px`,
color: props.widget.textColor
};
});
function getSubButtonStyle(sb, idx) {
const s = props.scale;
const w = props.widget.w;
const h = props.widget.h;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const centerX = w * s / 2;
const centerY = h * s / 2;
const orbitRadius = (props.widget.subButtonDistance || 80) * s;
const pos = sb.pos ?? idx;
const angle = (pos * (Math.PI / 4)) - (Math.PI / 2);
const x = centerX + orbitRadius * Math.cos(angle) - subBtnSize / 2;
const y = centerY + orbitRadius * Math.sin(angle) - subBtnSize / 2;
const bgColor = sb.colorOff || '#666666';
return {
width: `${subBtnSize}px`,
height: `${subBtnSize}px`,
left: `${x}px`,
top: `${y}px`,
backgroundColor: bgColor,
border: `2px solid ${hexToRgba('#ffffff', 0.3)}`
};
}
function getSubButtonIconStyle(sb) {
const s = props.scale;
const subBtnSize = (props.widget.subButtonSize || 40) * s;
const iconSize = subBtnSize * 0.5;
return {
fontSize: `${iconSize}px`,
color: '#ffffff'
};
}
const computedStyle = computed(() => {
const w = props.widget;
const s = props.scale;
const style = getBaseStyle(w, s);
if (w.cardStyle === 1) {
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
style.background = hexToRgba(w.bgColor || '#333333', alpha);
style.borderRadius = `${(w.radius || 16) * s}px`;
style.overflow = 'hidden';
if ((w.borderWidth || 0) > 0 && (w.borderOpacity ?? 0) > 0) {
Object.assign(style, getBorderStyle(w, s));
} else if (w.shadow && w.shadow.enabled) {
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
}
} else {
style.overflow = 'visible';
}
return style;
});
</script>