319 lines
14 KiB
HTML
319 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Configure Entities – {{ board.name }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<nav aria-label="breadcrumb" class="mb-3">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||
<li class="breadcrumb-item active">Configure Entities</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
<h2 class="fw-bold mb-1">Configure Entities</h2>
|
||
<p class="text-secondary mb-4">
|
||
Set a type, custom name, and optional icon override for each relay and input.
|
||
The type determines the icon, status colors, and state labels shown everywhere.
|
||
</p>
|
||
|
||
<form method="POST" action="{{ url_for('boards.edit_entities', board_id=board.id) }}">
|
||
|
||
<!-- ── Relay entities ────────────────────────────────────────────────────── -->
|
||
<h5 class="fw-semibold mb-3"><i class="bi bi-lightning-charge text-warning me-1"></i> Relay Outputs</h5>
|
||
<div class="row g-3 mb-4">
|
||
{% for n in range(1, board.num_relays + 1) %}
|
||
{% set e = board.get_relay_entity(n) %}
|
||
<div class="col-md-6">
|
||
<div class="card border-0 rounded-4 entity-card" data-entity="relay_{{ n }}" data-kind="relay">
|
||
<div class="card-body">
|
||
|
||
<!-- Header: icon preview + number label -->
|
||
<div class="d-flex align-items-center gap-3 mb-3">
|
||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-relay-{{ n }}"></i>
|
||
</div>
|
||
<div class="flex-grow-1">
|
||
<div class="text-secondary small mb-1">Relay {{ n }}</div>
|
||
<input type="text" class="form-control form-control-sm"
|
||
name="relay_{{ n }}_name"
|
||
value="{{ board.entities.get('relay_' ~ n, {}).get('name', '') }}"
|
||
maxlength="20" placeholder="{{ e.name }}" />
|
||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Type selector -->
|
||
<div class="mb-3">
|
||
<div class="small text-secondary mb-2">Entity type</div>
|
||
<div class="d-flex flex-wrap gap-2">
|
||
{% for tkey, tdef in relay_types.items() %}
|
||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||
data-icon="{{ tdef.icon }}"
|
||
data-target="relay_{{ n }}">
|
||
<input type="radio" name="relay_{{ n }}_type" value="{{ tkey }}" hidden
|
||
{% if e.type == tkey %}checked{% endif %} />
|
||
<i class="bi {{ tdef.icon }}"></i>
|
||
<span>{{ tdef.label }}</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom icon override -->
|
||
<details class="mt-2">
|
||
<summary class="text-secondary small" style="cursor:pointer">
|
||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||
</summary>
|
||
<div class="mt-2">
|
||
<div class="input-group input-group-sm mb-2">
|
||
<span class="input-group-text">
|
||
<i class="bi bi-search"></i>
|
||
</span>
|
||
<input type="text" class="form-control icon-text-input"
|
||
name="relay_{{ n }}_icon"
|
||
value="{{ board.entities.get('relay_' ~ n, {}).get('icon', '') }}"
|
||
placeholder="e.g. bi-lightbulb-fill"
|
||
data-target="relay_{{ n }}" />
|
||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||
data-target="relay_{{ n }}">
|
||
<i class="bi bi-x"></i>
|
||
</button>
|
||
</div>
|
||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||
{% for icon_cls, icon_name in icon_palette %}
|
||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="relay_{{ n }}">
|
||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- ── Input entities ────────────────────────────────────────────────────── -->
|
||
<h5 class="fw-semibold mb-3"><i class="bi bi-activity text-info me-1"></i> Digital Inputs</h5>
|
||
<div class="row g-3 mb-4">
|
||
{% for n in range(1, board.num_inputs + 1) %}
|
||
{% set e = board.get_input_entity(n) %}
|
||
<div class="col-md-6">
|
||
<div class="card border-0 rounded-4 entity-card" data-entity="input_{{ n }}" data-kind="input">
|
||
<div class="card-body">
|
||
|
||
<!-- Header: icon preview + number label -->
|
||
<div class="d-flex align-items-center gap-3 mb-3">
|
||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-input-{{ n }}"></i>
|
||
</div>
|
||
<div class="flex-grow-1">
|
||
<div class="text-secondary small mb-1">Input {{ n }}</div>
|
||
<input type="text" class="form-control form-control-sm"
|
||
name="input_{{ n }}_name"
|
||
value="{{ board.entities.get('input_' ~ n, {}).get('name', '') }}"
|
||
maxlength="20" placeholder="{{ e.name }}" />
|
||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Type selector -->
|
||
<div class="mb-3">
|
||
<div class="small text-secondary mb-2">Entity type</div>
|
||
<div class="d-flex flex-wrap gap-2">
|
||
{% for tkey, tdef in input_types.items() %}
|
||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||
data-icon="{{ tdef.icon }}"
|
||
data-target="input_{{ n }}">
|
||
<input type="radio" name="input_{{ n }}_type" value="{{ tkey }}" hidden
|
||
{% if e.type == tkey %}checked{% endif %} />
|
||
<i class="bi {{ tdef.icon }}"></i>
|
||
<span>{{ tdef.label }}</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom icon override -->
|
||
<details class="mt-2">
|
||
<summary class="text-secondary small" style="cursor:pointer">
|
||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||
</summary>
|
||
<div class="mt-2">
|
||
<div class="input-group input-group-sm mb-2">
|
||
<span class="input-group-text">
|
||
<i class="bi bi-search"></i>
|
||
</span>
|
||
<input type="text" class="form-control icon-text-input"
|
||
name="input_{{ n }}_icon"
|
||
value="{{ board.entities.get('input_' ~ n, {}).get('icon', '') }}"
|
||
placeholder="e.g. bi-door-open-fill"
|
||
data-target="input_{{ n }}" />
|
||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||
data-target="input_{{ n }}">
|
||
<i class="bi bi-x"></i>
|
||
</button>
|
||
</div>
|
||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||
{% for icon_cls, icon_name in icon_palette %}
|
||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="input_{{ n }}">
|
||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- ── Actions ────────────────────────────────────────────────────────────── -->
|
||
<div class="d-flex gap-2 mt-2 mb-5">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-floppy me-1"></i> Save Configuration
|
||
</button>
|
||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||
Cancel
|
||
</a>
|
||
</div>
|
||
|
||
</form>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<style>
|
||
/* ── Entity type pills ───────────────────────────────────────────── */
|
||
.entity-type-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--bs-border-color);
|
||
cursor: pointer;
|
||
font-size: .8rem;
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
user-select: none;
|
||
}
|
||
.entity-type-pill:hover {
|
||
border-color: var(--bs-primary);
|
||
color: var(--bs-primary);
|
||
}
|
||
.entity-type-pill:has(input:checked) {
|
||
border-color: var(--bs-primary);
|
||
background: var(--bs-primary-bg-subtle, rgba(13,110,253,.15));
|
||
color: var(--bs-primary);
|
||
font-weight: 600;
|
||
}
|
||
/* ── Icon palette grid ───────────────────────────────────────────── */
|
||
.icon-palette {
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
padding: 4px;
|
||
background: var(--bs-secondary-bg);
|
||
border-radius: .5rem;
|
||
}
|
||
.icon-pick-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.icon-pick-btn.active {
|
||
background: var(--bs-primary);
|
||
border-color: var(--bs-primary);
|
||
color: #fff;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
/* ── Live icon preview ───────────────────────────────────────────────────── */
|
||
function resolvePreview(target) {
|
||
const iconInput = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||
const customIcon = iconInput ? iconInput.value.trim() : "";
|
||
const preview = document.getElementById(`icon-preview-${target.replace("_", "-")}`);
|
||
if (!preview) return;
|
||
|
||
if (customIcon) {
|
||
// Remove all bi-* classes, add the custom one
|
||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||
const cls = customIcon.startsWith("bi-") ? customIcon : `bi-${customIcon}`;
|
||
preview.classList.add(cls);
|
||
} else {
|
||
// Use the checked type's icon
|
||
const checkedType = document.querySelector(`input[name="${target}_type"]:checked`);
|
||
if (checkedType) {
|
||
const pill = checkedType.closest(".entity-type-pill");
|
||
const typeIcon = pill ? pill.getAttribute("data-icon") : null;
|
||
if (typeIcon) {
|
||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||
preview.classList.add(typeIcon);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── Type pill selection ─────────────────────────────────────────────────── */
|
||
document.querySelectorAll(".entity-type-pill").forEach(pill => {
|
||
pill.addEventListener("click", () => {
|
||
const target = pill.getAttribute("data-target");
|
||
// Small delay so the radio value has flipped
|
||
requestAnimationFrame(() => resolvePreview(target));
|
||
});
|
||
});
|
||
|
||
/* ── Custom icon text input ──────────────────────────────────────────────── */
|
||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||
inp.addEventListener("input", () => resolvePreview(inp.getAttribute("data-target")));
|
||
});
|
||
|
||
/* ── Icon palette pick ───────────────────────────────────────────────────── */
|
||
document.querySelectorAll(".icon-pick-btn").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
const icon = btn.getAttribute("data-icon");
|
||
const target = btn.getAttribute("data-target");
|
||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||
if (inp) inp.value = icon;
|
||
// Highlight selected palette button
|
||
btn.closest(".icon-palette").querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
resolvePreview(target);
|
||
});
|
||
});
|
||
|
||
/* ── Clear custom icon ───────────────────────────────────────────────────── */
|
||
document.querySelectorAll(".icon-clear-btn").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
const target = btn.getAttribute("data-target");
|
||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||
if (inp) inp.value = "";
|
||
// Deselect all palette buttons for this target
|
||
const card = btn.closest(".card-body");
|
||
if (card) card.querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||
resolvePreview(target);
|
||
});
|
||
});
|
||
|
||
/* ── Init: highlight pre-selected palette icons, run initial previews ────── */
|
||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||
const val = inp.value.trim();
|
||
const target = inp.getAttribute("data-target");
|
||
if (val) {
|
||
const match = inp.closest(".card-body").querySelector(`.icon-pick-btn[data-icon="${val}"]`);
|
||
if (match) match.classList.add("active");
|
||
}
|
||
resolvePreview(target);
|
||
});
|
||
</script>
|
||
{% endblock %}
|