выделение удалений, и заготовки
This commit is contained in:
@@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select
|
|||||||
from .auth_bridge import HockeyUser
|
from .auth_bridge import HockeyUser
|
||||||
from .database import HockeyDatabase
|
from .database import HockeyDatabase
|
||||||
from .mapping_context import MappingDataService
|
from .mapping_context import MappingDataService
|
||||||
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField)
|
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)
|
||||||
|
|
||||||
|
|
||||||
AGENT_PROTOCOL_VERSION = 1
|
AGENT_PROTOCOL_VERSION = 1
|
||||||
@@ -2010,6 +2010,394 @@ class VmixAgentHub:
|
|||||||
})
|
})
|
||||||
return {"devices": items}
|
return {"devices": items}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]:
|
||||||
|
if device is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(device.project_inventory_json or "{}")
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None:
|
||||||
|
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
|
||||||
|
key = str(key or "").strip()
|
||||||
|
number = str(number or "").strip()
|
||||||
|
title = str(title or "").strip()
|
||||||
|
if key:
|
||||||
|
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
if number:
|
||||||
|
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
if title:
|
||||||
|
matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice:
|
||||||
|
requested = str(requested_device_id or "").strip()
|
||||||
|
if requested:
|
||||||
|
requested = self.normalise_device_id(requested)
|
||||||
|
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
|
||||||
|
return device
|
||||||
|
rows = list(session.scalars(
|
||||||
|
select(VmixDevice)
|
||||||
|
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
|
||||||
|
.order_by(desc(VmixDevice.last_seen_at))
|
||||||
|
))
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
|
||||||
|
live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None)
|
||||||
|
return live or rows[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
fields = json.loads(row.field_values_json or "{}")
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
fields = {}
|
||||||
|
except Exception:
|
||||||
|
fields = {}
|
||||||
|
clone_key = str(row.clone_input_key or "")
|
||||||
|
clone_number = str(row.clone_input_number or "")
|
||||||
|
clone_title = str(row.clone_input_title or "")
|
||||||
|
if inventory:
|
||||||
|
match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title)
|
||||||
|
if match is not None:
|
||||||
|
clone_key = str(match.get("key") or clone_key)
|
||||||
|
clone_number = str(match.get("number") or clone_number)
|
||||||
|
clone_title = str(match.get("title") or clone_title)
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"game_id": row.game_external_id,
|
||||||
|
"device_id": row.device_uuid,
|
||||||
|
"source_kind": row.source_kind,
|
||||||
|
"source_ref": row.source_ref,
|
||||||
|
"source_input": {
|
||||||
|
"key": row.source_input_key,
|
||||||
|
"number": row.source_input_number,
|
||||||
|
"title": row.source_input_title,
|
||||||
|
},
|
||||||
|
"clone_input": {"key": clone_key, "number": clone_number, "title": clone_title},
|
||||||
|
"field_values": fields,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else "",
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
|
||||||
|
with self.database.session() as session:
|
||||||
|
device = self._prepared_device_for_user(session, user, device_id)
|
||||||
|
resolved_game = str(game_id or device.current_match_external_id or "").strip()
|
||||||
|
query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id)
|
||||||
|
if resolved_game:
|
||||||
|
query = query.where(VmixPreparedTitle.game_external_id == resolved_game)
|
||||||
|
if device.device_uuid:
|
||||||
|
query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid)
|
||||||
|
rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id))))
|
||||||
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
|
# Refresh clone key/title opportunistically after Agent inventory catches up.
|
||||||
|
for row in rows:
|
||||||
|
found = self._prepared_find_input(
|
||||||
|
inventory,
|
||||||
|
key=str(row.clone_input_key or ""),
|
||||||
|
number=str(row.clone_input_number or ""),
|
||||||
|
title=str(row.clone_input_title or ""),
|
||||||
|
)
|
||||||
|
if found is not None:
|
||||||
|
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
|
||||||
|
row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
|
||||||
|
row.clone_input_title = str(found.get("title") or row.clone_input_title or "")[:300]
|
||||||
|
items = [self._prepared_title_payload(row, inventory) for row in rows]
|
||||||
|
return {
|
||||||
|
"device_id": device.device_uuid,
|
||||||
|
"game_id": resolved_game,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]:
|
||||||
|
profile = self._active_mapping_profile_for_device(session, device)
|
||||||
|
if profile is None:
|
||||||
|
return None, {}, {}
|
||||||
|
assignment = session.scalar(
|
||||||
|
select(VmixAssignment)
|
||||||
|
.where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True)))
|
||||||
|
.order_by(desc(VmixAssignment.id))
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"game_id": str(device.current_match_external_id or ""),
|
||||||
|
"device_id": device.device_uuid,
|
||||||
|
"assignment_id": str(device.current_assignment_key or ""),
|
||||||
|
"tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "",
|
||||||
|
}
|
||||||
|
preference = session.get(UserPreference, user.id)
|
||||||
|
if preference is not None:
|
||||||
|
language = str(preference.vmix_language or preference.display_language or "").strip().lower()
|
||||||
|
if language in {"ru", "en"}:
|
||||||
|
context["ui_language"] = language
|
||||||
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
|
return profile, context, inventory
|
||||||
|
|
||||||
|
async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]:
|
||||||
|
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
|
||||||
|
with self.database.session() as session:
|
||||||
|
device = self._prepared_device_for_user(session, user, device_id)
|
||||||
|
profile, _context, inventory = self._prepared_mapping_context(session, device, user)
|
||||||
|
if profile is None:
|
||||||
|
return {"device_id": device.device_uuid, "profile_id": 0, "items": []}
|
||||||
|
fields = list(session.scalars(
|
||||||
|
select(VmixMappingField)
|
||||||
|
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
|
||||||
|
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
|
||||||
|
))
|
||||||
|
if panel_id:
|
||||||
|
prefix = f"player_select.{panel_id}."
|
||||||
|
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
for field in fields:
|
||||||
|
identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
|
||||||
|
if not identity:
|
||||||
|
continue
|
||||||
|
item = grouped.setdefault(identity, {
|
||||||
|
"key": str(field.vmix_input_key or ""),
|
||||||
|
"number": str(field.vmix_input_number or ""),
|
||||||
|
"title": str(field.vmix_input_title or ""),
|
||||||
|
"mapped_fields": 0,
|
||||||
|
})
|
||||||
|
item["mapped_fields"] += 1
|
||||||
|
for item in grouped.values():
|
||||||
|
found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"])
|
||||||
|
if found is not None:
|
||||||
|
item.update({
|
||||||
|
"key": str(found.get("key") or item["key"]),
|
||||||
|
"number": str(found.get("number") or item["number"]),
|
||||||
|
"title": str(found.get("title") or item["title"]),
|
||||||
|
"type": str(found.get("type") or ""),
|
||||||
|
})
|
||||||
|
items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold()))
|
||||||
|
return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items}
|
||||||
|
|
||||||
|
async def prepared_mapping_snapshot(
|
||||||
|
self,
|
||||||
|
user: HockeyUser,
|
||||||
|
*,
|
||||||
|
device_id: str = "",
|
||||||
|
input_key: str = "",
|
||||||
|
input_number: str = "",
|
||||||
|
input_title: str = "",
|
||||||
|
panel_id: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
|
||||||
|
with self.database.session() as session:
|
||||||
|
device = self._prepared_device_for_user(session, user, device_id)
|
||||||
|
profile, context, inventory = self._prepared_mapping_context(session, device, user)
|
||||||
|
if profile is None:
|
||||||
|
return {"device_id": device.device_uuid, "profile_id": 0, "fields": []}
|
||||||
|
source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title)
|
||||||
|
source_key = str(source.get("key") or input_key or "") if source else str(input_key or "")
|
||||||
|
source_number = str(source.get("number") or input_number or "") if source else str(input_number or "")
|
||||||
|
source_title = str(source.get("title") or input_title or "") if source else str(input_title or "")
|
||||||
|
fields = list(session.scalars(
|
||||||
|
select(VmixMappingField)
|
||||||
|
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
|
||||||
|
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
|
||||||
|
))
|
||||||
|
if panel_id:
|
||||||
|
prefix = f"player_select.{panel_id}."
|
||||||
|
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
|
||||||
|
def target_matches(field: VmixMappingField) -> bool:
|
||||||
|
if source_key and str(field.vmix_input_key or "") == source_key:
|
||||||
|
return True
|
||||||
|
if source_title and str(field.vmix_input_title or "") == source_title:
|
||||||
|
return True
|
||||||
|
if source_number and str(field.vmix_input_number or "") == source_number:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
fields = [field for field in fields if target_matches(field)]
|
||||||
|
catalog = self.mapping_data.data_catalog(user, context)
|
||||||
|
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
|
||||||
|
result_fields = []
|
||||||
|
for field in fields:
|
||||||
|
item = by_key.get(str(field.data_key or ""))
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
result_fields.append({
|
||||||
|
"name": str(field.vmix_field or ""),
|
||||||
|
"type": str(field.field_type or "text"),
|
||||||
|
"data_key": str(field.data_key or ""),
|
||||||
|
"value": "" if item.get("value") is None else str(item.get("value")),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"device_id": device.device_uuid,
|
||||||
|
"game_id": str(device.current_match_external_id or ""),
|
||||||
|
"profile_id": profile.id,
|
||||||
|
"input": {"key": source_key, "number": source_number, "title": source_title},
|
||||||
|
"fields": result_fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||||
|
requested_device = str(getattr(payload, "device_id", "") or "").strip()
|
||||||
|
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
||||||
|
with self.database.session() as session:
|
||||||
|
device = self._prepared_device_for_user(session, user, requested_device)
|
||||||
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
|
source = self._prepared_find_input(
|
||||||
|
inventory,
|
||||||
|
key=str(getattr(payload, "source_input_key", "") or ""),
|
||||||
|
number=str(getattr(payload, "source_input_number", "") or ""),
|
||||||
|
title=str(getattr(payload, "source_input_title", "") or ""),
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте")
|
||||||
|
source_key = str(source.get("key") or "")
|
||||||
|
source_number = str(source.get("number") or "")
|
||||||
|
source_title = str(source.get("title") or "")
|
||||||
|
source_ref = source_key or source_number or source_title
|
||||||
|
before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)]
|
||||||
|
before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")}
|
||||||
|
before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")}
|
||||||
|
numeric_numbers = [int(value) for value in before_numbers if value.isdigit()]
|
||||||
|
expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1)
|
||||||
|
|
||||||
|
create_result = await self.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
[{"Function": "CreateVirtualInput", "Input": source_ref}],
|
||||||
|
device_id=device.device_uuid,
|
||||||
|
session_token=session_token,
|
||||||
|
timeout=6.0,
|
||||||
|
)
|
||||||
|
target_device_id = str(create_result.get("device_id") or device.device_uuid)
|
||||||
|
match_id = str(create_result.get("match_id") or "")
|
||||||
|
|
||||||
|
clone: dict[str, Any] | None = None
|
||||||
|
for _ in range(12):
|
||||||
|
await asyncio.sleep(0.18)
|
||||||
|
with self.database.session() as session:
|
||||||
|
refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
|
||||||
|
current_inventory = self._prepared_inventory_from_row(refreshed)
|
||||||
|
current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)]
|
||||||
|
candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)]
|
||||||
|
if candidates:
|
||||||
|
clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1]
|
||||||
|
break
|
||||||
|
clone_key = str(clone.get("key") or "") if clone else ""
|
||||||
|
clone_number = str(clone.get("number") or expected_number) if clone else expected_number
|
||||||
|
clone_title = str(clone.get("title") or source_title) if clone else source_title
|
||||||
|
clone_ref = clone_key or clone_number or clone_title
|
||||||
|
|
||||||
|
raw_values = getattr(payload, "field_values", {})
|
||||||
|
raw_values = raw_values if isinstance(raw_values, dict) else {}
|
||||||
|
source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
|
||||||
|
allowed = {str(field.get("name") or ""): field for field in source_fields if isinstance(field, dict) and str(field.get("name") or "")}
|
||||||
|
commands: list[dict[str, Any]] = []
|
||||||
|
saved_fields: dict[str, dict[str, str]] = {}
|
||||||
|
for name, field in list(allowed.items())[:240]:
|
||||||
|
if name not in raw_values:
|
||||||
|
continue
|
||||||
|
raw_entry = raw_values.get(name)
|
||||||
|
if isinstance(raw_entry, dict):
|
||||||
|
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
|
||||||
|
requested_type = str(raw_entry.get("type") or "")
|
||||||
|
else:
|
||||||
|
value = "" if raw_entry is None else str(raw_entry)
|
||||||
|
requested_type = ""
|
||||||
|
field_type = str(field.get("type") or requested_type or "text").lower()
|
||||||
|
if field_type not in {"text", "image", "source", "color", "colour"}:
|
||||||
|
field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text"
|
||||||
|
saved_fields[name] = {"value": value[:8000], "type": field_type}
|
||||||
|
# Empty text is meaningful (clear inherited content). Empty image/color is
|
||||||
|
# treated as "leave inherited value" because vMix rejects some empty sources.
|
||||||
|
if not value and field_type in {"image", "source", "color", "colour"}:
|
||||||
|
continue
|
||||||
|
commands.append({
|
||||||
|
"Function": _mapping_value_function(field_type),
|
||||||
|
"Input": clone_ref,
|
||||||
|
"SelectedName": name,
|
||||||
|
"Value": value,
|
||||||
|
})
|
||||||
|
set_result = None
|
||||||
|
if commands:
|
||||||
|
set_result = await self.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
commands,
|
||||||
|
device_id=target_device_id,
|
||||||
|
session_token=session_token,
|
||||||
|
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
|
||||||
|
)
|
||||||
|
|
||||||
|
title_name = str(getattr(payload, "name", "") or "").strip()[:200] or f"{source_title or 'Title'} · заготовка"
|
||||||
|
source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
|
||||||
|
source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = VmixPreparedTitle(
|
||||||
|
wfl_user_id=user.id,
|
||||||
|
game_external_id=match_id,
|
||||||
|
device_uuid=target_device_id,
|
||||||
|
name=title_name,
|
||||||
|
source_kind=source_kind,
|
||||||
|
source_ref=source_ref_name,
|
||||||
|
source_input_key=source_key,
|
||||||
|
source_input_number=source_number,
|
||||||
|
source_input_title=source_title,
|
||||||
|
clone_input_key=clone_key,
|
||||||
|
clone_input_number=clone_number,
|
||||||
|
clone_input_title=clone_title,
|
||||||
|
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
created_by=user.id,
|
||||||
|
created_at=_utcnow(),
|
||||||
|
updated_at=_utcnow(),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
result = self._prepared_title_payload(row)
|
||||||
|
result["ok"] = True
|
||||||
|
result["create_result"] = create_result
|
||||||
|
result["set_result"] = set_result or {"ok": True, "applied": 0}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = session.get(VmixPreparedTitle, int(prepared_id))
|
||||||
|
if row is None or row.wfl_user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Заготовка не найдена")
|
||||||
|
requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
|
||||||
|
device = self._prepared_device_for_user(session, user, requested_device)
|
||||||
|
inventory = self._prepared_inventory_from_row(device)
|
||||||
|
found = self._prepared_find_input(
|
||||||
|
inventory,
|
||||||
|
key=str(row.clone_input_key or ""),
|
||||||
|
number=str(row.clone_input_number or ""),
|
||||||
|
title=str(row.clone_input_title or ""),
|
||||||
|
)
|
||||||
|
input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
|
||||||
|
if not input_ref:
|
||||||
|
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
|
||||||
|
return await self.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
[{"Function": "PreviewInput", "Input": input_ref}],
|
||||||
|
device_id=device.device_uuid,
|
||||||
|
session_token=str(getattr(payload, "session_token", "") or ""),
|
||||||
|
timeout=4.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]:
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = session.get(VmixPreparedTitle, int(prepared_id))
|
||||||
|
if row is None or row.wfl_user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Заготовка не найдена")
|
||||||
|
# Deliberately do not RemoveInput in vMix: deleting a web list entry must
|
||||||
|
# never unexpectedly destroy a studio graphic that may already be used.
|
||||||
|
session.delete(row)
|
||||||
|
return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False}
|
||||||
|
|
||||||
async def latest_vmix_inventory(self) -> dict[str, Any]:
|
async def latest_vmix_inventory(self) -> dict[str, Any]:
|
||||||
"""Return the most recent connected vMix project inventory for editor helpers.
|
"""Return the most recent connected vMix project inventory for editor helpers.
|
||||||
|
|
||||||
@@ -3183,6 +3571,23 @@ class MappingSqlPreviewPayload(BaseModel):
|
|||||||
context: dict[str, Any] = Field(default_factory=dict)
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreparedTitleCreatePayload(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=200)
|
||||||
|
device_id: str = Field(default="", max_length=128)
|
||||||
|
session_token: str = Field(default="", max_length=128)
|
||||||
|
source_input_key: str = Field(default="", max_length=128)
|
||||||
|
source_input_number: str = Field(default="", max_length=32)
|
||||||
|
source_input_title: str = Field(default="", max_length=300)
|
||||||
|
source_kind: str = Field(default="manual", max_length=32)
|
||||||
|
source_ref: str = Field(default="", max_length=128)
|
||||||
|
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreparedTitlePreviewPayload(BaseModel):
|
||||||
|
device_id: str = Field(default="", max_length=128)
|
||||||
|
session_token: str = Field(default="", max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class AgentHelloPayload(BaseModel):
|
class AgentHelloPayload(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
type: str = "hello"
|
type: str = "hello"
|
||||||
@@ -3215,6 +3620,57 @@ def create_hockey_agent_router(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return await hub.vmix_inventory_for_user(user, device_id=device_id)
|
return await hub.vmix_inventory_for_user(user, device_id=device_id)
|
||||||
|
|
||||||
|
@router.get("/api/hockey/prepared-titles")
|
||||||
|
async def prepared_titles_list(
|
||||||
|
device_id: str = Query("", max_length=128),
|
||||||
|
game_id: str = Query("", max_length=64),
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id)
|
||||||
|
|
||||||
|
@router.get("/api/hockey/prepared-titles/mapping-sources")
|
||||||
|
async def prepared_titles_mapping_sources(
|
||||||
|
device_id: str = Query("", max_length=128),
|
||||||
|
panel_id: str = Query("", max_length=64),
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id)
|
||||||
|
|
||||||
|
@router.get("/api/hockey/prepared-titles/mapping-snapshot")
|
||||||
|
async def prepared_titles_mapping_snapshot(
|
||||||
|
device_id: str = Query("", max_length=128),
|
||||||
|
input_key: str = Query("", max_length=128),
|
||||||
|
input_number: str = Query("", max_length=32),
|
||||||
|
input_title: str = Query("", max_length=300),
|
||||||
|
panel_id: str = Query("", max_length=64),
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.prepared_mapping_snapshot(
|
||||||
|
user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/hockey/prepared-titles")
|
||||||
|
async def prepared_title_create(
|
||||||
|
payload: PreparedTitleCreatePayload,
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.create_prepared_title(user, payload)
|
||||||
|
|
||||||
|
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
|
||||||
|
async def prepared_title_preview(
|
||||||
|
prepared_id: int,
|
||||||
|
payload: PreparedTitlePreviewPayload,
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.preview_prepared_title(prepared_id, user, payload)
|
||||||
|
|
||||||
|
@router.delete("/api/hockey/prepared-titles/{prepared_id}")
|
||||||
|
async def prepared_title_delete(
|
||||||
|
prepared_id: int,
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await hub.delete_prepared_title(prepared_id, user)
|
||||||
|
|
||||||
@router.post("/api/hockey/agents/devices/{device_id}/pair")
|
@router.post("/api/hockey/agents/devices/{device_id}/pair")
|
||||||
async def pair_device(
|
async def pair_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
|
|||||||
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
|
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
|
||||||
|
|
||||||
|
{"key": "active_home_penalty_id", "label": "Активное удаление левой команды — ID", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_home_penalty_player_id", "label": "Активное удаление левой команды — игрок external ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_home_penalty_player_db_id", "label": "Активное удаление левой команды — игрок DB ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_home_penalty_team_penalty", "label": "Активное удаление левой команды — командное (1/0)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
|
||||||
|
{"key": "active_away_penalty_id", "label": "Активное удаление правой команды — ID", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_away_penalty_player_id", "label": "Активное удаление правой команды — игрок external ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_away_penalty_player_db_id", "label": "Активное удаление правой команды — игрок DB ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
|
||||||
|
{"key": "active_away_penalty_team_penalty", "label": "Активное удаление правой команды — командное (1/0)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
|
||||||
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
||||||
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
||||||
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
|
||||||
@@ -95,9 +104,9 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# These values describe the live state of the match itself, not a private UI
|
# Side-specific penalty selections and active-penalty mirrors are shared inside
|
||||||
# selection of one account. They must be visible to Mapping regardless of which
|
# one match. This lets the operator select a HOME/AWAY penalty while an admin
|
||||||
# operator/admin account opened the Mapping editor.
|
# watches Mapping from another account without getting a private copy.
|
||||||
MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
|
MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
|
||||||
"selected_home_penalty_id",
|
"selected_home_penalty_id",
|
||||||
"selected_home_penalty_player_id",
|
"selected_home_penalty_player_id",
|
||||||
@@ -107,6 +116,14 @@ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
|
|||||||
"selected_away_penalty_player_id",
|
"selected_away_penalty_player_id",
|
||||||
"selected_away_penalty_player_db_id",
|
"selected_away_penalty_player_db_id",
|
||||||
"selected_away_penalty_team_penalty",
|
"selected_away_penalty_team_penalty",
|
||||||
|
"active_home_penalty_id",
|
||||||
|
"active_home_penalty_player_id",
|
||||||
|
"active_home_penalty_player_db_id",
|
||||||
|
"active_home_penalty_team_penalty",
|
||||||
|
"active_away_penalty_id",
|
||||||
|
"active_away_penalty_player_id",
|
||||||
|
"active_away_penalty_player_db_id",
|
||||||
|
"active_away_penalty_team_penalty",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -981,6 +981,39 @@ class VmixAssignment(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VmixPreparedTitle(Base):
|
||||||
|
"""A match-scoped vMix title clone prepared by an operator.
|
||||||
|
|
||||||
|
The actual graphic lives in vMix. This row only remembers which source was
|
||||||
|
cloned, where the clone ended up and the field snapshot that was applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "hockey_vmix_prepared_titles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
wfl_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
game_external_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
||||||
|
device_uuid: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
|
||||||
|
source_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||||
|
source_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||||
|
source_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||||
|
source_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
|
||||||
|
clone_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||||
|
clone_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||||
|
clone_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
|
||||||
|
field_values_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
created_by: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_hockey_prepared_titles_user_game", "wfl_user_id", "game_external_id", "created_at"),
|
||||||
|
Index("ix_hockey_prepared_titles_device", "device_uuid", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VmixMappingProfile(Base):
|
class VmixMappingProfile(Base):
|
||||||
__tablename__ = "hockey_vmix_mapping_profiles"
|
__tablename__ = "hockey_vmix_mapping_profiles"
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,36 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
|
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_side_penalty_mapping_context_is_derived_from_active_penalties():
|
def test_side_penalty_mapping_context_is_derived_from_active_penalties_without_overwriting_manual_selection():
|
||||||
start = APP.index("async function hockeySyncPenaltySideMappingContext")
|
start = APP.index("async function hockeySyncPenaltySideMappingContext")
|
||||||
end = APP.index("function penaltyTargetAssignmentKey", start)
|
end = APP.index("function penaltyTargetAssignmentKey", start)
|
||||||
snippet = APP[start:end]
|
snippet = APP[start:end]
|
||||||
assert 'sortedPenaltyEntries("home")[0]' in snippet
|
assert 'sortedPenaltyEntries("home")[0]' in snippet
|
||||||
assert 'sortedPenaltyEntries("away")[0]' in snippet
|
assert 'sortedPenaltyEntries("away")[0]' in snippet
|
||||||
|
for key in (
|
||||||
|
"active_home_penalty_id",
|
||||||
|
"active_home_penalty_player_id",
|
||||||
|
"active_home_penalty_player_db_id",
|
||||||
|
"active_home_penalty_team_penalty",
|
||||||
|
"active_away_penalty_id",
|
||||||
|
"active_away_penalty_player_id",
|
||||||
|
"active_away_penalty_player_db_id",
|
||||||
|
"active_away_penalty_team_penalty",
|
||||||
|
):
|
||||||
|
assert f"{key}:" in snippet
|
||||||
|
# BUILD84: selected_* is an explicit operator choice, so the automatic mirror
|
||||||
|
# must never replace it with the first active penalty.
|
||||||
|
assert "selected_home_penalty_id:" not in snippet
|
||||||
|
assert "selected_away_penalty_id:" not in snippet
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_side_penalty_selection_keeps_separate_mapping_identifiers():
|
||||||
|
start = APP.index("async function hockeySelectPenaltyForPreview")
|
||||||
|
end = APP.index("function hockeyPenaltyCard", start)
|
||||||
|
snippet = APP[start:end]
|
||||||
for key in (
|
for key in (
|
||||||
"selected_home_penalty_id",
|
"selected_home_penalty_id",
|
||||||
"selected_home_penalty_player_id",
|
"selected_home_penalty_player_id",
|
||||||
@@ -22,25 +43,5 @@ def test_side_penalty_mapping_context_is_derived_from_active_penalties():
|
|||||||
"selected_away_penalty_team_penalty",
|
"selected_away_penalty_team_penalty",
|
||||||
):
|
):
|
||||||
assert f"{key}:" in snippet
|
assert f"{key}:" in snippet
|
||||||
assert 'fetch("/api/hockey/context/batch"' in snippet
|
assert key in MAPPING
|
||||||
|
assert "const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);" in snippet
|
||||||
|
|
||||||
def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish():
|
|
||||||
assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP
|
|
||||||
assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_saved_penalty_player_keeps_external_and_database_ids():
|
|
||||||
start = APP.index("function hockeyCompactPlayer")
|
|
||||||
end = APP.index("function hockeyCompactInfraction", start)
|
|
||||||
snippet = APP[start:end]
|
|
||||||
assert "const externalId" in snippet
|
|
||||||
assert "const dbId" in snippet
|
|
||||||
assert "externalId," in snippet
|
|
||||||
assert "dbId," in snippet
|
|
||||||
|
|
||||||
|
|
||||||
def test_mapping_admin_refreshes_live_after_runtime_context_update():
|
|
||||||
assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN
|
|
||||||
assert "await loadMappingCatalog()" in ADMIN
|
|
||||||
assert "renderMapping();" in ADMIN
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hockey_data.agent_bridge import VmixAgentHub
|
||||||
|
from hockey_data.models import VmixPreparedTitle
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
|
||||||
|
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
|
||||||
|
STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_and_away_penalties_have_independent_manual_selection():
|
||||||
|
assert 'selectedPreviewEventIds: { home: "", away: "" }' in APP
|
||||||
|
assert 'board.selectedPreviewEventIds[side] = event.id' in APP
|
||||||
|
assert 'card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));' in APP
|
||||||
|
# The old global selectedEventId must not drive the visual selection class.
|
||||||
|
penalty_start = APP.index("function hockeyPenaltyCard")
|
||||||
|
penalty_end = APP.index("function createHockeyDraftFromDrop", penalty_start)
|
||||||
|
penalty_card = APP[penalty_start:penalty_end]
|
||||||
|
assert 'card.classList.toggle("is-selected", board.selectedEventId === event.id)' not in penalty_card
|
||||||
|
assert '.hpd-team-penalty-card.team-home.is-preview-selected' in STYLES
|
||||||
|
assert '.hpd-team-penalty-card.team-away.is-preview-selected' in STYLES
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_penalty_context_is_manual_and_active_penalty_context_is_separate():
|
||||||
|
select_start = APP.index("async function hockeySelectPenaltyForPreview")
|
||||||
|
select_end = APP.index("function hockeyPenaltyCard", select_start)
|
||||||
|
select_block = APP[select_start:select_end]
|
||||||
|
for key in (
|
||||||
|
"selected_home_penalty_id",
|
||||||
|
"selected_home_penalty_player_id",
|
||||||
|
"selected_home_penalty_player_db_id",
|
||||||
|
"selected_home_penalty_team_penalty",
|
||||||
|
"selected_away_penalty_id",
|
||||||
|
"selected_away_penalty_player_id",
|
||||||
|
"selected_away_penalty_player_db_id",
|
||||||
|
"selected_away_penalty_team_penalty",
|
||||||
|
):
|
||||||
|
assert key in select_block
|
||||||
|
assert key in MAPPING
|
||||||
|
sync_start = APP.index("async function hockeySyncPenaltySideMappingContext")
|
||||||
|
sync_end = APP.index("function penaltyTargetAssignmentKey", sync_start)
|
||||||
|
sync_block = APP[sync_start:sync_end]
|
||||||
|
assert "active_home_penalty_id:" in sync_block
|
||||||
|
assert "active_away_penalty_id:" in sync_block
|
||||||
|
assert "selected_home_penalty_id:" not in sync_block
|
||||||
|
assert "selected_away_penalty_id:" not in sync_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_selection_values_survive_reload_using_server_state_and_browser_backup():
|
||||||
|
for token in (
|
||||||
|
"function hockeyPlayerSelectionStorageKey",
|
||||||
|
"function hockeyBackupPlayerSelectionValues",
|
||||||
|
"function hockeyStoredPlayerSelectionValues",
|
||||||
|
"hockeyBackupPlayerSelectionValues(gameId, payload?.values || {})",
|
||||||
|
'if (!control || String(control.game_id || "") !== String(gameId)) return false;',
|
||||||
|
"const hasServerSelectionKeys",
|
||||||
|
):
|
||||||
|
assert token in APP
|
||||||
|
assert "localStorage.setItem(hockeyPlayerSelectionStorageKey(id)" in APP
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_block_editor_is_compact_single_line():
|
||||||
|
assert "/* BUILD84 — compact, single-line player-block editor. */" in STYLES
|
||||||
|
assert "min-height:48px" in STYLES
|
||||||
|
assert "grid-template-columns:24px 66px" in STYLES
|
||||||
|
assert '> Главный</label>' in APP
|
||||||
|
assert '> Свёрнут</label>' in APP
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_titles_workspace_exists_and_clones_to_vmix():
|
||||||
|
assert '{ id: "prepared_titles", label: "Заготовки" }' in APP
|
||||||
|
for token in (
|
||||||
|
"function renderHockeyPreparedTitlesWorkspace()",
|
||||||
|
"function hockeyOpenPreparedFromPlayerPanel(panel)",
|
||||||
|
"data-player-panel-prepared",
|
||||||
|
"/api/hockey/prepared-titles",
|
||||||
|
"Сохранить новым Input",
|
||||||
|
"Подставить из Mapping",
|
||||||
|
"▶ Preview",
|
||||||
|
):
|
||||||
|
assert token in APP
|
||||||
|
assert VmixPreparedTitle.__tablename__ == "hockey_vmix_prepared_titles"
|
||||||
|
assert '{"Function": "CreateVirtualInput", "Input": source_ref}' in BRIDGE
|
||||||
|
assert '{"Function": "PreviewInput", "Input": input_ref}' in BRIDGE
|
||||||
|
assert '@router.post("/api/hockey/prepared-titles")' in BRIDGE
|
||||||
|
assert '@router.get("/api/hockey/prepared-titles/mapping-snapshot")' in BRIDGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_input_resolution_prefers_stable_key_then_number_then_unique_title():
|
||||||
|
inventory = {
|
||||||
|
"inputs": [
|
||||||
|
{"key": "stable-a", "number": "7", "title": "TITLE"},
|
||||||
|
{"key": "stable-b", "number": "9", "title": "OTHER"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert VmixAgentHub._prepared_find_input(inventory, key="stable-b")["number"] == "9"
|
||||||
|
assert VmixAgentHub._prepared_find_input(inventory, number="7")["key"] == "stable-a"
|
||||||
|
assert VmixAgentHub._prepared_find_input(inventory, title="OTHER")["key"] == "stable-b"
|
||||||
|
duplicate = {"inputs": [{"key": "a", "title": "SAME"}, {"key": "b", "title": "SAME"}]}
|
||||||
|
assert VmixAgentHub._prepared_find_input(duplicate, title="SAME") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_title_creation_clones_last_input_and_applies_field_snapshot(tmp_path):
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from sqlalchemy import select
|
||||||
|
from hockey_data.auth_bridge import HockeyUser
|
||||||
|
from hockey_data.models import VmixDevice, VmixPreparedTitle
|
||||||
|
from tests.support import LocalTestDatabase
|
||||||
|
|
||||||
|
database = LocalTestDatabase(tmp_path / "prepared.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
user = HockeyUser(id="prep-user", login="prep", display_name="Prep")
|
||||||
|
source_inventory = {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"key": "source-key",
|
||||||
|
"number": "12",
|
||||||
|
"title": "COMPARE",
|
||||||
|
"type": "GT",
|
||||||
|
"fields": [
|
||||||
|
{"name": "Name.Text", "type": "text", "index": "0"},
|
||||||
|
{"name": "Photo.Source", "type": "image", "index": "1"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with database.session() as session:
|
||||||
|
session.add(VmixDevice(
|
||||||
|
device_uuid="prep-device",
|
||||||
|
device_secret_hash="x" * 64,
|
||||||
|
name="Prep device",
|
||||||
|
wfl_user_id=user.id,
|
||||||
|
is_active_for_account=True,
|
||||||
|
vmix_connected=True,
|
||||||
|
current_match_external_id="9001",
|
||||||
|
project_inventory_json=json.dumps(source_inventory),
|
||||||
|
))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0):
|
||||||
|
calls.append(commands)
|
||||||
|
if commands and commands[0].get("Function") == "CreateVirtualInput":
|
||||||
|
cloned = {
|
||||||
|
"inputs": source_inventory["inputs"] + [
|
||||||
|
{
|
||||||
|
"key": "clone-key",
|
||||||
|
"number": "13",
|
||||||
|
"title": "COMPARE",
|
||||||
|
"type": "GT",
|
||||||
|
"fields": source_inventory["inputs"][0]["fields"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with database.session() as session:
|
||||||
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "prep-device"))
|
||||||
|
device.project_inventory_json = json.dumps(cloned)
|
||||||
|
return {"ok": True, "device_id": device_id or "prep-device", "match_id": "9001"}
|
||||||
|
|
||||||
|
hub.run_vmix_sequence_for_user = types.MethodType(fake_sequence, hub)
|
||||||
|
payload = SimpleNamespace(
|
||||||
|
name="Compare ready",
|
||||||
|
device_id="prep-device",
|
||||||
|
session_token="session",
|
||||||
|
source_input_key="source-key",
|
||||||
|
source_input_number="12",
|
||||||
|
source_input_title="COMPARE",
|
||||||
|
source_kind="player_selection",
|
||||||
|
source_ref="compare_home",
|
||||||
|
field_values={
|
||||||
|
"Name.Text": {"type": "text", "value": "Иванов"},
|
||||||
|
"Photo.Source": {"type": "image", "value": r"D:\\Photo\\ivanov.png"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(hub.create_prepared_title(user, payload))
|
||||||
|
assert result["clone_input"]["number"] == "13"
|
||||||
|
assert calls[0] == [{"Function": "CreateVirtualInput", "Input": "source-key"}]
|
||||||
|
assert any(command.get("Function") == "SetText" and command.get("Input") == "clone-key" for command in calls[1])
|
||||||
|
assert any(command.get("Function") == "SetImage" and command.get("SelectedName") == "Photo.Source" for command in calls[1])
|
||||||
|
with database.session() as session:
|
||||||
|
row = session.scalar(select(VmixPreparedTitle))
|
||||||
|
assert row is not None
|
||||||
|
assert row.clone_input_key == "clone-key"
|
||||||
|
assert row.clone_input_number == "13"
|
||||||
|
assert row.source_ref == "compare_home"
|
||||||
@@ -107,6 +107,17 @@
|
|||||||
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
|
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
|
||||||
shortcutInventoryLoading: false,
|
shortcutInventoryLoading: false,
|
||||||
shortcutEditorScrollTop: 0,
|
shortcutEditorScrollTop: 0,
|
||||||
|
preparedTitles: [],
|
||||||
|
preparedTitleInventory: { device_id: "", device_name: "", inventory: { inputs: [] } },
|
||||||
|
preparedTitlesLoading: false,
|
||||||
|
preparedTitlesLoadedKey: "",
|
||||||
|
preparedTitleSearch: "",
|
||||||
|
preparedTitleSourceKey: "",
|
||||||
|
preparedTitleFieldValues: {},
|
||||||
|
preparedTitleName: "",
|
||||||
|
preparedTitlePanelId: "",
|
||||||
|
preparedTitleMappingSources: [],
|
||||||
|
preparedTitleSnapshotLoading: false,
|
||||||
modalLocked: false,
|
modalLocked: false,
|
||||||
timerQuickEditorInterval: null,
|
timerQuickEditorInterval: null,
|
||||||
timers: {},
|
timers: {},
|
||||||
@@ -2550,6 +2561,13 @@ function startCustomTooltips() {
|
|||||||
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
|
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
|
||||||
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
|
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
|
||||||
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
|
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
|
||||||
|
// BUILD84: "Заготовки" is a built-in runtime workspace rather than a canvas
|
||||||
|
// component. Keep it near the game tab and recreate it if an older config
|
||||||
|
// does not contain it yet.
|
||||||
|
if (!state.config.tabs.some((tab) => tab?.id === "prepared_titles")) {
|
||||||
|
const mainIndex = Math.max(0, state.config.tabs.findIndex((tab) => tab?.id === "main"));
|
||||||
|
state.config.tabs.splice(mainIndex + 1, 0, { id: "prepared_titles", label: "Заготовки" });
|
||||||
|
}
|
||||||
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
|
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4665,14 +4683,14 @@ function startCustomTooltips() {
|
|||||||
const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home");
|
const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home");
|
||||||
const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away");
|
const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away");
|
||||||
const values = {
|
const values = {
|
||||||
selected_home_penalty_id: home?.penalty_id || "",
|
active_home_penalty_id: home?.penalty_id || "",
|
||||||
selected_home_penalty_player_id: home?.player_id || "",
|
active_home_penalty_player_id: home?.player_id || "",
|
||||||
selected_home_penalty_player_db_id: home?.player_db_id || "",
|
active_home_penalty_player_db_id: home?.player_db_id || "",
|
||||||
selected_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "",
|
active_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "",
|
||||||
selected_away_penalty_id: away?.penalty_id || "",
|
active_away_penalty_id: away?.penalty_id || "",
|
||||||
selected_away_penalty_player_id: away?.player_id || "",
|
active_away_penalty_player_id: away?.player_id || "",
|
||||||
selected_away_penalty_player_db_id: away?.player_db_id || "",
|
active_away_penalty_player_db_id: away?.player_db_id || "",
|
||||||
selected_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "",
|
active_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "",
|
||||||
};
|
};
|
||||||
const signature = JSON.stringify([gameId, values]);
|
const signature = JSON.stringify([gameId, values]);
|
||||||
if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false;
|
if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false;
|
||||||
@@ -6715,8 +6733,11 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
||||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||||
if (board.selectedEventId === event.id) board.selectedEventId = null;
|
const clearCommonSelection = board.selectedEventId === event.id;
|
||||||
if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = "";
|
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
|
||||||
|
if (clearCommonSelection) board.selectedEventId = null;
|
||||||
|
if (clearSideSelection) board.selectedPreviewEventIds[side] = "";
|
||||||
|
if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(side, { clearCommon: clearCommonSelection }).catch(() => {});
|
||||||
persistHockeyBoard(component, board, true);
|
persistHockeyBoard(component, board, true);
|
||||||
refreshHockeyBoardNodes(component);
|
refreshHockeyBoardNodes(component);
|
||||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||||
@@ -6735,10 +6756,13 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
emitHockeyEventUpdated(component, event, "event-time");
|
emitHockeyEventUpdated(component, event, "event-time");
|
||||||
} else if (command === "remove") {
|
} else if (command === "remove") {
|
||||||
board.penalties = board.penalties.filter((item) => item.id !== eventId);
|
board.penalties = board.penalties.filter((item) => item.id !== eventId);
|
||||||
if (board.selectedEventId === eventId) board.selectedEventId = null;
|
const clearCommonSelection = board.selectedEventId === eventId;
|
||||||
|
if (clearCommonSelection) board.selectedEventId = null;
|
||||||
{
|
{
|
||||||
const removedSide = String(event.player?.side || event.side || "").toLowerCase();
|
const removedSide = String(event.player?.side || event.side || "").toLowerCase();
|
||||||
if (board.selectedPreviewEventIds?.[removedSide] === eventId) board.selectedPreviewEventIds[removedSide] = "";
|
const clearSideSelection = board.selectedPreviewEventIds?.[removedSide] === eventId;
|
||||||
|
if (clearSideSelection) board.selectedPreviewEventIds[removedSide] = "";
|
||||||
|
if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(removedSide, { clearCommon: clearCommonSelection }).catch(() => {});
|
||||||
}
|
}
|
||||||
addHockeyHistory(
|
addHockeyHistory(
|
||||||
component,
|
component,
|
||||||
@@ -7342,6 +7366,57 @@ function hockeyPenaltyPlayerIdentifiers(component, event) {
|
|||||||
return { externalId, dbId };
|
return { externalId, dbId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = false } = {}) {
|
||||||
|
const gameId = hockeyTimerSelectedGameId();
|
||||||
|
const cleanSide = ["home", "away"].includes(String(side || "").toLowerCase()) ? String(side).toLowerCase() : "";
|
||||||
|
if (!gameId || !cleanSide) return false;
|
||||||
|
const values = cleanSide === "home"
|
||||||
|
? {
|
||||||
|
selected_home_penalty_id: "",
|
||||||
|
selected_home_penalty_player_id: "",
|
||||||
|
selected_home_penalty_player_db_id: "",
|
||||||
|
selected_home_penalty_team_penalty: "",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
selected_away_penalty_id: "",
|
||||||
|
selected_away_penalty_player_id: "",
|
||||||
|
selected_away_penalty_player_db_id: "",
|
||||||
|
selected_away_penalty_team_penalty: "",
|
||||||
|
};
|
||||||
|
if (clearCommon) Object.assign(values, {
|
||||||
|
selected_penalty_id: "",
|
||||||
|
selected_penalty_player_id: "",
|
||||||
|
selected_penalty_player_db_id: "",
|
||||||
|
selected_penalty_team_penalty: "",
|
||||||
|
selected_penalty_team_id: "",
|
||||||
|
selected_penalty_infraction_id: "",
|
||||||
|
selected_penalty_preset_id: "",
|
||||||
|
selected_penalty_side: "",
|
||||||
|
selected_penalty_event_time: "",
|
||||||
|
selected_penalty_status: "",
|
||||||
|
selected_penalty_remaining_ms: "",
|
||||||
|
selected_penalty_duration_ms: "",
|
||||||
|
selected_event_id: "",
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
context: {
|
||||||
|
game_id: gameId,
|
||||||
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hockeyPenaltyIsPreviewSelected(board, event) {
|
function hockeyPenaltyIsPreviewSelected(board, event) {
|
||||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||||
return ["home", "away"].includes(side)
|
return ["home", "away"].includes(side)
|
||||||
@@ -7363,6 +7438,7 @@ async function hockeySelectPenaltyForPreview(component, event) {
|
|||||||
: side === "away"
|
: side === "away"
|
||||||
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
|
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
|
||||||
: "";
|
: "";
|
||||||
|
const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
|
||||||
const detail = {
|
const detail = {
|
||||||
item_id: String(event.external_id || event.id || ""),
|
item_id: String(event.external_id || event.id || ""),
|
||||||
value: String(event.external_id || event.id || ""),
|
value: String(event.external_id || event.id || ""),
|
||||||
@@ -7461,7 +7537,6 @@ function hockeyPenaltyCard(component, event, runtime) {
|
|||||||
const side = event.player?.side || event.side || "neutral";
|
const side = event.player?.side || event.side || "neutral";
|
||||||
const card = div(`hpd-team-penalty-card team-${side}`);
|
const card = div(`hpd-team-penalty-card team-${side}`);
|
||||||
card.dataset.penaltyId = event.id;
|
card.dataset.penaltyId = event.id;
|
||||||
card.classList.toggle("is-selected", board.selectedEventId === event.id);
|
|
||||||
card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));
|
card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));
|
||||||
card.classList.toggle("is-running", event.running);
|
card.classList.toggle("is-running", event.running);
|
||||||
card.classList.toggle("is-finished", event.finished);
|
card.classList.toggle("is-finished", event.finished);
|
||||||
@@ -7817,8 +7892,8 @@ function openHockeyPrematchButtonsEditor() {
|
|||||||
<label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label>
|
<label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label>
|
||||||
<label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label>
|
<label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label>
|
||||||
<label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label>
|
<label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label>
|
||||||
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный выбранный игрок</label>
|
<label class="shortcut-inline-check" title="Использовать первый слот как общий selected_player"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный</label>
|
||||||
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> По умолчанию свёрнут</label>
|
<label class="shortcut-inline-check" title="Сворачивать блок при первом открытии"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> Свёрнут</label>
|
||||||
<label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label>
|
<label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label>
|
||||||
<div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div>
|
<div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div>
|
||||||
<button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button>
|
<button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button>
|
||||||
@@ -8189,6 +8264,7 @@ async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
|
|||||||
if (!Object.keys(values).length) return null;
|
if (!Object.keys(values).length) return null;
|
||||||
const language = hockeyGameControlLanguage();
|
const language = hockeyGameControlLanguage();
|
||||||
const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) });
|
const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) });
|
||||||
|
hockeyBackupPlayerSelectionValues(gameId, payload?.values || values);
|
||||||
if (refreshMapping) await hockeyRefreshQuickPanelMapping();
|
if (refreshMapping) await hockeyRefreshQuickPanelMapping();
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -8469,6 +8545,43 @@ function hockeyEventCategoryLabels(language) {
|
|||||||
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"};
|
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hockeyPlayerSelectionStorageKey(gameId) {
|
||||||
|
return `hockey:player-selections:${String(gameId || "").trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPlayerSelectionValuesOnly(values) {
|
||||||
|
const result = {};
|
||||||
|
Object.entries(values && typeof values === "object" ? values : {}).forEach(([key, value]) => {
|
||||||
|
if (String(key).startsWith("player_select.")) result[String(key)] = String(value ?? "");
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyBackupPlayerSelectionValues(gameId, values) {
|
||||||
|
const id = String(gameId || "").trim();
|
||||||
|
if (!id) return false;
|
||||||
|
const selected = hockeyPlayerSelectionValuesOnly(values);
|
||||||
|
if (!Object.keys(selected).length) return false;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(hockeyPlayerSelectionStorageKey(id), JSON.stringify({ game_id: id, saved_at: Date.now(), values: selected }));
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyStoredPlayerSelectionValues(gameId) {
|
||||||
|
const id = String(gameId || "").trim();
|
||||||
|
if (!id) return {};
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(localStorage.getItem(hockeyPlayerSelectionStorageKey(id)) || "{}");
|
||||||
|
if (String(payload?.game_id || "") !== id || !payload?.values || typeof payload.values !== "object") return {};
|
||||||
|
return hockeyPlayerSelectionValuesOnly(payload.values);
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hockeyPlayerSelectionPanels() {
|
function hockeyPlayerSelectionPanels() {
|
||||||
return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false);
|
return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false);
|
||||||
}
|
}
|
||||||
@@ -8616,8 +8729,17 @@ async function hockeyEnsurePlayerSelectionRuntimeValues() {
|
|||||||
const gameId = hockeyTimerSelectedGameId();
|
const gameId = hockeyTimerSelectedGameId();
|
||||||
const panels = hockeyPlayerSelectionPanels();
|
const panels = hockeyPlayerSelectionPanels();
|
||||||
if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false;
|
if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false;
|
||||||
|
// Do not seed empty values before the saved GameControlState has arrived.
|
||||||
|
// On a hard refresh that race used to erase an already prepared comparison.
|
||||||
|
const control = getByPath(state.data, "hockey.game_control");
|
||||||
|
if (!control || String(control.game_id || "") !== String(gameId)) return false;
|
||||||
const current = hockeyMatchRuntimeValues();
|
const current = hockeyMatchRuntimeValues();
|
||||||
const patch = {};
|
const patch = {};
|
||||||
|
const hasServerSelectionKeys = Object.keys(current).some((key) => String(key).startsWith("player_select."));
|
||||||
|
if (!hasServerSelectionKeys) {
|
||||||
|
const backup = hockeyStoredPlayerSelectionValues(gameId);
|
||||||
|
Object.entries(backup).forEach(([key, value]) => { patch[key] = value; });
|
||||||
|
}
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
const meta = {
|
const meta = {
|
||||||
[`player_select.${panel.id}._label`]: panel.label,
|
[`player_select.${panel.id}._label`]: panel.label,
|
||||||
@@ -8715,7 +8837,7 @@ function renderHockeyPlayerSelectionWindows() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join("")}
|
}).join("")}
|
||||||
</div>
|
</div>
|
||||||
${filled ? `<button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button>` : ""}
|
${filled ? `<div class="hockey-player-select-footer"><button type="button" class="hockey-player-select-prepared" data-player-panel-prepared="${escapeHtml(panel.id)}">+ В заготовки</button><button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button></div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
stack.appendChild(node);
|
stack.appendChild(node);
|
||||||
node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => {
|
node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => {
|
||||||
@@ -8773,6 +8895,7 @@ function renderHockeyPlayerSelectionWindows() {
|
|||||||
event.preventDefault(); event.stopPropagation();
|
event.preventDefault(); event.stopPropagation();
|
||||||
await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1));
|
await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1));
|
||||||
}));
|
}));
|
||||||
|
node.querySelector("[data-player-panel-prepared]")?.addEventListener("click", () => hockeyOpenPreparedFromPlayerPanel(panel));
|
||||||
node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel));
|
node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel));
|
||||||
}
|
}
|
||||||
setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0);
|
setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0);
|
||||||
@@ -8789,6 +8912,389 @@ function renderHockeyRuntimeSideWindows() {
|
|||||||
return Boolean(players || pbp);
|
return Boolean(players || pbp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function hockeyPreparedNumber(value, fallback = 999999) {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
return /^\d+$/.test(raw) ? Number(raw) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedInventoryInputs() {
|
||||||
|
const inventory = state.preparedTitleInventory?.inventory || {};
|
||||||
|
return (Array.isArray(inventory.inputs) ? inventory.inputs : [])
|
||||||
|
.filter((item) => item && typeof item === "object" && Array.isArray(item.fields) && item.fields.length)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedSourceIdentity(input) {
|
||||||
|
if (!input) return "";
|
||||||
|
return String(input.key || input.number || input.title || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedSelectedInput() {
|
||||||
|
const identity = String(state.preparedTitleSourceKey || "").trim();
|
||||||
|
if (!identity) return null;
|
||||||
|
return hockeyPreparedInventoryInputs().find((item) => hockeyPreparedSourceIdentity(item) === identity || String(item.key || "") === identity || String(item.number || "") === identity) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedFieldType(field) {
|
||||||
|
const explicit = String(field?.type || "").toLowerCase();
|
||||||
|
if (["color", "colour"].includes(explicit) || /\.Color$/i.test(String(field?.name || ""))) return "color";
|
||||||
|
if (["image", "source"].includes(explicit) || /\.Source$/i.test(String(field?.name || ""))) return "image";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedFieldValuesForInput(input, { preserve = true } = {}) {
|
||||||
|
const next = {};
|
||||||
|
(Array.isArray(input?.fields) ? input.fields : []).forEach((field) => {
|
||||||
|
const name = String(field?.name || "").trim();
|
||||||
|
if (!name) return;
|
||||||
|
const type = hockeyPreparedFieldType(field);
|
||||||
|
const previous = state.preparedTitleFieldValues?.[name];
|
||||||
|
next[name] = {
|
||||||
|
type,
|
||||||
|
value: preserve && previous && typeof previous === "object" ? String(previous.value ?? "") : "",
|
||||||
|
touched: Boolean(preserve && previous && typeof previous === "object" && previous.touched),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
state.preparedTitleFieldValues = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyPreparedWorkspaceKey() {
|
||||||
|
return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) {
|
||||||
|
const clean = String(panelId || "").trim();
|
||||||
|
if (!clean) {
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
if (render && state.activeTab === "prepared_titles") renderRuntime();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const deviceId = currentRuntimeVmixDeviceId();
|
||||||
|
if (deviceId) params.set("device_id", deviceId);
|
||||||
|
params.set("panel_id", clean);
|
||||||
|
const payload = await api(`/api/hockey/prepared-titles/mapping-sources?${params.toString()}`);
|
||||||
|
state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
|
if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) {
|
||||||
|
const source = state.preparedTitleMappingSources[0];
|
||||||
|
const inventoryMatch = hockeyPreparedInventoryInputs().find((item) =>
|
||||||
|
(source.key && String(item.key || "") === String(source.key)) ||
|
||||||
|
(source.number && String(item.number || "") === String(source.number)) ||
|
||||||
|
(source.title && String(item.title || "") === String(source.title))
|
||||||
|
);
|
||||||
|
if (inventoryMatch) {
|
||||||
|
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(inventoryMatch);
|
||||||
|
hockeyPreparedFieldValuesForInput(inventoryMatch, { preserve: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (render && state.activeTab === "prepared_titles") renderRuntime();
|
||||||
|
return state.preparedTitleMappingSources;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Prepared title mapping sources error", error);
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true } = {}) {
|
||||||
|
if (state.preparedTitlesLoading) return false;
|
||||||
|
const key = hockeyPreparedWorkspaceKey();
|
||||||
|
if (!force && state.preparedTitlesLoadedKey === key && state.preparedTitleInventory?.inventory) {
|
||||||
|
if (state.preparedTitlePanelId && !state.preparedTitleMappingSources.length) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state.preparedTitlesLoading = true;
|
||||||
|
if (render && state.activeTab === "prepared_titles") renderRuntime();
|
||||||
|
try {
|
||||||
|
const deviceId = currentRuntimeVmixDeviceId();
|
||||||
|
const gameId = hockeyTimerSelectedGameId();
|
||||||
|
const invParams = new URLSearchParams();
|
||||||
|
if (deviceId) invParams.set("device_id", deviceId);
|
||||||
|
const listParams = new URLSearchParams();
|
||||||
|
if (deviceId) listParams.set("device_id", deviceId);
|
||||||
|
if (gameId) listParams.set("game_id", gameId);
|
||||||
|
const [inventoryPayload, preparedPayload] = await Promise.all([
|
||||||
|
api(`/api/hockey/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`),
|
||||||
|
api(`/api/hockey/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`),
|
||||||
|
]);
|
||||||
|
state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } };
|
||||||
|
state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : [];
|
||||||
|
state.preparedTitlesLoadedKey = key;
|
||||||
|
const selected = hockeyPreparedSelectedInput();
|
||||||
|
if (state.preparedTitleSourceKey && !selected) {
|
||||||
|
state.preparedTitleSourceKey = "";
|
||||||
|
state.preparedTitleFieldValues = {};
|
||||||
|
} else if (selected && !Object.keys(state.preparedTitleFieldValues || {}).length) {
|
||||||
|
hockeyPreparedFieldValuesForInput(selected, { preserve: false });
|
||||||
|
}
|
||||||
|
if (state.preparedTitlePanelId) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId);
|
||||||
|
if (state.preparedTitleAutoSnapshotPending && state.preparedTitleSourceKey) {
|
||||||
|
state.preparedTitleAutoSnapshotPending = false;
|
||||||
|
setTimeout(() => hockeyApplyPreparedMappingSnapshot().catch(() => {}), 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Заготовки: ${error.message}`, true);
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
state.preparedTitlesLoading = false;
|
||||||
|
if (render && state.activeTab === "prepared_titles") renderRuntime();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
|
||||||
|
if (!input) return false;
|
||||||
|
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
|
||||||
|
hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||||
|
if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`;
|
||||||
|
if (clearPanel) {
|
||||||
|
state.preparedTitlePanelId = "";
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
}
|
||||||
|
renderRuntime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyApplyPreparedMappingSnapshot() {
|
||||||
|
const input = hockeyPreparedSelectedInput();
|
||||||
|
if (!input) {
|
||||||
|
toast("Сначала выберите vMix Input", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.preparedTitleSnapshotLoading = true;
|
||||||
|
renderRuntime();
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const deviceId = currentRuntimeVmixDeviceId();
|
||||||
|
if (deviceId) params.set("device_id", deviceId);
|
||||||
|
if (input.key) params.set("input_key", input.key);
|
||||||
|
if (input.number) params.set("input_number", input.number);
|
||||||
|
if (input.title) params.set("input_title", input.title);
|
||||||
|
if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId);
|
||||||
|
const payload = await api(`/api/hockey/prepared-titles/mapping-snapshot?${params.toString()}`);
|
||||||
|
const fields = Array.isArray(payload?.fields) ? payload.fields : [];
|
||||||
|
if (!fields.length) {
|
||||||
|
toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const next = { ...(state.preparedTitleFieldValues || {}) };
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const name = String(field?.name || "");
|
||||||
|
if (!name || !Object.prototype.hasOwnProperty.call(next, name)) return;
|
||||||
|
next[name] = { type: hockeyPreparedFieldType(field), value: String(field?.value ?? ""), touched: true };
|
||||||
|
});
|
||||||
|
state.preparedTitleFieldValues = next;
|
||||||
|
toast(`Подставлено из Mapping: ${fields.length}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Mapping → заготовка: ${error.message}`, true);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.preparedTitleSnapshotLoading = false;
|
||||||
|
renderRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyCreatePreparedTitle() {
|
||||||
|
const input = hockeyPreparedSelectedInput();
|
||||||
|
if (!input) {
|
||||||
|
toast("Сначала выберите исходный vMix Input", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`;
|
||||||
|
const fieldValues = {};
|
||||||
|
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
|
||||||
|
if (!entry?.touched) return;
|
||||||
|
fieldValues[fieldName] = {
|
||||||
|
type: String(entry?.type || "text"),
|
||||||
|
value: String(entry?.value ?? ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
state.preparedTitlesLoading = true;
|
||||||
|
renderRuntime();
|
||||||
|
try {
|
||||||
|
const payload = await api("/api/hockey/prepared-titles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
|
source_input_key: String(input.key || ""),
|
||||||
|
source_input_number: String(input.number || ""),
|
||||||
|
source_input_title: String(input.title || ""),
|
||||||
|
source_kind: state.preparedTitlePanelId ? "player_selection" : "manual",
|
||||||
|
source_ref: state.preparedTitlePanelId || "",
|
||||||
|
field_values: fieldValues,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
||||||
|
state.preparedTitleName = "";
|
||||||
|
state.preparedTitlePanelId = "";
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
state.preparedTitlesLoadedKey = "";
|
||||||
|
await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Не удалось создать заготовку: ${error.message}`, true);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.preparedTitlesLoading = false;
|
||||||
|
renderRuntime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyPreviewPreparedTitle(id) {
|
||||||
|
try {
|
||||||
|
await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }),
|
||||||
|
});
|
||||||
|
toast("Заготовка отправлена в Preview");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Preview: ${error.message}`, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hockeyDeletePreparedTitle(id) {
|
||||||
|
if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false;
|
||||||
|
try {
|
||||||
|
await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
||||||
|
renderRuntime();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Удаление заготовки: ${error.message}`, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hockeyOpenPreparedFromPlayerPanel(panel) {
|
||||||
|
if (!panel) return false;
|
||||||
|
state.preparedTitlePanelId = String(panel.id || "");
|
||||||
|
state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`;
|
||||||
|
state.preparedTitleSourceKey = "";
|
||||||
|
state.preparedTitleFieldValues = {};
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
state.preparedTitleAutoSnapshotPending = true;
|
||||||
|
state.preparedTitlesLoadedKey = "";
|
||||||
|
activateRuntimeTab("prepared_titles", { source: "player-selection" });
|
||||||
|
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHockeyPreparedTitlesWorkspace() {
|
||||||
|
const root = document.createElement("section");
|
||||||
|
root.className = "hockey-prepared-workspace";
|
||||||
|
const inputs = hockeyPreparedInventoryInputs();
|
||||||
|
const search = String(state.preparedTitleSearch || "").trim().toLowerCase();
|
||||||
|
const filtered = inputs.filter((item) => !search || `${item.number || ""} ${item.title || ""} ${item.type || ""}`.toLowerCase().includes(search));
|
||||||
|
const selected = hockeyPreparedSelectedInput();
|
||||||
|
const fields = selected && Array.isArray(selected.fields)
|
||||||
|
? selected.fields.slice().sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""), "ru", { numeric: true, sensitivity: "base" }))
|
||||||
|
: [];
|
||||||
|
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
|
||||||
|
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : [];
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="hockey-prepared-head">
|
||||||
|
<div><span>VMIX PRESETS</span><strong>Заготовки</strong><small>${escapeHtml(state.preparedTitleInventory?.device_name || "Agent не выбран")} · матч ${escapeHtml(hockeyTimerSelectedGameId() || "—")}</small></div>
|
||||||
|
<button type="button" data-prepared-refresh ${state.preparedTitlesLoading ? "disabled" : ""}>↻ Обновить vMix</button>
|
||||||
|
</div>
|
||||||
|
${panel ? `<div class="hockey-prepared-context"><div><span>БЛОК ИГРОКОВ</span><strong>${escapeHtml(panel.label)}</strong><small>Выберите титр, связанный с <code>player_select.${escapeHtml(panel.id)}.*</code>, затем подставьте текущие данные.</small></div><button type="button" data-prepared-clear-context>×</button></div>` : ""}
|
||||||
|
<div class="hockey-prepared-layout">
|
||||||
|
<aside class="hockey-prepared-inputs">
|
||||||
|
<label class="hockey-prepared-search"><span>Поиск Input</span><input type="search" value="${escapeHtml(state.preparedTitleSearch)}" placeholder="номер или название"></label>
|
||||||
|
${mappingSources.length ? `<div class="hockey-prepared-recommended"><span>Подходят для ${escapeHtml(panel?.label || "блока")}</span>${mappingSources.map((item) => `<button type="button" data-prepared-mapping-source="${escapeHtml(String(item.key || item.number || item.title || ""))}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Input")}</span><small>${escapeHtml(item.mapped_fields || 0)} связей</small></button>`).join("")}</div>` : ""}
|
||||||
|
<div class="hockey-prepared-input-list">${filtered.map((item) => {
|
||||||
|
const identity = hockeyPreparedSourceIdentity(item);
|
||||||
|
const active = selected && hockeyPreparedSourceIdentity(selected) === identity;
|
||||||
|
return `<button type="button" class="${active ? "active" : ""}" data-prepared-input="${escapeHtml(identity)}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Без названия")}</span><small>${escapeHtml(item.type || "")}${Array.isArray(item.fields) ? ` · ${item.fields.length}` : ""}</small></button>`;
|
||||||
|
}).join("") || `<div class="hockey-prepared-empty">Нет Inputs с редактируемыми элементами</div>`}</div>
|
||||||
|
</aside>
|
||||||
|
<main class="hockey-prepared-editor">
|
||||||
|
${selected ? `
|
||||||
|
<div class="hockey-prepared-source"><div><span>ИСТОЧНИК</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${escapeHtml(selected.type || "")} · элементов ${fields.length}</small></div>${panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
|
||||||
|
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Например: Сравнение вратарей · студия"></label>
|
||||||
|
<div class="hockey-prepared-fields">${fields.map((field) => {
|
||||||
|
const name = String(field?.name || "");
|
||||||
|
const type = hockeyPreparedFieldType(field);
|
||||||
|
const value = String(state.preparedTitleFieldValues?.[name]?.value ?? "");
|
||||||
|
const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff";
|
||||||
|
return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`;
|
||||||
|
}).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div>
|
||||||
|
<div class="hockey-prepared-savebar"><small>При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.</small><button type="button" data-prepared-save ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю…" : "+ Сохранить новым Input"}</button></div>
|
||||||
|
` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
|
||||||
|
</main>
|
||||||
|
<aside class="hockey-prepared-saved">
|
||||||
|
<div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div>
|
||||||
|
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || "Заготовка")}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · из #${escapeHtml(item.source_input?.number || "—")} ${escapeHtml(item.source_input?.title || "")}</small></div><div><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
|
||||||
|
</aside>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
root.querySelector("[data-prepared-refresh]")?.addEventListener("click", () => {
|
||||||
|
state.preparedTitlesLoadedKey = "";
|
||||||
|
hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
root.querySelector(".hockey-prepared-search input")?.addEventListener("input", (event) => {
|
||||||
|
state.preparedTitleSearch = event.target.value || "";
|
||||||
|
renderRuntime();
|
||||||
|
setTimeout(() => el.runtimeStage.querySelector(".hockey-prepared-search input")?.focus({ preventScroll: true }), 0);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-prepared-input]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
const identity = button.dataset.preparedInput || "";
|
||||||
|
const input = inputs.find((item) => hockeyPreparedSourceIdentity(item) === identity);
|
||||||
|
if (input) hockeySelectPreparedSource(input);
|
||||||
|
}));
|
||||||
|
root.querySelectorAll("[data-prepared-mapping-source]").forEach((button) => button.addEventListener("click", async () => {
|
||||||
|
const identity = button.dataset.preparedMappingSource || "";
|
||||||
|
const source = mappingSources.find((item) => String(item.key || item.number || item.title || "") === identity);
|
||||||
|
const input = source ? inputs.find((item) =>
|
||||||
|
(source.key && String(item.key || "") === String(source.key)) ||
|
||||||
|
(source.number && String(item.number || "") === String(source.number)) ||
|
||||||
|
(source.title && String(item.title || "") === String(source.title))
|
||||||
|
) : null;
|
||||||
|
if (!input) return;
|
||||||
|
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
|
||||||
|
hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||||
|
renderRuntime();
|
||||||
|
await hockeyApplyPreparedMappingSnapshot();
|
||||||
|
}));
|
||||||
|
root.querySelector("[data-prepared-clear-context]")?.addEventListener("click", () => {
|
||||||
|
state.preparedTitlePanelId = "";
|
||||||
|
state.preparedTitleMappingSources = [];
|
||||||
|
renderRuntime();
|
||||||
|
});
|
||||||
|
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot());
|
||||||
|
root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; });
|
||||||
|
root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => {
|
||||||
|
const name = inputNode.dataset.preparedField || "";
|
||||||
|
if (!name) return;
|
||||||
|
state.preparedTitleFieldValues[name] = { type: inputNode.dataset.preparedFieldType || "text", value: inputNode.value || "", touched: true };
|
||||||
|
if ((inputNode.dataset.preparedFieldType || "") === "color") {
|
||||||
|
const picker = root.querySelector(`[data-prepared-color="${CSS.escape(name)}"]`);
|
||||||
|
if (picker && /^#[0-9A-Fa-f]{6}$/.test(inputNode.value || "")) picker.value = inputNode.value;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
root.querySelectorAll("[data-prepared-color]").forEach((picker) => picker.addEventListener("input", () => {
|
||||||
|
const name = picker.dataset.preparedColor || "";
|
||||||
|
const text = root.querySelector(`[data-prepared-field="${CSS.escape(name)}"]`);
|
||||||
|
if (text) text.value = picker.value;
|
||||||
|
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
|
||||||
|
}));
|
||||||
|
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle());
|
||||||
|
root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview)));
|
||||||
|
root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete)));
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
function hockeyEventCategory(item) {
|
function hockeyEventCategory(item) {
|
||||||
const declared = String(item?.category || "").trim().toLowerCase();
|
const declared = String(item?.category || "").trim().toLowerCase();
|
||||||
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
|
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
|
||||||
@@ -10588,6 +11094,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
{ hockey: { game_control: payload } },
|
{ hockey: { game_control: payload } },
|
||||||
{ render }
|
{ render }
|
||||||
);
|
);
|
||||||
|
hockeyBackupPlayerSelectionValues(gameId, payload?.values || {});
|
||||||
if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) {
|
if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) {
|
||||||
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
|
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -11832,6 +12339,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runtimeHasFixedHockeyCanvas() {
|
function runtimeHasFixedHockeyCanvas() {
|
||||||
|
if (state.activeTab === "prepared_titles") return true;
|
||||||
return state.config.components.some((component) =>
|
return state.config.components.some((component) =>
|
||||||
[
|
[
|
||||||
"hockey_penalty_dashboard",
|
"hockey_penalty_dashboard",
|
||||||
@@ -11936,11 +12444,18 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
el.runtimeStage.innerHTML = "";
|
el.runtimeStage.innerHTML = "";
|
||||||
state.timerNodes = new Map();
|
state.timerNodes = new Map();
|
||||||
state.hockeyPenaltyBoardNodes = new Map();
|
state.hockeyPenaltyBoardNodes = new Map();
|
||||||
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) => {
|
if (state.activeTab === "prepared_titles") {
|
||||||
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);
|
el.runtimeStage.appendChild(renderHockeyPreparedTitlesWorkspace());
|
||||||
});
|
if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) {
|
||||||
renderHockeyRuntimeSideWindows();
|
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
|
||||||
renderHockeyQuickCommandDock();
|
}
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
renderHockeyRuntimeSideWindows();
|
||||||
|
renderHockeyQuickCommandDock();
|
||||||
|
}
|
||||||
Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
|
Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
|
||||||
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`);
|
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`);
|
||||||
if (list) list.scrollTop = top;
|
if (list) list.scrollTop = top;
|
||||||
|
|||||||
@@ -7223,6 +7223,15 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
|
|||||||
.hpd-team-penalty-card.is-preview-selected {
|
.hpd-team-penalty-card.is-preview-selected {
|
||||||
outline: 2px solid rgba(255, 255, 255, .72);
|
outline: 2px solid rgba(255, 255, 255, .72);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255,255,255,.06);
|
||||||
|
}
|
||||||
|
.hpd-team-penalty-card.team-home.is-preview-selected {
|
||||||
|
outline-color: color-mix(in srgb,var(--hpd-home) 82%,#fff);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-home) 18%,transparent);
|
||||||
|
}
|
||||||
|
.hpd-team-penalty-card.team-away.is-preview-selected {
|
||||||
|
outline-color: color-mix(in srgb,var(--hpd-away) 82%,#fff);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-away) 18%,transparent);
|
||||||
}
|
}
|
||||||
.hpd-team-card-identifiers {
|
.hpd-team-card-identifiers {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -7433,3 +7442,242 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
|
|||||||
user-select:none;
|
user-select:none;
|
||||||
}
|
}
|
||||||
.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }
|
.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }
|
||||||
|
|
||||||
|
/* BUILD84 — compact, single-line player-block editor. */
|
||||||
|
.player-selection-editor.prematch-editor-section .player-selection-editor-list { gap:4px; }
|
||||||
|
.player-selection-editor-row {
|
||||||
|
grid-template-columns:24px 66px minmax(150px,1fr) minmax(135px,.9fr) 52px minmax(135px,.9fr) 84px 82px minmax(150px,1fr) 56px 30px;
|
||||||
|
gap:5px;
|
||||||
|
align-items:center;
|
||||||
|
min-height:48px;
|
||||||
|
padding:4px 5px;
|
||||||
|
border-radius:8px;
|
||||||
|
}
|
||||||
|
.player-selection-editor-row label { gap:2px; font-size:6.5px; line-height:1.05; }
|
||||||
|
.player-selection-editor-row input,
|
||||||
|
.player-selection-editor-row select { height:26px; padding-top:3px; padding-bottom:3px; font-size:9px; }
|
||||||
|
.player-selection-editor-row .shortcut-inline-check {
|
||||||
|
align-self:center;
|
||||||
|
justify-self:start;
|
||||||
|
gap:4px;
|
||||||
|
white-space:nowrap;
|
||||||
|
font-size:7px;
|
||||||
|
}
|
||||||
|
.player-selection-editor-row .shortcut-inline-check input { width:16px; height:16px; padding:0; }
|
||||||
|
.player-selection-editor-description { min-width:120px; }
|
||||||
|
.player-selection-editor-order { align-items:center; }
|
||||||
|
.player-selection-editor-order .mini-btn,
|
||||||
|
.player-selection-editor-row > .icon-btn { width:26px; min-width:26px; height:26px; padding:0; }
|
||||||
|
@media (max-width:1450px) {
|
||||||
|
.player-selection-editor-row {
|
||||||
|
grid-template-columns:24px 64px minmax(130px,1fr) minmax(120px,.9fr) 50px minmax(120px,.9fr) 80px 78px minmax(130px,1fr) 54px 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BUILD84 — prepared vMix title workspace + player block handoff. */
|
||||||
|
.hockey-player-select-footer {
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
gap:6px;
|
||||||
|
margin-top:7px;
|
||||||
|
}
|
||||||
|
.hockey-player-select-prepared {
|
||||||
|
padding:5px 8px;
|
||||||
|
color:#071a1d;
|
||||||
|
border:1px solid #54e5c4;
|
||||||
|
border-radius:7px;
|
||||||
|
background:#48dfbd;
|
||||||
|
font-size:7px;
|
||||||
|
font-weight:950;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.hockey-player-select-prepared:hover { filter:brightness(1.08); }
|
||||||
|
.hockey-player-select-footer .hockey-player-select-clear { margin-left:auto; }
|
||||||
|
|
||||||
|
.hockey-prepared-workspace {
|
||||||
|
position:absolute;
|
||||||
|
inset:0;
|
||||||
|
display:grid;
|
||||||
|
grid-template-rows:auto auto minmax(0,1fr);
|
||||||
|
gap:9px;
|
||||||
|
padding:14px;
|
||||||
|
box-sizing:border-box;
|
||||||
|
color:#dcecff;
|
||||||
|
background:linear-gradient(180deg,#0a1522 0%,#09131f 100%);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.hockey-prepared-head,
|
||||||
|
.hockey-prepared-context,
|
||||||
|
.hockey-prepared-source,
|
||||||
|
.hockey-prepared-savebar,
|
||||||
|
.hockey-prepared-saved-head {
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:12px;
|
||||||
|
}
|
||||||
|
.hockey-prepared-head {
|
||||||
|
min-height:52px;
|
||||||
|
padding:0 12px;
|
||||||
|
border:1px solid #263f56;
|
||||||
|
border-radius:11px;
|
||||||
|
background:#0c1b2a;
|
||||||
|
}
|
||||||
|
.hockey-prepared-head > div,
|
||||||
|
.hockey-prepared-context > div,
|
||||||
|
.hockey-prepared-source > div { min-width:0; display:grid; gap:2px; }
|
||||||
|
.hockey-prepared-head span,
|
||||||
|
.hockey-prepared-context span,
|
||||||
|
.hockey-prepared-source span,
|
||||||
|
.hockey-prepared-saved-head span {
|
||||||
|
color:#56e3c1;
|
||||||
|
font-size:7px;
|
||||||
|
font-weight:950;
|
||||||
|
letter-spacing:.08em;
|
||||||
|
}
|
||||||
|
.hockey-prepared-head strong { font-size:16px; line-height:1; }
|
||||||
|
.hockey-prepared-head small,
|
||||||
|
.hockey-prepared-context small,
|
||||||
|
.hockey-prepared-source small { color:#7891a8; font-size:8px; }
|
||||||
|
.hockey-prepared-head button,
|
||||||
|
.hockey-prepared-context button,
|
||||||
|
.hockey-prepared-source button,
|
||||||
|
.hockey-prepared-savebar button,
|
||||||
|
.hockey-prepared-saved article button {
|
||||||
|
min-height:29px;
|
||||||
|
padding:0 10px;
|
||||||
|
border:1px solid #31516a;
|
||||||
|
border-radius:7px;
|
||||||
|
color:#cfe3f3;
|
||||||
|
background:#10263a;
|
||||||
|
font-size:8px;
|
||||||
|
font-weight:900;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.hockey-prepared-head button:hover,
|
||||||
|
.hockey-prepared-source button:hover,
|
||||||
|
.hockey-prepared-saved article button:hover { border-color:#54dcbf; color:#fff; }
|
||||||
|
.hockey-prepared-head button:disabled,
|
||||||
|
.hockey-prepared-source button:disabled,
|
||||||
|
.hockey-prepared-savebar button:disabled { opacity:.5; cursor:default; }
|
||||||
|
.hockey-prepared-context {
|
||||||
|
min-height:42px;
|
||||||
|
padding:7px 10px;
|
||||||
|
border:1px solid #31586a;
|
||||||
|
border-radius:10px;
|
||||||
|
background:#0d2431;
|
||||||
|
}
|
||||||
|
.hockey-prepared-context code { color:#86dfca; }
|
||||||
|
.hockey-prepared-context > button { width:29px; padding:0; color:#8ba3b8; }
|
||||||
|
.hockey-prepared-layout {
|
||||||
|
min-height:0;
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:310px minmax(0,1fr) 310px;
|
||||||
|
gap:9px;
|
||||||
|
}
|
||||||
|
.hockey-prepared-inputs,
|
||||||
|
.hockey-prepared-editor,
|
||||||
|
.hockey-prepared-saved {
|
||||||
|
min-width:0;
|
||||||
|
min-height:0;
|
||||||
|
border:1px solid #263f56;
|
||||||
|
border-radius:11px;
|
||||||
|
background:#0b1927;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.hockey-prepared-inputs { display:grid; grid-template-rows:auto auto minmax(0,1fr); padding:9px; gap:7px; }
|
||||||
|
.hockey-prepared-search { display:grid; gap:4px; color:#7f98af; font-size:7px; font-weight:900; }
|
||||||
|
.hockey-prepared-search input,
|
||||||
|
.hockey-prepared-name input,
|
||||||
|
.hockey-prepared-field input[type="text"] {
|
||||||
|
width:100%;
|
||||||
|
min-width:0;
|
||||||
|
height:30px;
|
||||||
|
box-sizing:border-box;
|
||||||
|
padding:0 9px;
|
||||||
|
border:1px solid #2b465e;
|
||||||
|
border-radius:7px;
|
||||||
|
outline:0;
|
||||||
|
color:#e5f2ff;
|
||||||
|
background:#0c2133;
|
||||||
|
font-size:9px;
|
||||||
|
font-weight:700;
|
||||||
|
font-family:inherit;
|
||||||
|
}
|
||||||
|
.hockey-prepared-search input:focus,
|
||||||
|
.hockey-prepared-name input:focus,
|
||||||
|
.hockey-prepared-field input[type="text"]:focus { border-color:#4ed9bd; box-shadow:0 0 0 1px rgba(78,217,189,.18); }
|
||||||
|
.hockey-prepared-input-list,
|
||||||
|
.hockey-prepared-saved-list { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:2px; }
|
||||||
|
.hockey-prepared-input-list > button,
|
||||||
|
.hockey-prepared-recommended button {
|
||||||
|
width:100%;
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:42px minmax(0,1fr) auto;
|
||||||
|
align-items:center;
|
||||||
|
gap:7px;
|
||||||
|
min-height:37px;
|
||||||
|
padding:5px 7px;
|
||||||
|
text-align:left;
|
||||||
|
border:1px solid #243d53;
|
||||||
|
border-radius:7px;
|
||||||
|
color:#a8bdd0;
|
||||||
|
background:#0d2031;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.hockey-prepared-input-list > button:hover,
|
||||||
|
.hockey-prepared-input-list > button.active,
|
||||||
|
.hockey-prepared-recommended button:hover { border-color:#3e826f; background:#102b3c; }
|
||||||
|
.hockey-prepared-input-list > button.active { box-shadow:inset 3px 0 0 #4dddbc; }
|
||||||
|
.hockey-prepared-input-list b,
|
||||||
|
.hockey-prepared-recommended b { color:#61e0c2; font:900 8px "Roboto Mono",Consolas,monospace; }
|
||||||
|
.hockey-prepared-input-list span,
|
||||||
|
.hockey-prepared-recommended span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; font-weight:900; color:#e0effb; }
|
||||||
|
.hockey-prepared-input-list small,
|
||||||
|
.hockey-prepared-recommended small { color:#6f879d; font-size:7px; white-space:nowrap; }
|
||||||
|
.hockey-prepared-recommended { display:grid; gap:4px; padding:6px; border:1px solid #2d554d; border-radius:8px; background:#0d2728; }
|
||||||
|
.hockey-prepared-recommended > span { color:#70bea9; font-size:7px; font-weight:900; }
|
||||||
|
.hockey-prepared-editor { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; gap:8px; padding:9px; }
|
||||||
|
.hockey-prepared-source { padding:7px 8px; border:1px solid #29475e; border-radius:8px; background:#0d2031; }
|
||||||
|
.hockey-prepared-source strong { font-size:10px; }
|
||||||
|
.hockey-prepared-name { display:grid; gap:4px; color:#8099ae; font-size:7px; font-weight:900; }
|
||||||
|
.hockey-prepared-fields { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:3px; }
|
||||||
|
.hockey-prepared-field {
|
||||||
|
min-height:42px;
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(160px,.8fr) minmax(220px,1.2fr);
|
||||||
|
align-items:center;
|
||||||
|
gap:10px;
|
||||||
|
padding:5px 7px;
|
||||||
|
border:1px solid #223b51;
|
||||||
|
border-radius:7px;
|
||||||
|
background:#0c1d2d;
|
||||||
|
}
|
||||||
|
.hockey-prepared-field > span { min-width:0; display:flex; align-items:baseline; gap:7px; }
|
||||||
|
.hockey-prepared-field > span b { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:850 8px "Roboto Mono",Consolas,monospace; color:#cfe0ee; }
|
||||||
|
.hockey-prepared-field > span small { color:#52768e; font-size:6px; text-transform:uppercase; }
|
||||||
|
.hockey-prepared-field > div { min-width:0; display:flex; align-items:center; gap:5px; }
|
||||||
|
.hockey-prepared-field input[type="color"] { width:32px; min-width:32px; height:30px; padding:2px; border:1px solid #2b465e; border-radius:6px; background:#0c2133; }
|
||||||
|
.hockey-prepared-savebar { padding-top:7px; border-top:1px solid #21394d; }
|
||||||
|
.hockey-prepared-savebar small { max-width:520px; color:#70899f; font-size:7px; line-height:1.35; }
|
||||||
|
.hockey-prepared-savebar button { min-width:155px; color:#071a1d; border-color:#54e5c4; background:#48dfbd; }
|
||||||
|
.hockey-prepared-saved { display:grid; grid-template-rows:auto minmax(0,1fr); padding:9px; gap:7px; }
|
||||||
|
.hockey-prepared-saved-head { min-height:28px; padding:0 2px; }
|
||||||
|
.hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; }
|
||||||
|
.hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; }
|
||||||
|
.hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; }
|
||||||
|
.hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; }
|
||||||
|
.hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; }
|
||||||
|
.hockey-prepared-saved article small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#71899e; font-size:7px; }
|
||||||
|
.hockey-prepared-saved article > div:last-child { display:flex; gap:4px; }
|
||||||
|
.hockey-prepared-saved article button { min-height:25px; padding:0 7px; font-size:7px; }
|
||||||
|
.hockey-prepared-saved article button.danger { width:25px; min-width:25px; padding:0; color:#ff7d91; border-color:#593646; }
|
||||||
|
.hockey-prepared-empty,
|
||||||
|
.hockey-prepared-editor-empty { display:grid; place-items:center; align-content:center; gap:5px; min-height:90px; padding:16px; text-align:center; color:#637e94; font-size:8px; }
|
||||||
|
.hockey-prepared-editor-empty { height:100%; }
|
||||||
|
.hockey-prepared-editor-empty b { color:#b9cfdf; font-size:13px; }
|
||||||
|
@media (max-width:1100px) {
|
||||||
|
.hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; }
|
||||||
|
.hockey-prepared-field { grid-template-columns:1fr; gap:4px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user