// ==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()
}
}
})();