diff --git a/app.py b/app.py
index 9976514..609edca 100644
--- a/app.py
+++ b/app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent
-BUILD_VERSION = "2026.08.19.28"
+BUILD_VERSION = "2026.08.20.01"
+# compatibility: BUILD_VERSION = "2026.08.19.28"
# compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22"
load_dotenv(BASE_DIR / ".env.local", override=False)
@@ -261,12 +262,15 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
buttons = payload.get("prematch_buttons")
groups = payload.get("prematch_groups", [])
selectors = payload.get("quick_panel_selectors", [])
+ player_panels = payload.get("player_selection_panels", [])
if not isinstance(buttons, list):
raise HTTPException(status_code=422, detail="prematch_buttons must be a list")
if not isinstance(groups, list):
raise HTTPException(status_code=422, detail="prematch_groups must be a list")
if not isinstance(selectors, list):
raise HTTPException(status_code=422, detail="quick_panel_selectors must be a list")
+ if not isinstance(player_panels, list):
+ raise HTTPException(status_code=422, detail="player_selection_panels must be a list")
draft_manager = app.state.ui_builder_draft_manager
published_manager = app.state.ui_builder_published_manager
@@ -275,12 +279,14 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
draft_config["prematch_groups"] = groups
draft_config["prematch_buttons"] = buttons
draft_config["quick_panel_selectors"] = selectors
+ draft_config["player_selection_panels"] = player_panels
saved_draft = draft_manager.save(draft_config)
published_config = published_manager.load()
published_config["prematch_groups"] = saved_draft.get("prematch_groups", [])
published_config["prematch_buttons"] = saved_draft.get("prematch_buttons", [])
published_config["quick_panel_selectors"] = saved_draft.get("quick_panel_selectors", [])
+ published_config["player_selection_panels"] = saved_draft.get("player_selection_panels", [])
saved_published = published_manager.save(published_config, create_backup=True)
return {
@@ -288,6 +294,7 @@ async def save_hockey_prematch_buttons(payload: dict[str, Any]) -> dict[str, Any
"prematch_groups": saved_published.get("prematch_groups", []),
"prematch_buttons": saved_published.get("prematch_buttons", []),
"quick_panel_selectors": saved_published.get("quick_panel_selectors", []),
+ "player_selection_panels": saved_published.get("player_selection_panels", []),
"config": saved_published,
}
diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py
index 4027aa4..651329f 100644
--- a/hockey_data/mapping_context.py
+++ b/hockey_data/mapping_context.py
@@ -544,16 +544,42 @@ class MappingDataService:
control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id))
if control is not None:
runtime_values = HockeyDataService._runtime_values_from_row(control)
+ player_panel_labels: dict[str, str] = {}
+ for meta_key, meta_value in runtime_values.items():
+ meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
+ if meta_match:
+ player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for raw_key, raw_value in runtime_values.items():
- sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw_key)).strip("_")[:120]
+ raw_key_text = str(raw_key)
+ player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", raw_key_text)
+ if player_match and player_match.group(2).startswith("_"):
+ continue
+ sql_key = re.sub(r"[^A-Za-z0-9_]+", "_", raw_key_text).strip("_")[:120]
if not sql_key or not re.match(r"^[A-Za-z]", sql_key):
sql_key = f"panel_{sql_key}"[:120]
context.setdefault(sql_key, str(raw_value))
+ if player_match:
+ panel_id, field_key = player_match.groups()
+ slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
+ field_labels = {
+ "id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
+ "side": "сторона", "number": "номер", "name": "имя",
+ }
+ label = field_key
+ if slot_match:
+ slot_no, field_name = slot_match.groups()
+ label = f"Игрок {slot_no} — {field_labels.get(field_name, field_name)}"
+ category = f"Игровые выборки · {player_panel_labels.get(panel_id, panel_id)}"
+ description = f"Игрок, перетащенный оператором в блок {player_panel_labels.get(panel_id, panel_id)}."
+ else:
+ label = raw_key_text
+ category = "Нижняя панель"
+ description = f"Runtime-параметр нижней панели: {raw_key_text}"
runtime_variable_payloads.append({
"key": sql_key,
- "label": str(raw_key),
- "category": "Нижняя панель",
- "description": f"Runtime-параметр нижней панели: {raw_key}",
+ "label": label,
+ "category": category,
+ "description": description,
"scope": "match",
"source_type": "system",
"is_system": True,
@@ -800,6 +826,11 @@ class MappingDataService:
"description": "Текущее состояние операторской кнопки матча.",
})
runtime_values = HockeyDataService._runtime_values_from_row(control) if control is not None else {}
+ player_panel_labels: dict[str, str] = {}
+ for meta_key, meta_value in runtime_values.items():
+ meta_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\._label$", str(meta_key))
+ if meta_match:
+ player_panel_labels[meta_match.group(1)] = str(meta_value or meta_match.group(1))
for key, value in runtime_values.items():
mapping_key = str(key)
if mapping_key.startswith("panel."):
@@ -812,6 +843,32 @@ class MappingDataService:
"source_code": "live_control",
"description": "Выбранное значение параметризованной кнопки нижней панели.",
})
+ continue
+ player_match = re.match(r"^player_select\.([A-Za-z0-9_]+)\.(.+)$", mapping_key)
+ if not player_match:
+ continue
+ panel_id, field_key = player_match.groups()
+ if field_key.startswith("_"):
+ continue
+ slot_match = re.match(r"player(\d+)_(id|db_id|team_id|side|number|name)$", field_key)
+ field_labels = {
+ "id": "external ID", "db_id": "DB ID", "team_id": "ID команды",
+ "side": "сторона", "number": "номер", "name": "имя",
+ }
+ label = field_key
+ if slot_match:
+ slot_no, field_name = slot_match.groups()
+ label = f"Игрок {slot_no} — {field_labels.get(field_name, field_name)}"
+ panel_label = player_panel_labels.get(panel_id, panel_id)
+ result.append({
+ "key": mapping_key,
+ "label": label,
+ "category": f"Игровые выборки · {panel_label}",
+ "value": value,
+ "kind": "text",
+ "source_code": "live_control",
+ "description": f"Значение слота блока игроков «{panel_label}».",
+ })
return result
def data_catalog(
diff --git a/tests/test_build80_player_selection_panels_onair.py b/tests/test_build80_player_selection_panels_onair.py
new file mode 100644
index 0000000..591d47a
--- /dev/null
+++ b/tests/test_build80_player_selection_panels_onair.py
@@ -0,0 +1,90 @@
+from pathlib import Path
+
+from ui_builder.manager import DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS, UIBuilderManager
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
+APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
+MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
+STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
+
+
+def test_old_hockey_config_gets_default_player_selection_panels(tmp_path: Path):
+ manager = UIBuilderManager(tmp_path)
+ config = {
+ "version": 21,
+ "project_name": "Hockey",
+ "tabs": [{"id": "main", "label": "Игра"}],
+ "components": [{"id": "h", "type": "hockey_penalty_dashboard", "action_id": "hockey_game"}],
+ }
+ normalized = manager._normalize(config)
+ panels = normalized["player_selection_panels"]
+ assert [item["id"] for item in panels] == [item["id"] for item in DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS]
+ assert {item["id"]: item["slots"] for item in panels} == {
+ "compare_home": 2,
+ "compare_away": 2,
+ "compare_mixed": 2,
+ "three_stars": 3,
+ "selected_player": 1,
+ }
+
+
+def test_player_selection_panels_are_persisted_with_quick_panel_settings():
+ assert 'player_panels = payload.get("player_selection_panels", [])' in APP_PY
+ assert 'draft_config["player_selection_panels"] = player_panels' in APP_PY
+ assert 'published_config["player_selection_panels"]' in APP_PY
+ assert 'player_selection_panels: normalizedPlayerPanels' in APP_JS
+
+
+def test_runtime_player_slots_accept_roster_drag_drop_and_generate_identifiers():
+ for token in (
+ "function renderHockeyPlayerSelectionWindows()",
+ 'readHockeyDragData(event, "player")',
+ "hockeySetPlayerSelectionSlot(panel",
+ "player_select.${panelId}.player${slot}_${field}",
+ '"db_id"',
+ '"team_id"',
+ '"side"',
+ ):
+ assert token in APP_JS
+ assert 'panel.rule === "home"' in APP_JS
+ assert 'panel.rule === "away"' in APP_JS
+ assert 'panel.rule === "different_teams"' in APP_JS
+
+
+def test_single_selected_player_panel_mirrors_legacy_signature_context():
+ assert '"selected_player"' in APP_JS or 'selected_player' in APP_JS
+ assert "sync_selected_player" in APP_JS
+ assert "selected_player_id:" in APP_JS
+ assert "selected_player_team_id:" in APP_JS
+
+
+def test_mapping_catalog_exposes_dynamic_player_selection_groups():
+ assert 'r"^player_select\\.([A-Za-z0-9_]+)\\.(.+)$"' in MAPPING
+ assert 'Игровые выборки ·' in MAPPING
+ assert "DB ID" in MAPPING
+ assert "ID команды" in MAPPING
+
+
+def test_player_panels_are_collapsible_and_have_editor_ui():
+ assert "hockey-player-select-panel" in STYLES
+ assert ".hockey-player-select-panel.is-collapsed .hockey-player-select-body" in STYLES
+ assert 'data-player-panel-field="slots"' in APP_JS
+ assert 'data-player-panel-field="rule"' in APP_JS
+ assert 'data-player-panel-add' in APP_JS
+
+
+def test_all_overlay_sequences_latch_onair_state_for_lower_dock():
+ start = APP_JS.index("function trackRuntimeOverlayCommands")
+ end = APP_JS.index("async function sendRuntimeVmixSequence", start)
+ block = APP_JS[start:end]
+ assert "state.shortcutSequenceOverlayState.set(ownerSequenceId, true);" in block
+ assert "state.shortcutSequenceOverlayState.set(ownerSequenceId, false);" in block
+ assert "refreshQuickPanelOnAirClasses();" in block
+
+
+def test_player_panel_id_edit_rerenders_row_so_followup_fields_use_new_id():
+ assert 'const eventName = field === "id"' in APP_JS
+ assert 'if (field === "id") {' in APP_JS
+ assert "draftPlayerPanels = normalized;" in APP_JS
+ assert "renderEditor();" in APP_JS
diff --git a/ui_builder/manager.py b/ui_builder/manager.py
index 774287a..b265057 100644
--- a/ui_builder/manager.py
+++ b/ui_builder/manager.py
@@ -34,9 +34,19 @@ DEFAULT_CONFIG: dict[str, Any] = {
"prematch_groups": [],
"prematch_buttons": [],
"quick_panel_selectors": [],
+ "player_selection_panels": [],
}
+DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS: list[dict[str, Any]] = [
+ {"id": "compare_home", "label": "Сравнение · левая команда", "description": "Два игрока левой команды", "slots": 2, "rule": "home", "sync_selected_player": False, "collapsed_default": True, "sort_order": 0, "enabled": True},
+ {"id": "compare_away", "label": "Сравнение · правая команда", "description": "Два игрока правой команды", "slots": 2, "rule": "away", "sync_selected_player": False, "collapsed_default": True, "sort_order": 10, "enabled": True},
+ {"id": "compare_mixed", "label": "Сравнение · разные команды", "description": "По одному игроку каждой команды", "slots": 2, "rule": "different_teams", "sync_selected_player": False, "collapsed_default": True, "sort_order": 20, "enabled": True},
+ {"id": "three_stars", "label": "3 звезды", "description": "Три игрока для финального титра", "slots": 3, "rule": "any", "sync_selected_player": False, "collapsed_default": True, "sort_order": 30, "enabled": True},
+ {"id": "selected_player", "label": "Выбранный игрок", "description": "Один игрок для подписи / индивидуального титра", "slots": 1, "rule": "any", "sync_selected_player": True, "collapsed_default": True, "sort_order": 40, "enabled": True},
+]
+
+
class UIBuilderManager:
"""JSON settings storage with atomic writes and rolling backups."""
@@ -546,10 +556,49 @@ class UIBuilderManager:
"enabled": bool(selector.get("enabled", True)),
})
+ raw_player_selection_panels = config.get("player_selection_panels") if isinstance(config.get("player_selection_panels"), list) else None
+ if raw_player_selection_panels is None:
+ raw_player_selection_panels = deepcopy(DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS) if ("player_selection_panels" not in config and UIBuilderManager._is_hockey_config(config)) else []
+ normalized_player_selection_panels: list[dict[str, Any]] = []
+ seen_player_panel_ids: set[str] = set()
+ allowed_player_panel_rules = {"any", "home", "away", "different_teams"}
+ for panel_index, panel in enumerate(raw_player_selection_panels[:16]):
+ if not isinstance(panel, dict):
+ continue
+ raw_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel.get("id") or f"players_{panel_index + 1}").strip())[:48].strip("_")
+ panel_id = raw_id or f"players_{panel_index + 1}"
+ if not re.match(r"^[A-Za-z]", panel_id):
+ panel_id = f"p_{panel_id}"[:48]
+ base_id = panel_id
+ suffix = 2
+ while panel_id in seen_player_panel_ids:
+ panel_id = f"{base_id}_{suffix}"[:48]
+ suffix += 1
+ seen_player_panel_ids.add(panel_id)
+ try:
+ slots = max(1, min(6, int(panel.get("slots") or 1)))
+ except (TypeError, ValueError):
+ slots = 1
+ rule = str(panel.get("rule") or "any").strip().lower()
+ if rule not in allowed_player_panel_rules:
+ rule = "any"
+ normalized_player_selection_panels.append({
+ "id": panel_id,
+ "label": str(panel.get("label") or f"Игроки {panel_index + 1}")[:80],
+ "description": str(panel.get("description") or "")[:300],
+ "slots": slots,
+ "rule": rule,
+ "sync_selected_player": bool(panel.get("sync_selected_player", False)),
+ "collapsed_default": bool(panel.get("collapsed_default", True)),
+ "sort_order": int(panel.get("sort_order") if str(panel.get("sort_order", "")).lstrip("-").isdigit() else panel_index * 10),
+ "enabled": bool(panel.get("enabled", True)),
+ })
+
result["components"] = normalized_components
result["triggers"] = normalized_triggers
result["shortcut_sequences"] = normalized_shortcut_sequences
result["prematch_groups"] = sorted(normalized_prematch_groups, key=lambda item: (item["sort_order"], item["label"]))
result["prematch_buttons"] = sorted(normalized_prematch_buttons, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
result["quick_panel_selectors"] = sorted(normalized_quick_panel_selectors, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
+ result["player_selection_panels"] = sorted(normalized_player_selection_panels, key=lambda item: (item["sort_order"], item["label"]))
return result
diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js
index f912639..876cb56 100644
--- a/ui_builder/static/app.js
+++ b/ui_builder/static/app.js
@@ -52,6 +52,7 @@
prematch_groups: [],
prematch_buttons: [],
quick_panel_selectors: [],
+ player_selection_panels: [],
},
data: {},
sources: [],
@@ -86,6 +87,8 @@
hockeyPenaltyMappingContextSignature: "",
hockeyPenaltyMappingContextPending: false,
hockeyPenaltyMappingContextQueued: false,
+ hockeyPlayerPanelSeedPending: false,
+ hockeyPlayerPanelSeedSignature: "",
vmixFinishOverlayTimers: new Map(),
vmixStrengthMappingRefreshPending: false,
vmixStrengthMappingRefreshQueued: null,
@@ -2460,6 +2463,31 @@ function startCustomTooltips() {
});
}
+ function normalizePlayerSelectionPanels(rawPanels) {
+ const source = Array.isArray(rawPanels) ? rawPanels : [];
+ const seen = new Set();
+ const allowedRules = new Set(["any", "home", "away", "different_teams"]);
+ return source.slice(0, 16).map((panel, index) => {
+ let id = String(panel?.id || `players_${index + 1}`).trim().replace(/[^A-Za-z0-9_]+/g, "_").slice(0, 48).replace(/^_+|_+$/g, "") || `players_${index + 1}`;
+ if (!/^[A-Za-z]/.test(id)) id = `p_${id}`.slice(0, 48);
+ const base = id; let suffix = 2;
+ while (seen.has(id)) id = `${base}_${suffix++}`.slice(0, 48);
+ seen.add(id);
+ const rule = allowedRules.has(String(panel?.rule || "any")) ? String(panel.rule || "any") : "any";
+ return {
+ id,
+ label: String(panel?.label || `Игроки ${index + 1}`).slice(0, 80),
+ description: String(panel?.description || "").slice(0, 300),
+ slots: clamp(Number(panel?.slots) || 1, 1, 6),
+ rule,
+ sync_selected_player: Boolean(panel?.sync_selected_player),
+ collapsed_default: panel?.collapsed_default !== false,
+ sort_order: Number.isFinite(Number(panel?.sort_order)) ? Number(panel.sort_order) : index * 10,
+ enabled: panel?.enabled !== false,
+ };
+ }).sort((a, b) => Number(a.sort_order) - Number(b.sort_order) || a.label.localeCompare(b.label, "ru"));
+ }
+
function normalizeShortcutSequence(sequence = {}, index = 0) {
return {
id: String(sequence.id || `sequence-${index + 1}-${Math.random().toString(36).slice(2, 7)}`),
@@ -2541,6 +2569,7 @@ function startCustomTooltips() {
group_id: validPrematchGroups.has(selector.group_id) ? selector.group_id : "",
button_id: validPrematchButtons.has(selector.button_id) ? selector.button_id : "",
}));
+ state.config.player_selection_panels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
ensureHockeyQuickCommandWorkspace();
const validIds = new Set();
const usedActionIds = new Set();
@@ -4210,6 +4239,7 @@ function startCustomTooltips() {
// sequence, that is the strongest signal and needs no fallback.
if (sequenceStillOwnsRuntimeOverlay(id)) {
state.quickPanelOnAirSequences.add(id);
+ state.shortcutSequenceOverlayState.set(id, true);
refreshQuickPanelOnAirClasses();
return true;
}
@@ -4232,6 +4262,7 @@ function startCustomTooltips() {
}
if (active) state.quickPanelOnAirSequences.add(id);
else state.quickPanelOnAirSequences.delete(id);
+ state.shortcutSequenceOverlayState.set(id, active);
refreshQuickPanelOnAirClasses();
return active;
}
@@ -4317,7 +4348,10 @@ function startCustomTooltips() {
const key = String(layer);
const previous = state.vmixOverlayRuntime.get(key) || null;
state.vmixOverlayRuntime.delete(String(layer));
- if (previous?.sequence_id) setQuickPanelSequenceOnAir(previous.sequence_id, false);
+ if (previous?.sequence_id) {
+ setQuickPanelSequenceOnAir(previous.sequence_id, false);
+ state.shortcutSequenceOverlayState.set(String(previous.sequence_id), false);
+ }
return previous;
};
const setLayer = (layer, command) => {
@@ -4331,8 +4365,12 @@ function startCustomTooltips() {
});
if (previous?.sequence_id && String(previous.sequence_id) !== ownerSequenceId) {
setQuickPanelSequenceOnAir(previous.sequence_id, false);
+ state.shortcutSequenceOverlayState.set(String(previous.sequence_id), false);
+ }
+ if (ownerSequenceId) {
+ setQuickPanelSequenceOnAir(ownerSequenceId, true);
+ state.shortcutSequenceOverlayState.set(ownerSequenceId, true);
}
- if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, true);
};
(commands || []).forEach((command) => {
const fn = String(command?.Function || "").trim();
@@ -4349,14 +4387,20 @@ function startCustomTooltips() {
setLayer(parsed.layer, command);
} else if (parsed.action === "Out" || parsed.action === "Off") {
clearLayer(parsed.layer);
- if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false);
+ if (ownerSequenceId) {
+ setQuickPanelSequenceOnAir(ownerSequenceId, false);
+ state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
+ }
} else if (parsed.action === "toggle") {
const current = state.vmixOverlayRuntime.get(String(parsed.layer));
const sameOwner = Boolean(current && ownerSequenceId && String(current.sequence_id || "") === ownerSequenceId);
const sameInput = Boolean(current && String(current.input || "") === String(command?.Input || ""));
if (current && (sameOwner || sameInput || !ownerSequenceId)) {
clearLayer(parsed.layer);
- if (ownerSequenceId) setQuickPanelSequenceOnAir(ownerSequenceId, false);
+ if (ownerSequenceId) {
+ setQuickPanelSequenceOnAir(ownerSequenceId, false);
+ state.shortcutSequenceOverlayState.set(ownerSequenceId, false);
+ }
} else {
setLayer(parsed.layer, command);
}
@@ -7530,7 +7574,7 @@ function hockeyPrematchSequenceOptions(selectedId = "") {
return triggerSelectOptions(options, selectedId);
}
-async function hockeyPersistPrematchButtons(buttons, groups = state.config.prematch_groups, selectors = state.config.quick_panel_selectors) {
+async function hockeyPersistPrematchButtons(buttons, groups = state.config.prematch_groups, selectors = state.config.quick_panel_selectors, playerPanels = state.config.player_selection_panels) {
const normalizedGroups = normalizePrematchGroups(groups);
const validGroups = new Set(normalizedGroups.map((group) => group.id));
const normalizedButtons = normalizePrematchButtons(buttons).map((button) => ({
@@ -7546,13 +7590,15 @@ async function hockeyPersistPrematchButtons(buttons, groups = state.config.prema
state.config.prematch_groups = normalizedGroups;
state.config.prematch_buttons = normalizedButtons;
state.config.quick_panel_selectors = normalizedSelectors;
+ const normalizedPlayerPanels = normalizePlayerSelectionPanels(playerPanels);
+ state.config.player_selection_panels = normalizedPlayerPanels;
try {
const response = await fetch("/api/hockey/ui/prematch-buttons", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ prematch_groups: normalizedGroups, prematch_buttons: normalizedButtons, quick_panel_selectors: normalizedSelectors }),
+ body: JSON.stringify({ prematch_groups: normalizedGroups, prematch_buttons: normalizedButtons, quick_panel_selectors: normalizedSelectors, player_selection_panels: normalizedPlayerPanels }),
});
let payload = {};
try { payload = await response.json(); } catch (_) {}
@@ -7562,6 +7608,7 @@ async function hockeyPersistPrematchButtons(buttons, groups = state.config.prema
state.config.prematch_groups = normalizePrematchGroups(payload.prematch_groups || normalizedGroups);
state.config.prematch_buttons = normalizePrematchButtons(payload.prematch_buttons || normalizedButtons);
state.config.quick_panel_selectors = normalizeQuickPanelSelectors(payload.quick_panel_selectors || normalizedSelectors);
+ state.config.player_selection_panels = normalizePlayerSelectionPanels(payload.player_selection_panels || normalizedPlayerPanels);
}
ensureConfig();
toast("Нижняя панель кнопок сохранена");
@@ -7607,6 +7654,7 @@ function openHockeyPrematchButtonsEditor() {
let draftGroups = normalizePrematchGroups(state.config.prematch_groups);
let draft = normalizePrematchButtons(state.config.prematch_buttons);
let draftSelectors = normalizeQuickPanelSelectors(state.config.quick_panel_selectors);
+ let draftPlayerPanels = normalizePlayerSelectionPanels(state.config.player_selection_panels);
let dragButtonId = "";
let dragSelectorId = "";
let activeEditorGroupId = String(state.quickPanelActiveTab || "");
@@ -7669,6 +7717,21 @@ function openHockeyPrematchButtonsEditor() {
`;
+ const renderPlayerPanelRow = (panel, index) => `
+
player_select.${escapeHtml(panel.id)}.*