- 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)
334 lines
13 KiB
HTML
334 lines
13 KiB
HTML
{% 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 %}
|