// ==UserScript== // @name Runcity // @namespace http://tampermonkey.net/ // @version 2024-11-18 // @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' const adminSite = "runcity.geo.rictum.ru" 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"] 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) } } 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 }) ) } 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 cpLink(cpId) { return `/${getCompetition()}/cp_mgmt/?action=edit&cp_id=${cpId}` } async function updatePoint(formData) { return await fetch(`https://${adminSite}/api/competitions/${getCompetition()}/update`, { method: 'POST', body: formData }) } async function deletePoint() { return await fetch(`https://${adminSite}/api/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 fetch(`https://${adminSite}/api/competitions/${getCompetition()}/points?${params.toString()}`, { method: 'GET' }) return await response.json() } async function updateRoute(formData) { return await fetch(`https://${adminSite}/api/competitions/${getCompetition()}/route`, { method: 'POST', body: formData }) } async function getRoutes() { let response = await fetch(`https://${adminSite}/api/points/${urlParams().get("cp_id")}/routes`, { method: 'GET' }) return await response.json() } async function getUnknownPoints() { let response = await fetch(`https://${adminSite}/api/competitions/${getCompetition()}/routes/${urlParams().get("cat_id")}/unknown`, { method: 'GET' }) return await response.json() } function relatedPointsHref() { return `https://${adminSite}/competitions/${getCompetition()}/relations` } 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 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 makeDownloadLink(name, href = null) { return Tag.a({ download: name, textContent: "Скачать", target: "_blank", href: href }) } 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 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 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) } removedFilesLinks.length = 0 } 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 } 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] } 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() }) } })() 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 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 }) } } 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 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 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 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 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) } 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) } 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) } } } 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() } 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) })() } 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 } } })() } 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() } } })();