Restyled to the same row pattern (one line, details in an expandable body, one action plus a menu, tokens for light and dark): - hardware coverage, audit log (family chips, destructive actions in red, client-side filters, first 300 rows), Clonezilla backups, enrollment packages (active package marked and undeletable, MCL flags with a visible legend) - image Overview, OS and packages, Unattend (sticky save bar), Import - Dell, iPXE, Blancco and BPRT update pages; Dell history links to the rollback review instead of rolling back on one click - boot menu editor (tabs, rows with a menu, lint chips that jump to the line, tokens instead of hard-coded dark colours) and the imaging session detail page Fixes found on the way: - BPRT batch Activate never asked to confirm: a hidden input named 'confirm' shadowed window.confirm in the inline handler, so the form submitted without a prompt. It now calls window.confirm. - Backup upload refused when a backup with that name exists, unless 'Replace the existing backup' is ticked (it overwrote silently). - app.js: em dashes in comments replaced (plain ASCII).
792 lines
43 KiB
HTML
792 lines
43 KiB
HTML
{% extends "base.html" %}
|
|
{% import '_ui.html' as ui %}
|
|
{% block title %}Boot menu editor - PXE Server Manager{% endblock %}
|
|
|
|
{% set snap_labels = {'STARTNET_SAVE': 'Raw edit', 'STARTNET_SETTINGS': 'Settings change', 'STARTNET_MENU': 'Boot menu change', 'STARTNET_RESTORE': 'Restore'} %}
|
|
{% set n_lint = (lint.errors | length) + (lint.warnings | length) %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
/* ---- Code palette. Contained to this page and documented here: the
|
|
editor uses the theme surface tokens for its background, gutter and
|
|
text, and these five syntax colours, picked for 4.5:1 or better on
|
|
--surface-2 in each theme. Light first, then dark (same keys as
|
|
pxe-ui.css: data-theme, and prefers-color-scheme with no data-theme). */
|
|
.stn {
|
|
--code-bg: var(--surface-2); --code-gutter: var(--surface-3); --code-text: var(--text); --code-line: var(--line-2);
|
|
--tok-comment: #3F6E2A; --tok-label: #7A4E00; --tok-keyword: #1F5FBF; --tok-var: #0B6B60; --tok-string: #9C3A16;
|
|
--code-sel: rgba(31, 95, 191, .22);
|
|
}
|
|
[data-theme="dark"] .stn {
|
|
--code-bg: #121820; --code-gutter: #0F141A;
|
|
--tok-comment: #8CC277; --tok-label: #E5D38A; --tok-keyword: #8DB8F5; --tok-var: #5FD3BC; --tok-string: #E3A587;
|
|
--code-sel: rgba(120, 169, 240, .30);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme]) .stn {
|
|
--code-bg: #121820; --code-gutter: #0F141A;
|
|
--tok-comment: #8CC277; --tok-label: #E5D38A; --tok-keyword: #8DB8F5; --tok-var: #5FD3BC; --tok-string: #E3A587;
|
|
--code-sel: rgba(120, 169, 240, .30);
|
|
}
|
|
}
|
|
|
|
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
|
|
.stn-mono { font-family: var(--font-mono); font-size: 13px; line-height: 21px; letter-spacing: normal; tab-size: 4; -moz-tab-size: 4; }
|
|
|
|
/* ---- header + underline tabs (same look as _image_header.html) ---- */
|
|
.stn-head h1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
|
|
.stn-head h1 .mono { font-size: 15px; font-weight: 400; color: var(--muted); }
|
|
.stn-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line-2); overflow-x: auto; scrollbar-width: none; margin: 0 0 20px; padding: 0; list-style: none; }
|
|
.stn-tabs .stn-tab { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 42px; padding: 0 14px; font: inherit; font-size: 14px;
|
|
color: var(--text-2); background: none; border: 0; border-bottom: 3px solid transparent; margin-bottom: -1px; white-space: nowrap; cursor: pointer; }
|
|
.stn-tabs .stn-tab:hover { color: var(--text); }
|
|
.stn-tabs .stn-tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--text); }
|
|
.stn-tabs .tab-count { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-weight: 400; }
|
|
.stn-tabs .chip { font-size: 11px; padding: 1px 6px; }
|
|
@media (max-width: 600px) { .stn-tabs { flex-wrap: wrap; row-gap: 0; } .stn-tabs .stn-tab { padding: 0 10px; min-height: 40px; } }
|
|
|
|
/* ---- panels + two-column layout ---- */
|
|
.stn-cols { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
|
|
@media (max-width: 1100px) { .stn-cols { grid-template-columns: minmax(0, 1fr); } }
|
|
.stn-pad { padding: 18px 20px; }
|
|
.stn-side { display: flex; flex-direction: column; min-width: 0; }
|
|
.stn-side > section { padding: 16px 18px; }
|
|
.stn-side > section + section, .stn-side > details { border-top: 1px solid var(--line); }
|
|
.stn h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
|
.stn h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
|
|
.stn-bar { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: center; }
|
|
.stn-bar .grow { margin-right: auto; min-width: 0; }
|
|
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
|
|
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
|
|
|
/* ---- settings form ---- */
|
|
.stn-form { display: flex; flex-direction: column; gap: 16px; max-width: 460px; margin-top: 14px; }
|
|
.stn-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
|
.stn-form .field { width: 100%; }
|
|
.field-hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
|
select.field { padding-right: 28px; }
|
|
|
|
/* ---- editable list rows (boot menu, shopfloor) ---- */
|
|
.ed-list { container-type: inline-size; margin-top: 14px; }
|
|
.ed-row, .ed-hrow { display: grid; align-items: center; gap: 8px 12px; padding: 8px 0; }
|
|
.ed-row { border-top: 1px solid var(--line); }
|
|
.ed-hrow { padding: 0 0 6px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
|
.m-grid { grid-template-columns: 2rem minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) 44px; grid-template-areas: "n lab tgt img act"; }
|
|
.sf-grid { grid-template-columns: 2rem minmax(0, 1.3fr) minmax(0, 1.1fr) minmax(0, 1fr) 64px 44px; grid-template-areas: "n lab key hint en act"; }
|
|
.ed-row .field { width: 100%; height: 34px; font-size: 13px; padding: 0 10px; }
|
|
.ed-n { grid-area: n; font-variant-numeric: tabular-nums; color: var(--muted); font-family: var(--font-mono); font-size: 13px; }
|
|
.ed-lab { grid-area: lab; } .ed-tgt { grid-area: tgt; } .ed-img { grid-area: img; } .ed-key { grid-area: key; } .ed-hint { grid-area: hint; }
|
|
.ed-en { grid-area: en; display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
|
.ed-en input { width: 16px; height: 16px; accent-color: var(--accent); }
|
|
.ed-act { grid-area: act; display: flex; justify-content: flex-end; }
|
|
.ed-cap { display: none; font-size: 12px; color: var(--muted); margin-bottom: 2px; }
|
|
.ed-empty { padding: 14px 0; border-top: 1px solid var(--line); }
|
|
@container (max-width: 700px) {
|
|
.ed-hrow { display: none; }
|
|
.ed-cap { display: block; }
|
|
.m-grid { grid-template-columns: 2rem minmax(0, 1fr) 44px; grid-template-areas: "n lab act" ". tgt tgt" ". img img"; padding: 12px 0; }
|
|
.sf-grid { grid-template-columns: 2rem minmax(0, 1fr) 44px; grid-template-areas: "n lab act" ". key key" ". hint hint" ". en en"; padding: 12px 0; }
|
|
}
|
|
|
|
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
|
|
.raw-editor-wrap { display: flex; border: 1px solid var(--code-line); border-radius: 8px; background: var(--code-bg); overflow: hidden; height: 62vh; min-height: 460px; }
|
|
.raw-gutter { flex: 0 0 auto; width: 3.25rem; padding: 1rem 0.5rem 1rem 0; text-align: right; color: var(--muted); background: var(--code-gutter);
|
|
border-right: 1px solid var(--code-line); overflow: hidden; user-select: none; white-space: pre; }
|
|
.raw-code { position: relative; flex: 1 1 auto; overflow: hidden; }
|
|
.raw-highlight, .raw-textarea { margin: 0; padding: 1rem; border: 0; white-space: pre; word-wrap: normal; overflow: auto; width: 100%; height: 100%; box-sizing: border-box; }
|
|
.raw-highlight { position: absolute; inset: 0; color: var(--code-text); background: transparent; pointer-events: none; overflow: hidden; }
|
|
.raw-textarea { position: relative; display: block; resize: none; background: transparent;
|
|
color: var(--code-text); /* visible if JS/overlay never activates (fallback) */
|
|
caret-color: var(--code-text); }
|
|
/* only once the overlay is proven live do we hide the textarea's own glyphs */
|
|
.raw-editor-wrap.overlay-live .raw-textarea { color: transparent; }
|
|
.raw-textarea:focus { outline: none; }
|
|
.raw-editor-wrap:focus-within { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
.raw-textarea::selection { background: var(--code-sel); }
|
|
.tok-comment { color: var(--tok-comment); font-style: italic; }
|
|
.tok-label { color: var(--tok-label); font-weight: 600; }
|
|
.tok-keyword { color: var(--tok-keyword); }
|
|
.tok-var { color: var(--tok-var); }
|
|
.tok-string { color: var(--tok-string); }
|
|
|
|
/* ---- lint ---- */
|
|
.lint-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
.lint-list li { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; line-height: 1.45; }
|
|
.lint-list .msg { min-width: 0; overflow-wrap: anywhere; padding-top: 2px; }
|
|
.lint-chip { border: 0; cursor: pointer; font-family: var(--font-mono); flex: none; }
|
|
.lint-chip[disabled] { cursor: default; }
|
|
.cmd-ref { display: flex; flex-direction: column; gap: 10px; padding: 0 18px 16px; font-size: 13px; }
|
|
.cmd-ref code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text); overflow-wrap: anywhere; }
|
|
|
|
/* ---- history rows ---- */
|
|
.hs-row { display: grid; grid-template-columns: minmax(150px, 190px) minmax(0, 1fr) 80px; grid-template-areas: "ts note size"; align-items: center; gap: 4px 16px;
|
|
padding: 12px 150px 12px 20px; min-height: 52px; box-sizing: border-box; }
|
|
.hs-ts { grid-area: ts; font-family: var(--font-mono); font-size: 13px; }
|
|
.hs-note { grid-area: note; min-width: 0; display: flex; gap: 8px; align-items: center; }
|
|
.hs-size { grid-area: size; color: var(--muted); font-size: 13px; text-align: right; }
|
|
.ui-row.hs > .act { top: 8px; }
|
|
@container (max-width: 640px) {
|
|
.hs-row { grid-template-columns: minmax(0, 1fr); grid-template-areas: "ts" "note" "size"; padding: 12px 18px; }
|
|
.hs-size { text-align: left; }
|
|
.hs-head { display: none; }
|
|
.ui-row.hs > .act { position: static; padding: 0 18px 12px; justify-content: flex-start; }
|
|
}
|
|
|
|
/* ---- diff modal ---- */
|
|
.stn-modal { max-width: min(900px, 96vw); }
|
|
.diff-view { margin: 0; max-height: 55vh; overflow: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
|
|
background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 6px 0; }
|
|
.diff-view span { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-all; min-height: 1.5em; }
|
|
.diff-add { background: var(--add-bg); color: var(--add-text); }
|
|
.diff-del { background: var(--del-bg); color: var(--del-text); }
|
|
.diff-hunk { background: var(--surface-2); color: var(--accent); }
|
|
.diff-meta { color: var(--muted); }
|
|
.diff-identical { padding: 1.5rem; text-align: center; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="stn">
|
|
<header class="ui-head stn-head">
|
|
<div style="min-width: 0">
|
|
<div class="eyebrow-t">WinPE boot</div>
|
|
<h1>Boot menu editor <span class="mono">startnet.cmd</span></h1>
|
|
<div class="sub" style="margin-top: 4px; font-size: 13px; max-width: 70ch">
|
|
Saves rewrite startnet.cmd inside boot.wim and affect every PXE boot. The prior copy is snapshotted first, so any save can be restored from History.
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<span class="tip right inl" data-tip="{{ wim_path }}"><span class="chip {{ 'c-neutral' if wim_exists else 'c-err' }} mono" style="font-weight: 400">boot.wim{{ '' if wim_exists else ' missing' }}</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
{% if not wim_exists %}
|
|
<div class="ui-stack">
|
|
<div class="notice bad" role="alert"><span>
|
|
<strong>boot.wim not found</strong> at <span class="mono">{{ wim_path }}</span>.
|
|
Run the PXE server setup playbook and import the WinPE boot files first. Nothing on this page can be edited until it is there.</span>
|
|
</div>
|
|
{% if snapshots %}
|
|
<section class="panel stn-pad">
|
|
<h2>Snapshots</h2>
|
|
<p class="sub" style="margin: 6px 0 0">{{ snapshots | length }} earlier startnet.cmd snapshot{{ 's' if snapshots | length != 1 }} {{ 'are' if snapshots | length != 1 else 'is' }} still on disk. Restore is unavailable until boot.wim is present.</p>
|
|
</section>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
|
|
<ul class="stn-tabs" id="stnTabs" role="tablist" aria-label="Editor sections">
|
|
<li role="presentation"><button class="stn-tab active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab" aria-controls="tab-settings" aria-selected="true">Settings</button></li>
|
|
<li role="presentation"><button class="stn-tab" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab" aria-controls="tab-menu" aria-selected="false">Boot menu
|
|
{% if boot_menu %}<span class="tab-count">{{ boot_menu | length }}</span>{% endif %}</button></li>
|
|
<li role="presentation"><button class="stn-tab" id="tab-shopfloor-btn" data-bs-toggle="tab" data-bs-target="#tab-shopfloor" type="button" role="tab" aria-controls="tab-shopfloor" aria-selected="false">Shopfloor sub-menu
|
|
{% if shopfloor_items %}<span class="tab-count">{{ shopfloor_items | length }}</span>{% endif %}</button></li>
|
|
<li role="presentation"><button class="stn-tab" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab" aria-controls="tab-raw" aria-selected="false">Raw
|
|
{% if lint.errors %}{{ ui.chip(lint.errors | length ~ (' error' if lint.errors | length == 1 else ' errors'), 'err') }}
|
|
{% elif lint.warnings %}{{ ui.chip(lint.warnings | length ~ (' warning' if lint.warnings | length == 1 else ' warnings'), 'warn') }}{% endif %}</button></li>
|
|
<li role="presentation"><button class="stn-tab" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab" aria-controls="tab-history" aria-selected="false">History
|
|
{% if snapshots %}<span class="tab-count">{{ snapshots | length }}</span>{% endif %}</button></li>
|
|
</ul>
|
|
|
|
{% macro wim_info_dl() %}
|
|
<dl class="kv">
|
|
{% for key, val in wim_info.items() %}
|
|
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description', 'Name', 'Description', 'Size'] %}
|
|
<dt>{{ key }}</dt><dd>{{ val }}</dd>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</dl>
|
|
{% if not wim_info %}<p class="sub" style="margin: 0">Could not read WIM info.</p>{% endif %}
|
|
{% endmacro %}
|
|
|
|
<div class="tab-content">
|
|
|
|
<!-- =============================== SETTINGS =============================== -->
|
|
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel" aria-labelledby="tab-settings-btn">
|
|
<div class="stn-cols">
|
|
<section class="panel stn-pad" aria-labelledby="setHead">
|
|
<h2 id="setHead">Boot settings</h2>
|
|
<p class="sub" style="margin: 4px 0 0; font-size: 13px; max-width: 62ch">
|
|
Each field edits only its own line in startnet.cmd. Leave a field blank to keep the current value.
|
|
The save goes through the same boot.wim path as the Raw tab.
|
|
</p>
|
|
<form action="{{ url_for('startnet_save_settings') }}" method="post" class="stn-form">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<div>
|
|
<label for="setServerIp">PXE / deployment server IP</label>
|
|
<input type="text" class="field stn-mono" id="setServerIp" name="server_ip" aria-describedby="setServerIpHint"
|
|
value="{{ settings.server_ip if settings.server_ip is not none else '' }}" placeholder="e.g. 172.16.9.1">
|
|
<div class="field-hint" id="setServerIpHint">Address startnet.cmd maps the winpeapps share from.</div>
|
|
</div>
|
|
<div>
|
|
<label for="setTimeout">Menu timeout (seconds)</label>
|
|
<input type="number" min="0" class="field" id="setTimeout" name="menu_timeout" aria-describedby="setTimeoutHint"
|
|
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}" placeholder="e.g. 30">
|
|
<div class="field-hint" id="setTimeoutHint">Whole seconds before the default menu entry auto-selects.</div>
|
|
</div>
|
|
<div>
|
|
<label for="setDefault">Default menu entry</label>
|
|
<input type="text" class="field" id="setDefault" name="menu_default" aria-describedby="setDefaultHint"
|
|
value="{{ settings.menu_default if settings.menu_default is not none else '' }}" placeholder="e.g. 1">
|
|
<div class="field-hint" id="setDefaultHint">The entry chosen when the timeout elapses.</div>
|
|
</div>
|
|
<div><button type="submit" class="ui-btn pri">Save settings to boot.wim</button></div>
|
|
</form>
|
|
</section>
|
|
<div class="panel stn-side">
|
|
<section><h3>WIM info</h3>{{ wim_info_dl() }}</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- =============================== BOOT MENU ============================== -->
|
|
<div class="tab-pane fade" id="tab-menu" role="tabpanel" aria-labelledby="tab-menu-btn">
|
|
<section class="panel stn-pad" aria-labelledby="menuHead">
|
|
<div class="stn-bar">
|
|
<div class="grow"><h2 id="menuHead">Boot menu entries</h2></div>
|
|
<button type="button" class="ui-btn" id="menuAddBtn">Add entry</button>
|
|
<button type="submit" form="menuForm" class="ui-btn pri">Save menu to boot.wim</button>
|
|
</div>
|
|
<div class="notice info" style="margin-top: 12px">
|
|
<span>Appending a new image at the end always works. Reordering or removing an entry whose number the enrollment
|
|
<span class="mono">%choice%</span> router cross-references may be refused by the server to keep numbering in sync.
|
|
If so, the raw text is left untouched and the reason is shown at the top of the page.</span>
|
|
</div>
|
|
|
|
<form action="{{ url_for('startnet_save_menu') }}" method="post" id="menuForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="items" id="menuItemsData" value="[]">
|
|
<div class="ed-list" id="menuTable">
|
|
<div class="ed-hrow m-grid" aria-hidden="true"><span>#</span><span class="ed-lab">Label</span><span class="ed-tgt">Target label</span><span class="ed-img">Target image</span><span></span></div>
|
|
<div id="menuBody"><!-- rows injected by JS --></div>
|
|
<div id="menuEmpty" class="ed-empty sub" style="display:none;">No menu entries parsed. Use the Raw tab, or add an entry above.</div>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- ============================ SHOPFLOOR SUB-MENU ======================== -->
|
|
<div class="tab-pane fade" id="tab-shopfloor" role="tabpanel" aria-labelledby="tab-shopfloor-btn">
|
|
<section class="panel stn-pad" aria-labelledby="sfHead">
|
|
<div class="stn-bar">
|
|
<div class="grow"><h2 id="sfHead">Shopfloor sub-menu</h2>
|
|
<div class="sub">menu.json on the enrollment share. No boot.wim edit.</div></div>
|
|
<button type="button" class="ui-btn" id="sfAddBtn">Add item</button>
|
|
<button type="submit" form="sfForm" class="ui-btn pri">Save shopfloor menu</button>
|
|
</div>
|
|
<div class="notice info" style="margin-top: 12px"><span>
|
|
The GEA Shopfloor choice drills into this sub-menu. Each PC type must be an existing
|
|
<span class="mono">shopfloor-setup/gea-shopfloor-*</span> handler. WinPE falls back to the
|
|
built-in menu if the share or picker is unavailable. Rows without a label are dropped on save.</span>
|
|
</div>
|
|
<form action="{{ url_for('shopfloor_menu') }}" method="post" id="sfForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="items" id="sfItemsData" value="[]">
|
|
<div class="ed-list" id="sfTable">
|
|
<div class="ed-hrow sf-grid" aria-hidden="true"><span>#</span><span class="ed-lab">Label</span><span class="ed-key">PC type (key)</span><span class="ed-hint">Hint</span><span class="ed-en">Shown</span><span></span></div>
|
|
<div id="sfBody"><!-- rows injected by JS --></div>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- ================================= RAW ================================= -->
|
|
<div class="tab-pane fade" id="tab-raw" role="tabpanel" aria-labelledby="tab-raw-btn">
|
|
<div class="stn-cols">
|
|
<section class="panel stn-pad" aria-labelledby="rawHead">
|
|
<div class="stn-bar" style="margin-bottom: 12px">
|
|
<div class="grow"><h2 id="rawHead" class="vh">Raw startnet.cmd</h2>
|
|
<span class="tip" data-tip="{{ wim_path }}"><span class="trunc mono" style="font-size: 13px">Windows\System32\startnet.cmd</span></span></div>
|
|
<button type="button" class="ui-btn" id="rawDiffBtn">Diff vs current</button>
|
|
<button type="submit" form="startnetForm" class="ui-btn pri">Save to boot.wim</button>
|
|
</div>
|
|
|
|
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<div class="raw-editor-wrap" id="rawWrap">
|
|
<div class="raw-gutter stn-mono" id="rawGutter" aria-hidden="true">1</div>
|
|
<div class="raw-code">
|
|
<pre class="raw-highlight stn-mono" id="rawHighlight" aria-hidden="true"></pre>
|
|
<textarea name="content" class="raw-textarea stn-mono" id="cmdEditor" aria-label="startnet.cmd contents"
|
|
spellcheck="false" autocomplete="off" autocapitalize="off"
|
|
wrap="off">{{ content }}</textarea>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<div class="sub" style="margin-top: 10px">Editing startnet.cmd inside <span class="mono">{{ wim_path }}</span>. Tab inserts a tab; Esc then Tab moves focus on.</div>
|
|
</section>
|
|
|
|
<div class="panel stn-side">
|
|
<section aria-labelledby="lintHead">
|
|
<div class="stn-bar" style="margin-bottom: 10px; gap: 6px 8px">
|
|
<h3 id="lintHead" class="grow" style="margin: 0">Lint</h3>
|
|
{% if lint.errors %}{{ ui.chip(lint.errors | length ~ ' error' ~ ('s' if lint.errors | length != 1 else ''), 'err') }}{% endif %}
|
|
{% if lint.warnings %}{{ ui.chip(lint.warnings | length ~ ' warning' ~ ('s' if lint.warnings | length != 1 else ''), 'warn') }}{% endif %}
|
|
</div>
|
|
{% if not n_lint %}
|
|
<p class="sub" style="margin: 0">No issues found in the saved copy.</p>
|
|
{% else %}
|
|
<ul class="lint-list">
|
|
{% for e in lint.errors %}
|
|
<li><button type="button" class="chip c-err lint-chip" data-line="{{ e.line if e.line is not none else '' }}"
|
|
{% if e.line is none %}disabled{% endif %} aria-label="Error{{ (' on line ' ~ e.line) if e.line is not none else '' }}{{ ', go to line' if e.line is not none }}">{{ ('L' ~ e.line) if e.line is not none else 'Error' }}</button>
|
|
<span class="msg">{{ e.message }}</span></li>
|
|
{% endfor %}
|
|
{% for w in lint.warnings %}
|
|
<li><button type="button" class="chip c-warn lint-chip" data-line="{{ w.line if w.line is not none else '' }}"
|
|
{% if w.line is none %}disabled{% endif %} aria-label="Warning{{ (' on line ' ~ w.line) if w.line is not none else '' }}{{ ', go to line' if w.line is not none }}">{{ ('L' ~ w.line) if w.line is not none else 'Warn' }}</button>
|
|
<span class="msg">{{ w.message }}</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p class="sub" style="margin: 10px 0 0">Lint runs on the saved copy. Select a line number to jump to it.</p>
|
|
{% endif %}
|
|
</section>
|
|
<section><h3>WIM info</h3>{{ wim_info_dl() }}</section>
|
|
<details>
|
|
<summary class="disc" style="padding: 0 18px; min-height: 44px"><span class="chev" aria-hidden="true">▾</span>Common startnet.cmd commands</summary>
|
|
<div class="cmd-ref">
|
|
<div><code>wpeinit</code><span class="sub">Initialize WinPE networking</span></div>
|
|
<div><code>wpeutil WaitForNetwork</code><span class="sub">Wait for network to be ready</span></div>
|
|
<div><code>net use Z: \\172.16.9.1\winpeapps</code><span class="sub">Map the Samba share for deployment</span></div>
|
|
<div><code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code><span class="sub">Launch the deployment script</span></div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- =============================== HISTORY =============================== -->
|
|
<div class="tab-pane fade" id="tab-history" role="tabpanel" aria-labelledby="tab-history-btn">
|
|
<section class="panel ui-list" aria-labelledby="histHead">
|
|
<div class="stn-pad" style="padding-bottom: 14px">
|
|
<h2 id="histHead">Snapshots</h2>
|
|
<p class="sub" style="margin: 4px 0 0; font-size: 13px; max-width: 70ch">
|
|
Every save snapshots the prior startnet.cmd first. Diff a snapshot against the current boot.wim, or restore it.
|
|
Restoring also snapshots the current copy first.
|
|
</p>
|
|
</div>
|
|
{% if not snapshots %}
|
|
<div class="ed-empty sub" style="padding: 16px 20px">No snapshots yet.</div>
|
|
{% else %}
|
|
<div class="hs-row hs-head ui-rowhead" style="min-height: 0; padding-top: 0; padding-bottom: 6px" aria-hidden="true"><span class="hs-ts">Taken</span><span class="hs-note">Before</span><span class="hs-size">Size</span></div>
|
|
{% for s in snapshots %}
|
|
<div class="ui-row hs">
|
|
<div class="hs-row">
|
|
<span class="hs-ts">{{ s.timestamp }}</span>
|
|
<span class="hs-note"><span class="tip" data-tip="{{ s.id }}"><span class="trunc">{{ snap_labels.get(s.note, s.note) if s.note else 'Snapshot' }}</span></span>
|
|
{% if loop.first %}{{ ui.chip('Latest', 'neutral') }}{% endif %}</span>
|
|
<span class="hs-size">{{ '%.1f'|format(s.size / 1024) }} KB</span>
|
|
</div>
|
|
<div class="act">
|
|
<button type="button" class="ui-btn sm snap-diff-btn" data-snapshot-id="{{ s.id }}">Diff</button>
|
|
{% call ui.more('snapshot ' ~ s.timestamp, left=True) %}
|
|
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post"
|
|
onsubmit="return confirm('Restore this snapshot to boot.wim? The current startnet.cmd is snapshotted first.');">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="snapshot_id" value="{{ s.id }}">
|
|
<button type="submit" class="snap-restore-btn">Restore to boot.wim</button>
|
|
</form>
|
|
{% endcall %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ============================= DIFF MODAL ============================= -->
|
|
<div class="modal fade" id="diffModal" tabindex="-1" aria-labelledby="diffModalTitle" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable stn-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title" id="diffModalTitle">Diff</h3>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div id="diffModalBody"><div class="sub" style="padding:1rem;">Loading...</div></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="ui-btn" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
{% if wim_exists %}
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
|
var DIFF_URL = "{{ url_for('startnet_diff') }}";
|
|
|
|
// ------------------------------------------------------------------ escape
|
|
function esc(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ---------------------------------------------------- batch highlighter
|
|
// Per-line so comments/labels never leak across lines. Operates on ESCAPED
|
|
// text and only ever wraps whole matched tokens, so it cannot corrupt input.
|
|
var KW = /\b(echo|set|setlocal|endlocal|if|else|for|in|do|goto|call|exit|pause|cls|start|del|copy|xcopy|robocopy|md|mkdir|rd|rmdir|cd|pushd|popd|choice|timeout|ping|reg|net|use|wpeinit|wpeutil|ver|title|color|shutdown|wmic|diskpart|dism|bcdboot|drvload|not|exist|defined|errorlevel|equ|neq|gtr|geq|lss|leq)\b/gi;
|
|
|
|
function escText(s) {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function hlLine(raw) {
|
|
var line = escText(raw);
|
|
var trimmed = line.replace(/^\s+/, '');
|
|
// whole-line comment (rem, ::, @rem)
|
|
if (/^(@?rem\b|::)/i.test(trimmed)) {
|
|
return '<span class="tok-comment">' + line + '</span>';
|
|
}
|
|
// label line ( :name )
|
|
if (/^:[^:\s]/.test(trimmed)) {
|
|
return '<span class="tok-label">' + line + '</span>';
|
|
}
|
|
// token pass: strings first, then %vars%, then leading keyword-ish words
|
|
line = line.replace(/"[^&]*"|"[^"]*"/g, function (m) {
|
|
return '<span class="tok-string">' + m + '</span>';
|
|
});
|
|
line = line.replace(/%[^%\s]+%|%%?[a-zA-Z]\b|![^!\s]+!/g, function (m) {
|
|
return '<span class="tok-var">' + m + '</span>';
|
|
});
|
|
line = line.replace(KW, function (m) {
|
|
return '<span class="tok-keyword">' + m + '</span>';
|
|
});
|
|
return line;
|
|
}
|
|
|
|
var wrap = document.getElementById('rawWrap');
|
|
var ta = document.getElementById('cmdEditor');
|
|
var hl = document.getElementById('rawHighlight');
|
|
var gutter = document.getElementById('rawGutter');
|
|
|
|
function renderHighlight() {
|
|
if (!ta || !hl) return;
|
|
var lines = ta.value.split('\n');
|
|
var out = [];
|
|
for (var i = 0; i < lines.length; i++) out.push(hlLine(lines[i]));
|
|
// trailing newline guard so the overlay height tracks the textarea
|
|
hl.innerHTML = out.join('\n') + '\n';
|
|
}
|
|
|
|
function renderGutter() {
|
|
if (!ta || !gutter) return;
|
|
var n = ta.value.split('\n').length;
|
|
var buf = [];
|
|
for (var i = 1; i <= n; i++) buf.push(i);
|
|
gutter.textContent = buf.join('\n');
|
|
}
|
|
|
|
function syncScroll() {
|
|
if (hl) { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
|
|
if (gutter) { gutter.scrollTop = ta.scrollTop; }
|
|
}
|
|
|
|
if (ta) {
|
|
try {
|
|
renderHighlight();
|
|
renderGutter();
|
|
syncScroll();
|
|
// Only hide the textarea glyphs once the overlay is confirmed built.
|
|
if (wrap && hl.innerHTML) wrap.classList.add('overlay-live');
|
|
|
|
ta.addEventListener('input', function () {
|
|
renderHighlight();
|
|
renderGutter();
|
|
syncScroll();
|
|
});
|
|
ta.addEventListener('scroll', syncScroll);
|
|
|
|
// Tab key inserts a real tab instead of moving focus. Esc first lets
|
|
// a keyboard user Tab out of the editor.
|
|
var escaped = false;
|
|
ta.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { escaped = true; return; }
|
|
if (e.key === 'Tab' && !escaped) {
|
|
e.preventDefault();
|
|
var start = this.selectionStart, end = this.selectionEnd;
|
|
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
|
this.selectionStart = this.selectionEnd = start + 1;
|
|
renderHighlight(); renderGutter(); syncScroll();
|
|
}
|
|
escaped = false;
|
|
});
|
|
} catch (err) {
|
|
// Highlight is a nicety; on any failure fall back to the plain textarea.
|
|
if (wrap) wrap.classList.remove('overlay-live');
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- lint chip: go to line
|
|
document.querySelectorAll('.lint-chip[data-line]').forEach(function (chip) {
|
|
chip.addEventListener('click', function () {
|
|
var n = parseInt(chip.getAttribute('data-line'), 10);
|
|
if (!ta || !n) return;
|
|
var lines = ta.value.split('\n');
|
|
if (n > lines.length) n = lines.length;
|
|
var pos = 0;
|
|
for (var i = 0; i < n - 1; i++) pos += lines[i].length + 1;
|
|
ta.focus();
|
|
ta.setSelectionRange(pos, pos + lines[n - 1].length);
|
|
var lh = parseFloat(getComputedStyle(ta).lineHeight) || 21;
|
|
ta.scrollTop = Math.max(0, (n - 1) * lh - ta.clientHeight / 3);
|
|
syncScroll();
|
|
});
|
|
});
|
|
|
|
// ------------------------------------------------------------- diff modal
|
|
var diffModalEl = document.getElementById('diffModal');
|
|
var diffModal = diffModalEl && window.bootstrap ? new bootstrap.Modal(diffModalEl) : null;
|
|
|
|
function renderDiff(diffText) {
|
|
var body = document.getElementById('diffModalBody');
|
|
if (!diffText) {
|
|
body.innerHTML = '<div class="diff-identical">' + ui_chip('No differences', 'ok') +
|
|
'<div class="sub" style="margin-top:6px">Identical to the current boot.wim.</div></div>';
|
|
return;
|
|
}
|
|
var lines = diffText.split('\n');
|
|
var html = '';
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var ln = lines[i], cls = '';
|
|
if (/^\+\+\+|^---/.test(ln)) cls = 'diff-meta';
|
|
else if (ln.charAt(0) === '@') cls = 'diff-hunk';
|
|
else if (ln.charAt(0) === '+') cls = 'diff-add';
|
|
else if (ln.charAt(0) === '-') cls = 'diff-del';
|
|
// spans are display:block, so no newline between them
|
|
html += '<span class="' + cls + '">' + escText(ln) + '</span>';
|
|
}
|
|
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
|
|
}
|
|
|
|
function ui_chip(text, kind) {
|
|
return '<span class="chip c-' + kind + '">' + esc(text) + '</span>';
|
|
}
|
|
|
|
function openDiff(title, payload) {
|
|
var body = document.getElementById('diffModalBody');
|
|
document.getElementById('diffModalTitle').textContent = title;
|
|
body.innerHTML = '<div class="sub" style="padding:1rem;">Loading diff...</div>';
|
|
if (diffModal) diffModal.show();
|
|
fetch(DIFF_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
|
body: JSON.stringify(payload)
|
|
}).then(function (r) {
|
|
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
|
|
}).then(function (res) {
|
|
if (!res.ok) {
|
|
body.innerHTML = '<div class="errbox">' + esc(res.j.error || 'Diff failed.') + '</div>';
|
|
return;
|
|
}
|
|
renderDiff(res.j.diff || '');
|
|
}).catch(function () {
|
|
body.innerHTML = '<div class="errbox">Could not reach the diff endpoint.</div>';
|
|
});
|
|
}
|
|
|
|
var rawDiffBtn = document.getElementById('rawDiffBtn');
|
|
if (rawDiffBtn) {
|
|
rawDiffBtn.addEventListener('click', function () {
|
|
openDiff('Pending edit vs current boot.wim', { content: ta.value });
|
|
});
|
|
}
|
|
document.querySelectorAll('.snap-diff-btn').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var id = btn.getAttribute('data-snapshot-id');
|
|
openDiff('Snapshot ' + id + ' vs current', { snapshot_id: id });
|
|
});
|
|
});
|
|
|
|
// Row "..." menus: close any other open one, and close on outside click.
|
|
document.addEventListener('click', function (e) {
|
|
document.querySelectorAll('.stn details.more[open]').forEach(function (d) {
|
|
if (!d.contains(e.target)) d.removeAttribute('open');
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------- boot-menu editor
|
|
var MENU = {{ boot_menu | tojson }};
|
|
var IMAGE_TYPES = {{ image_types | tojson }};
|
|
var body = document.getElementById('menuBody');
|
|
var emptyMsg = document.getElementById('menuEmpty');
|
|
var form = document.getElementById('menuForm');
|
|
var hidden = document.getElementById('menuItemsData');
|
|
|
|
function optionsFor(selected) {
|
|
var opts = '';
|
|
var found = false;
|
|
for (var i = 0; i < IMAGE_TYPES.length; i++) {
|
|
var v = IMAGE_TYPES[i];
|
|
if (v === selected) found = true;
|
|
opts += '<option value="' + esc(v) + '"' + (v === selected ? ' selected' : '') + '>' + esc(v) + '</option>';
|
|
}
|
|
// preserve an existing target_image that is not in the current image list
|
|
if (selected && !found) {
|
|
opts = '<option value="' + esc(selected) + '" selected>' + esc(selected) + ' (unlisted)</option>' + opts;
|
|
}
|
|
opts = '<option value="">-- none --</option>' + opts;
|
|
return opts;
|
|
}
|
|
|
|
// One line per entry: number, label, target, image, and a "..." menu with
|
|
// Move up / Move down / Remove (Remove is DOM-only until Save).
|
|
function rowHtml(item, idx, total) {
|
|
var n = idx + 1;
|
|
return '' +
|
|
'<span class="ed-n">' + n + '</span>' +
|
|
'<div class="ed-lab"><label class="ed-cap" for="mLab' + idx + '">Label</label>' +
|
|
'<input type="text" class="field m-label" id="mLab' + idx + '" aria-label="Entry ' + n + ' label" value="' + esc(item.label || '') + '" placeholder="Menu label"></div>' +
|
|
'<div class="ed-tgt"><label class="ed-cap" for="mTgt' + idx + '">Target label</label>' +
|
|
'<input type="text" class="field stn-mono m-target" id="mTgt' + idx + '" aria-label="Entry ' + n + ' target label" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></div>' +
|
|
'<div class="ed-img"><label class="ed-cap" for="mImg' + idx + '">Target image</label>' +
|
|
'<select class="field m-image" id="mImg' + idx + '" aria-label="Entry ' + n + ' target image">' + optionsFor(item.target_image || '') + '</select></div>' +
|
|
'<div class="ed-act"><details class="more">' +
|
|
'<summary class="ui-btn ghost" aria-label="More actions for entry ' + n + '">⋯</summary>' +
|
|
'<div class="menu">' +
|
|
'<button type="button" class="m-up"' + (idx === 0 ? ' disabled' : '') + '>Move up</button>' +
|
|
'<button type="button" class="m-down"' + (idx === total - 1 ? ' disabled' : '') + '>Move down</button>' +
|
|
'<div class="sep"></div>' +
|
|
'<button type="button" class="m-del del">Remove entry</button>' +
|
|
'</div></details></div>';
|
|
}
|
|
|
|
// Read the live DOM back into an array (so in-progress typing is captured).
|
|
function collect() {
|
|
var items = [];
|
|
body.querySelectorAll('.m-row').forEach(function (row) {
|
|
items.push({
|
|
label: (row.querySelector('.m-label') || {}).value || '',
|
|
target: (row.querySelector('.m-target') || {}).value || '',
|
|
target_image: (row.querySelector('.m-image') || {}).value || ''
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
function render(items, focusIdx) {
|
|
body.innerHTML = '';
|
|
for (var i = 0; i < items.length; i++) {
|
|
var row = document.createElement('div');
|
|
row.className = 'ed-row m-grid m-row';
|
|
row.innerHTML = rowHtml(items[i], i, items.length);
|
|
body.appendChild(row);
|
|
}
|
|
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
|
|
if (focusIdx != null && body.children[focusIdx]) {
|
|
var s = body.children[focusIdx].querySelector('summary');
|
|
if (s) s.focus();
|
|
}
|
|
}
|
|
|
|
if (body) {
|
|
render(MENU.map(function (m) {
|
|
return { label: m.label, target: m.target, target_image: m.target_image };
|
|
}));
|
|
|
|
body.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('button');
|
|
if (!btn) return;
|
|
var row = btn.closest('.m-row');
|
|
var rows = Array.prototype.slice.call(body.querySelectorAll('.m-row'));
|
|
var idx = rows.indexOf(row);
|
|
var items = collect();
|
|
if (btn.classList.contains('m-up') && idx > 0) {
|
|
var t = items[idx - 1]; items[idx - 1] = items[idx]; items[idx] = t;
|
|
render(items, idx - 1);
|
|
} else if (btn.classList.contains('m-down') && idx < items.length - 1) {
|
|
var d = items[idx + 1]; items[idx + 1] = items[idx]; items[idx] = d;
|
|
render(items, idx + 1);
|
|
} else if (btn.classList.contains('m-del')) {
|
|
items.splice(idx, 1);
|
|
render(items);
|
|
}
|
|
});
|
|
|
|
var addBtn = document.getElementById('menuAddBtn');
|
|
if (addBtn) {
|
|
addBtn.addEventListener('click', function () {
|
|
var items = collect();
|
|
items.push({ label: '', target: '', target_image: '' });
|
|
render(items);
|
|
var last = body.lastElementChild && body.lastElementChild.querySelector('.m-label');
|
|
if (last) last.focus();
|
|
});
|
|
}
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', function () {
|
|
hidden.value = JSON.stringify(collect());
|
|
});
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
<script>
|
|
(function(){
|
|
const SF_AVAIL = {{ shopfloor_available | tojson }};
|
|
const SF_ITEMS = {{ shopfloor_items | tojson }};
|
|
const body = document.getElementById('sfBody');
|
|
if (!body) return;
|
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
const opts = sel => SF_AVAIL.map(k=>`<option value="${esc(k)}" ${k===sel?'selected':''}>${esc(k)}</option>`).join('');
|
|
let uid = 0;
|
|
// One line per item; Move up / Move down / Remove live in the row's "..." menu.
|
|
function row(it){
|
|
const r=document.createElement('div');
|
|
r.className='ed-row sf-grid sf-row';
|
|
const key=it.key||(SF_AVAIL[0]||'');
|
|
const u=++uid;
|
|
r.innerHTML =
|
|
`<span class="ed-n sf-n"></span>`+
|
|
`<div class="ed-lab"><label class="ed-cap" for="sfL${u}">Label</label><input class="field sf-label" id="sfL${u}" value="${esc(it.label||'')}" placeholder="Menu label"></div>`+
|
|
`<div class="ed-key"><label class="ed-cap" for="sfK${u}">PC type (key)</label><select class="field sf-key" id="sfK${u}">${opts(key)}</select></div>`+
|
|
`<div class="ed-hint"><label class="ed-cap" for="sfH${u}">Hint</label><input class="field sf-hint" id="sfH${u}" value="${esc(it.hint||'')}" placeholder="(optional)"></div>`+
|
|
`<label class="ed-en"><input type="checkbox" class="sf-en" ${it.enabled===false?'':'checked'}><span class="ed-cap sf-en-txt" style="margin:0">Shown</span></label>`+
|
|
`<div class="ed-act"><details class="more"><summary class="ui-btn ghost sf-more">⋯</summary><div class="menu">`+
|
|
`<button type="button" class="sf-up">Move up</button><button type="button" class="sf-down">Move down</button>`+
|
|
`<div class="sep"></div><button type="button" class="sf-rm del">Remove item</button></div></details></div>`;
|
|
return r;
|
|
}
|
|
const rows=()=>[...body.querySelectorAll('.sf-row')];
|
|
const renum=()=>rows().forEach((r,i,all)=>{
|
|
const n=i+1;
|
|
r.querySelector('.sf-n').textContent=n;
|
|
r.querySelector('.sf-label').setAttribute('aria-label','Item '+n+' label');
|
|
r.querySelector('.sf-key').setAttribute('aria-label','Item '+n+' PC type');
|
|
r.querySelector('.sf-hint').setAttribute('aria-label','Item '+n+' hint');
|
|
r.querySelector('.sf-en').setAttribute('aria-label','Item '+n+' shown in menu');
|
|
r.querySelector('.sf-more').setAttribute('aria-label','More actions for item '+n);
|
|
r.querySelector('.sf-up').disabled = i===0;
|
|
r.querySelector('.sf-down').disabled = i===all.length-1;
|
|
});
|
|
const add=it=>{body.appendChild(row(it||{}));renum();};
|
|
body.addEventListener('click',e=>{
|
|
const r=e.target.closest('.sf-row'); if(!r)return;
|
|
const menu=r.querySelector('details.more');
|
|
if(e.target.closest('.sf-rm')){r.remove();renum();}
|
|
else if(e.target.closest('.sf-up')&&r.previousElementSibling){r.parentNode.insertBefore(r,r.previousElementSibling);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
|
|
else if(e.target.closest('.sf-down')&&r.nextElementSibling){r.parentNode.insertBefore(r.nextElementSibling,r);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
|
|
});
|
|
document.getElementById('sfAddBtn').addEventListener('click',()=>{add({});const l=body.lastElementChild.querySelector('.sf-label');if(l)l.focus();});
|
|
document.getElementById('sfForm').addEventListener('submit',()=>{
|
|
const items=rows().map(r=>({label:r.querySelector('.sf-label').value.trim(),key:r.querySelector('.sf-key').value,hint:r.querySelector('.sf-hint').value.trim(),enabled:r.querySelector('.sf-en').checked})).filter(x=>x.label&&x.key);
|
|
document.getElementById('sfItemsData').value=JSON.stringify(items);
|
|
});
|
|
(SF_ITEMS.length?SF_ITEMS:[]).forEach(add);
|
|
if(!rows().length) add({});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|