Initial commit: Location Management Flask app

This commit is contained in:
ske087
2026-02-26 19:24:17 +02:00
commit 7a22575dab
52 changed files with 3481 additions and 0 deletions

View File

@@ -0,0 +1,318 @@
{% 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 %}