V2: GrapesJS-based HMI design editor

Complete rewrite of the editor frontend using GrapesJS:
- Full drag/drop visual editor with style manager
- Custom HMI blocks: Temperature, Motor, Output, Burner, Automation, Gauge
- Layout blocks: Page Container, Tab Bar, Top Bar, Label, Divider, Spacer
- Style panel: font family/size/weight/color, background, border, layout, effects
- Traits panel: active/inactive colors, animation, linked control, setpoints
- Device manager: Tab5 (1280x720), Schneider HMIDT651 (1280x800), Desktop
- Theme switcher: Dark Industrial, Light Industrial, High Contrast, Classic SCADA
- Layers panel with z-index reorder and show/hide
- Undo/redo, preview mode, canvas zoom
- V1 layout preserved (saved as current.json), V2 uses current_v2.json
- Login/users/comments backend unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Richard Sauer 2026-04-08 17:06:01 +10:00
parent 6374749397
commit 1831759563
7 changed files with 609 additions and 1397 deletions

23
app.py
View File

@ -14,16 +14,29 @@ os.makedirs(PHOTO_DIR, exist_ok=True)
CURRENT_LAYOUT = os.path.join(LAYOUT_DIR, "current.json") CURRENT_LAYOUT = os.path.join(LAYOUT_DIR, "current.json")
def get_layout(): def get_layout():
# V2: GrapesJS project JSON stored in current_v2.json
v2_path = os.path.join(LAYOUT_DIR, "current_v2.json")
if os.path.exists(v2_path):
with open(v2_path) as f:
return json.load(f)
# No V2 layout yet — return empty so GrapesJS starts fresh
return {}
def get_v1_layout():
"""Legacy V1 layout for reference"""
if os.path.exists(CURRENT_LAYOUT): if os.path.exists(CURRENT_LAYOUT):
with open(CURRENT_LAYOUT) as f: with open(CURRENT_LAYOUT) as f:
return json.load(f) return json.load(f)
default = os.path.join(LAYOUT_DIR, "default.json") return {}
with open(default) as f:
layout = json.load(f)
save_layout(layout)
return layout
def save_layout(layout): def save_layout(layout):
# V2: save GrapesJS project data
v2_path = os.path.join(LAYOUT_DIR, "current_v2.json")
with open(v2_path, "w") as f:
json.dump(layout, f, indent=2)
def save_v1_layout(layout):
"""Legacy V1 save"""
with open(CURRENT_LAYOUT, "w") as f: with open(CURRENT_LAYOUT, "w") as f:
json.dump(layout, f, indent=2) json.dump(layout, f, indent=2)

277
static/hmi-blocks.js Normal file
View File

@ -0,0 +1,277 @@
/* BFA Banana Dryer — Custom HMI Blocks for GrapesJS */
function registerHMIBlocks(editor) {
const bm = editor.BlockManager;
const dm = editor.DomComponents;
// ── Shared styles ──
const cardBase = {
'display': 'flex', 'flex-direction': 'column', 'align-items': 'center', 'justify-content': 'center',
'gap': '6px', 'padding': '16px', 'border-radius': '10px', 'cursor': 'pointer',
'min-height': '120px', 'width': '48%', 'font-family': 'Segoe UI, system-ui, sans-serif',
'border': '1px solid #1e2a45', 'transition': 'background 0.15s', 'box-sizing': 'border-box'
};
// ══════════════════════════════════════════
// TEMPERATURE CARD
// ══════════════════════════════════════════
dm.addType('temp-card', {
model: {
defaults: {
tagName: 'div',
draggable: true,
droppable: false,
attributes: {'data-type': 'temp'},
traits: [
{type: 'text', name: 'label', label: 'Name', changeProp: true},
{type: 'color', name: 'temp_color', label: 'Temp Color', changeProp: true},
{type: 'number', name: 'setpoint', label: 'Setpoint (°C)', changeProp: true, min: 0, max: 200},
{type: 'text', name: 'linked_id', label: 'Linked Control ID', changeProp: true},
],
label: 'Heat Input',
temp_color: '#ff4444',
setpoint: 130,
linked_id: '',
components: [
{tagName: 'div', attributes: {'data-role': 'name'}, content: 'Heat Input',
style: {'font-size': '20px', 'color': '#7a8baa'}},
{tagName: 'div', attributes: {'data-role': 'value'}, content: '-- °C',
style: {'font-size': '36px', 'font-weight': '700', 'color': '#ff4444'}},
{tagName: 'div', attributes: {'data-role': 'bar'},
style: {'width': '80%', 'height': '6px', 'background': '#1a2240', 'border-radius': '3px', 'overflow': 'hidden'},
components: [{tagName: 'div', style: {'width': '50%', 'height': '100%', 'background': '#ff4444', 'border-radius': '3px'}}]},
{tagName: 'div', attributes: {'data-role': 'sp'}, content: 'SP: 130 °C',
style: {'font-size': '14px', 'color': '#4a5670'}},
],
style: {...cardBase, 'background': '#131a2b'},
}
}
});
bm.add('temp-card', {
label: '🌡 Temperature',
category: 'HMI Controls',
content: {type: 'temp-card'},
attributes: {class: 'gjs-fonts gjs-f-b1'}
});
// ══════════════════════════════════════════
// MOTOR CARD
// ══════════════════════════════════════════
dm.addType('motor-card', {
model: {
defaults: {
tagName: 'div',
draggable: true,
droppable: false,
attributes: {'data-type': 'motor'},
traits: [
{type: 'text', name: 'label', label: 'Name', changeProp: true},
{type: 'number', name: 'speed_sp', label: 'Speed SP (%)', changeProp: true, min: 0, max: 100, step: 5},
{type: 'color', name: 'active_color', label: 'Active Color', changeProp: true},
{type: 'color', name: 'inactive_color', label: 'Inactive Color', changeProp: true},
{type: 'select', name: 'animation', label: 'Animation', changeProp: true,
options: [{value:'none'},{value:'fan'},{value:'agitator'},{value:'conveyor'},{value:'pulse'},{value:'vibrate'},{value:'glow'},{value:'blink'},{value:'flow'}]},
{type: 'text', name: 'linked_id', label: 'Linked Control ID', changeProp: true},
],
label: 'Motor',
speed_sp: 50,
active_color: '#00c853',
inactive_color: '#ff1744',
animation: 'none',
linked_id: '',
components: [
{tagName: 'div', style: {'display': 'flex', 'gap': '10px', 'align-items': 'center'},
components: [
{tagName: 'span', attributes: {'data-role': 'name'}, content: 'Motor',
style: {'font-size': '24px', 'font-weight': '600', 'color': '#e8ecf4'}},
{tagName: 'span', attributes: {'data-role': 'speed'}, content: '50%',
style: {'font-size': '24px', 'color': '#7a8baa'}},
]},
{tagName: 'div', attributes: {'data-role': 'state'}, content: 'OFF',
style: {'font-size': '36px', 'font-weight': '700', 'color': '#e8ecf4'}},
],
style: {...cardBase, 'background': '#ff1744'},
}
}
});
bm.add('motor-card', {
label: '⚙ Motor / Speed',
category: 'HMI Controls',
content: {type: 'motor-card'},
});
// ══════════════════════════════════════════
// OUTPUT CARD
// ══════════════════════════════════════════
dm.addType('output-card', {
model: {
defaults: {
tagName: 'div',
draggable: true,
droppable: false,
attributes: {'data-type': 'output'},
traits: [
{type: 'text', name: 'label', label: 'Name', changeProp: true},
{type: 'color', name: 'active_color', label: 'Active Color', changeProp: true},
{type: 'color', name: 'inactive_color', label: 'Inactive Color', changeProp: true},
{type: 'text', name: 'linked_id', label: 'Linked Control ID', changeProp: true},
],
label: 'Output',
active_color: '#00c853',
inactive_color: '#ff1744',
linked_id: '',
components: [
{tagName: 'div', attributes: {'data-role': 'name'}, content: 'Output',
style: {'font-size': '20px', 'text-align': 'center', 'color': '#e8ecf4'}},
{tagName: 'div', attributes: {'data-role': 'state'}, content: 'OFF',
style: {'font-size': '28px', 'font-weight': '700', 'color': '#e8ecf4'}},
],
style: {...cardBase, 'background': '#ff1744'},
}
}
});
bm.add('output-card', {
label: '⚡ Output / Switch',
category: 'HMI Controls',
content: {type: 'output-card'},
});
// ══════════════════════════════════════════
// BURNER CARD
// ══════════════════════════════════════════
dm.addType('burner-card', {
model: {
defaults: {
tagName: 'div',
draggable: true,
droppable: false,
attributes: {'data-type': 'burner'},
traits: [
{type: 'text', name: 'label', label: 'Name', changeProp: true},
{type: 'color', name: 'active_color', label: 'Active Color', changeProp: true},
{type: 'color', name: 'inactive_color', label: 'Inactive Color', changeProp: true},
],
label: 'Burner',
active_color: '#ffab00',
inactive_color: '#ff1744',
components: [
{tagName: 'div', attributes: {'data-role': 'name'}, content: 'Burner',
style: {'font-size': '24px', 'color': '#e8ecf4'}},
{tagName: 'div', attributes: {'data-role': 'state'}, content: 'OFF',
style: {'font-size': '32px', 'font-weight': '700', 'color': '#e8ecf4'}},
],
style: {...cardBase, 'background': '#ff1744'},
}
}
});
bm.add('burner-card', {
label: '🔥 Burner',
category: 'HMI Controls',
content: {type: 'burner-card'},
});
// ══════════════════════════════════════════
// AUTOMATION CARD
// ══════════════════════════════════════════
dm.addType('automation-card', {
model: {
defaults: {
tagName: 'div',
draggable: true,
droppable: false,
attributes: {'data-type': 'automation'},
traits: [
{type: 'text', name: 'label', label: 'Name', changeProp: true},
{type: 'color', name: 'active_color', label: 'Active Color', changeProp: true},
{type: 'color', name: 'inactive_color', label: 'Inactive Color', changeProp: true},
],
label: 'Automation',
active_color: '#00c853',
inactive_color: '#ff1744',
components: [
{tagName: 'div', attributes: {'data-role': 'name'}, content: 'Automation',
style: {'font-size': '24px', 'font-weight': '700', 'color': '#e8ecf4'}},
{tagName: 'div', attributes: {'data-role': 'rules'}, content: 'Click to configure',
style: {'font-size': '12px', 'color': '#7a8baa'}},
],
style: {...cardBase, 'background': '#ff1744', 'width': '100%'},
}
}
});
bm.add('automation-card', {
label: '🤖 Automation',
category: 'HMI Controls',
content: {type: 'automation-card'},
});
// ══════════════════════════════════════════
// GAUGE (arc)
// ══════════════════════════════════════════
bm.add('gauge', {
label: '📊 Gauge',
category: 'HMI Controls',
content: `<div style="width:150px;height:150px;display:flex;align-items:center;justify-content:center;position:relative" data-type="gauge">
<svg viewBox="0 0 100 100" width="120" height="120">
<circle cx="50" cy="50" r="42" fill="none" stroke="#1a2240" stroke-width="6"/>
<circle cx="50" cy="50" r="42" fill="none" stroke="#2d7ff9" stroke-width="6"
stroke-dasharray="264" stroke-dashoffset="132" transform="rotate(-90 50 50)" stroke-linecap="round"/>
<text x="50" y="50" text-anchor="middle" dominant-baseline="central" fill="#e8ecf4" font-size="18" font-weight="700">50%</text>
</svg>
</div>`,
});
// ══════════════════════════════════════════
// LAYOUT HELPERS
// ══════════════════════════════════════════
bm.add('page-container', {
label: '📄 Page Container',
category: 'Layout',
content: `<div style="display:flex;flex-wrap:wrap;gap:8px;padding:8px;width:100%;height:100%;align-content:flex-start;background:#0a0e17" data-type="page">
</div>`,
});
bm.add('tab-bar', {
label: '📑 Tab Bar',
category: 'Layout',
content: `<div style="display:flex;height:50px;background:#0d1220;border-bottom:1px solid #1e2a45;width:100%">
<div style="flex:1;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;color:#2d7ff9;border-bottom:3px solid #2d7ff9;cursor:pointer">PAGE 1</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;color:#7a8baa;cursor:pointer">PAGE 2</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;color:#7a8baa;cursor:pointer">PAGE 3</div>
</div>`,
});
bm.add('top-bar', {
label: '📌 Top Bar',
category: 'Layout',
content: `<div style="display:flex;align-items:center;justify-content:space-between;height:48px;padding:0 20px;background:#111827;border-bottom:1px solid #1e2a45;width:100%">
<div style="display:flex;align-items:center;gap:16px">
<span style="font-size:20px;font-weight:700;color:#e8ecf4">BFA BANANA DRYER</span>
<span style="font-size:14px;color:#7a8baa">SAE Engineering</span>
</div>
<span style="font-size:16px;font-weight:600;color:#ff1744">RS485 OFFLINE</span>
</div>`,
});
bm.add('text-label', {
label: '📝 Text Label',
category: 'Layout',
content: `<div style="font-size:18px;color:#e8ecf4;padding:8px;text-align:center">Label Text</div>`,
});
bm.add('divider', {
label: ' Divider',
category: 'Layout',
content: `<div style="width:100%;height:1px;background:#1e2a45;margin:8px 0"></div>`,
});
bm.add('spacer', {
label: '⬜ Spacer',
category: 'Layout',
content: `<div style="width:100%;height:20px"></div>`,
});
}

8
static/hmi-canvas.css Normal file
View File

@ -0,0 +1,8 @@
/* Canvas styles — injected into GrapesJS iframe */
:root {
--bg: #0a0e17; --card: #131a2b; --border: #1e2a45;
--text: #e8ecf4; --text2: #7a8baa; --dim: #4a5670;
--blue: #2d7ff9; --green: #00c853; --amber: #ffab00; --red: #ff1744;
}
body { margin: 0; background: var(--bg); font-family: 'Segoe UI', system-ui, sans-serif; color: var(--text); }
* { box-sizing: border-box; }

41
static/hmi-themes.js Normal file
View File

@ -0,0 +1,41 @@
/* BFA Banana Dryer — HMI Theme Presets */
const HMI_THEMES = {
'dark-industrial': {
'--bg': '#0a0e17', '--card': '#131a2b', '--border': '#1e2a45',
'--text': '#e8ecf4', '--text2': '#7a8baa', '--dim': '#4a5670',
'--blue': '#2d7ff9', '--green': '#00c853', '--amber': '#ffab00', '--red': '#ff1744',
},
'light-industrial': {
'--bg': '#f0f2f5', '--card': '#ffffff', '--border': '#d0d5dd',
'--text': '#1a1a2e', '--text2': '#667085', '--dim': '#98a2b3',
'--blue': '#2563eb', '--green': '#16a34a', '--amber': '#d97706', '--red': '#dc2626',
},
'high-contrast': {
'--bg': '#000000', '--card': '#1a1a1a', '--border': '#444444',
'--text': '#ffffff', '--text2': '#cccccc', '--dim': '#888888',
'--blue': '#00aaff', '--green': '#00ff55', '--amber': '#ffcc00', '--red': '#ff3333',
},
'classic-scada': {
'--bg': '#c0c0c0', '--card': '#d4d4d4', '--border': '#808080',
'--text': '#000000', '--text2': '#333333', '--dim': '#666666',
'--blue': '#0000cc', '--green': '#008800', '--amber': '#cc8800', '--red': '#cc0000',
}
};
function applyHMITheme(editor, themeName) {
const theme = HMI_THEMES[themeName];
if (!theme) return;
// Apply to canvas iframe
const frame = editor.Canvas.getFrameEl();
if (frame && frame.contentDocument) {
const doc = frame.contentDocument.documentElement;
Object.entries(theme).forEach(([k, v]) => doc.style.setProperty(k, v));
doc.style.background = theme['--bg'];
}
// Apply to editor UI
const root = document.documentElement;
Object.entries(theme).forEach(([k, v]) => root.style.setProperty(k, v));
}

1
static/vendor/grapes.min.css vendored Normal file

File diff suppressed because one or more lines are too long

3
static/vendor/grapes.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff