From 484c2363c905101aa5db7c9192ee5d9ec6ce5498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 20 Aug 2026 12:56:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D1=8F=D0=B3?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D0=B8=20drap-and-drop=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20=D1=81=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._player_panel_reorder_and_settings_tabs.py | 51 ++++++++ ui_builder/static/app.js | 109 ++++++++++++++---- ui_builder/static/styles.css | 70 +++++++++++ 3 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 tests/test_build82_player_panel_reorder_and_settings_tabs.py diff --git a/tests/test_build82_player_panel_reorder_and_settings_tabs.py b/tests/test_build82_player_panel_reorder_and_settings_tabs.py new file mode 100644 index 0000000..67d6e5a --- /dev/null +++ b/tests/test_build82_player_panel_reorder_and_settings_tabs.py @@ -0,0 +1,51 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8") + + +def test_player_selection_slots_can_be_reordered_inside_panel(): + for token in ( + "hockeyPlayerSelectionDrag", + "async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot)", + 'application/x-ui-builder-hockey-player-selection-slot', + 'event.dataTransfer.dropEffect = "move"', + "await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot)", + ): + assert token in APP_JS + + +def test_swap_updates_both_slots_in_one_mapping_refresh(): + start = APP_JS.index("async function hockeySwapPlayerSelectionSlots") + end = APP_JS.index("async function hockeyClearPlayerSelectionSlot", start) + block = APP_JS[start:end] + assert "...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer)" in block + assert "...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer)" in block + assert "await hockeySetMatchValues(patch, { refreshMapping: true });" in block + + +def test_filled_player_slots_expose_drag_handle_and_move_cursor(): + assert 'draggable="true" title="Перетащите на другой слот' in APP_JS + assert "hockey-player-select-drag" in APP_JS + assert '.hockey-player-select-slot[draggable="true"] { cursor:grab; }' in STYLES + assert ".hockey-player-select-slot.is-dragging" in STYLES + + +def test_bottom_panel_settings_are_split_into_two_tabs(): + for token in ( + 'data-prematch-editor-tab="buttons"', + 'data-prematch-editor-tab="players"', + 'Кнопки', + 'Блоки игроков ${draftPlayerPanels.length}', + 'activeEditorSection = section;', + ): + assert token in APP_JS + assert ".prematch-editor-tabs" in STYLES + assert ".prematch-editor-tab.active" in STYLES + + +def test_player_panel_editor_only_renders_in_players_tab(): + assert 'activeEditorSection === "buttons" ? `' in APP_JS + assert 'data-prematch-editor-section="players"' in APP_JS + assert 'player-selection-editor prematch-editor-section' in APP_JS diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 73a3301..d534fc0 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -122,6 +122,7 @@ hockeyTimerDirty: false, hockeyTimerRevision: 0, hockeyDragPlayer: null, + hockeyPlayerSelectionDrag: null, hockeyDragInfraction: null, hockeyDragPreset: null, editorAuthTimer: null, @@ -7648,6 +7649,7 @@ function openHockeyPrematchButtonsEditor() { let dragButtonId = ""; let dragSelectorId = ""; let activeEditorGroupId = String(state.quickPanelActiveTab || ""); + let activeEditorSection = "buttons"; if (activeEditorGroupId === "__ungrouped") activeEditorGroupId = ""; if (activeEditorGroupId && !draftGroups.some((group) => group.id === activeEditorGroupId)) activeEditorGroupId = draftGroups[0]?.id || ""; const collapsedEditorGroups = new Set(); @@ -7755,22 +7757,31 @@ function openHockeyPrematchButtonsEditor() { draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels); showSettingsModal("Нижняя панель · кнопки", `
-
-

Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.

-
- - - -
-
-
- ${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("") : `
Блоков игроков пока нет
`}
-
+ + ${activeEditorSection === "buttons" ? ` +
+
+

Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.

+
+ + + +
+
+
+ ${renderGroup(null, -1)} + ${draftGroups.map((group, index) => renderGroup(group, index)).join("")} +
+
+ ` : ` +
+
DRAG & DROP ИГРОКОВБлоки выбора игроковСоздание сравнений, «3 звезды» и одиночных выборов. ID автоматически формирует Mapping: player_select.<ID>.player1_id и т. д.
+
${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `
Блоков игроков пока нет
`}
+
+ `}
@@ -7778,6 +7789,13 @@ function openHockeyPrematchButtonsEditor() {
`, { className: "modal-prematch-full" }); + document.querySelectorAll("[data-prematch-editor-tab]").forEach((button) => button.addEventListener("click", () => { + const section = String(button.dataset.prematchEditorTab || "buttons"); + if (!["buttons", "players"].includes(section) || section === activeEditorSection) return; + activeEditorSection = section; + renderEditor(); + })); + document.querySelector("[data-prematch-add-group]")?.addEventListener("click", () => { if (draftGroups.length >= 24) return toast("Можно создать до 24 вкладок", true); const newGroup = { id: `group_${draftGroups.length + 1}`, label: `Вкладка ${draftGroups.length + 1}`, sort_order: draftGroups.length * 10, enabled: true }; @@ -8363,7 +8381,7 @@ 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 || ""; + const direct = player.teamId || player.team_id || 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}`) || {}; @@ -8456,6 +8474,27 @@ async function hockeySetPlayerSelectionSlot(panel, slot, player) { return true; } +async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot) { + const sourceSlot = clamp(Number(fromSlot) || 1, 1, panel.slots); + const targetSlot = clamp(Number(toSlot) || 1, 1, panel.slots); + if (sourceSlot === targetSlot) return false; + const source = hockeyPlayerSelectionSlot(panel, sourceSlot); + if (!source.id && !source.dbId) return false; + const target = hockeyPlayerSelectionSlot(panel, targetSlot); + const sourcePlayer = { externalId: source.id, dbId: source.dbId, teamId: source.teamId, side: source.side, number: source.number, name: source.name }; + const targetPlayer = (target.id || target.dbId) + ? { externalId: target.id, dbId: target.dbId, teamId: target.teamId, side: target.side, number: target.number, name: target.name } + : null; + const patch = { + ...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer), + ...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer), + }; + await hockeySetMatchValues(patch, { refreshMapping: true }); + if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(sourcePlayer, source.teamId); + toast(targetPlayer ? `${panel.label}: игроки ${sourceSlot} и ${targetSlot} поменяны местами` : `${panel.label}: игрок перемещён ${sourceSlot} → ${targetSlot}`); + return true; +} + async function hockeyClearPlayerSelectionSlot(panel, slot) { await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true }); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); @@ -8569,10 +8608,10 @@ function renderHockeyPlayerSelectionWindows() { 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 `
+ return `
${number}
${escapeHtml(title)}${escapeHtml(ids)}
- ${filledSlot ? `` : `DROP`} + ${filledSlot ? `` : `DROP`}
`; }).join("")}
@@ -8584,7 +8623,29 @@ function renderHockeyPlayerSelectionWindows() { renderRuntime(); }); node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => { + slotNode.addEventListener("dragstart", (event) => { + const slot = Number(slotNode.dataset.playerPanelSlot || 1); + const current = hockeyPlayerSelectionSlot(panel, slot); + if (!current.id && !current.dbId) { event.preventDefault(); return; } + state.hockeyPlayerSelectionDrag = { panelId: panel.id, slot }; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-ui-builder-hockey-player-selection-slot", JSON.stringify(state.hockeyPlayerSelectionDrag)); + event.dataTransfer.setData("text/plain", current.name || current.id || `player-${slot}`); + slotNode.classList.add("is-dragging"); + }); + slotNode.addEventListener("dragend", () => { + state.hockeyPlayerSelectionDrag = null; + slotNode.classList.remove("is-dragging"); + node.querySelectorAll(".hockey-player-select-slot.is-over").forEach((item) => item.classList.remove("is-over")); + }); slotNode.addEventListener("dragover", (event) => { + const internal = state.hockeyPlayerSelectionDrag; + if (internal && internal.panelId === panel.id) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + slotNode.classList.add("is-over"); + return; + } const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); if (!player) return; event.preventDefault(); @@ -8595,9 +8656,17 @@ function renderHockeyPlayerSelectionWindows() { slotNode.addEventListener("drop", async (event) => { event.preventDefault(); slotNode.classList.remove("is-over"); + const targetSlot = Number(slotNode.dataset.playerPanelSlot || 1); + const internal = state.hockeyPlayerSelectionDrag; + if (internal && internal.panelId === panel.id) { + const sourceSlot = Number(internal.slot || 1); + state.hockeyPlayerSelectionDrag = null; + await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot); + return; + } const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); if (!player) return; - await hockeySetPlayerSelectionSlot(panel, Number(slotNode.dataset.playerPanelSlot || 1), player); + await hockeySetPlayerSelectionSlot(panel, targetSlot, player); }); }); node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => { diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css index adb7b5c..d644e94 100644 --- a/ui_builder/static/styles.css +++ b/ui_builder/static/styles.css @@ -7363,3 +7363,73 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import .hockey-runtime-side-stack { flex-basis:290px; width:290px; } .player-selection-editor-row { grid-template-columns:1fr 1fr; } } + +/* Build82 — quick-panel settings tabs + reorderable player slots. */ +.prematch-editor-tabs { + display:flex; + align-items:center; + gap:6px; + padding:4px; + border:1px solid #2a4058; + border-radius:11px; + background:#0b1725; +} +.prematch-editor-tab { + min-height:34px; + display:flex; + align-items:center; + justify-content:center; + gap:7px; + padding:0 14px; + color:#7890a8; + border:1px solid transparent; + border-radius:8px; + background:transparent; + font-size:10px; + font-weight:950; + cursor:pointer; +} +.prematch-editor-tab:hover { color:#c8d7e5; background:#102238; } +.prematch-editor-tab.active { color:#eaf5ff; border-color:#365a72; background:#14304a; box-shadow:inset 0 -2px 0 #48dfbd; } +.prematch-editor-tab span { + min-width:20px; + height:18px; + display:grid; + place-items:center; + padding:0 5px; + border-radius:9px; + color:#a9c1d5; + background:#203b53; + font:900 8px "Roboto Mono",Consolas,monospace; +} +.prematch-editor-section { min-height:0; } +.player-selection-editor.prematch-editor-section { + min-height:0; + margin-top:0; + padding-top:0; + border-top:0; + display:grid; + grid-template-rows:auto minmax(0,1fr); +} +.player-selection-editor.prematch-editor-section .player-selection-editor-list { + min-height:0; + overflow:auto; + padding-right:4px; + overscroll-behavior:contain; +} + +.hockey-player-select-slot { grid-template-columns:27px minmax(0,1fr) auto auto; } +.hockey-player-select-slot[draggable="true"] { cursor:grab; } +.hockey-player-select-slot[draggable="true"]:active { cursor:grabbing; } +.hockey-player-select-slot.is-dragging { opacity:.48; border-color:#48dfbd; } +.hockey-player-select-drag { + width:16px; + display:grid; + place-items:center; + color:#58748b; + font-size:10px; + font-weight:950; + letter-spacing:-2px; + user-select:none; +} +.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }