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