diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..602eb8e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+test.js
\ No newline at end of file
diff --git a/py/build.py b/py/build.py
new file mode 100644
index 0000000..e470be0
--- /dev/null
+++ b/py/build.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Сборщик итогового файла test.js.
+
+Берёт файл tpl.js и заменяет в нём строку-заглушку "code;" на
+объединённое содержимое всех файлов из папки src (в алфавитном порядке).
+Результат сохраняется в файл test.js.
+"""
+
+import os
+
+# Файлы, участвующие в сборке (пути относительно скрипта)
+TPL_FILE = "tpl.js"
+SRC_DIR = "src"
+OUTPUT_FILE = "test.js"
+
+# Строка-заглушка, которую заменяем на код из src
+PLACEHOLDER = "code;"
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def read_file(path):
+ """Читает файл и возвращает его содержимое в виде строки."""
+ with open(path, "r", encoding="utf-8") as f:
+ return f.read()
+
+
+def write_file(path, content):
+ """Записывает содержимое в файл."""
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+def build():
+ """Собирает итоговый файл test.js из tpl.js и содержимого src/."""
+ # Читаем шаблон
+ tpl_path = os.path.join(BASE_DIR, TPL_FILE)
+ if not os.path.isfile(tpl_path):
+ raise FileNotFoundError("Не найден файл шаблона: {}".format(TPL_FILE))
+ template = read_file(tpl_path)
+
+ # Читаем файлы из папки src в алфавитном порядке
+ src_path = os.path.join(BASE_DIR, SRC_DIR)
+ parts = []
+ if os.path.isdir(src_path):
+ src_files = sorted(
+ f for f in os.listdir(src_path)
+ if os.path.isfile(os.path.join(src_path, f))
+ )
+ for name in src_files:
+ parts.append(read_file(os.path.join(src_path, name)))
+ else:
+ print("Предупреждение: не найдена папка: {}".format(SRC_DIR + "/"))
+
+ code_content = "\n".join(parts)
+
+ # Проверяем наличие заглушки
+ if PLACEHOLDER not in template:
+ raise ValueError(
+ "В шаблоне {} не найдена строка-заглушка '{}'".format(
+ TPL_FILE, PLACEHOLDER
+ )
+ )
+
+ # Заменяем заглушку на объединённый код src
+ result = template.replace(PLACEHOLDER, code_content)
+
+ # Записываем результат
+ output_path = os.path.join(BASE_DIR, OUTPUT_FILE)
+ write_file(output_path, result)
+
+ print("Готово: {} -> {} ({} символов)".format(
+ ", ".join([TPL_FILE, SRC_DIR + "/*"]),
+ OUTPUT_FILE,
+ len(result),
+ ))
+
+
+if __name__ == "__main__":
+ build()
\ No newline at end of file
diff --git a/py/split.py b/py/split.py
new file mode 100644
index 0000000..d8b9376
--- /dev/null
+++ b/py/split.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Разбивает src/all.js на несколько файлов по заданным диапазонам строк.
+
+Диапазоны указаны как (start, end) включительно, в порядке добавления.
+Файлы создаются в src/, затем удаляется исходный src/all.js.
+"""
+
+import os
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(BASE_DIR, "src")
+SOURCE = os.path.join(SRC, "all.js")
+
+# (filename, [(start, end), ...])
+PLAN = [
+ ("00-config.js", [(1, 72)]),
+ ("10-dom.js", [(74, 122)]),
+ ("20-property.js", [(124, 239)]),
+ ("30-utils.js", [(241, 302), (601, 626), (899, 922)]),
+ ("40-api.js", [(304, 363)]),
+ ("50-common-ui.js", [(365, 382), (462, 469), (924, 999), (1149, 1259), (1261, 1265), (1627, 1642)]),
+ ("60-cp-files.js", [(471, 599), (628, 858), (1131, 1147)]),
+ ("70-cp-edit.js", [(384, 460), (862, 897), (1001, 1129), (1320, 1443), (1445, 1625), (1644, 2103)]),
+ ("75-map.js", [(1267, 1285), (2105, 2128)]),
+ ("80-route-build.js", [(2130, 2386)]),
+ ("90-route-edit.js", [(2388, 2555)]),
+ ("100-route-stages.js", [(1287, 1318), (2557, 2614)]),
+ ("110-main.js", [(2616, 2821)]),
+]
+
+
+def main():
+ with open(SOURCE, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+
+ total = len(lines)
+ for name, ranges in PLAN:
+ parts = []
+ for start, end in ranges:
+ if start < 1 or end > total or start > end:
+ raise ValueError(
+ "{}: неверный диапазон {}-{} (всего строк {})".format(name, start, end, total)
+ )
+ parts.append("".join(lines[start - 1:end]))
+ content = "\n".join(parts).rstrip("\n") + "\n"
+ path = os.path.join(SRC, name)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ print("OK {} (строк в файле: {})".format(name, content.count("\n")))
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/00-config.js b/src/00-config.js
new file mode 100644
index 0000000..b1be66b
--- /dev/null
+++ b/src/00-config.js
@@ -0,0 +1,72 @@
+ const adminSite = "runcity.geo.rictum.ru"
+
+ const adminApiKey = "2|KaYFQk0txpFivlhphdTHDlS37uat8ILuCbVR6HxH9dd21a66"
+
+ const links = {
+ "Волонтеры": "suv_comp",
+ "Контрольные пункты": "cp_mgmt",
+ "+ КП": "cp_mgmt/?action=edit",
+ "Маршруты": "route_mgmt",
+ "Тесты": "route_mgmt?test=1",
+ "+ Тест": "suv_comp?action=test_online",
+ "Склеить": "suv_comp?action=test_merge",
+ "Прохождение": "callcenter?action=online_log",
+ "КП в трассах": "route_mgmt?action=cp_list",
+ "Связанные КП": relatedPointsHref(),
+ }
+
+ const catLinks = {
+ "Редактор маршрута": (catId) => `route_mgmt?action=edit&cat_id=${catId}`,
+ "Конструктор маршрута": (catId) => `route_mgmt/?action=build2&cat_id=${catId}`,
+ "Карта": (catId) => `route_mgmt?action=map&cat_id=${catId}`,
+ "Этапы": (catId) => `route_mgmt/?action=stages&cat_id=${catId}`,
+ "Легенда": (catId) => `route_mgmt?action=preview&cat_id=${catId}&locale_id=ru`,
+ }
+
+ const fileInputs = {
+ "attachment1": {
+ "l": "legend_photo",
+ "h": "history_photo"
+ },
+ "attachment4": {
+ "l": "legend_files",
+ "h": "history_files"
+ },
+ "attachment2": "admin_photo",
+ "attachment3": "admin_files"
+ }
+
+ const fileInpitVariants = {
+ "l": "для легенды",
+ "h": "для ист. справки"
+ }
+
+ const mapsCenterByCompetition = {
+ "msk2025": {
+ lat: 55.839808,
+ ifSouthern: {
+ lat: 55.798531,
+ lon: 37.690380,
+ },
+ ifEverywhere: {
+ lat: 55.839759,
+ lon: 37.706577
+ }
+ }
+ }
+
+ const localStorageItems = {
+ NEED_UPDATE_ID: "needUpdateId",
+ JUST_CREATED: "justCreated",
+ PREV_CREATED: "prevCreated",
+ LATTITUDE: "lattitude",
+ LONGITUDE: "longitude",
+ NOT_PRETTIFY_EDIT_CP: "notPrettifyEditCp",
+ REDIRECT_EXIT: "redirectExit",
+ DISABLED_PAGES: "disabledPages"
+ }
+
+ const ZOOM = 17
+ const METERS = 510
+
+ const stageColors = ["#ff8080", "#ffc680", "#ffff80", "#80ff80", "#80ffff", "#8080ff", "#ff80ff"]
diff --git a/src/10-dom.js b/src/10-dom.js
new file mode 100644
index 0000000..2eea61e
--- /dev/null
+++ b/src/10-dom.js
@@ -0,0 +1,49 @@
+ class Tag {
+ static make(name, params = {}) {
+ let element = document.createElement(name)
+ for (const [name, value] of Object.entries(params)) {
+ if (name == "on") {
+ for (const [event, listener] of Object.entries(value)) {
+ element.addEventListener(event, listener)
+ }
+ }
+ else if (name == "classes") {
+ element.classList.add(...value.split(" "))
+ }
+ else if (name == "children") {
+ element.append(...value)
+ }
+ else if (name.startsWith("_")) {
+ element.setAttribute(name.substring(1), value)
+ }
+ else
+ element[name] = value
+ }
+
+ return element
+ }
+
+ static div(params) {
+ return this.make("div", params)
+ }
+
+ static span(params) {
+ return this.make("span", params)
+ }
+
+ static button(params) {
+ return this.make("button", params)
+ }
+
+ static input(params) {
+ return this.make("input", params)
+ }
+
+ static a(params) {
+ return this.make("a", params)
+ }
+
+ static img(params) {
+ return this.make("img", params)
+ }
+ }
diff --git a/src/100-route-stages.js b/src/100-route-stages.js
new file mode 100644
index 0000000..023ff0f
--- /dev/null
+++ b/src/100-route-stages.js
@@ -0,0 +1,91 @@
+ function addStageLink() {
+ let linkCells = [...document.querySelectorAll("#content table td:nth-child(2)")]
+ for (const linkCell of linkCells) {
+ let href = linkCell.querySelector("a").href
+ let catId = new URLSearchParams(href).get("cat_id")
+ let constructorLink = linkCell.querySelector(`a[href="${catLinks["Конструктор маршрута"](catId).replace("route_mgmt/", "")}"]`)
+
+ if (constructorLink) {
+ let innerText = "Этапы"
+ let stagesLink = Tag.a({
+ innerText: innerText,
+ href: catLinks[innerText](catId)
+ })
+
+ constructorLink.after(stagesLink)
+ constructorLink.after(". ")
+ }
+ }
+ }
+
+ function hideNonTesters() {
+ if (urlParams().get("test") == null) return
+
+ document.querySelectorAll(`td:nth-child(2)`).forEach(el => {
+ let innerHTML = el.innerHTML
+ let from = innerHTML.indexOf("Отчёты тестировщиков")
+ if (from == -1)
+ from = innerHTML.length
+
+ el.innerHTML = innerHTML.substring(from)
+ })
+ }
+
+ function toggleStagePageRows(rows) {
+ let showIndex = [0, 3]
+ let to = rows.findIndex(el => el.querySelector("th")?.textContent.trim() == "Бонусы")
+ if (to == -1)
+ to = rows.length
+
+ toggleRows(rows, 3, to, 8, showIndex)
+ toggleRows(rows, to, rows.length, 1, [])
+ }
+
+ function hideUselessRowsFromRouteStagesPage() {
+ let styles = `
+ tr.collapsed {
+ display: block;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ }
+ `
+
+ let rows = [...document.querySelectorAll(`#content tbody tr`)]
+ let collapseButton = Tag.button({
+ type: "button",
+ textContent: "Показать",
+ on: {
+ click: () => {
+ toggleStagePageRows(rows)
+ collapseButton.textContent = toggleText(collapseButton.textContent, "Показать", "Скрыть")
+ }
+ }
+ })
+
+ toggleStagePageRows(rows)
+
+ document.querySelector(`table tr:first-child th`).append(collapseButton)
+
+ addStylesToHead(styles)
+ }
+
+ function addCpNameToOptions() {
+ let options = [...document.querySelectorAll(`option`)]
+ let cpIds = [...new Set(
+ options
+ .map($option => $option.value)
+ .filter($value => $value != null)
+ )
+ ]
+
+ ;(async () => {
+ let points = await getPointsByField("cp_id", cpIds)
+
+ for (const option of options) {
+ if (option.value != null && points[option.value] != null) {
+ option.innerText = option.innerText + " - " + points[option.value].name_int
+ }
+ }
+ })()
+ }
diff --git a/src/110-main.js b/src/110-main.js
new file mode 100644
index 0000000..aac60c7
--- /dev/null
+++ b/src/110-main.js
@@ -0,0 +1,206 @@
+ function addCommonStyles() {
+ addStylesToHead(`
+ #content h1 {
+ margin-bottom: 0;
+ }
+ `)
+ }
+
+ function getDisabledPages() {
+ return JSON.parse(localStorage.getItem(localStorageItems.DISABLED_PAGES) ?? "{}")
+ }
+
+ function saveDisabledPages(pages) {
+ localStorage.setItem(localStorageItems.DISABLED_PAGES, JSON.stringify(pages))
+ }
+
+ function isPageDisabled() {
+ let disabledPages = getDisabledPages()
+ let pageType = getPageType()
+
+ return disabledPages[pageType]?.[getAction()]
+ }
+
+ function addDisabledPage() {
+ let disabledPages = getDisabledPages()
+ let pageType = getPageType()
+ if (!(pageType in disabledPages)) {
+ disabledPages[pageType] = {}
+ }
+ disabledPages[pageType][getAction()] = true
+
+ saveDisabledPages(disabledPages)
+ }
+
+ function removeDisabledPage() {
+ let disabledPages = getDisabledPages()
+ let pageType = getPageType()
+ if (!(pageType in disabledPages)) {
+ return
+ }
+ delete disabledPages[pageType][getAction()]
+
+ saveDisabledPages(disabledPages)
+ }
+
+ function addEnableButtons() {
+ let enableButton = Tag.button({
+ type: "button",
+ id: "enable-button",
+ textContent: "Сделать красиво",
+ on: {
+ click: () => {
+ if (pretty) return
+
+ hide([enableButton])
+ prettify()
+ }
+ }
+ })
+
+ if (!isPageDisabled()) {
+ hide([enableButton])
+ }
+
+ let header = document.querySelector("#header")
+ header.insertBefore(enableButton, header.querySelector("#globalmenu"))
+ header.insertBefore(createAlwaysEnable(), header.querySelector("#globalmenu"))
+
+ addStylesToHead(`
+ #always-enable-container {
+ display: inline-block;
+ }
+
+ .hidden {
+ display: none;
+ }
+ `)
+ }
+
+ function addDisableButton() {
+ let disableButton = Tag.button({
+ id: "disable-button",
+ type: "button",
+ textContent: "Сделать как было",
+ on: {
+ click: () => {
+ if (!pretty) return
+
+ if (!confirm("Это действие отключит скрипт на этой странице и удалит все несохраненные данные")) return
+
+ addDisabledPage()
+ pretty = false
+ location.reload()
+ }
+ }
+ })
+
+ header.querySelector("#enable-button").after(disableButton)
+ }
+
+ function createAlwaysEnable() {
+ let alwaysEnableContainer = Tag.div({
+ id: "always-enable-container",
+ children: [
+ Tag.make("label", {
+ _for: "always-enable",
+ textContent: "Всегда красиво"
+ })
+ ]
+ })
+
+ let alwaysEnableCheckbox = Tag.input({
+ type: "checkbox",
+ id: "always-enable",
+ name: "always-enable",
+ on: {
+ change: function () {
+ if (this.checked)
+ removeDisabledPage()
+ else
+ addDisabledPage()
+ }
+ }
+ })
+
+
+ if (!isPageDisabled()) {
+ alwaysEnableCheckbox.checked = true
+ }
+
+ alwaysEnableContainer.append(alwaysEnableCheckbox)
+
+ return alwaysEnableContainer
+ }
+
+ function prettify() {
+ if (pretty) return
+
+ pretty = true
+
+ addDisableButton()
+
+ addCommonStyles()
+
+ /* SWITCH FOR DIFFERENT PAGES */
+
+ addStickyMenu()
+
+ if (isCpEditPage()) {
+ prettifyEditCpPage()
+ }
+
+ if (isRouteListPage()) {
+ addStageLink()
+ hideNonTesters()
+ }
+
+ if (isRouteBuildPage()) {
+ prettifyRouteBuildPage()
+ }
+
+ if (isRouteEditPage()) {
+ prettifyRouteEditPage()
+ }
+
+ if (isRouteStagesPage()) {
+ hideUselessRowsFromRouteStagesPage()
+ addCpNameToOptions()
+ }
+ }
+
+ function prettifyIfEnabled() {
+ if (isPageDisabled()) return
+
+ prettify()
+ }
+
+ /* REDIRECTS */
+
+ redirectAfterNewCpIfNeeded()
+
+ /* PRETTIFY */
+
+ let pretty = false
+
+ addEnableButtons()
+
+ if (isCpDeletePage()) {
+ bindDeleteButton()
+ return
+ }
+
+ prettifyIfEnabled()
+
+ /* MAPS */
+
+ addClearBoth()
+
+ if (hasMap()) {
+ initMapbox()
+ addFullscreenButton()
+
+ if (isRouteBuildPage() || isRouteMapPage()) {
+ centerMap()
+ }
+ }
diff --git a/src/20-property.js b/src/20-property.js
new file mode 100644
index 0000000..223b62d
--- /dev/null
+++ b/src/20-property.js
@@ -0,0 +1,116 @@
+ class Property {
+ name
+ content
+ desc
+
+ constructor(row = null) {
+ if (row == null) return
+
+ let columns = [...row.querySelectorAll('td, th')].map(el => el.cloneNode(true))
+ if (columns.length === 1) {
+ this.content = columns[0].innerHTML.trim()
+ }
+ else if (columns.length >= 2) {
+ this.name = columns[0].innerHTML.trim()
+ let elementsToRemove = columns[1].querySelector("input[name=\"read_gps2\"]")
+ if (elementsToRemove) {
+ columns[1].removeChild(elementsToRemove)
+ }
+
+ this.content = columns[1].innerHTML
+ .replaceAll("
", "")
+ .replaceAll(/\s*Перейти к легенде.*$/gs, "")
+ .replaceAll(`общие ("c")`, "")
+ .replaceAll(`("l")`, "")
+ .replaceAll(`("h")`, "")
+ .replaceAll("Получить координаты КП из картинки", "")
+ .trim()
+ }
+
+ if (columns.length === 3) {
+ this.desc = columns[2].innerHTML.replaceAll("
", "").replaceAll(/(\n)\s*/g, "$1").trim()
+ }
+ }
+
+ toDiv(altName = null, altDesc = null) {
+ let desc = altDesc ?? this.desc
+ let descTooltip = desc ? Tag.div({
+ classes: 'desc-icon-container',
+ title: desc,
+ children: [
+ Tag.img({
+ classes: 'desc-icon',
+ src: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYXRlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgo8c3ZnIGlkPSJzdmcyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjAwIiB3aWR0aD0iMjAwIiB2ZXJzaW9uPSIxLjAiPgogPHBhdGggaWQ9InBhdGgyMzgyIiBkPSJtMTY1LjMzIDExMy40NGExMDMuNjEgMTAzLjYxIDAgMSAxIC0yMDcuMjIgMCAxMDMuNjEgMTAzLjYxIDAgMSAxIDIwNy4yMiAweiIgdHJhbnNmb3JtPSJtYXRyaXgoLjkzNzM5IDAgMCAuOTM3MzkgNDIuMTQzIC02LjMzOTIpIiBzdHJva2Utd2lkdGg9IjAiIGZpbGw9IiNmZmYiLz4KIDxnIGlkPSJsYXllcjEiPgogIDxwYXRoIGlkPSJwYXRoMjQxMyIgZD0ibTEwMCAwYy01NS4yIDAtMTAwIDQ0LjgtMTAwIDEwMC01LjA0OTVlLTE1IDU1LjIgNDQuOCAxMDAgMTAwIDEwMHMxMDAtNDQuOCAxMDAtMTAwLTQ0LjgtMTAwLTEwMC0xMDB6bTAgMTIuODEyYzQ4LjEzIDAgODcuMTkgMzkuMDU4IDg3LjE5IDg3LjE4OHMtMzkuMDYgODcuMTktODcuMTkgODcuMTktODcuMTg4LTM5LjA2LTg3LjE4OC04Ny4xOSAzOS4wNTgtODcuMTg4IDg3LjE4OC04Ny4xODh6bTEuNDcgMjEuMjVjLTUuNDUgMC4wMy0xMC42NTMgMC43MzctMTUuMjgyIDIuMDYzLTQuNjk5IDEuMzQ2LTkuMTI2IDMuNDg0LTEyLjg3NiA2LjIxOS0zLjIzOCAyLjM2Mi02LjMzMyA1LjM5MS04LjY4NyA4LjUzMS00LjE1OSA1LjU0OS02LjQ2MSAxMS42NTEtNy4wNjMgMTguNjg3LTAuMDQgMC40NjgtMC4wNyAwLjg2OC0wLjA2MiAwLjg3NiAwLjAxNiAwLjAxNiAyMS43MDIgMi42ODcgMjEuODEyIDIuNjg3IDAuMDUzIDAgMC4xMTMtMC4yMzQgMC4yODItMC45MzcgMS45NDEtOC4wODUgNS40ODYtMTMuNTIxIDEwLjk2OC0xNi44MTMgNC4zMi0yLjU5NCA5LjgwOC0zLjYxMiAxNS43NzgtMi45NjkgMi43NCAwLjI5NSA1LjIxIDAuOTYgNy4zOCAyIDIuNzEgMS4zMDEgNS4xOCAzLjM2MSA2Ljk0IDUuODEzIDEuNTQgMi4xNTYgMi40NiA0LjU4NCAyLjc1IDcuMzEyIDAuMDggMC43NTkgMC4wNSAyLjQ4LTAuMDMgMy4yMTktMC4yMyAxLjgyNi0wLjcgMy4zNzgtMS41IDQuOTY5LTAuODEgMS41OTctMS40OCAyLjUxNC0yLjc2IDMuODEyLTIuMDMgMi4wNzctNS4xOCA0LjgyOS0xMC43OCA5LjQwNy0zLjYgMi45NDQtNi4wNCA1LjE1Ni04LjEyIDcuMzQzLTQuOTQzIDUuMTc5LTcuMTkxIDkuMDY5LTguNTY0IDE0LjcxOS0wLjkwNSAzLjcyLTEuMjU2IDcuNTUtMS4xNTYgMTMuMTkgMC4wMjUgMS40IDAuMDYyIDIuNzMgMC4wNjIgMi45N3YwLjQzaDIxLjU5OGwwLjAzLTIuNGMwLjAzLTMuMjcgMC4yMS01LjM3IDAuNTYtNy40MSAwLjU3LTMuMjcgMS40My01IDMuOTQtNy44MSAxLjYtMS44IDMuNy0zLjc2IDYuOTMtNi40NyA0Ljc3LTMuOTkxIDguMTEtNi45OSAxMS4yNi0xMC4xMjUgNC45MS00LjkwNyA3LjQ2LTguMjYgOS4yOC0xMi4xODcgMS40My0zLjA5MiAyLjIyLTYuMTY2IDIuNDYtOS41MzIgMC4wNi0wLjgxNiAwLjA3LTMuMDMgMC0zLjk2OC0wLjQ1LTcuMDQzLTMuMS0xMy4yNTMtOC4xNS0xOS4wMzItMC44LTAuOTA5LTIuNzgtMi44ODctMy43Mi0zLjcxOC00Ljk2LTQuMzk0LTEwLjY5LTcuMzUzLTE3LjU2LTkuMDk0LTQuMTktMS4wNjItOC4yMy0xLjYtMTMuMzUtMS43NS0wLjc4LTAuMDIzLTEuNTktMC4wMzYtMi4zNy0wLjAzMnptLTEwLjkwOCAxMDMuNnYyMmgyMS45OTh2LTIyaC0yMS45OTh6Ii8+CiA8L2c+Cjwvc3ZnPgo='
+ })
+ ]
+ }) : ""
+
+ return Tag.div({
+ children: [
+ Tag.make("label", {
+ _for: '',
+ innerHTML: altName ?? this.name ?? ""
+ }),
+ Tag.div({
+ innerHTML: this.content ?? "",
+ }),
+ descTooltip
+ ]
+ })
+ }
+ }
+
+ function createFrom(rows, classList, data) {
+ let container = Tag.div({
+ classes: classList
+ })
+ for (const options of data) {
+ let div
+ if (options.index != null) {
+ let prop = new Property(rows[options.index])
+ div = prop.toDiv(options.name, options.desc)
+ }
+ else {
+ let prop = new Property()
+ div = prop.toDiv(options.name)
+ }
+ container.append(div)
+ }
+
+ return container
+ }
+
+ function createFromMulti(rows, classList, data) {
+ let rowData = []
+ let toIndex = data.to ?? rows.length - 1
+ for (let i = data.from; i <= toIndex; i++) {
+ rowData.push({ index: i })
+ }
+
+ return createFrom(rows, classList, rowData)
+ }
+
+ function addCss(link) {
+ document.head.append(
+ Tag.make("link", {
+ href: link,
+ rel: "stylesheet"
+ })
+ )
+ }
+
+ function addJs(link) {
+ document.head.append(
+ Tag.make("script", {
+ src: link
+ })
+ )
+ }
+
+ function addStylesToHead(styles) {
+ document.head.append(
+ Tag.make("style", {
+ textContent: styles
+ })
+ )
+ }
diff --git a/src/30-utils.js b/src/30-utils.js
new file mode 100644
index 0000000..abcc7c7
--- /dev/null
+++ b/src/30-utils.js
@@ -0,0 +1,114 @@
+ const sleep = ms => new Promise(res => setTimeout(res, ms))
+
+ function pathNameSplit() {
+ return window.location.pathname.split('/')
+ }
+
+ function getCompetition() {
+ return pathNameSplit()[1]
+ }
+
+ function getPageType() {
+ return pathNameSplit()[2]
+ }
+
+ function urlParams() {
+ return new URLSearchParams(location.search)
+ }
+
+ function getAction() {
+ let params = urlParams()
+ return params.get("action")
+ }
+
+ function isCpManagement() {
+ return getPageType() === "cp_mgmt"
+ }
+
+ function isRouteManagement() {
+ return getPageType() === "route_mgmt"
+ }
+
+ function isCpListPage() {
+ return isCpManagement() && getAction() === null
+ }
+
+ function isCpEditPage() {
+ return isCpManagement() && getAction() === "edit"
+ }
+
+ function isCpDeletePage() {
+ return isCpManagement() && getAction() === "delete"
+ }
+
+ function isRouteListPage() {
+ return isRouteManagement() && getAction() === null
+ }
+
+ function isRouteBuildPage() {
+ return isRouteManagement() && getAction() === "build2"
+ }
+
+ function isRouteEditPage() {
+ return isRouteManagement() && getAction() === "edit"
+ }
+
+ function isRouteStagesPage() {
+ return isRouteManagement() && getAction() === "stages"
+ }
+
+ function isRouteMapPage() {
+ return isRouteManagement() && getAction() === "map"
+ }
+
+ function getAttachmentIndex(input) {
+ return input.name.replace(/\D/g, '')
+ }
+
+ function getContainersByVariant(variant, attachmentIndex) {
+ let invertedVariant = variant == "l" ? "h" : "l"
+ let variants = fileInputs[`attachment${attachmentIndex}`]
+
+ return [document.querySelector(`[data-container="${variants[invertedVariant]}"]`), document.querySelector(`[data-container="${variants[variant]}"]`)]
+ }
+
+ function makeFileListContainer() {
+ return Tag.div({
+ classes: "file-list-container"
+ })
+ }
+
+ function getFileListContainer(filesContainer) {
+ let fileListContainer = filesContainer.querySelector(".file-list-container")
+ if (fileListContainer == null) {
+ fileListContainer = makeFileListContainer()
+ filesContainer.append(fileListContainer)
+ }
+
+ return fileListContainer
+ }
+
+ function hide(elList) {
+ elList.forEach(el => el.classList.add("hidden"))
+ }
+
+ function show(elList) {
+ elList.forEach(el => el.classList.remove("hidden"))
+ }
+
+ function toggleRows(rows, from, to, cycle, showIndex) {
+ for (let i = from; i < to; i += cycle) {
+ for (let j = 0; j < cycle; j++) {
+ if (!showIndex.includes(j)) {
+ rows[i + j]?.classList.toggle("collapsed")
+ }
+ }
+ }
+ }
+
+ function toggleText(current, ...variants) {
+ let index = variants.indexOf(current)
+ if (index == -1) return null
+
+ return variants[index + 1 < variants.length ? index + 1 : 0]
+ }
diff --git a/src/40-api.js b/src/40-api.js
new file mode 100644
index 0000000..7c0bd7f
--- /dev/null
+++ b/src/40-api.js
@@ -0,0 +1,60 @@
+ function cpLink(cpId) {
+ return `/${getCompetition()}/cp_mgmt/?action=edit&cp_id=${cpId}`
+ }
+
+ async function fetchApi(route, params) {
+ return await fetch(`https://${adminSite}/api/${route}`, Object.assign({
+ headers: new Headers({
+ 'Authorization': `Bearer ${adminApiKey}`,
+ }),
+ }, params))
+ }
+
+ async function updatePoint(formData) {
+ return await fetchApi(`competitions/${getCompetition()}/update`, {
+ method: 'POST',
+ body: formData
+ })
+ }
+
+ async function deletePoint() {
+ return await fetchApi(`points/${urlParams().get("cp_id")}`, {
+ method: 'DELETE'
+ })
+ }
+
+ async function getPointsByField(field, values) {
+ let params = new URLSearchParams()
+ for (const value of values) {
+ params.append(`${field}[]`, value)
+ }
+ let response = await fetchApi(`competitions/${getCompetition()}/points?${params.toString()}`, {
+ method: 'GET'
+ })
+ return await response.json()
+ }
+
+ async function updateRoute(formData) {
+ return await fetchApi(`competitions/${getCompetition()}/route`, {
+ method: 'POST',
+ body: formData
+ })
+ }
+
+ async function getRoutes() {
+ let response = await fetchApi(`points/${urlParams().get("cp_id")}/routes`, {
+ method: 'GET'
+ })
+ return await response.json()
+ }
+
+ async function getUnknownPoints() {
+ let response = await fetchApi(`competitions/${getCompetition()}/routes/${urlParams().get("cat_id")}/unknown`, {
+ method: 'GET'
+ })
+ return await response.json()
+ }
+
+ function relatedPointsHref() {
+ return `https://${adminSite}/competitions/${getCompetition()}/relations`
+ }
diff --git a/src/50-common-ui.js b/src/50-common-ui.js
new file mode 100644
index 0000000..49013e9
--- /dev/null
+++ b/src/50-common-ui.js
@@ -0,0 +1,239 @@
+ function createPseudoButton(button, callback, clickAfter = true) {
+ let pseudoButton = Tag.button({
+ type: "button",
+ textContent: button.value,
+ classes: [...button.classList].join(" ") + " pseudo-save",
+ on: {
+ click: async () => {
+ await callback()
+ if (clickAfter) {
+ button.click()
+ }
+ }
+ }
+ })
+
+ button.style.display = "none"
+ button.parentElement.insertBefore(pseudoButton, button)
+ }
+
+ function makeDownloadLink(name, href = null) {
+ return Tag.a({
+ download: name,
+ textContent: "Скачать",
+ target: "_blank",
+ href: href
+ })
+ }
+
+ function makeSwiper(dialog, fileListContainer, src) {
+ let files = [...fileListContainer.querySelectorAll(`:is(.preview, .preview-small)`)]
+
+ let swiperWrapper = Tag.div({
+ classes: "swiper-wrapper"
+ })
+
+ for (const file of files) {
+ let swiperSlide = Tag.div({
+ classes: "swiper-slide",
+ children: [
+ Tag.a({
+ classes: "swiper-download-link",
+ href: file.dataset.origin,
+ text: "Скачать",
+ target: "_blank"
+ }),
+ Tag.img({
+ src: file.dataset.origin
+ })
+ ]
+ })
+
+ swiperWrapper.append(swiperSlide)
+ }
+
+ let swiperDiv = Tag.div({
+ classes: "swiper",
+ children: [
+ swiperWrapper,
+ Tag.div({
+ classes: "swiper-button-prev"
+ }),
+ Tag.div({
+ classes: "swiper-button-next"
+ })
+ ]
+ })
+
+ dialog.append(swiperDiv)
+
+ new Swiper('.swiper', {
+ initialSlide: files.findIndex(el => el.dataset.origin && el.dataset.origin === src || el.src === src),
+
+ navigation: {
+ nextEl: '.swiper-button-next',
+ prevEl: '.swiper-button-prev',
+ },
+ })
+
+ bindArrowsForGallery('.swiper-button-prev', '.swiper-button-next')
+ }
+
+ const bindArrowsForGallery = (function () {
+ let executed = false
+ return function(leftButtonQuery, rightButtonQuery) {
+ if (executed) return
+ executed = true
+
+ document.addEventListener("keydown", function (e) {
+ if (!document.querySelector("dialog").open) return
+ switch (e.key) {
+ case "ArrowLeft":
+ document.querySelector(leftButtonQuery).click()
+ break
+
+ case "ArrowRight":
+ document.querySelector(rightButtonQuery).click()
+ break
+
+ default: return
+ }
+ e.preventDefault()
+ })
+ }
+ })()
+
+ function createMenuFromLinks(links, blanks = []) {
+ let menu = Tag.make("menu")
+
+ for (const [label, href] of Object.entries(links)) {
+ let menuItem = Tag.make("li")
+ let link = Tag.a({
+ href: href,
+ innerText: label
+ })
+
+ if (blanks.includes(label)) {
+ link.target = "_blank"
+ }
+
+ menuItem.append(link)
+ menu.append(menuItem)
+ }
+
+ return menu
+ }
+
+ function addStickyMenu() {
+ let competition = getCompetition()
+ if (!competition) return
+
+ let menuContainer = Tag.make("nav", {
+ classes: "sticky-menu"
+ })
+
+ let mainLinksFormatted = {}
+ for (const [label, href] of Object.entries(links)) {
+ mainLinksFormatted[label] = href.startsWith("http") ? href : `/${competition}/${href}`
+ }
+ menuContainer.append(createMenuFromLinks(mainLinksFormatted, ["Легенда"]))
+
+ let goToContainer = Tag.make("li", {
+ classes: "go-to-container",
+ children: [
+ Tag.input({
+ type: "text",
+ id: "go-to-cp"
+ }),
+ Tag.span({
+ textContent: "✏️ КП",
+ on: {
+ click: async function () {
+ let number = document.querySelector("#go-to-cp").value
+ let json = await getPointsByField("number", [number])
+ let id = json[number].cp_id
+
+ if (id == null) {
+ alert("Нет КП с таким номером")
+ return
+ }
+
+ location.href = cpLink(id)
+ }
+ }
+ })
+ ]
+ })
+
+ ;[...menuContainer.querySelectorAll(`li`)].find(el => el.querySelector(`a`).textContent.trim() == "Контрольные пункты")?.after(goToContainer)
+
+ let catId = urlParams().get("cat_id")
+ if (catId !== null) {
+ let catLinksFormatted = {}
+ for (const [label, href] of Object.entries(catLinks)) {
+ catLinksFormatted[label] = href(catId)
+ }
+
+ menuContainer.append(createMenuFromLinks(catLinksFormatted))
+ }
+
+ document.querySelector("#header").after(menuContainer)
+
+ addStylesToHead(`
+ .sticky-menu {
+ position: sticky;
+ top: 0;
+ z-index: 9999;
+
+ menu {
+ display: flex;
+ gap: .3rem;
+ background: white;
+ font-size: 1.2rem;
+ padding-left: 10px;
+
+ li {
+ list-style-type: none;
+
+ &:not(:last-child)::after {
+ content: " | "
+ }
+ }
+ }
+
+ .go-to-container {
+ input {
+ width: 5em;
+ }
+
+ span {
+ margin-left: .5em;
+ cursor: pointer;
+ }
+ }
+ }
+ `)
+ }
+
+ function addClearBoth() {
+ let clearEl = Tag.div()
+ clearEl.style.clear = "both"
+ document.body.append(clearEl)
+ }
+
+ function makeDialog() {
+ let dialog = Tag.make("dialog", {
+ id: "dialog",
+ on: {
+ click: e => {
+ if (e.target == dialog) {
+ e.target.close()
+ }
+ },
+ close: e => {
+ e.target.innerHTML = ""
+ }
+ }
+ })
+ document.body.append(dialog)
+ }
diff --git a/src/60-cp-files.js b/src/60-cp-files.js
new file mode 100644
index 0000000..1b4ad51
--- /dev/null
+++ b/src/60-cp-files.js
@@ -0,0 +1,380 @@
+ async function sendFileDeleted(inputName, fileInputVariant = null) {
+ let formData = new FormData()
+
+ let normalInputName = fileInputVariant !== null ? fileInputs[inputName][fileInputVariant] : fileInputs[inputName]
+ formData.set(`cp[id]`, document.querySelector("input[name='cp[id]']").value)
+ formData.set(`cp[${normalInputName}]`, "-1")
+ let result = await updatePoint(formData)
+ try {
+ console.log(result.ok)
+ }
+ catch (e) {
+ console.log(e)
+ }
+ }
+
+ function parsePurpose(cell) {
+ return cell?.querySelector("td:nth-child(2)")?.innerHTML.match(/для[^\|]+/g)?.[0].trim()
+ }
+
+ function parsefileInputVariant(cell) {
+ return Object.keys(fileInpitVariants).find(key => fileInpitVariants[key] === parsePurpose(cell))
+ }
+
+ function addDisplayedFile(inputName, type, file, preview, removeLink, removedFilesLinks, variant = null) {
+ if (!inputName || !file || !removeLink) return
+
+ let fileListContainer
+ if (variant !== null) {
+ if (inputName == "attachment1" || inputName == "attachment4") {
+ if (variant == "l") {
+ fileListContainer = document.querySelector(`[data-container="${fileInputs[inputName][variant]}"] .file-list-container`)
+ }
+ else if (variant == "h") {
+ let filesContainer = document.querySelector(`[data-container="${fileInputs[inputName].h}"]`)
+ fileListContainer = getFileListContainer(filesContainer)
+ }
+ }
+ }
+
+ let input = document.querySelector(`input[name="${inputName}\[\]"]`)
+ fileListContainer ??= input.parentElement.parentElement.querySelector(".file-list-container")
+ let fileContainer = Tag.div({
+ classes: "file-container"
+ })
+
+ const displayedFile = Tag.make(type ?? "a", {
+ classes: "preview-small"
+ })
+
+ if (type == "img") {
+ displayedFile.src = preview
+ displayedFile.dataset.origin = file
+ displayedFile.addEventListener("click", () => {
+ let dialog = document.querySelector("dialog")
+ makeSwiper(dialog, fileListContainer, file)
+ dialog.showModal()
+ })
+ }
+ else if (type == "audio") {
+ displayedFile.src = file
+ displayedFile.setAttribute("controls", "")
+ }
+ else {
+ displayedFile.href = file
+ displayedFile.textContent = "Файл"
+ }
+
+ let fileButtonsContainer = Tag.div({
+ classes: "file-buttons-container"
+ })
+
+ fileButtonsContainer.append(makeDownloadLink(/[^/]*$/.exec(new URL(file).pathname)[0], file))
+
+ let deleteButton = Tag.button({
+ type: "button",
+ classes: "button-delete",
+ textContent: "x",
+ on: {
+ click: async e => {
+ if (!confirm("Точно?")) return
+
+ await fetch(removeLink)
+ await sendFileDeleted(inputName, variant)
+ removedFilesLinks.push(removeLink)
+
+ fileListContainer.removeChild(fileContainer)
+ }
+ }
+ })
+
+ fileButtonsContainer.append(deleteButton)
+
+ fileContainer.append(displayedFile)
+ fileContainer.append(fileButtonsContainer)
+
+ fileListContainer.append(fileContainer)
+ }
+
+ function dataURLtoFile(dataurl, filename) {
+ let arr = dataurl.split(',')
+ let mime = arr[0].match(/:(.*?);/)[1]
+ let bstr = atob(arr[arr.length - 1])
+ let n = bstr.length
+ let u8arr = new Uint8Array(n)
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n)
+ }
+ return new File([u8arr], filename, { type: mime })
+ }
+
+ function getHtmlElementByFileType(file) {
+ const htmlElementsForTypes = {
+ "image/": "img",
+ "audio/": "audio"
+ }
+ let res = "div"
+ for (const [type, el] of Object.entries(htmlElementsForTypes)) {
+ if (file.type.startsWith(type)) {
+ res = el
+ break
+ }
+ }
+ let el = Tag.make(res)
+ if (res == "audio") {
+ el.setAttribute("controls", "")
+ }
+
+ return el
+ }
+
+ function makeHandleFilesFunc() {
+ let storedFiles = []
+
+ return function () {
+ let dt = new DataTransfer()
+
+ if (storedFiles.length) {
+ for (const oldFile of storedFiles) {
+ let oldFileObj = dataURLtoFile(oldFile.data, oldFile.name)
+ dt.items.add(oldFileObj)
+ }
+ }
+
+ for (let i = 0; i < this.files.length; i++) {
+ const file = this.files[i]
+ dt.items.add(file)
+
+ let variant = this.parentElement.querySelector('input[type="radio"]:checked')?.value
+ let fileListContainer
+
+ if (variant) {
+ let [_, filesContainer] = getContainersByVariant(variant, getAttachmentIndex(this))
+ fileListContainer = getFileListContainer(filesContainer)
+ }
+ else {
+ fileListContainer = this.parentElement.parentElement.querySelector(".file-list-container")
+ }
+
+ let fileContainer = Tag.div({
+ classes: "file-container file-container-new"
+ })
+
+ const displayedFile = getHtmlElementByFileType(file)
+ displayedFile.classList.add("preview")
+ displayedFile.file = file
+ displayedFile.addEventListener("click", () => {
+ let dialog = document.querySelector("dialog")
+ makeSwiper(dialog, fileListContainer, displayedFile.src)
+ dialog.showModal()
+ })
+
+ let fileButtonsContainer = Tag.div({
+ classes: "file-buttons-container"
+ })
+
+ let downloadLink = makeDownloadLink(file.name)
+ fileButtonsContainer.append(downloadLink)
+
+ let deleteButton = Tag.button({
+ classes: "button-delete",
+ textContent: "x",
+ type: "button",
+ on: {
+ click: e => {
+ let index = [...fileContainer.parentElement.children].indexOf(fileContainer)
+
+ if (!confirm("Точно?")) return
+
+ storedFiles.splice(storedFiles.length - this.files.length + index, 1)
+
+ let rdt = new DataTransfer()
+ for (const file of this.files) {
+ rdt.items.add(file)
+ }
+ rdt.items.remove(index)
+ this.files = rdt.files
+
+ fileContainer.parentElement.removeChild(fileContainer)
+ }
+ }
+ })
+
+ fileButtonsContainer.append(deleteButton)
+
+ fileContainer.append(displayedFile)
+ fileContainer.append(fileButtonsContainer)
+
+ fileListContainer.append(fileContainer)
+
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ displayedFile.src = e.target.result
+ downloadLink.setAttribute("href", displayedFile.src)
+
+ storedFiles.push({ data: displayedFile.src, name: file.name })
+ }
+ reader.readAsDataURL(file)
+ }
+
+ this.files = dt.files
+ }
+ }
+
+ let prettifyFiles = (function () {
+ let executed = false
+ return function (insertedFileRows, removedFilesLinks) {
+ if (executed) return
+ executed = true
+
+ document.querySelector("#new").querySelectorAll("input[type=file]").forEach((element, index) => {
+ element.id = `input-file-${index}`
+ element.setAttribute("multiple", "multiple")
+ element.dataset.index = index
+ let pseudoInput = Tag.make("label", {
+ classes: "custom-file-upload",
+ _for: element.id,
+ textContent: "+"
+ })
+ element.parentElement.insertBefore(pseudoInput, element)
+
+ element.parentElement.parentElement.append(makeFileListContainer())
+
+ element.addEventListener("change", makeHandleFilesFunc(), false)
+ })
+
+ moveFileInputValues(insertedFileRows, removedFilesLinks)
+ }
+ })()
+
+ function saveInputValues(from) {
+ let fromInputs = [...from.querySelectorAll("input, textarea")]
+ let result = new Map()
+
+ for (const fromEl of fromInputs) {
+ result.set(fromEl, fromEl.type == "radio" ? fromEl.checked : fromEl.value)
+ }
+
+ return result
+ }
+
+ function setInputValues(to, values) {
+ let toInputs = [...to.querySelectorAll("input, textarea")]
+
+ for (const [fromEl, value] of values) {
+ let currentToInput = toInputs.find(toEl =>
+ toEl.type !== 'file' &&
+ toEl.type === fromEl.type &&
+ toEl.name === fromEl.name &&
+ toEl.id === fromEl.id &&
+ (toEl.type !== "radio" || toEl.value === fromEl.value)
+ )
+
+ if (currentToInput) {
+ currentToInput.checked = fromEl.checked
+ currentToInput.value = fromEl.value
+ }
+ }
+ }
+
+ function moveFileInputValues(insertedFileRows, removedFilesLinks) {
+ for (const [inputRowIndex, rows] of insertedFileRows) {
+ for (const row of rows) {
+ let fileInput = null, type = null, fileUrl = null, deleteUrl = null, preview = null
+ if (row.querySelector("img")) {
+ type = "img"
+ fileUrl = row.querySelector("td:first-child a").href
+ preview = row.querySelector("img").src
+ }
+ else if (row.querySelector("audio")) {
+ type = "audio"
+ fileUrl = row.querySelector("audio").src
+ }
+ else {
+ fileUrl = row.querySelector("td:first-child a").href
+ }
+
+ deleteUrl = row.querySelector("td:nth-child(2) a").href
+
+ if (inputRowIndex == 23) {
+ fileInput = "attachment1"
+ }
+ else if (inputRowIndex == 27) {
+ fileInput = "attachment4"
+ }
+ else if (inputRowIndex == 31) {
+ fileInput = "attachment2"
+ }
+ else if (inputRowIndex == 35) {
+ fileInput = "attachment3"
+ }
+ else {
+ console.log(inputRowIndex)
+ return
+ }
+
+ let variant = parsefileInputVariant(row)
+
+ addDisplayedFile(fileInput, type, fileUrl, preview, deleteUrl, removedFilesLinks, variant)
+ }
+ }
+ }
+
+ function moveNewFilesOnVariantChange() {
+ document.querySelectorAll(`input[name^="new_file_type"]`).forEach(el => el.addEventListener("change", function () {
+ let attachmentIndex = getAttachmentIndex(this)
+
+ let [from, to] = getContainersByVariant(this.value, attachmentIndex)
+ let newFiles = [...from.querySelectorAll(`.file-container-new`)]
+ for (const newFile of newFiles) {
+ getFileListContainer(to).append(newFile)
+ }
+ }))
+ }
+
+ function removeDeletedFilesFromUglyVersion(removeLink) {
+ let shortHref = removeLink.split("/").at(-1)
+ document.querySelector(`tr:has(a[href="${shortHref}"])`).remove()
+ }
+
+ function moveInputValues(form, was, became) {
+ let inputValues = saveInputValues(was)
+ form.removeChild(was)
+ form.append(became)
+ setInputValues(became, inputValues)
+ createSendButtons()
+ }
+
+ function prettifyEditCpForm(form, was, became, insertedFileRows, removedFilesLinks) {
+ moveInputValues(form, was, became)
+ prettifyFiles(insertedFileRows, removedFilesLinks)
+
+ moveNewFilesOnVariantChange()
+ $("#cps_main").select2()
+ makeCoordinatesLinks()
+ }
+
+ function uglifyEditCpForm(form, was, became, removedFilesLinks) {
+ moveInputValues(form, was, became)
+ for (const removedFileLink of removedFilesLinks) {
+ removeDeletedFilesFromUglyVersion(removedFileLink)
+ }
+ }
+
+ function addUglyDeleteListener() {
+ let links = [...document.querySelectorAll("a[href*='del_stored']")]
+
+ for (const link of links) {
+ let shortHref = link.href.split("/").at(-1)
+ let fileInput = document.querySelector(`tr:has(a[href$="${shortHref}"]) ~ tr:has(input[type="file"]) input[type="file"]`)
+ let fileInputVariant = parsefileInputVariant(document.querySelector(`tr:has(a[href$="${shortHref}"])`))
+
+ link.addEventListener("click", async e => {
+ e.preventDefault()
+ if (!confirm("Точно?")) return
+
+ await sendFileDeleted(fileInput.name.split("[")[0], fileInputVariant)
+ location.href = link.href
+ })
+ }
+ }
diff --git a/src/70-cp-edit.js b/src/70-cp-edit.js
new file mode 100644
index 0000000..359b9e8
--- /dev/null
+++ b/src/70-cp-edit.js
@@ -0,0 +1,1012 @@
+ function copyCoordinates() {
+ return Tag.button({
+ classes: "copy-button",
+ on: {
+ click: async event => {
+ event.preventDefault()
+ let lat = document.querySelector(`input[name="cp[lattitude]"]`).value
+ let lon = document.querySelector(`input[name="cp[longitude]"]`).value
+
+ const text = new Blob([`${lat}, ${lon}`], { type: "text/plain" })
+ const data = new ClipboardItem({ "text/plain": text })
+ await navigator.clipboard.write([data])
+ }
+ },
+ children: [
+ Tag.img({
+ src: "https://upload.wikimedia.org/wikipedia/commons/a/aa/Bw_copy_icon_320x320.svg"
+ })
+ ]
+ })
+ }
+
+ function yandexMaps(lat, lon, zoom) {
+ return `https://yandex.ru/maps/?ll=${lon}%2C${lat}&z=${zoom}`
+ }
+
+ function googleMaps(lat, lon, meters) {
+ return `https://www.google.ru/maps/@${lat},${lon},${meters}m`
+ }
+
+ function pastvu(lat, lon, zoom) {
+ return `https://pastvu.com/?g=${lat},${lon}&z=${zoom}&s=yandex&t=scheme&type=1`
+ }
+
+ function twoGis(lat, lon, zoom) {
+ return `https://2gis.ru?m=${lon}%2C${lat}%2F${zoom}`
+ }
+
+ function makeRef(linkCallback, iconSrc, zoom) {
+ return Tag.a({
+ href: linkCallback(lat, lon, zoom),
+ target: "_blank",
+ on: {
+ click: function (e) {
+ let lat = document.querySelector(`input[name="cp[lattitude]"]`).value
+ let lon = document.querySelector(`input[name="cp[longitude]"]`).value
+ this.href = linkCallback(lat, lon, zoom)
+ }
+ },
+ children: [
+ Tag.img({
+ src: iconSrc,
+ classes: "map-icon"
+ })
+ ]
+ })
+ }
+
+ let makeCoordinatesLinks = (function () {
+ let executed = false
+ return function () {
+ if (executed) return
+ executed = true
+
+ let linksContainer = Tag.div({
+ children: [
+ copyCoordinates(),
+ makeRef(yandexMaps, "https://upload.wikimedia.org/wikipedia/commons/7/72/Yandex_Maps_icon.svg", ZOOM),
+ makeRef(googleMaps, "https://upload.wikimedia.org/wikipedia/commons/a/aa/Google_Maps_icon_%282020%29.svg", METERS),
+ makeRef(pastvu, "https://pastvu.com/coast-icon.png", ZOOM),
+ makeRef(twoGis, "https://d-assets.2gis.ru/favicon.png", ZOOM)
+ ]
+ })
+
+ document.querySelector(`div:has(> div > input[name="cp[longitude]"])`).after(linksContainer)
+ }
+ })()
+
+ function createAlwaysPrettifyInput(index) {
+ let alwaysPrettify = Tag.div({
+ classes: "always-prettify-container"
+ })
+
+ let alwaysPrettifyLabel = Tag.make("label", {
+ _for: "always-prettify-" + index,
+ textContent: "Всегда"
+ })
+
+ alwaysPrettify.append(alwaysPrettifyLabel)
+
+ let alwaysPrettifyCheckbox = Tag.input({
+ type: "checkbox",
+ id: "always-prettify-" + index,
+ name: "always-prettify-" + index,
+ on: {
+ change: function () {
+ let otherCheckboxes = document.querySelectorAll(`input[name^="always-prettify-"]`)
+ for (let checkbox of otherCheckboxes) {
+ if (checkbox.id !== this.id) {
+ checkbox.checked = this.checked
+ }
+ }
+ if (this.checked)
+ localStorage.removeItem(localStorageItems.NOT_PRETTIFY_EDIT_CP)
+ else
+ localStorage.setItem(localStorageItems.NOT_PRETTIFY_EDIT_CP, "+")
+ }
+ }
+ })
+
+ alwaysPrettify.append(alwaysPrettifyCheckbox)
+
+ return alwaysPrettify
+ }
+
+ async function sendForm() {
+ let formData = new FormData(document.querySelector("form"))
+
+ let fileInputNames = Object.values(fileInputs).reduce((res, el) => typeof el == "string" ? [...res, el] : [...res, ...Object.values(el)], [])
+ for (const fileInput of fileInputNames) {
+ let fileListContainer = document.querySelector(`[data-container=${fileInput}] .file-list-container`)
+ if (fileListContainer) {
+ let count = [...fileListContainer.children].filter(el => !el.classList.contains("file-container-new")).length
+ formData.set(`cp[${fileInput}]`, count)
+ }
+ }
+
+ if (formData.get("cp[id]") == '') {
+ localStorage.setItem(localStorageItems.NEED_UPDATE_ID, true)
+ }
+
+ let props = document.querySelectorAll("input[name^=\"prop_\"]")
+ for (let prop of props) {
+ let parent = prop.parentElement
+ let propName
+
+ if (parent.tagName === "DIV") {
+ propName = parent.parentElement.querySelector("label").textContent
+ }
+ else if (parent.tagName === "TD") {
+ propName = parent.parentElement.querySelector("td:first-child").textContent
+ }
+
+ formData.set(`propname[${prop.name}]`, propName)
+ }
+
+ let result = await updatePoint(formData)
+
+ try {
+ console.log(result.ok)
+ }
+ catch (e) {
+ console.log(e)
+ }
+ }
+
+ function createSaveAndNewButton() {
+ let saveAndNewButton = Tag.button({
+ textContent: "+",
+ type: "button",
+ classes: "safe-action",
+ on: {
+ click: async () => {
+ await sendForm()
+
+ let cpNumber = document.querySelector(`input[name="cp[number]"]`).value
+ let lattitude = document.querySelector(`input[name="cp[lattitude]"]`).value
+ let longitude = document.querySelector(`input[name="cp[longitude]"]`).value
+
+ localStorage.setItem(localStorageItems.JUST_CREATED, cpNumber)
+ localStorage.setItem(localStorageItems.LATTITUDE, lattitude)
+ localStorage.setItem(localStorageItems.LONGITUDE, longitude)
+
+ document.querySelector(`input[name="save_go"]`).click()
+ }
+ }
+ })
+
+ return saveAndNewButton
+ }
+
+ function createSendButtons() {
+ if (document.querySelector(".pseudo-save")) return
+
+ let saveAndStayButtons = document.querySelectorAll(`input[name="save_go"]`)
+ let saveAndExitButtons = document.querySelectorAll(`input[name="save_exit"]`)
+ let saveButtons = [...saveAndStayButtons, ...saveAndExitButtons]
+ let cpIsKnown = document.querySelector(`input[name="cp[id]"]`).value != ''
+
+ for (let saveButton of saveButtons) {
+ createPseudoButton(saveButton, async () => {
+ await sendForm()
+
+ if (cpIsKnown) return
+
+ if (saveButton.name == "save_exit") {
+ localStorage.setItem(localStorageItems.REDIRECT_EXIT, true)
+ }
+
+ saveAndStayButtons[0].click()
+ }, cpIsKnown)
+ }
+
+ saveAndStayButtons.forEach(el => el.after(createSaveAndNewButton()))
+ }
+
+ function redirectAfterNewCpIfNeeded() {
+ let justCreated = localStorage.getItem(localStorageItems.JUST_CREATED)
+ let prevCreated = localStorage.getItem(localStorageItems.PREV_CREATED)
+ let needUpdateId = localStorage.getItem(localStorageItems.NEED_UPDATE_ID)
+ let exit = localStorage.getItem(localStorageItems.REDIRECT_EXIT)
+
+ if (needUpdateId) {
+ let formData = new FormData()
+ formData.set("cp[id]", urlParams().get("cp_id"))
+ formData.set("cp[number]", document.querySelector(`input[name="cp[number]"]`).value)
+ formData.set("update_id", true)
+ updatePoint(formData).then(() => localStorage.removeItem(localStorageItems.NEED_UPDATE_ID))
+ }
+
+ if (exit) {
+ localStorage.removeItem(localStorageItems.REDIRECT_EXIT)
+
+ location.href = location.href.replace(location.search, '')
+ return
+ }
+
+ if (justCreated !== null) {
+ localStorage.setItem(localStorageItems.PREV_CREATED, justCreated)
+ localStorage.removeItem(localStorageItems.JUST_CREATED)
+
+ location.href = location.href.replace(location.search, "?action=edit")
+ return
+ }
+ if (prevCreated !== null) {
+ document.querySelector(`input[name="cp[number]"]`).value = parseInt(localStorage.getItem(localStorageItems.PREV_CREATED)) + 1
+ document.querySelector(`input[name="cp[lattitude]"]`).value = localStorage.getItem(localStorageItems.LATTITUDE)
+ document.querySelector(`input[name="cp[longitude]"]`).value = localStorage.getItem(localStorageItems.LONGITUDE)
+
+ localStorage.removeItem(localStorageItems.PREV_CREATED)
+ localStorage.removeItem(localStorageItems.LATTITUDE)
+ localStorage.removeItem(localStorageItems.LONGITUDE)
+ }
+ }
+
+ function getRows() {
+ return [...document.querySelectorAll('#props > tbody > tr')]
+ }
+
+ function getInsertedFileRows(rows) {
+ let insertedFileRows = new Map()
+ let i = 0
+ while (i < rows.length) {
+ if ([23, 27, 31, 35].includes(i) && rows[i].querySelector(`input[type="file"]`) == null) {
+ if (!insertedFileRows.has(i)) {
+ insertedFileRows.set(i, [rows[i]])
+ }
+ else {
+ let savedRows = insertedFileRows.get(i)
+ savedRows.push(rows[i])
+ insertedFileRows.set(i, savedRows)
+ }
+ rows.splice(i, 1)
+ }
+ else {
+ i++
+ }
+ }
+
+ return insertedFileRows
+ }
+
+ function makeContainer() {
+ return Tag.div({
+ id: "new"
+ })
+ }
+
+ function makeHeader(rows) {
+ let headerContainer = createFrom(rows, "header", [
+ { index: 2, name: "№", desc: "" },
+ { index: 8, name: "Название" }
+ ])
+
+ let copyLink = Tag.div({
+ innerHTML: new Property(rows[2]).desc
+ })
+ headerContainer.append(copyLink)
+
+ return headerContainer
+ }
+
+ function bindDeleteButton() {
+ createPseudoButton(document.querySelector(`input[name="delete_go"]`), deletePoint)
+ }
+
+ function makeTopAndBottomButtons(rows, form, oldTable, container, insertedFileRows, removedFilesLinks) {
+ let topButtonsContainer = createFrom(rows, "buttons", [
+ { index: 0 }
+ ])
+
+ let bottomButtonsContainer = topButtonsContainer.cloneNode(true)
+
+ document.querySelectorAll("#props tr:is(:first-child, :last-child) th").forEach((el, index) => {
+ let rowContentWrapper = Tag.div({
+ classes: "buttons-row__content-wrapper"
+ })
+ while ([...el.children].length > 0) {
+ let child = el.firstChild
+ rowContentWrapper.append(child)
+ }
+
+ let prettifyButton = Tag.button({
+ type: "button",
+ textContent: "Сделать красиво",
+ on: {
+ click: () => {
+ prettifyEditCpForm(form, oldTable, container, insertedFileRows, removedFilesLinks)
+ }
+ }
+ })
+
+ rowContentWrapper.append(prettifyButton)
+ rowContentWrapper.append(createAlwaysPrettifyInput(index))
+
+ el.append(rowContentWrapper)
+ })
+
+ ;[topButtonsContainer, bottomButtonsContainer].forEach((el, index) => {
+ let unglifyButton = Tag.button({
+ type: "button",
+ textContent: "Сделать как было",
+ on: {
+ "click": () => {
+ uglifyEditCpForm(form, container, oldTable, removedFilesLinks)
+ }
+ }
+ })
+
+ let topRowContentWrapper = el.querySelector("div > div > div")
+ topRowContentWrapper.append(unglifyButton)
+ topRowContentWrapper.append(createAlwaysPrettifyInput(index))
+ })
+
+ return [topButtonsContainer, bottomButtonsContainer]
+ }
+
+ function makeTopOptions(rows) {
+ return createFrom(rows, "options", [
+ { index: 3 },
+ { index: 4, name: "КП-загадка" },
+ { index: 16, name: "Этапник" },
+ { index: 7, name: "Пиктограмма" },
+ { index: 12, name: "Нужна ИС" },
+ { index: 17, name: "Спрятать ИС" },
+ { index: 14, name: "Основной КП" },
+ { index: 10, name: "Широта" },
+ { index: 11, name: "Долгота" },
+ { index: 5, name: "Старт" },
+ { index: 6, name: "Финиш" },
+ { index: 15, name: "Знак" }
+ ])
+ }
+
+ function makeComment(rows) {
+ return createFrom(rows, "comment", [
+ { index: 9, desc: "" }
+ ])
+ }
+
+ function makeLegend(container, rows) {
+ let legendContainer = Tag.div({
+ classes: "legend-container"
+ })
+
+ /* LEGEND DESC */
+
+ const LEGEND_RU_LABEL = "Русский"
+ const LEGEND_EN_LABEL = "Английский"
+
+ let legendDescHeader = Tag.div({
+ classes: "legend-container__desc-header"
+ })
+
+ let legendLang = Tag.div({
+ classes: "legend-desc__lang",
+ textContent: LEGEND_RU_LABEL
+ })
+ legendDescHeader.append(legendLang)
+
+ let legendEnSwitchContainer = createFrom(rows, "legend-switch-container", [
+ { index: 48 }
+ ])
+ legendEnSwitchContainer.addEventListener("click", event => {
+ hide([legendRuDescContainer, legendRuHiddenDescContainer, legendEnSwitchContainer])
+ show([legendEnDescContainer, legendEnHiddenDescContainer, legendRuSwitchContainer])
+ legendLang.textContent = LEGEND_EN_LABEL
+ })
+ legendDescHeader.append(legendEnSwitchContainer)
+
+ let copyDescButton = Tag.button({
+ type: "button",
+ textContent: "Копировать",
+ on: {
+ click: () => {
+ let ruInputs = [...container.querySelectorAll(":is(input, textarea)[name^=\"cp_strings\[ru\]\"]")]
+ let enInputs = [...container.querySelectorAll(":is(input, textarea)[name^=\"cp_strings\[en\]\"]")]
+ for (const [i, enInput] of enInputs.entries()) {
+ enInput.value = ruInputs[i].value
+ }
+ }
+ }
+ })
+
+ let legendRuSwitchContainer = createFrom(rows, "legend-switch-container hidden", [
+ { index: 39 }
+ ])
+ legendRuSwitchContainer.addEventListener("click", event => {
+ hide([legendEnDescContainer, legendEnHiddenDescContainer, legendRuSwitchContainer])
+ show([legendRuDescContainer, legendRuHiddenDescContainer, legendEnSwitchContainer])
+ legendLang.textContent = LEGEND_RU_LABEL
+ })
+ legendDescHeader.append(legendRuSwitchContainer)
+
+ legendDescHeader.append(copyDescButton)
+
+ let legendRuDescContainer = createFrom(rows, "legend-desc", [
+ { index: 40, desc: "" },
+ { index: 41, desc: "" },
+ { index: 42, desc: "" },
+ { index: 43, desc: "" }
+ ])
+
+ let legendRuHiddenDescContainer = createFrom(rows, "legend-desc collapsible collapsed", [
+ { index: 44, desc: "" },
+ { index: 45, desc: "" },
+ { index: 46, desc: "" },
+ { index: 47, desc: "" },
+ ])
+
+ let legendEnDescContainer = createFrom(rows, "legend-desc hidden", [
+ { index: 49, desc: "" },
+ { index: 50, desc: "" },
+ { index: 51, desc: "" },
+ { index: 52, desc: "" }
+ ])
+
+ let legendEnHiddenDescContainer = createFrom(rows, "legend-desc collapsible collapsed hidden", [
+ { index: 53, desc: "" },
+ { index: 54, desc: "" },
+ { index: 55, desc: "" },
+ { index: 56, desc: "" }
+ ])
+
+ let hider = Tag.div({
+ children: [
+ Tag.button({
+ classes: "collapse-button",
+ type: "button",
+ on: {
+ click: event => {
+ let container = document.querySelector('.legend-container')
+ container.querySelectorAll(".legend-desc.collapsible").forEach(element => {
+ element.classList.toggle("collapsed")
+ })
+ }
+ }
+ })
+ ]
+ })
+
+ let legendDescContainer = Tag.div({
+ classes: "legend-container__desc",
+ children: [
+ legendDescHeader,
+ legendRuDescContainer,
+ legendRuHiddenDescContainer,
+ legendEnDescContainer,
+ legendEnHiddenDescContainer,
+ hider
+ ]
+ })
+
+ legendContainer.append(legendDescContainer)
+
+ return legendContainer
+ }
+
+ function chooseLegendAsDefaultVariant() {
+ document.querySelector(`#props input[name="new_file_type1"][value="l"]`).click()
+ document.querySelector(`#props input[name="new_file_type4"][value="l"]`).click()
+ }
+
+ function makeLegendFiles(rows) {
+ let legendFilesContainer = Tag.div({
+ classes: "legend-container__files"
+ })
+
+ let imagesForLegendContainer = createFrom(rows, "files-container", [
+ { index: 23, name: "Фото в легенде" }
+ ])
+ imagesForLegendContainer.dataset.container = fileInputs.attachment1.l
+ legendFilesContainer.append(imagesForLegendContainer)
+
+ let imagesForHistoryContainer = createFrom(rows, "files-container", [
+ { name: "Фото для ИС" }
+ ])
+ imagesForHistoryContainer.dataset.container = fileInputs.attachment1.h
+ legendFilesContainer.append(imagesForHistoryContainer)
+
+ let audioForLegendContainer = createFrom(rows, "files-container", [
+ { index: 27, name: "Файлы в легенде" }
+ ])
+ audioForLegendContainer.dataset.container = fileInputs.attachment4.l
+ legendFilesContainer.append(audioForLegendContainer)
+
+ let audioForHistoryContainer = createFrom(rows, "files-container", [
+ { name: "Файлы для ИС" }
+ ])
+ audioForHistoryContainer.dataset.container = fileInputs.attachment4.h
+ legendFilesContainer.append(audioForHistoryContainer)
+
+ return legendFilesContainer
+ }
+
+ function makeAdminFiles(rows) {
+ let adminFilesContainer = Tag.div({
+ classes: 'admin-files-container'
+ })
+
+ let imagesForAdminContainer = createFrom(rows, "files-container admin-photo-container", [
+ { index: 31, name: "Фото в админке" }
+ ])
+ imagesForAdminContainer.dataset.container = fileInputs.attachment2
+ adminFilesContainer.append(imagesForAdminContainer)
+
+ let audioForAdminContainer = createFrom(rows, "files-container admin-files-container", [
+ { index: 35, name: "Файлы в админке" }
+ ])
+ audioForAdminContainer.dataset.container = fileInputs.attachment3
+ adminFilesContainer.append(audioForAdminContainer)
+
+ return adminFilesContainer
+ }
+
+ function makeBottomOptions(rows) {
+ return createFromMulti(rows, "options bottom-options", {
+ from: 59,
+ to: rows.length - 3
+ })
+ }
+
+ function formatMap() {
+ if (map !== undefined && L !== undefined) {
+ let content = document.querySelector("#content")
+ let contentWrapper = Tag.div({
+ id: "content-wrapper",
+ children: [
+ document.querySelector("form"),
+ document.querySelector("#map-wrapper")
+ ]
+ })
+ content.append(contentWrapper)
+
+ let panToCenter = Tag.button({
+ type: "button",
+ textContent: "В центр",
+ on: {
+ click: () => {
+ let lat = document.querySelector("input[name=\"cp\[lattitude\]\"").value
+ let lon = document.querySelector("input[name=\"cp\[longitude\]\"").value
+ map.setView(new L.LatLng(parseFloat(lat), parseFloat(lon)), 16)
+ }
+ }
+ })
+
+ document.querySelector("#map_controls").append(panToCenter)
+ }
+ }
+
+ function makeCurrentPointHigherOnMap() {
+ map.eachLayer(function (layer) {
+ if (layer instanceof L.Marker) {
+ if (layer.getElement().classList.contains("leaflet-marker-draggable")) {
+ layer.setZIndexOffset(9999)
+ }
+ }
+ })
+ }
+
+ function addRoutesList() {
+ (async () => {
+ let routes = await getRoutes()
+
+ if (routes.error || routes.length == 0) return
+
+ let result = Tag.div()
+ result.append("Маршруты: ")
+ for (const [i, route] of routes.entries()) {
+ if (i !== 0) {
+ result.append(", ")
+ }
+
+ result.append(Tag.a({
+ href: `/${getCompetition()}/` + catLinks["Редактор маршрута"](route.cat_id),
+ textContent: route.name
+ }))
+ }
+
+ document.querySelector(`#map_controls`).after(result)
+ })()
+ }
+
+ function checkIfAlwaysPrettify(form, oldTable, container, insertedFileRows, removedFilesLinks) {
+ if (localStorage.getItem(localStorageItems.NOT_PRETTIFY_EDIT_CP) === null) {
+ document.querySelector(`input[name^="always-prettify-0"]`).click()
+ prettifyEditCpForm(form, oldTable, container, insertedFileRows, removedFilesLinks)
+ }
+ }
+
+ function addPrettifyEditPageCss() {
+ addStylesToHead(`
+ #content-wrapper {
+ display: flex;
+ gap: 20px;
+
+ #props {
+ width: unset !important;
+ }
+
+ form {
+ width: 50%;
+ }
+
+ #map-wrapper {
+ width: 50%;
+ }
+
+ #map {
+ width: unset !important;
+ }
+
+ #new {
+ display: flex;
+ flex-direction: column;
+
+ & > :not(:first-child) {
+ padding-top: 5px;
+ border-top: 1px solid #ddd;
+ }
+
+ & > :not(:last-child) {
+ padding-bottom: 5px;
+ }
+
+ .map-icon {
+ width: 15px;
+ height: 15px;
+ }
+
+ .copy-button {
+ all: unset;
+ cursor: pointer;
+ }
+
+ .copy-button img {
+ width: 15px;
+ heigth: 15px;
+ }
+
+ input[type=radio], input[type=checkbox] {
+ margin: 0;
+ }
+
+ input[type=radio] {
+ margin-right: 5px;
+ }
+
+ input[type="file"] {
+ display: none;
+ }
+
+ div > input[type="checkbox"] {
+ display: flex;
+ align-items: center;
+ }
+
+ input#cp_number {
+ width: 3em;
+ }
+
+ select#cps_main {
+ width: 6em;
+ }
+
+ input:is(#lat, #lng) {
+ width: 5.5em;
+ }
+
+ input[name="cp[name_int]"] {
+ width: 34em;
+ }
+ }
+
+ #cps_main {
+ width: 150px;
+ }
+
+ input[type=submit] {
+ width: fit-content;
+ }
+
+ .desc-icon-container {
+ display: flex;
+ align-items: center;
+ }
+
+ .desc-icon {
+ width: 15px;
+ height: 15px;
+ cursor: help;
+ }
+
+ button, input[type=submit] {
+ cursor: pointer;
+ }
+
+ .buttons-row__content-wrapper {
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ font-weight: normal;
+ }
+
+ .custom-file-upload {
+ display: block;
+ width: fit-content;
+ border: 1px solid black;
+ padding: 5px 14px;
+ border-radius: 5px;
+ box-shadow: 0 1px 3px #ddd;
+ background: linear-gradient(white, #ddd);
+ }
+
+ .buttons > * > * {
+ display: flex;
+ gap: 10px;
+ }
+
+ .always-prettify-container {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ }
+
+ .header > * {
+ display: flex;
+ gap: 5px;
+ }
+
+ :is(.header, .options) input {
+ width: unset;
+ }
+
+ .header {
+ display: flex;
+ gap: 30px;
+ align-items: start;
+ }
+
+ .options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .options > * {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ }
+
+ .legend-container {
+ display: flex;
+ }
+
+ .legend-container > :not(:last-child) {
+ padding-right: 15px;
+ border-right: 1px solid #ddd;
+ }
+
+ .legend-container > :not(:first-child) {
+ padding-left: 15px;
+ }
+
+ .legend-container > * {
+ display: flex;
+ flex-direction: column;
+ flex: 0 1 48%;
+ gap: 5px;
+ }
+
+ .legend-container__desc-header {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ justify-content: end;
+ }
+
+ .legend-desc {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ .collapsed, .hidden {
+ display: none;
+ }
+
+ .collapse-button::after {
+ content: "Свернуть"
+ }
+
+ .collapsed + * > .collapse-button::after {
+ content: "Развернуть"
+ }
+
+ :is(.comment, .legend-desc) > * {
+ display: flex;
+ gap: 5px;
+ }
+
+ :is(.comment, .legend-desc) label {
+ display: block;
+ width: 10em;
+ }
+
+ :is(.comment, .legend-desc) > * > :nth-child(2) {
+ flex: 1 1 auto;
+ }
+
+ textarea {
+ width: 100%;
+ }
+ }
+
+ .files-container input[type=radio][value="c"] {
+ display: none;
+ }
+
+ .file-list-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 5px;
+ }
+
+ .file-container {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ .file-container img.preview {
+ width: 120px;
+ }
+
+ .file-container img.preview-small {
+ width: 60px;
+ }
+
+ .file-container img:is(.preview, .preview-small) {
+ cursor: zoom-in;
+ }
+
+ dialog .preview,
+ .swiper {
+ max-width: 1000px;
+ width: 100%;
+ max-height: 500px;
+ height: 100%;
+ }
+
+ .swiper-button-next,
+ .swiper-button-prev {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+
+ .swiper-wrapper {
+ align-items: center;
+ }
+
+ .swiper-wrapper > .swiper-slide {
+ width: 100%;
+ max-height: 500px;
+ height: 100%;
+ }
+
+ .swiper-slide img {
+ width: 100%;
+ max-height: 500px;
+ height: 100%;
+ object-fit: contain;
+ object-position: center;
+ }
+
+ .swiper-download-link {
+ display: block;
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: white;
+ padding: 5px 10px;
+ margin: 5px;
+ border: 1px solid #ccc;
+ border-radius: 7.5px;
+ }
+
+ button.button-delete {
+ padding: 0;
+ border: 0;
+ margin: 0;
+ background: none;
+ font-size: 18px;
+ }
+
+ .admin-files-container {
+ display: flex;
+ }
+
+ .admin-files-container > :not(:last-child) {
+ padding-right: 10px;
+ border-right: 1px solid #ddd;
+ }
+
+ .admin-files-container > :not(:first-child) {
+ padding-left: 10px;
+ }
+
+ dialog img.preview {
+ width: auto;
+ }
+
+ .file-buttons-container {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ }
+ `)
+ }
+
+ function prettifyEditCpPage() {
+ addCss("https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css")
+ addCss("https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css")
+ addPrettifyEditPageCss()
+
+ /* NEW DEFALUT VALUES */
+
+ chooseLegendAsDefaultVariant()
+
+ /* CONTAINER */
+
+ let form = document.querySelector('form')
+ let rows = getRows()
+ let insertedFileRows = getInsertedFileRows(rows)
+ let removedFilesLinks = []
+
+ let container = makeContainer()
+ let oldTable = document.querySelector("#props")
+
+ /* HEADER */
+
+ let headerContainer = makeHeader(rows)
+
+ /* TOP BUTTONS */
+
+ let [topButtonsContainer, bottomButtonsContainer] = makeTopAndBottomButtons(rows, form, oldTable, container, insertedFileRows, removedFilesLinks)
+
+ addUglyDeleteListener()
+ createSendButtons()
+
+ /* LEGEND */
+
+ let legendContainer = makeLegend(container, rows)
+ legendContainer.append(makeLegendFiles(rows))
+
+ /* APPEND ALL */
+
+ container.append(topButtonsContainer)
+ container.append(headerContainer)
+ container.append(makeTopOptions(rows))
+ container.append(makeComment(rows))
+ container.append(legendContainer)
+ container.append(makeAdminFiles(rows))
+ container.append(makeBottomOptions(rows))
+ container.append(bottomButtonsContainer)
+
+ /* MAP */
+
+ formatMap()
+ makeCurrentPointHigherOnMap()
+ addRoutesList()
+
+ /* DIALOG */
+
+ makeDialog()
+
+ /* PRETTIFY CHECKBOX */
+
+ checkIfAlwaysPrettify(form, oldTable, container, insertedFileRows, removedFilesLinks)
+ }
diff --git a/src/75-map.js b/src/75-map.js
new file mode 100644
index 0000000..6fec429
--- /dev/null
+++ b/src/75-map.js
@@ -0,0 +1,44 @@
+ function hasMap() {
+ return document.querySelector(".leaflet-container") !== null
+ }
+
+ function initMapbox() {
+ addJs('https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/Leaflet.fullscreen.min.js')
+ addCss('https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/leaflet.fullscreen.css')
+ }
+
+ function addFullscreenButton() {
+ $(async () => {
+ if (document.querySelector("#map") !== null) {
+ while (L.Control.Fullscreen === undefined) {
+ await sleep(500)
+ }
+ map.addControl(new L.Control.Fullscreen())
+ }
+ })
+ }
+
+ function isAllPointsSouthOfLat(lat) {
+ map.eachLayer(function (layer) {
+ if (layer instanceof L.Marker) {
+ let latLng = layer.getLatLng()
+ if (latLng.lat > lat) {
+ return false
+ }
+ }
+ })
+ }
+
+ function centerMap() {
+ if (map !== undefined && L !== undefined) {
+ let coords = mapsCenterByCompetition[getCompetition()]
+ if (coords == null) return
+
+ if (isAllPointsSouthOfLat(lat)) {
+ map.setView(new L.LatLng(coords.ifSouthern.lat, coords.ifSouthern.lon), 13)
+ }
+ else {
+ map.setView(new L.LatLng(coords.ifEverywhere.lat, coords.ifEverywhere.lon), 12)
+ }
+ }
+ }
diff --git a/src/80-route-build.js b/src/80-route-build.js
new file mode 100644
index 0000000..a02dbfb
--- /dev/null
+++ b/src/80-route-build.js
@@ -0,0 +1,257 @@
+ function prettifyRouteBuildPage() {
+ function changeColumnWidth() {
+ let styles = `
+ #content table table {
+ td:nth-child(2) {
+ width: 10%;
+
+ select {
+ width: 5em !important;
+ }
+ }
+
+ td:nth-child(3) {
+ width: 12%;
+ }
+
+ td:nth-child(4) {
+ width: 12%;
+
+ input {
+ width: 4em;
+ }
+ }
+ }
+ `
+ addStylesToHead(styles)
+ }
+
+ function hideDescription() {
+ document.querySelectorAll(`#content > form > table > tbody > tr:is(:nth-child(3), :nth-child(6)) `).forEach(el => el.remove())
+ }
+
+ function hideStartRow() {
+ document.querySelector(`#content table table tr:nth-child(2) `).remove()
+ }
+
+ function getRows() {
+ return [...document.querySelectorAll(`#content > form > table > tbody > tr:nth-child(3) tr`)]
+ }
+
+ function useColspanForFinishWarning() {
+ let rows = getRows()
+ let finishRow = rows.find(el => el.querySelector(`td`)?.textContent.trim() == "Финиш маршрута")
+
+ finishRow.querySelectorAll(`td:not(:nth-child(2))`).forEach(el => el.remove())
+ finishRow.querySelector(`td`).colSpan = 4
+ }
+
+ function makeStatsSpan(text) {
+ let statsSpan = Tag.span({
+ textContent: text,
+ classes: "stats"
+ })
+
+ return statsSpan
+ }
+
+ function getPointCells() {
+ return [...document.querySelectorAll(`#props table td:first-child`)]
+ }
+
+ function getPointNumberFromCell(cell) {
+ return cell.textContent.trim().match(/^\d+/)?.[0]
+ }
+
+ function getPointNumbers() {
+ let pointNumbers = {}
+ for (const [rowIndex, pointCell] of getPointCells().entries()) {
+ let pointNumber = getPointNumberFromCell(pointCell)
+ if (pointNumber != null)
+ pointNumbers[rowIndex] = pointNumber
+ }
+
+ return pointNumbers
+ }
+
+ async function getPointsFromRows() {
+ return await getPointsByField("number", Object.values(getPointNumbers()))
+ }
+
+ function countCp(points) {
+ let rows = getRows()
+
+ let fullCount = 0
+ let stageCount = 0
+ let fullPuzzleCount = 0
+ let stagePuzzleCount = 0
+ let fullNeedHistoryCount = 0
+ let stageNeedHistoryCount = 0
+ let fullHistoryCount = 0
+ let stageHistoryCount = 0
+
+ let stageRow = null
+ for (const [i, row] of rows.entries()) {
+ if (i < 2) continue
+ let isFinish = row.querySelector(".attention") !== null
+
+ if (isFinish || row.querySelector(`th`)) {
+ if (stageRow) {
+ let stageHeader = stageRow.querySelector("th")
+ stageHeader.append(makeStatsSpan(`КП в этапе: ${stageCount}`))
+ stageHeader.append(makeStatsSpan(`Загадок: ${stagePuzzleCount}`))
+ stageHeader.append(makeStatsSpan(`Нужно ИС: ${stageNeedHistoryCount}`))
+ stageHeader.append(makeStatsSpan(`ИС: ${stageHistoryCount}`))
+ }
+
+ if (isFinish) break
+
+ stageRow = row
+ stageCount = 0
+ stagePuzzleCount = 0
+ stageNeedHistoryCount = 0
+ stageHistoryCount = 0
+ continue
+ }
+
+ let pointNumber = getPointNumberFromCell(row.querySelector("td"))
+ if (!pointNumber) continue
+
+ fullCount++
+ stageCount++
+
+ let point = points[pointNumber]
+ if (!point) continue
+
+ if (point.is_puzzle) {
+ fullPuzzleCount++
+ stagePuzzleCount++
+ }
+
+ if (point.history_needed) {
+ fullNeedHistoryCount++
+ stageNeedHistoryCount++
+ }
+
+ if (point.history_ru) {
+ fullHistoryCount++
+ stageHistoryCount++
+ }
+ }
+
+ let tableHeader = document.querySelector("table tr:nth-child(2) th")
+ tableHeader.append(makeStatsSpan(`Всего КП: ${fullCount}`))
+ tableHeader.append(makeStatsSpan(`Загадок: ${fullPuzzleCount}`))
+ tableHeader.append(makeStatsSpan(`Нужно ИС: ${fullNeedHistoryCount}`))
+ tableHeader.append(makeStatsSpan(`ИС: ${fullHistoryCount}`))
+
+ addStylesToHead(`
+ .stats {
+ color: red;
+ margin-left: .5em;
+ }
+ `)
+ }
+
+ function addLinksToCp(points) {
+ let pointCells = getPointCells()
+
+ for (const [i, pointCell] of pointCells.entries()) {
+ let pointNumber = getPointNumberFromCell(pointCell)
+ if (pointNumber == null) continue
+
+ let cellContent = pointCell.textContent
+ pointCell.innerHTML = ''
+ pointCell.append(Tag.a({
+ href: cpLink(points[pointNumber].cp_id),
+ target: "_blank",
+ textContent: cellContent
+ }))
+ }
+ }
+
+ function showHistoryData(points) {
+ let pointCells = getPointCells()
+
+ for (const [i, pointCell] of pointCells.entries()) {
+ let pointNumber = getPointNumberFromCell(pointCell)
+ if (pointNumber == null) continue
+
+ let point = points[pointNumber]
+
+ if (point.history_needed) {
+ pointCell.append(Tag.span({ classes: "point-info", textContent: "Нужна ИС" }))
+ }
+
+ if (point.history_ru) {
+ pointCell.append(Tag.span({ classes: "point-info", textContent: "Есть ИС" }))
+ }
+
+ if (point.photo_answer) {
+ pointCell.append(Tag.span({ classes: "point-info", textContent: "Селфи" }))
+ }
+ }
+
+ addStylesToHead(`
+ .point-info {
+ &:first-of-type {
+ margin-left: 5px;
+ }
+
+ & + &::before {
+ content: " | "
+ }
+ }
+ `)
+ }
+
+ function getRouteName() {
+ return document.querySelector(`#content h1`).textContent.match(/(?<=\")[^\"]+/)
+ }
+
+ function sendRouteData() {
+ createPseudoButton(document.querySelector(`input[name="save_sort"]`), async () => {
+ let formData = new FormData(document.querySelector(`form[name="main_form"]`))
+
+ let catId = urlParams().get("cat_id")
+ formData.set("cat_id", catId)
+ formData.set("cat_name", getRouteName())
+ await updateRoute(formData)
+ })
+ }
+
+ function showUnknownPoints() {
+ (async () => {
+ let unknownPoints = await getUnknownPoints()
+ if (unknownPoints.error || !unknownPoints.length) return
+
+ let unknownPointsContainer = Tag.div()
+ unknownPointsContainer.append("Несохраненные точки: ")
+ for (const [i, cpId] of unknownPoints.entries()) {
+ if (i !== 0) {
+ unknownPointsContainer.append(", ")
+ }
+
+ unknownPointsContainer.append(Tag.a({
+ href: cpLink(cpId),
+ textContent: cpId
+ }))
+ }
+ document.querySelector(`#content h1`).after(unknownPointsContainer)
+ })()
+ }
+
+ addJs("https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js")
+ changeColumnWidth()
+ hideDescription()
+ hideStartRow()
+ useColspanForFinishWarning()
+ ;(async () => {
+ let points = await getPointsFromRows()
+ countCp(points)
+ addLinksToCp(points)
+ showHistoryData(points)
+ })()
+ sendRouteData()
+ showUnknownPoints()
+ }
diff --git a/src/90-route-edit.js b/src/90-route-edit.js
new file mode 100644
index 0000000..4f67e86
--- /dev/null
+++ b/src/90-route-edit.js
@@ -0,0 +1,168 @@
+ function getCpNumberFromOption(option) {
+ return option.textContent.match(/(?<=^\s*#)\d+/g)?.[0]
+ }
+
+ function matchNumberFromSelect(select) {
+ return [...document.querySelector(select).options].reduce(
+ (res, el) => ({ ...res, [getCpNumberFromOption(el)]: el.value }),
+ {}
+ )
+ }
+
+ function unselectAll(select) {
+ [...document.querySelector(select).selectedOptions].forEach(el => el.selected = false)
+ }
+
+ function scrollSelectToBottom(select) {
+ document.querySelector(`${select} option:last-child`).scrollIntoView()
+ }
+
+ function prettifyRouteEditPage() {
+ function createPointsInpit() {
+ let pointInput = Tag.input({
+ id: "add-point",
+ type: "text",
+ on: {
+ focus: () => {
+ pointInput.classList.remove("success", "error")
+ }
+ }
+ })
+
+ let addButton = Tag.button({
+ type: "button",
+ textContent: "Добавить",
+ id: "add-button",
+ on: {
+ click: () => {
+ let point = pointInput.value
+ if (point == "") return
+
+ let availablePoints = matchNumberFromSelect("#cps_avail")
+ let addedPoints = matchNumberFromSelect("#cps_in")
+
+ if (point in addedPoints) {
+ alert("Точка уже добавлена")
+ return
+ }
+
+ if (!(point in availablePoints)) {
+ alert("Точка не найдена")
+ return
+ }
+
+ unselectAll("#cps_avail")
+ let option = document.querySelector(`#cps_avail option[value="${availablePoints[point]}"]`)
+ option.selected = true
+ document.querySelector(`#cps_add`).click()
+ pointInput.classList.add("success")
+ pointInput.value = ""
+ option.selected = false
+ scrollSelectToBottom("#cps_in")
+ }
+ }
+ })
+
+ let removeButton = Tag.button({
+ type: "button",
+ textContent: "Убрать",
+ on: {
+ click: () => {
+ let point = pointInput.value
+ if (point == "") return
+
+ let addedPoints = matchNumberFromSelect("#cps_in")
+
+ if (!(point in addedPoints)) return
+
+ unselectAll("#cps_in")
+ let option = document.querySelector(`#cps_in option[value="${addedPoints[point]}"]`)
+ option.selected = true
+ document.querySelector(`#cps_delete`).click()
+ pointInput.classList.add("error")
+ pointInput.value = ""
+ option.selected = false
+ scrollSelectToBottom("#cps_in")
+ }
+ }
+ })
+
+ let addPointsContainer = Tag.div({
+ children: [
+ addButton,
+ pointInput,
+ removeButton
+ ]
+ })
+ document.querySelector("table tr:nth-child(16) td").prepend(addPointsContainer)
+
+ addStylesToHead(`
+ #cps_in, #cps_avail {
+ height: 15em;
+ }
+
+ .success {
+ border-color: green;
+ }
+
+ .error {
+ border-color: red;
+ }
+
+ #add-point {
+ width: 5em;
+ }
+
+ #add-button {
+ margin-left: 420px;
+ }
+ `)
+ }
+
+ function makeTextareasOneRow() {
+ document.querySelectorAll(`textarea:is([name="track[comment_int]"], [name="track[comment_ext]"])`).forEach(el => {
+ el.rows = 1
+ })
+ }
+
+ function hideRedundantRows() {
+ document.querySelectorAll(`table tr:is(:nth-child(3), :nth-child(4), :nth-child(5))`).forEach(el => el.classList.add("hidden"))
+ }
+
+ async function getPointsFromSelect() {
+ let options = [...document.querySelectorAll(`#cps_in option`)]
+ return await getPointsByField("number", options.map(option => getCpNumberFromOption(option)))
+ }
+
+ function paintCpList(points) {
+ let options = [...document.querySelectorAll(`#cps_in option`)]
+ let catId = urlParams().get("cat_id")
+
+ for (const option of options) {
+ let category = points[getCpNumberFromOption(option)].categories.find(category => category.cat_id === catId)
+ if (category && category.pivot.bonus_time == "0") {
+ let stage = parseInt(category.pivot.stage)
+ option.style['background-color'] = stageColors[(stage - 1) % stageColors.length]
+ }
+ }
+ }
+
+ function hideAlreadyAddedPoints(points) {
+ let options = [...document.querySelectorAll(`#cps_avail option`)]
+
+ for (const option of options) {
+ if (getCpNumberFromOption(option) in points) {
+ option.style.display = "none"
+ }
+ }
+ }
+
+ makeTextareasOneRow()
+ hideRedundantRows()
+ createPointsInpit()
+ ;(async () => {
+ let points = await getPointsFromSelect()
+ paintCpList(points)
+ hideAlreadyAddedPoints(points)
+ })()
+ }
diff --git a/tpl.js b/tpl.js
new file mode 100644
index 0000000..4ffc33f
--- /dev/null
+++ b/tpl.js
@@ -0,0 +1,19 @@
+// ==UserScript==
+// @name Runcity
+// @namespace http://tampermonkey.net/
+// @version 2026-08-15
+// @description Prettify pages & store data on server
+// @author You
+// @match https://rst.runcity.org/*
+// @icon https://www.google.com/s2/favicons?sz=64&domain=runcity.org
+// @grant none
+// @require https://code.jquery.com/jquery-3.7.1.min.js
+// @require https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js
+// @require https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js
+// ==/UserScript==
+
+(function () {
+ 'use strict';
+
+code;
+})();
\ No newline at end of file