crafty-4/app/frontend/templates/panel/server_files.html

806 lines
27 KiB
HTML
Raw Normal View History

{% extends ../base.html %}
{% block meta %}
<!-- <meta http-equiv="refresh" content="60">-->
{% end %}
{% block title %}Crafty Controller - {{ translate('serverDetails', 'serverDetails') }}{% end %}
{% block content %}
<div class="content-wrapper">
<!-- Page Title Header Starts-->
<div class="row page-title-header">
<div class="col-12">
<div class="page-header">
<h4 class="page-title">
{{ translate('serverDetails', 'serverDetails') }} - {{ data['server_stats']['server_id']['server_name'] }}
<br />
<small>UUID: {{ data['server_stats']['server_id']['server_uuid'] }}</small>
</h4>
</div>
</div>
</div>
<!-- Page Title Header Ends-->
{% include "parts/details_stats.html %}
<div class="row">
<div class="col-sm-12 grid-margin">
<div class="card">
<div class="card-body pt-0">
{% include "parts/server_controls_list.html %}
<div class="row">
<div class="col-md-6 col-sm-12">
<noscript>
{{ translate('serverFiles', 'noscript') }}
</noscript>
2021-01-17 17:20:28 +00:00
<div id="files-tree-nav" class="overlay">
<!-- Button to close the overlay navigation -->
<!-- Overlay content -->
<div id="files-tree-nav-content" class="overlay-content">
<a onclick="createFileE(event)" href="javascript:void(0)" id="createFile" href="#">{{ translate('serverFiles', 'createFile') }}</a>
<a onclick="createDirE(event)" href="javascript:void(0)" id="createDir" href="#">{{ translate('serverFiles', 'createDir') }}</a>
<a onclick="renameItemE(event)" href="javascript:void(0)" id="renameItem" href="#">{{ translate('serverFiles', 'rename') }}</a>
<a onclick="deleteFileE(event)" href="javascript:void(0)" id="deleteFile" href="#">{{ translate('serverFiles', 'delete') }}</a>
<a onclick="deleteDirE(event)" href="javascript:void(0)" id="deleteDir" href="#">{{ translate('serverFiles', 'delete') }}</a>
<a onclick="uploadFilesE(event)" href="javascript:void(0)" id="upload" href="#">Upload Files</a>
<a onclick="unzipFilesE(event)" href="javascript:void(0)" id="unzip" href="#">Unzip</a>
2021-08-20 17:46:01 +00:00
<a href="javascript:void(0)" class="closebtn" style="color: red;" onclick="document.getElementById('files-tree-nav').style.display = 'none';">Close</a>
2021-01-17 17:20:28 +00:00
</div>
</div>
<style>
/* The Overlay (background) */
.overlay {
2021-08-20 17:46:01 +00:00
display: none;
flex-direction: column;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgb(64 64 64 / 5%);
padding: 10px 0;
z-index: 10000;
overflow: scroll;
2021-01-17 17:20:28 +00:00
}
2021-08-20 19:18:55 +00:00
.overlay::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.overlay {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
2021-01-17 17:20:28 +00:00
}
/* Position the content inside the overlay */
.overlay-content {
2021-08-20 17:46:01 +00:00
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgb(64 64 64 / 5%);
padding: 10px 0;
2021-01-17 17:20:28 +00:00
}
/* The navigation links inside the overlay */
.overlay a {
2021-08-20 17:46:01 +00:00
font: inherit;
border: 0;
padding: 10px 30px 10px 15px;
width: 100%;
display: flex;
align-items: center;
position: relative;
text-decoration: unset;
color: #000;
font-weight: 500;
transition: 0.5s linear;
-webkit-transition: 0.5s linear;
-moz-transition: 0.5s linear;
-ms-transition: 0.5s linear;
-o-transition: 0.5s linear;
2021-01-17 17:20:28 +00:00
}
/* When you mouse over the navigation links, change their color */
.overlay a:hover, .overlay a:focus {
2021-08-20 17:46:01 +00:00
background:#f1f3f7;
color: #4b00ff;
2021-01-17 17:20:28 +00:00
}
/* Position the close button (top right corner) */
2021-08-20 17:46:01 +00:00
.overlay .closebtn .closebtn:hover {
background-color: red;
color: red;
z-index: 10000;
2021-01-17 17:20:28 +00:00
}
/* When the height of the screen is less than 450 pixels, change the font-size of the links and position the close button again, so they don't overlap */
@media screen and (max-height: 450px) {
.overlay a {font-size: 20px}
.overlay .closebtn {
font-size: 40px;
top: 15px;
right: 35px;
}
}
</style>
<ul class="tree-view">
<li>
2021-01-22 22:46:33 +00:00
<div class="tree-caret tree-ctx-item files-tree-title">
<i class="far fa-folder"></i>
<i class="far fa-folder-open"></i>
{{ translate('serverFiles', 'files') }}
2021-01-22 22:46:33 +00:00
</div>
<ul class="tree-nested" id="files-tree">
<li>{{ translate('serverFiles', 'error') }}</li>
</ul>
</li>
</ul>
</div>
<style>
/* Remove default bullets */
2021-01-22 22:46:33 +00:00
.tree-view,
.tree-nested {
list-style-type: none;
margin: 0;
padding: 0;
2021-01-22 22:46:33 +00:00
margin-left: 10px;
}
/* Style the items */
.tree-item,
.files-tree-title {
cursor: pointer;
user-select: none; /* Prevent text selection */
}
/* Create the caret/arrow with a unicode, and style it */
2021-01-22 22:46:33 +00:00
.tree-caret .fa-folder {
display: inline-block;
2021-01-22 22:46:33 +00:00
}
.tree-caret .fa-folder-open {
display: none;
}
/* Rotate the caret/arrow icon when clicked on (using JavaScript) */
2021-01-22 22:46:33 +00:00
.tree-caret-down .fa-folder {
display: none;
}
.tree-caret-down .fa-folder-open {
display: inline-block;
}
/* Hide the nested list */
.tree-nested {
display: none;
}
</style>
<div class="col-md-6 col-sm-12">
<h2 id="fileError"></h2>
<div id="editorParent">
{{ translate('serverFiles', 'editingFile') }} <span id="editingFile"></span>
<div id="editor" onresize="editor.resize()" style="resize: both;width: 100%;">file_contents</div>
<br/>
</div>
{{ translate('serverFiles', 'keybindings') }}:
<div class="btn-group" role="group">
<button onclick="setKeyboard(event.target)" class="btn btn-primary" data-handler-name="null">{{ translate('serverFiles', 'default') }}</button>
<button onclick="setKeyboard(event.target)" class="btn btn-secondary" data-handler-name="ace/keyboard/vim">Vim</button>
<button onclick="setKeyboard(event.target)" class="btn btn-secondary" data-handler-name="ace/keyboard/emacs">Emacs</button>
<button onclick="setKeyboard(event.target)" class="btn btn-secondary" data-handler-name="ace/keyboard/sublime">Sublime</button>
</div>
<h3 id="file_warn"></h3>
<button class="btn btn-success" onclick="save()">{{ translate('serverFiles', 'save') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- content-wrapper ends -->
{% end %}
{% block js %}
<script src="/static/assets/vendors/ace-builds/src-min/ace.js" type="text/javascript" charset="utf-8"></script>
<script>
//used to get cookies from browser - this is part of tornados xsrf protection - it's for extra security
function getCookie(name) {
var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
return r ? r[1] : undefined;
}
let editor = ace.edit('editor');
editor.setTheme('ace/theme/dracula');
editor.session.setUseSoftTabs(true);
// mouseup = css resize end
document.addEventListener("mouseup", function(e){
editor.resize();
});
let extensionChanges = [
{
regex: /^js$/,
replaceWith: 'ace/mode/javascript'
},
{
regex: /^py$/,
replaceWith: 'ace/mode/python'
},
{
regex: /^html$/,
replaceWith: 'ace/mode/html'
},
{
regex: /^yml$/,
replaceWith: 'ace/mode/yaml'
},
{
regex: /^yaml$/,
replaceWith: 'ace/mode/yaml'
},
{
regex: /^txt$/,
replaceWith: 'ace/mode/text'
},
{
regex: /^json$/,
replaceWith: 'ace/mode/json'
},
{
regex: /^java$/,
replaceWith: 'ace/mode/java'
},
{
regex: /^cpp$/,
replaceWith: 'ace/mode/c_cpp'
},
{
regex: /^c$/,
replaceWith: 'ace/mode/c_cpp'
},
{
regex: /^css$/,
replaceWith: 'ace/mode/css'
},
{
regex: /^scss$/,
replaceWith: 'ace/mode/scss'
},
{
regex: /^sass$/,
replaceWith: 'ace/mode/sass'
},
{
regex: /^lua$/,
replaceWith: 'ace/mode/lua'
},
{
regex: /^php$/,
replaceWith: 'ace/mode/php'
},
{
regex: /^ps1$/,
replaceWith: 'ace/mode/powershell'
},
{
regex: /^svg$/,
replaceWith: 'ace/mode/svg'
},
{
regex: /^sh$/,
replaceWith: 'ace/mode/sh'
},
{
regex: /^xml$/,
replaceWith: 'ace/mode/xml'
},
{
regex: /^ts$/,
replaceWith: 'ace/mode/typescript'
},
{
regex: /^properties$/,
replaceWith: 'ace/mode/properties'
},
];
var filePath = '';
function clickOnFile(event) {
filePath = event.target.getAttribute('data-path');
$.ajax({
type: 'GET',
url: '/ajax/get_file?id={{ data['server_stats']['server_id']['server_id'] }}&file_path=' + encodeURIComponent(filePath),
dataType: 'text',
success: function (data) {
console.log('Got File Contents From Server');
json = JSON.parse(data)
if (json.error) {
$('#editorParent').toggle(false) // hide
$('#fileError').toggle(true) // show
$('#fileError').text("{{ translate('serverFiles', 'fileReadError') }}: " + json.error) // show error
editor.blur()
} else {
$('#editorParent').toggle(true) // show
$('#fileError').toggle(false) // hide
setFileName(event.target.innerText);
editor.session.setValue(json.content);
}
},
});
}
function setFileName(name) {
let fileName = name || 'default.txt';
document.getElementById('editingFile').innerText = fileName;
if (fileName.match('.')) {
// The pop method removes and returns the last element.
setMode(fileName
.split('.')
.pop()
.replace('ace/mode/', ''));
} else {
setMode('txt');
document
.querySelector('#file_warn')
.innerText = "{% raw translate('serverFiles', 'unsupportedLanguage') %}";
}
}
setFileName();
$('#editorParent').toggle(false) // show
$('#fileError').toggle(false) // hide
editor.blur()
function setMode (extension) {
// if the extension matches with the RegEx it will return the replaceWith
// property. else it will return the one it has. defaults to the extension.
// this runs for each element in extensionChanges.
let aceMode = extensionChanges.reduce((output, element) => {
return extension.match(element.regex)
? element.replaceWith
: output;
}, extension);
if (!aceMode.startsWith('ace/mode/')) {
document
.querySelector('#file_warn')
.innerText = "{% raw translate('serverFiles', 'unsupportedLanguage') %}";
} else {
document
.querySelector('#file_warn')
.innerText = '';
console.log(aceMode || 'ace/mode/text');
editor.session.setMode(aceMode || 'ace/mode/text');
}
}
function save() {
let text = editor.session.getValue();
var token = getCookie("_xsrf")
$.ajax({
2021-01-17 17:20:28 +00:00
type: "PUT",
headers: {'X-XSRFToken': token},
url: '/ajax/save_file?id={{ data['server_stats']['server_id']['server_id'] }}',
data: {
file_contents: text,
file_path: filePath
},
success: function(data){
console.log("got response:");
console.log(data);
},
});
}
2021-01-17 17:20:28 +00:00
function createFile(parent, name, callback) {
var token = getCookie("_xsrf")
$.ajax({
type: "POST",
headers: {'X-XSRFToken': token},
url: '/ajax/create_file?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
data: {
file_parent: parent,
file_name: name
},
success: function(data){
console.log("got response:");
console.log(data);
callback();
},
});
}
function createDir(parent, name, callback) {
var token = getCookie("_xsrf")
$.ajax({
type: "POST",
headers: {'X-XSRFToken': token},
url: '/ajax/create_dir?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
data: {
dir_parent: parent,
dir_name: name
},
success: function(data){
console.log("got response:");
console.log(data);
callback();
},
});
}
function renameItem(path, name, callback) {
var token = getCookie("_xsrf")
$.ajax({
type: "PUT",
headers: {'X-XSRFToken': token},
url: '/ajax/rename_item?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
data: {
item_path: path,
new_item_name: name
},
success: function(data){
console.log("got response:");
console.log(data);
callback();
},
});
}
function deleteFile(path, callback) {
console.log('Deleting: ' + path)
var token = getCookie("_xsrf")
$.ajax({
type: "DELETE",
headers: {'X-XSRFToken': token},
url: '/ajax/del_file?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
data: {
file_path: path
},
success: function(data){
console.log("got response:");
console.log(data);
callback();
},
});
}
function deleteDir(path, callback) {
var token = getCookie("_xsrf")
$.ajax({
type: "DELETE",
headers: {'X-XSRFToken': token},
url: '/ajax/del_dir?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
data: {
dir_path: path
},
success: function(data){
console.log("got response:");
console.log(data);
callback();
},
});
}
function unZip(path, callback) {
var token = getCookie("_xsrf")
$.ajax({
type: "POST",
headers: {'X-XSRFToken': token},
url: '/ajax/unzip_file?id={{ data['server_stats']['server_id']['server_id'] }}',
data: {
path: path
},
});
window.location.href = "/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=files"
}
function uploadFilesE(event){
path = event.target.parentElement.getAttribute('data-path');
console.log("PATH: " + path);
$(function () {
server_id = {{ data['server_stats']['server_id']['server_id'] }};
var uploadHtml = "<div>" +
'<form id="upload_file" enctype="multipart/form-data" action="/ajax/upload_files?id=' + server_id +'&path='+ path +'"method="post">{% raw xsrf_form_html() %}'+"<label class='upload-area' style='width:100%;text-align:center;' for='files'>" +
"<input id='files' name='files' type='file' style='display:none;' multiple='true'>" +
"<i class='fa fa-cloud-upload fa-3x'></i>" +
"<br />" +
"Click Here To Upload" +
"</label></form>" +
"<br />" +
"<ul style='margin-left:5px !important;' id='fileList'></ul>" +
"</div><div class='clearfix'></div>";
bootbox.dialog({
message: uploadHtml,
title: "File Upload",
buttons: {
success: {
label: "Upload",
className: "btn-default",
callback: function () {
$('#upload_file').submit(); //.trigger('submit');
}
}
}
});
var fileList = document.getElementById("files");
fileList.addEventListener("change", function (e) {
var list = "";
for (var i = 0; i < this.files.length; i++) {
list += "<li class='col-xs-12 file-list'>" + this.files[i].name + "</li>"
}
document.getElementById("fileList").innerHTML = list;
}, false);
});
}
function uploadFiles(e){
path = event.target.parentElement.getAttribute('data-path');
server_id = {{ data['server_stats']['server_id']['server_id'] }};
var uploadHtml = '<form enctype="multipart/form-data" action="/ajax/upload_files?id=' + server_id +'&path='+ path +'"method="post">{% raw xsrf_form_html() %}<div class="form-group">'+"<label class='upload-area' style='width:100%;text-align:center;' for='fupload'>" +'<input id="files" type="file" name="files" multiple>' +"<i class='fa fa-cloud-upload fa-3x'></i>" +"<br />" +"Upload Files Here" +"</label>" +"<br />" +"<span style='margin-left:5px !important;' id='fileList'></span>"+'</div><br><br><input id="upload_file" type="submit"value="Upload File" class="btn btn-success hidden"/></form>';
bootbox.dialog({
message: uploadHtml,
title: "Upload Files To "+path,
});
}
2021-01-17 17:20:28 +00:00
function getTreeView() {
$.ajax({
type: "GET",
url: '/ajax/get_tree?id={{ data['server_stats']['server_id']['server_id'] }}',
2021-01-17 17:20:28 +00:00
dataType: 'text',
success: function(data){
console.log("got response:");
console.log(data);
dataArr = data.split('\n');
serverDir = dataArr.shift(); // Remove & return first element (server directory)
text = dataArr.join('\n');
document.getElementById('files-tree').innerHTML = text;
document.getElementsByClassName('files-tree-title')[0].setAttribute('data-path', serverDir);
document.getElementsByClassName('files-tree-title')[0].setAttribute('data-name', 'Files');
setTimeout(function () {setTreeViewContext()}, 1000);
var toggler = document.getElementsByClassName("tree-caret");
var i;
for (i = 0; i < toggler.length; i++) {
if (toggler[i].classList.contains('files-tree-title')) continue;
toggler[i].addEventListener("click", function caretListener() {
this.parentElement.querySelector(".tree-nested").classList.toggle("d-block");
this.classList.toggle("tree-caret-down");
});
}
},
});
}
function setTreeViewContext() {
var treeItems = document.getElementsByClassName('tree-ctx-item');
for (var i = 0; i < treeItems.length; i++) {
var treeItem = treeItems[i];
treeItem.addEventListener('contextmenu', function contextListener(event) {
event.preventDefault();
var ctxmenuPath = event.target.getAttribute('data-path');
var ctxmenuName = event.target.getAttribute('data-name');
if (!ctxmenuPath) {
console.log({ 'event.target': event.target, ctxmenuPath });
return;
}
$('#renameItem').show();
var isDir = event.target.classList.contains('tree-folder');
$('#createFile').toggle(isDir);
$('#createDir').toggle(isDir);
$('#deleteDir').toggle(isDir);
$('#upload').toggle(isDir);
2021-01-17 17:20:28 +00:00
var isFile = event.target.classList.contains('tree-file');
$('#deleteFile').toggle(isFile);
console.log({ 'event.target': event.target, isDir, isFile });
if(event.target.classList.contains('files-tree-title')) {
$('#createFile').show();
$('#createDir').show();
$('#renameItem').hide();
$('#deleteDir').hide();
$('#deleteFile').hide();
$('#upload').show();
2021-01-17 17:20:28 +00:00
}
2021-08-20 17:46:01 +00:00
if(event.target.textContent.endsWith('.zip')){
$('#unzip').show();
console.log(event.target.textContent)
}else{
$('#unzip').hide();}
var clientX = event.clientX;
var clientY = event.clientY;
2021-01-17 17:20:28 +00:00
document.getElementById('files-tree-nav-content')
.setAttribute('data-path', ctxmenuPath);
document.getElementById('files-tree-nav-content')
.setAttribute('data-name', ctxmenuName);
2021-08-20 17:46:01 +00:00
document.getElementById("files-tree-nav").style.display = "flex";
document.getElementById("files-tree-nav").style.position = "fixed";
domRect = document.getElementById("files-tree-nav").getBoundingClientRect();
sum = (clientY+domRect['height']) - window.innerHeight
if(domRect['height']+clientY > window.innerHeight){
clientY = clientY - sum
}
document.getElementById("files-tree-nav").style.top = clientY + 'px';
document.getElementById("files-tree-nav").style.left = clientX + 'px';
console.log(domRect)
console.log(window.innerHeight)
2021-01-17 17:20:28 +00:00
})
}
}
document.addEventListener('click', function(e){
let inside = (e.target.closest('#files-tree-nav'));
if(!inside){
let contextMenu = document.getElementById('files-tree-nav');
contextMenu.setAttribute('style', 'display:none');
}
});
2021-01-17 17:20:28 +00:00
function createFileE(event) {
bootbox.prompt("{% raw translate('serverFiles', 'createFileQuestion') %}", function(result) {
2021-01-17 17:20:28 +00:00
path = event.target.parentElement.getAttribute('data-path');
name = event.target.parentElement.getAttribute('data-name');
if (!result) return;
createFile(path, result, function () {
getTreeView()
2021-08-20 17:46:01 +00:00
document.getElementById('files-tree-nav').style.display = 'none';
2021-01-17 17:20:28 +00:00
});
})
}
function createDirE(event) {
bootbox.prompt("{% raw translate('serverFiles', 'createDirQuestion') %}", function(result) {
2021-01-17 17:20:28 +00:00
path = event.target.parentElement.getAttribute('data-path');
name = event.target.parentElement.getAttribute('data-name');
if (!result) return;
createDir(path, result, function () {
getTreeView()
2021-08-20 17:46:01 +00:00
document.getElementById('files-tree-nav').style.display = 'none';
2021-01-17 17:20:28 +00:00
});
})
}
function renameItemE(event) {
bootbox.prompt("{% raw translate('serverFiles', 'renameItemQuestion') %}", function(result) {
2021-01-17 17:20:28 +00:00
path = event.target.parentElement.getAttribute('data-path');
name = event.target.parentElement.getAttribute('data-name');
if (!result) return;
renameItem(path, result, function () {
getTreeView()
2021-08-20 17:46:01 +00:00
document.getElementById('files-tree-nav').style.display = 'none';
2021-01-17 17:20:28 +00:00
});
})
}
function unzipFilesE(event) {
path = event.target.parentElement.getAttribute('data-path');
unZip(path)
}
2021-01-17 17:20:28 +00:00
function deleteFileE(event) {
path = event.target.parentElement.getAttribute('data-path');
name = event.target.parentElement.getAttribute('data-name');
bootbox.confirm({
size: "",
title: "{% raw translate('serverFiles', 'deleteItemQuestion') %}",
2021-01-17 17:20:28 +00:00
closeButton: false,
message: "{% raw translate('serverFiles', 'deleteItemQuestionMessage') %}",
2021-01-17 17:20:28 +00:00
buttons: {
confirm: {
label: "{{ translate('serverFiles', 'yesDelete') }}",
2021-01-17 17:20:28 +00:00
className: 'btn-danger'
},
cancel: {
label: "{{ translate('serverFiles', 'noDelete') }}",
2021-01-17 17:20:28 +00:00
className: 'btn-link'
}
},
callback: function(result) {
if (!result) return;
deleteFile(path, function () {
getTreeView()
2021-08-20 17:46:01 +00:00
document.getElementById('files-tree-nav').style.display = 'none';
2021-01-17 17:20:28 +00:00
});
}
});
}
function deleteDirE(event) {
path = event.target.parentElement.getAttribute('data-path');
name = event.target.parentElement.getAttribute('data-name');
bootbox.confirm({
size: "",
title: "{% raw translate('serverFiles', 'deleteItemQuestion') %}",
2021-01-17 17:20:28 +00:00
closeButton: false,
message: "{% raw translate('serverFiles', 'deleteItemQuestionMessage') %}",
2021-01-17 17:20:28 +00:00
buttons: {
confirm: {
label: "{{ translate('serverFiles', 'yesDelete') }}",
2021-01-17 17:20:28 +00:00
className: 'btn-danger'
},
cancel: {
label: "{{ translate('serverFiles', 'noDelete') }}",
2021-01-17 17:20:28 +00:00
className: 'btn-link'
}
},
callback: function(result) {
if (!result) return;
deleteDir(path, function () {
getTreeView()
2021-08-20 17:46:01 +00:00
document.getElementById('files-tree-nav').style.display = 'none';
2021-01-17 17:20:28 +00:00
});
}
});
}
document.getElementsByClassName('files-tree-title')[0].addEventListener("click", function caretListener() {
this.parentElement.querySelector(".tree-nested").classList.toggle("d-block");
this.classList.toggle("tree-caret-down");
});
getTreeView();
setTreeViewContext();
function setKeyboard(target) {
var handlerName = target.getAttribute('data-handler-name');
if (handlerName == 'null') handlerName = null;
editor.setKeyboardHandler(handlerName);
var nodes = target.parentNode.querySelectorAll("[data-handler-name]");
for (var i = 0; i < nodes.length; i++) {
nodes[i].classList.remove('btn-primary');
nodes[i].classList.add('btn-secondary');
}
target.classList.remove('btn-secondary');
target.classList.add('btn-primary');
}
</script>
{% end %}