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) => ` +
+ ${index + 1} + + + + + + + + +
+ +
`; + const renderGroup = (group, groupIndex) => { const groupId = group?.id || ""; const buttons = groupButtons(groupId); @@ -7699,6 +7762,7 @@ function openHockeyPrematchButtonsEditor() { draft = normalizePrematchButtons(draft).map((button) => ({ ...button, group_id: validGroups.has(button.group_id) ? button.group_id : "" })); const validButtons = new Set(draft.map((button) => button.id)); draftSelectors = normalizeQuickPanelSelectors(draftSelectors).map((selector) => ({ ...selector, group_id: validGroups.has(selector.group_id) ? selector.group_id : "", button_id: validButtons.has(selector.button_id) ? selector.button_id : "" })); + draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels); showSettingsModal("Нижняя панель · кнопки", `
@@ -7713,6 +7777,10 @@ function openHockeyPrematchButtonsEditor() { ${renderGroup(null, -1)} ${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
+
+
DRAG & DROP ИГРОКОВБлоки выбора игроковНазвание ID автоматически формирует идентификаторы Mapping: player_select.<ID>.player1_id и т. д.
+
${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `
Блоков игроков пока нет
`}
+
@@ -7735,6 +7803,12 @@ function openHockeyPrematchButtonsEditor() { draftSelectors.push({ id: `selector_${draftSelectors.length + 1}`, label: `Период ${draftSelectors.length + 1}`, description: "", group_id: targetGroup, button_id: targetButton, style: "segments", options: [{value:"all",label:"МАТЧ"},{value:"1",label:"1"},{value:"2",label:"2"},{value:"3",label:"3"}], default_value: "all", sort_order: draftSelectors.length * 10, enabled: true }); renderEditor(); }); + document.querySelector("[data-player-panel-add]")?.addEventListener("click", () => { + if (draftPlayerPanels.length >= 16) return toast("Можно создать до 16 блоков игроков", true); + draftPlayerPanels.push({ id: `players_${draftPlayerPanels.length + 1}`, label: `Игроки ${draftPlayerPanels.length + 1}`, description: "", slots: 2, rule: "any", sync_selected_player: false, collapsed_default: true, sort_order: draftPlayerPanels.length * 10, enabled: true }); + renderEditor(); + }); + document.querySelector("[data-prematch-add]")?.addEventListener("click", () => { if (draft.length >= 64) return toast("Можно создать до 64 кнопок", true); const targetGroup = draftGroups.some((group) => group.id === activeEditorGroupId) ? activeEditorGroupId : ""; @@ -7807,6 +7881,46 @@ function openHockeyPrematchButtonsEditor() { renderEditor(); })); + document.querySelectorAll("[data-player-panel-field]").forEach((control) => { + const field = control.dataset.playerPanelField; + // Changing the system ID also changes every data-player-panel-id attribute + // in this editor row. Commit it on blur/change and rebuild the row at once, + // otherwise the remaining controls would still point at the old ID. + const eventName = field === "id" || control.type === "checkbox" || control.tagName === "SELECT" || control.type === "number" ? "change" : "input"; + control.addEventListener(eventName, () => { + const panel = draftPlayerPanels.find((item) => item.id === control.dataset.playerPanelId); + if (!panel) return; + const previousId = panel.id; + panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value); + if (field === "id") { + const normalized = normalizePlayerSelectionPanels(draftPlayerPanels); + draftPlayerPanels = normalized; + const updated = draftPlayerPanels.find((item) => item.id === String(panel.id || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "")) + || draftPlayerPanels.find((item) => item.sort_order === panel.sort_order && item.label === panel.label); + if (updated && previousId !== updated.id) toast(`ID блока: ${updated.id}`); + renderEditor(); + } + }); + }); + document.querySelectorAll("[data-player-panel-up]").forEach((button) => button.addEventListener("click", () => { + const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelUp); + if (index > 0) [draftPlayerPanels[index - 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index - 1]]; + draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; }); + renderEditor(); + })); + document.querySelectorAll("[data-player-panel-down]").forEach((button) => button.addEventListener("click", () => { + const index = draftPlayerPanels.findIndex((item) => item.id === button.dataset.playerPanelDown); + if (index >= 0 && index < draftPlayerPanels.length - 1) [draftPlayerPanels[index + 1], draftPlayerPanels[index]] = [draftPlayerPanels[index], draftPlayerPanels[index + 1]]; + draftPlayerPanels.forEach((item, i) => { item.sort_order = i * 10; }); + renderEditor(); + })); + document.querySelectorAll("[data-player-panel-delete]").forEach((button) => button.addEventListener("click", () => { + const panel = draftPlayerPanels.find((item) => item.id === button.dataset.playerPanelDelete); + if (!panel || !window.confirm(`Удалить блок «${panel.label}»? Сохранённые значения матча останутся в базе, но блок исчезнет из интерфейса.`)) return; + draftPlayerPanels = draftPlayerPanels.filter((item) => item.id !== panel.id); + renderEditor(); + })); + document.querySelectorAll("[data-quick-selector-field]").forEach((control) => { const eventName = control.type === "checkbox" || control.tagName === "SELECT" ? "change" : "input"; control.addEventListener(eventName, () => { @@ -7907,9 +8021,18 @@ function openHockeyPrematchButtonsEditor() { const options = parseQuickPanelSelectorOptions(control.value); if (options.length) selector.options = options; }); + document.querySelectorAll("[data-player-panel-row]").forEach((row) => { + const panel = draftPlayerPanels.find((item) => item.id === row.dataset.playerPanelRow); + if (!panel) return; + row.querySelectorAll("[data-player-panel-field]").forEach((control) => { + const field = control.dataset.playerPanelField; + panel[field] = control.type === "checkbox" ? control.checked : (control.type === "number" ? clamp(Number(control.value) || 1, 1, 6) : control.value); + }); + }); draftGroups.forEach((item, index) => { item.sort_order = index * 10; }); + draftPlayerPanels.forEach((item, index) => { item.sort_order = index * 10; }); for (const groupId of ["", ...draftGroups.map((group) => group.id)]) renumberGroup(groupId); - if (await hockeyPersistPrematchButtons(draft, draftGroups, draftSelectors)) closeModal(); + if (await hockeyPersistPrematchButtons(draft, draftGroups, draftSelectors, draftPlayerPanels)) closeModal(); }); }; renderEditor(); @@ -8238,6 +8361,275 @@ function hockeyEventCategoryLabels(language) { : {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"}; } +function hockeyPlayerSelectionPanels() { + return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false); +} + +function hockeyPlayerSelectionValueKey(panelId, slot, field) { + return `player_select.${panelId}.player${slot}_${field}`; +} + +function hockeyPlayerSelectionTeamId(player) { + if (!player) return ""; + const side = player.side === "away" ? "away" : player.side === "home" ? "home" : ""; + const raw = player.raw || {}; + const direct = raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || ""; + if (direct) return String(direct); + if (!side) return ""; + const team = getByPath(state.data, `hockey.selected_game.${side}`) || {}; + return String(team.external_id || team.team_external_id || team.team_id || team.club_id || team.id || ""); +} + +function hockeyPlayerSelectionSlot(panel, slot) { + const values = hockeyMatchRuntimeValues(); + const read = (field) => String(values[hockeyPlayerSelectionValueKey(panel.id, slot, field)] ?? ""); + return { + id: read("id"), + dbId: read("db_id"), + teamId: read("team_id"), + side: read("side"), + number: read("number"), + name: read("name"), + }; +} + +function hockeyPlayerSelectionRuleLabel(rule) { + return ({ any: "любые команды", home: "только левая", away: "только правая", different_teams: "разные команды" })[rule] || "любые команды"; +} + +function hockeyPlayerSelectionAllows(panel, slot, player) { + const side = String(player?.side || ""); + if (panel.rule === "home" && side !== "home") return { ok: false, message: "В этот блок можно добавлять только игроков левой команды" }; + if (panel.rule === "away" && side !== "away") return { ok: false, message: "В этот блок можно добавлять только игроков правой команды" }; + if (panel.rule === "different_teams") { + const occupiedSides = []; + for (let index = 1; index <= panel.slots; index += 1) { + if (index === slot) continue; + const current = hockeyPlayerSelectionSlot(panel, index); + if (current.id || current.dbId) occupiedSides.push(current.side); + } + if (side && occupiedSides.includes(side)) return { ok: false, message: "В этом блоке игроки должны быть из разных команд" }; + } + return { ok: true }; +} + +async function hockeySyncLegacySelectedPlayer(player, teamId = "") { + const gameId = hockeyTimerSelectedGameId(); + if (!gameId) return false; + const compact = player ? hockeyCompactPlayer(player) : null; + try { + const response = await fetch("/api/hockey/context/batch", { + method: "POST", cache: "no-store", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + values: { + selected_player_id: compact?.externalId || "", + selected_player_team_id: String(teamId || ""), + }, + context: { + game_id: gameId, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + }, + }), + }); + return response.ok; + } catch (_) { return false; } +} + +function hockeyPlayerSelectionPatch(panel, slot, player = null) { + const compact = player ? hockeyCompactPlayer(player) : null; + const teamId = player ? hockeyPlayerSelectionTeamId(player) : ""; + return { + [`player_select.${panel.id}._label`]: panel.label, + [`player_select.${panel.id}._slots`]: String(panel.slots), + [`player_select.${panel.id}._rule`]: panel.rule, + [hockeyPlayerSelectionValueKey(panel.id, slot, "id")]: compact?.externalId || "", + [hockeyPlayerSelectionValueKey(panel.id, slot, "db_id")]: compact?.dbId || "", + [hockeyPlayerSelectionValueKey(panel.id, slot, "team_id")]: teamId, + [hockeyPlayerSelectionValueKey(panel.id, slot, "side")]: compact?.side || "", + [hockeyPlayerSelectionValueKey(panel.id, slot, "number")]: compact?.number || "", + [hockeyPlayerSelectionValueKey(panel.id, slot, "name")]: compact?.name || "", + }; +} + +async function hockeySetPlayerSelectionSlot(panel, slot, player) { + if (!player) return false; + const validation = hockeyPlayerSelectionAllows(panel, slot, player); + if (!validation.ok) { + toast(validation.message, true); + return false; + } + await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, player), { refreshMapping: true }); + if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(player, hockeyPlayerSelectionTeamId(player)); + toast(`${panel.label}: игрок ${slot} — ${player.name || player.id}`); + return true; +} + +async function hockeyClearPlayerSelectionSlot(panel, slot) { + await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true }); + if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); + return true; +} + +async function hockeyClearPlayerSelectionPanel(panel) { + const patch = { + [`player_select.${panel.id}._label`]: panel.label, + [`player_select.${panel.id}._slots`]: String(panel.slots), + [`player_select.${panel.id}._rule`]: panel.rule, + }; + for (let slot = 1; slot <= panel.slots; slot += 1) Object.assign(patch, hockeyPlayerSelectionPatch(panel, slot, null)); + await hockeySetMatchValues(patch, { refreshMapping: true }); + if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); +} + +async function hockeyEnsurePlayerSelectionRuntimeValues() { + const gameId = hockeyTimerSelectedGameId(); + const panels = hockeyPlayerSelectionPanels(); + if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false; + const current = hockeyMatchRuntimeValues(); + const patch = {}; + for (const panel of panels) { + const meta = { + [`player_select.${panel.id}._label`]: panel.label, + [`player_select.${panel.id}._slots`]: String(panel.slots), + [`player_select.${panel.id}._rule`]: panel.rule, + }; + Object.entries(meta).forEach(([key, value]) => { if (!(key in current) || String(current[key]) !== String(value)) patch[key] = value; }); + for (let slot = 1; slot <= panel.slots; slot += 1) { + for (const field of ["id", "db_id", "team_id", "side", "number", "name"]) { + const key = hockeyPlayerSelectionValueKey(panel.id, slot, field); + if (!(key in current)) patch[key] = ""; + } + } + } + const entries = Object.entries(patch); + if (!entries.length) return false; + const signature = `${gameId}|${entries.map(([key, value]) => `${key}=${value}`).join("|")}`; + if (state.hockeyPlayerPanelSeedSignature === signature) return false; + state.hockeyPlayerPanelSeedSignature = signature; + state.hockeyPlayerPanelSeedPending = true; + try { + const language = hockeyGameControlLanguage(); + let latest = null; + for (let offset = 0; offset < entries.length; offset += 60) { + const chunk = Object.fromEntries(entries.slice(offset, offset + 60)); + latest = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/values`, { + method: "PUT", + body: JSON.stringify({ values: chunk, language }), + }); + hockeyStoreGameControl(gameId, latest, { render: false, dispatch: false }); + } + await hockeyRefreshQuickPanelMapping(); + renderRuntime(); + return true; + } catch (error) { + console.error("Player selection panel seed failed", error); + return false; + } finally { + state.hockeyPlayerPanelSeedPending = false; + } +} + +function hockeyEnsureRuntimeSideStack() { + let stack = document.getElementById("hockeyRuntimeSideStack"); + if (!stack && el.runtimeViewport) { + stack = document.createElement("aside"); + stack.id = "hockeyRuntimeSideStack"; + stack.className = "hockey-runtime-side-stack"; + el.runtimeViewport.appendChild(stack); + } + if (stack) el.runtimeViewport?.classList.add("has-hockey-pbp"); + return stack; +} + +function hockeyRemoveRuntimeSideStack() { + document.getElementById("hockeyRuntimeSideStack")?.remove(); + document.getElementById("hockeyStandalonePbp")?.remove(); + el.runtimeViewport?.classList.remove("has-hockey-pbp"); +} + +function renderHockeyPlayerSelectionWindows() { + document.querySelectorAll(".hockey-player-select-panel").forEach((node) => node.remove()); + if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false; + const panels = hockeyPlayerSelectionPanels(); + if (!panels.length) return false; + const stack = hockeyEnsureRuntimeSideStack(); + if (!stack) return false; + for (const panel of panels) { + const stateKey = `hockey-player-panel:${panel.id}:collapsed`; + const hasStored = Object.prototype.hasOwnProperty.call(state.formValues, stateKey); + const collapsed = hasStored ? Boolean(state.formValues[stateKey]) : Boolean(panel.collapsed_default); + const slots = Array.from({ length: panel.slots }, (_, index) => hockeyPlayerSelectionSlot(panel, index + 1)); + const filled = slots.filter((slot) => slot.id || slot.dbId).length; + const node = document.createElement("section"); + node.className = `hockey-player-select-panel ${collapsed ? "is-collapsed" : ""}`; + node.dataset.playerPanelId = panel.id; + node.innerHTML = ` +
+
PLAYER SELECT${escapeHtml(panel.label)}${escapeHtml(panel.description || hockeyPlayerSelectionRuleLabel(panel.rule))}
+
${filled}/${panel.slots}
+
+
+
${escapeHtml(hockeyPlayerSelectionRuleLabel(panel.rule))} · Mapping: player_select.${escapeHtml(panel.id)}.*
+
+ ${slots.map((slot, index) => { + const number = index + 1; + const filledSlot = Boolean(slot.id || slot.dbId); + const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : ""; + const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока"); + const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа"; + return `
+ ${number} +
${escapeHtml(title)}${escapeHtml(ids)}
+ ${filledSlot ? `` : `DROP`} +
`; + }).join("")} +
+ ${filled ? `` : ""} +
`; + stack.appendChild(node); + node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => { + state.formValues[stateKey] = !collapsed; + renderRuntime(); + }); + node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => { + slotNode.addEventListener("dragover", (event) => { + const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); + if (!player) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + slotNode.classList.add("is-over"); + }); + slotNode.addEventListener("dragleave", () => slotNode.classList.remove("is-over")); + slotNode.addEventListener("drop", async (event) => { + event.preventDefault(); + slotNode.classList.remove("is-over"); + const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); + if (!player) return; + await hockeySetPlayerSelectionSlot(panel, Number(slotNode.dataset.playerPanelSlot || 1), player); + }); + }); + node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => { + event.preventDefault(); event.stopPropagation(); + await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1)); + })); + node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel)); + } + setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0); + return true; +} + +function renderHockeyRuntimeSideWindows() { + hockeyRemoveRuntimeSideStack(); + if (!el.runtimeView || !el.runtimeViewport || state.activeTab !== "main") return false; + const players = renderHockeyPlayerSelectionWindows(); + const pbp = renderStandaloneHockeyPlayByPlayWindow(); + const stack = document.getElementById("hockeyRuntimeSideStack"); + if (!stack || !stack.children.length) hockeyRemoveRuntimeSideStack(); + return Boolean(players || pbp); +} + function hockeyEventCategory(item) { const declared = String(item?.category || "").trim().toLowerCase(); // Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a @@ -8282,7 +8674,6 @@ function hockeyEventIconMarkup(category, language, extraClass = "") { function renderStandaloneHockeyPlayByPlayWindow() { document.getElementById("hockeyStandalonePbp")?.remove(); - el.runtimeViewport?.classList.remove("has-hockey-pbp"); // Play-by-play belongs to the Game tab. It stays rendered underneath top-right // menus/settings so opening an operator window never destroys its state. @@ -8358,8 +8749,9 @@ function renderStandaloneHockeyPlayByPlayWindow() { state.formValues[collapsedKey] = !collapsed; renderRuntime(); }); - el.runtimeViewport.appendChild(windowNode); - el.runtimeViewport.classList.add("has-hockey-pbp"); + const sideStack = hockeyEnsureRuntimeSideStack(); + if (!sideStack) return false; + sideStack.appendChild(windowNode); return true; } @@ -11388,7 +11780,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); }); - renderStandaloneHockeyPlayByPlayWindow(); + renderHockeyRuntimeSideWindows(); renderHockeyQuickCommandDock(); Object.entries(shootoutRosterScroll).forEach(([side, top]) => { const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`); @@ -12763,8 +13155,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { el.modalHost.querySelector(".modal-backdrop").addEventListener("click", (event) => { if (!locked && event.target.classList.contains("modal-backdrop")) closeModal(); }); - document.getElementById("hockeyStandalonePbp")?.remove(); - el.runtimeViewport?.classList.remove("has-hockey-pbp"); + hockeyRemoveRuntimeSideStack(); scheduleRuntimeScale(); scheduleStyledControls(); } @@ -12781,7 +13172,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { state.timerQuickEditorInterval = null; state.modalLocked = false; el.modalHost.innerHTML = ""; - renderStandaloneHockeyPlayByPlayWindow(); + renderHockeyRuntimeSideWindows(); scheduleRuntimeScale(); } @@ -12897,10 +13288,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { window.addEventListener("hockey:navigation-toggle", (event) => { const open = Boolean(event?.detail?.open); if (open) { - document.getElementById("hockeyStandalonePbp")?.remove(); - el.runtimeViewport?.classList.remove("has-hockey-pbp"); + hockeyRemoveRuntimeSideStack(); } else { - renderStandaloneHockeyPlayByPlayWindow(); + renderHockeyRuntimeSideWindows(); } scheduleRuntimeScale(); }); diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css index 283e22d..adb7b5c 100644 --- a/ui_builder/static/styles.css +++ b/ui_builder/static/styles.css @@ -7251,3 +7251,115 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import text-transform: uppercase; letter-spacing: .035em; } + + +/* BUILD80 — universal player-selection side panels + compact editor. */ +.hockey-runtime-side-stack { + flex: 0 0 clamp(330px, 23vw, 410px); + width: clamp(330px, 23vw, 410px); + max-height: 100%; + min-height: 0; + display: grid; + align-content: start; + gap: 8px; + overflow: auto; + scrollbar-width: thin; +} +.hockey-runtime-side-stack > .hockey-pbp-window, +.hockey-runtime-side-stack > .hockey-pbp-window[style] { + position: relative !important; + inset: auto !important; + flex: none !important; + width: 100% !important; + max-width: none !important; + height: min(560px, 70vh); + max-height: 70vh; + margin: 0; +} +.hockey-runtime-side-stack > .hockey-pbp-window.is-collapsed { width: 100% !important; height: auto; } +.hockey-player-select-panel { + overflow: hidden; + color: #edf5fc; + border: 1px solid #36546e; + border-radius: 13px; + background: #071522; + box-shadow: 0 10px 28px rgba(0,0,0,.25); +} +.hockey-player-select-head { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 9px 8px 12px; + border-bottom: 1px solid #243d54; + background: linear-gradient(100deg, rgba(30,63,88,.8), rgba(8,22,35,.96)); +} +.hockey-player-select-head > div:first-child { min-width:0; display:grid; gap:2px; } +.hockey-player-select-head span { color:#48dfbd; font-size:7px; font-weight:950; letter-spacing:.15em; } +.hockey-player-select-head strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:950; } +.hockey-player-select-head small { overflow:hidden; color:#6f8aa2; text-overflow:ellipsis; white-space:nowrap; font-size:7px; font-weight:800; } +.hockey-player-select-actions { display:flex; align-items:center; gap:5px; } +.hockey-player-select-actions b { min-width:31px; height:27px; display:grid; place-items:center; border:1px solid #38556d; border-radius:8px; background:#10283b; font:950 9px "Roboto Mono",Consolas,monospace; } +.hockey-player-select-actions button { width:27px; height:27px; display:grid; place-items:center; padding:0; color:#b9cada; border:1px solid #38556d; border-radius:8px; background:#10283b; font-size:15px; font-weight:950; cursor:pointer; } +.hockey-player-select-actions button:hover { color:#07150f; border-color:transparent; background:#48dfbd; } +.hockey-player-select-panel.is-collapsed .hockey-player-select-body { display:none; } +.hockey-player-select-panel.is-collapsed .hockey-player-select-head { border-bottom:0; } +.hockey-player-select-body { display:grid; gap:6px; padding:7px; } +.hockey-player-select-rule { color:#6d859b; font-size:7px; font-weight:800; } +.hockey-player-select-rule code { color:#8db8d9; font:800 7px "Roboto Mono",Consolas,monospace; } +.hockey-player-select-slots { display:grid; gap:5px; } +.hockey-player-select-slot { + min-height:45px; + display:grid; + grid-template-columns:27px minmax(0,1fr) auto; + gap:7px; + align-items:center; + padding:6px 7px; + border:1px dashed #38546d; + border-radius:9px; + background:#0a1a29; + transition:border-color .12s ease, background .12s ease, box-shadow .12s ease; +} +.hockey-player-select-slot.is-filled { border-style:solid; border-color:#2d5168; } +.hockey-player-select-slot.is-over { border-color:#48dfbd; background:#0e302a; box-shadow:0 0 0 2px rgba(72,223,189,.14) inset; } +.hockey-player-select-index { width:24px; height:24px; display:grid; place-items:center; color:#9fb3c4; border:1px solid #355169; border-radius:7px; background:#10283b; font:950 9px "Roboto Mono",Consolas,monospace; } +.hockey-player-select-slot > div { min-width:0; display:grid; gap:2px; } +.hockey-player-select-slot strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; font-weight:950; } +.hockey-player-select-slot small { overflow:hidden; color:#6e879d; text-overflow:ellipsis; white-space:nowrap; font:750 7px "Roboto Mono",Consolas,monospace; } +.hockey-player-select-slot > i { color:#4c6980; font:900 7px "Roboto Mono",Consolas,monospace; font-style:normal; } +.hockey-player-select-slot > button { width:22px; height:22px; padding:0; color:#8ea2b4; border:1px solid #354b60; border-radius:7px; background:#102133; cursor:pointer; } +.hockey-player-select-slot > button:hover { color:#fff; border-color:#d45f6c; background:#772532; } +.hockey-player-select-clear { justify-self:end; padding:5px 8px; color:#8ca1b4; border:1px solid #30475c; border-radius:7px; background:#0d1e2d; font-size:7px; font-weight:900; cursor:pointer; } +.hockey-player-select-clear:hover { color:#fff; border-color:#516d85; } + +.player-selection-editor { margin-top:14px; padding-top:14px; border-top:1px solid #263d53; } +.player-selection-editor > header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom:8px; } +.player-selection-editor > header > div { display:grid; gap:3px; } +.player-selection-editor > header span { color:#48dfbd; font-size:8px; font-weight:950; letter-spacing:.12em; } +.player-selection-editor > header strong { font-size:13px; } +.player-selection-editor > header small { color:#738da4; font-size:8px; } +.player-selection-editor-list { display:grid; gap:6px; } +.player-selection-editor-row { + display:grid; + grid-template-columns:28px 82px minmax(135px,1fr) minmax(125px,.8fr) 65px minmax(145px,.9fr) 135px 135px minmax(160px,1fr) 62px 34px; + gap:6px; + align-items:end; + padding:7px; + border:1px solid #2b4359; + border-radius:9px; + background:#0b1a28; +} +.player-selection-editor-row label { min-width:0; display:grid; gap:3px; color:#7890a7; font-size:7px; font-weight:900; } +.player-selection-editor-row input,.player-selection-editor-row select { min-width:0; height:30px; } +.player-selection-editor-row .shortcut-inline-check { align-self:center; display:flex; align-items:center; gap:5px; } +.player-selection-editor-description { min-width:150px; } +.player-selection-editor-order { display:flex; gap:3px; } +@media (max-width: 1450px) { + .player-selection-editor-row { grid-template-columns:28px 82px minmax(140px,1fr) minmax(120px,1fr) 65px minmax(140px,1fr) 130px 130px; } + .player-selection-editor-description,.player-selection-editor-order,.player-selection-editor-row > .icon-btn { grid-column:auto; } +} +@media (max-width:980px) { + .hockey-runtime-side-stack { flex-basis:290px; width:290px; } + .player-selection-editor-row { grid-template-columns:1fr 1fr; } +}