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,333 @@
{% extends "base.html" %}
{% block title %}Builder {{ layout.name }}{% endblock %}
{% block content %}
<style>
/* Make builder fill the full content area with no extra padding */
#page-content { padding: 0 !important; overflow: hidden; }
/* ── Builder shell ───────────────────────────────────────────────────────── */
#lb-wrap {
display: flex;
height: calc(100vh);
background: #0d1117;
font-size: .85rem;
}
/* ── Left sidebar ────────────────────────────────────────────────────────── */
#lb-sidebar {
width: 230px;
min-width: 230px;
background: #161b22;
border-right: 1px solid #30363d;
display: flex;
flex-direction: column;
overflow: hidden;
}
#lb-sidebar-header {
padding: .75rem 1rem;
font-weight: 600;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: .5rem;
white-space: nowrap;
overflow: hidden;
}
#lb-sidebar-body {
flex: 1;
overflow-y: auto;
padding: .5rem;
}
/* ── Tool palette ────────────────────────────────────────────────────────── */
.lb-tool-btn {
display: flex;
align-items: center;
gap: .5rem;
width: 100%;
padding: .45rem .7rem;
border: 1px solid transparent;
border-radius: .4rem;
background: transparent;
color: #8b949e;
cursor: pointer;
text-align: left;
transition: background .15s, color .15s, border-color .15s;
margin-bottom: 2px;
font-size: .82rem;
}
.lb-tool-btn:hover { background: #1f2937; color: #e6edf3; }
.lb-tool-btn.active {
background: rgba(79,140,205,.15);
border-color: #4f8ccd;
color: #4f8ccd;
}
.lb-section-label {
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: #484f58;
padding: .4rem .3rem .2rem;
margin-top: .25rem;
}
/* ── Device chips (draggable) ────────────────────────────────────────────── */
.lb-board-group { margin-bottom: .5rem; }
.lb-board-name {
font-size: .72rem;
font-weight: 600;
color: #58a6ff;
padding: .2rem .3rem;
text-transform: uppercase;
letter-spacing: .05em;
display: flex;
align-items: center;
gap: .3rem;
}
.lb-device-chip {
display: flex;
align-items: center;
gap: .45rem;
padding: .35rem .6rem;
margin-bottom: 3px;
border-radius: .4rem;
border: 1px solid #30363d;
background: #1c2129;
color: #c9d1d9;
cursor: grab;
user-select: none;
font-size: .8rem;
transition: border-color .15s, background .15s;
}
.lb-device-chip:hover { border-color: #58a6ff; background: #1f2937; }
.lb-device-chip:active { cursor: grabbing; }
.lb-device-chip .chip-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Canvas area ─────────────────────────────────────────────────────────── */
#lb-canvas-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
#lb-toolbar {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: .4rem .75rem;
display: flex;
align-items: center;
gap: .5rem;
flex-wrap: wrap;
}
#lb-toolbar .tb-sep { width: 1px; height: 24px; background: #30363d; margin: 0 .25rem; }
#lb-toolbar .tb-label { font-size: .72rem; color: #484f58; font-weight: 600; text-transform: uppercase; }
.tb-btn {
padding: .25rem .55rem;
border-radius: .35rem;
border: 1px solid #30363d;
background: #1c2129;
color: #8b949e;
cursor: pointer;
font-size: .8rem;
display: flex;
align-items: center;
gap: .3rem;
transition: background .15s, color .15s, border-color .15s;
white-space: nowrap;
}
.tb-btn:hover { background: #21262d; color: #e6edf3; }
.tb-btn.active { background: rgba(79,140,205,.18); border-color: #4f8ccd; color: #4f8ccd; }
.tb-btn.tb-mode { font-weight: 600; }
#lb-save-status {
font-size: .75rem;
color: #484f58;
margin-left: auto;
white-space: nowrap;
}
#lb-save-status.saving { color: #f0883e; }
#lb-save-status.saved { color: #3fb950; }
/* ── Konva stage container ───────────────────────────────────────────────── */
#lb-stage { flex: 1; overflow: hidden; position: relative; }
#konva-container { width: 100%; height: 100%; }
#konva-container.drag-over { outline: 2px dashed #4f8ccd; outline-offset: -2px; }
/* Ghost that follows cursor during drag-over canvas */
#lb-drag-ghost {
position: fixed;
pointer-events: none;
background: #1f2937;
border: 1px solid #58a6ff;
border-radius: .4rem;
padding: .3rem .6rem;
font-size: .8rem;
color: #58a6ff;
display: none;
z-index: 9999;
}
</style>
<div id="lb-wrap">
<!-- ══ Sidebar ══════════════════════════════════════════════════════════════ -->
<div id="lb-sidebar">
<div id="lb-sidebar-header">
<i class="bi bi-map text-info"></i>
<span class="text-truncate" title="{{ layout.name }}">{{ layout.name }}</span>
</div>
<div id="lb-sidebar-body">
<!-- ── Structure tools (shown in structure mode) ──────────────────── -->
<div id="lb-structure-palette">
<div class="lb-section-label">Draw tool</div>
<button class="lb-tool-btn active" data-tool="select">
<i class="bi bi-cursor"></i> Select / Move
</button>
<button class="lb-tool-btn" data-tool="pan">
<i class="bi bi-arrows-move"></i> Pan
</button>
<div class="lb-section-label" style="margin-top:.5rem">Structure shapes</div>
<button class="lb-tool-btn" data-tool="wall" style="border-left:3px solid #6e7681">
<i class="bi bi-square-fill" style="color:#6e7681"></i> Wall
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="room" style="border-left:3px solid #4f8ccd">
<i class="bi bi-square" style="color:#4f8ccd"></i> Room
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="door" style="border-left:3px solid #f0883e">
<i class="bi bi-slash-circle" style="color:#f0883e"></i> Door
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="window" style="border-left:3px solid #79c0ff">
<i class="bi bi-grip-horizontal" style="color:#79c0ff"></i> Window
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="fence" style="border-left:3px solid #8b5cf6">
<i class="bi bi-border-all" style="color:#8b5cf6"></i> Fence
<span class="ms-auto text-secondary" style="font-size:.7rem">drag</span>
</button>
<button class="lb-tool-btn" data-tool="text" style="border-left:3px solid #f0883e">
<i class="bi bi-fonts" style="color:#f0883e"></i> Text label
<span class="ms-auto text-secondary" style="font-size:.7rem">click</span>
</button>
<div class="lb-section-label" style="margin-top:.5rem">Canvas</div>
<button class="lb-tool-btn" id="btn-clear-structure" onclick="LB.clearStructure()">
<i class="bi bi-eraser text-danger"></i> Clear structure
</button>
</div>
<!-- ── Device palette (shown in devices mode) ─────────────────────── -->
<div id="lb-devices-palette" style="display:none">
<!-- Selected device panel (visible when a device is clicked) -->
<div id="lb-device-selected" style="display:none">
<div class="lb-section-label">Selected</div>
<div id="lb-sel-card" class="p-2 rounded mb-2" style="background:#1c2129;border:1px solid #4f8ccd">
<div id="lb-sel-name" style="color:#c9d1d9;font-weight:600;font-size:.84rem"></div>
<div id="lb-sel-board" class="text-secondary" style="font-size:.72rem"></div>
<div id="lb-sel-type" style="font-size:.7rem;color:#f0883e;margin-top:.15rem"></div>
</div>
<div class="text-secondary mb-2" style="font-size:.75rem;padding:.1rem .3rem">
<i class="bi bi-arrows-move me-1"></i>Drag to reposition
</div>
<button class="lb-tool-btn" onclick="LB.removeSelectedDevice()">
<i class="bi bi-trash text-danger"></i> Remove from canvas
</button>
<button class="lb-tool-btn" onclick="LB.deselectDevice()">
<i class="bi bi-x-circle" style="color:#8b949e"></i> Deselect
</button>
<hr style="border-color:#30363d;margin:.4rem 0">
</div>
<div class="lb-section-label" id="lb-drag-hint">Drag onto canvas</div>
<div id="lb-device-list">
<!-- populated by JS from LB_CONFIG.boards -->
</div>
</div>
<!-- ── View mode info (shown in view mode) ─────────────────────────── -->
<div id="lb-view-info" style="display:none; padding:.5rem; color:#8b949e; font-size:.8rem; line-height:1.5">
<i class="bi bi-info-circle me-1"></i>
<strong>Live view</strong><br>
Click relay / switch devices to toggle them.<br>
State updates in real-time via WebSocket.
</div>
</div><!-- sidebar-body -->
</div><!-- sidebar -->
<!-- ══ Canvas area ══════════════════════════════════════════════════════════ -->
<div id="lb-canvas-area">
<!-- Toolbar -->
<div id="lb-toolbar">
<span class="tb-label">Mode</span>
<button class="tb-btn tb-mode active" data-mode="structure">
<i class="bi bi-pencil-square"></i> Structure
</button>
<button class="tb-btn tb-mode" data-mode="devices">
<i class="bi bi-grid-3x3-gap"></i> Devices
</button>
<button class="tb-btn tb-mode" data-mode="view">
<i class="bi bi-eye"></i> Live View
</button>
<div class="tb-sep"></div>
<span class="tb-label">Zoom</span>
<button class="tb-btn" onclick="LB.zoom(1.2)"><i class="bi bi-zoom-in"></i></button>
<button class="tb-btn" onclick="LB.zoom(1/1.2)"><i class="bi bi-zoom-out"></i></button>
<button class="tb-btn" onclick="LB.resetView()"><i class="bi bi-fullscreen"></i></button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="LB.clearDevices()" id="btn-clear-devices" title="Remove all devices from canvas">
<i class="bi bi-trash text-warning"></i> Clear devices
</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="LB.save()" id="btn-save">
<i class="bi bi-floppy"></i> Save
</button>
<span id="lb-save-status">Unsaved</span>
</div>
<!-- Konva container -->
<div id="lb-stage">
<div id="konva-container"></div>
</div>
</div><!-- canvas-area -->
</div><!-- lb-wrap -->
<!-- Drag ghost tooltip -->
<div id="lb-drag-ghost"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/konva/konva.min.js') }}"></script>
<script>
// ── Server-injected config ────────────────────────────────────────────────────
window.LB_CONFIG = {
layoutId: {{ layout.id }},
saveUrl: "{{ url_for('layouts.save_layout', layout_id=layout.id) }}",
canvasJson: {{ (layout.canvas_json or 'null') | safe }},
boards: {{ boards_data | tojson | safe }},
toggleBase: "/boards/{boardId}/relay/{relayNum}/toggle",
};
</script>
<script src="{{ url_for('static', filename='js/layout_builder.js') }}"></script>
{% endblock %}

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 %}