Add Layouts module with Konva.js builder; smart offline polling; UI improvements

- Move board cards from dashboard to top of boards list page
- Fix Werkzeug duplicate polling (WERKZEUG_RUN_MAIN guard)
- Smart offline polling: fast loop for online boards, slow recheck for offline
- Add manual ping endpoint POST /api/boards/<id>/ping
- Add spin animation CSS for ping button

Layouts module (new):
- app/models/layout.py: Layout model (canvas_json, thumbnail_b64)
- app/routes/layouts.py: 5 routes (list, create, builder, save, delete)
- app/templates/layouts/: list and builder templates
- app/static/js/layout_builder.js: full Konva.js builder engine
- app/static/vendor/konva/: vendored Konva.js 9
- Structure mode: wall, room, door, window, fence, text shapes
- Devices mode: drag relay/input/Sonoff channels onto canvas
- Live view mode: click relays/Sonoff to toggle, socket.io state updates
- Device selection: click to select, remove individual device, Delete key
- Fix door/Arc size persistence across save/reload (outerRadius, scaleX/Y)
- Fix Sonoff devices missing from palette (add makeSonoffChip function)
This commit is contained in:
ske087
2026-02-27 13:34:44 +02:00
parent 30806560a6
commit 90cbf4e1f0
15 changed files with 2006 additions and 177 deletions

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Layouts Location Management{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="fw-bold mb-0"><i class="bi bi-map me-2 text-info"></i>Layouts</h2>
{% if current_user.is_admin() %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newLayoutModal">
<i class="bi bi-plus-circle me-1"></i> New Layout
</button>
{% endif %}
</div>
{% if layouts %}
<div class="row g-3">
{% for layout in layouts %}
<div class="col-sm-6 col-xl-4">
<div class="card border-0 rounded-4 h-100">
<!-- thumbnail or placeholder -->
<div class="rounded-top-4 overflow-hidden d-flex align-items-center justify-content-center bg-dark"
style="height:180px">
{% if layout.thumbnail_b64 %}
<img src="{{ layout.thumbnail_b64 }}" alt="thumbnail"
style="max-width:100%;max-height:100%;object-fit:contain" />
{% else %}
<i class="bi bi-map display-1 text-secondary opacity-25"></i>
{% endif %}
</div>
<div class="card-body">
<h5 class="fw-semibold mb-1">{{ layout.name }}</h5>
{% if layout.description %}
<p class="text-secondary small mb-0">{{ layout.description }}</p>
{% endif %}
</div>
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center">
<span class="text-secondary small">
{% if layout.updated_at %}
Updated {{ layout.updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Created {{ layout.created_at.strftime('%Y-%m-%d') }}
{% endif %}
</span>
<div class="d-flex gap-1">
<a href="{{ url_for('layouts.builder', layout_id=layout.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil-square me-1"></i>Edit
</a>
{% if current_user.is_admin() %}
<form method="POST" action="{{ url_for('layouts.delete_layout', layout_id=layout.id) }}"
class="d-inline" onsubmit="return confirm('Delete layout \'{{ layout.name }}\'?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-map display-2 opacity-25"></i>
<p class="mt-3 fs-5">No layouts yet.</p>
{% if current_user.is_admin() %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newLayoutModal">
<i class="bi bi-plus-circle me-1"></i> Create your first layout
</button>
{% endif %}
</div>
{% endif %}
<!-- ── New layout modal ───────────────────────────────────────────────────── -->
{% if current_user.is_admin() %}
<div class="modal fade" id="newLayoutModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('layouts.create_layout') }}">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title"><i class="bi bi-map me-2 text-info"></i>New Layout</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required maxlength="128"
placeholder="e.g. Ground Floor, Garden" autofocus />
</div>
<div class="mb-3">
<label class="form-label text-secondary">Description <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" name="description" maxlength="256"
placeholder="Short description" />
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-right-circle me-1"></i>Create & Open Builder
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}