Compare commits
2 commits
07c9522444
...
b01a6b5034
Author | SHA1 | Date | |
---|---|---|---|
b01a6b5034 | |||
a661f8222d |
6 changed files with 294 additions and 32 deletions
45
app/api.py
45
app/api.py
|
@ -204,6 +204,51 @@ def delete_file_or_folder():
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api.route('/files/content', methods=['GET'])
|
||||||
|
@oidc.require_login
|
||||||
|
def get_file_content():
|
||||||
|
username = oidc.user_getfield('preferred_username')
|
||||||
|
path = request.args.get('path')
|
||||||
|
|
||||||
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
|
file_path = os.path.abspath(os.path.join(base_path, path))
|
||||||
|
|
||||||
|
if not file_path.startswith(base_path) or not os.path.isfile(file_path):
|
||||||
|
return abort(403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
return jsonify({"success": True, "content": content})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api.route('/files/content', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
|
def save_file_content():
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username')
|
||||||
|
if username != oidc.user_getfield('preferred_username'):
|
||||||
|
return jsonify({"error": "Unauthorized request."}), 403
|
||||||
|
|
||||||
|
path = data.get('path')
|
||||||
|
content = data.get('content')
|
||||||
|
|
||||||
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
|
file_path = os.path.abspath(os.path.join(base_path, path))
|
||||||
|
|
||||||
|
if not file_path.startswith(base_path) or not os.path.isfile(file_path):
|
||||||
|
return abort(403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# Server config
|
# Server config
|
||||||
@api.route('/config')
|
@api.route('/config')
|
||||||
@oidc.require_login
|
@oidc.require_login
|
||||||
|
|
|
@ -115,9 +115,9 @@ def start_server(username):
|
||||||
f"{ports[1]}/tcp": ports[1],
|
f"{ports[1]}/tcp": ports[1],
|
||||||
f"{ports[2]}/tcp": ports[2],
|
f"{ports[2]}/tcp": ports[2],
|
||||||
},
|
},
|
||||||
volumes={
|
volumes=[
|
||||||
os.path.abspath(path): {'bind': '/data', 'mode': 'rw'}
|
f"{os.path.abspath(path)}:/data"
|
||||||
},
|
],
|
||||||
environment=environment,
|
environment=environment,
|
||||||
restart_policy={"Name": "unless-stopped"}
|
restart_policy={"Name": "unless-stopped"}
|
||||||
)
|
)
|
||||||
|
|
134
app/static/js/file_editor.js
Normal file
134
app/static/js/file_editor.js
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
let codeMirrorEditor = null;
|
||||||
|
let currentEditingFilePath = null;
|
||||||
|
|
||||||
|
function getFileExtension(filename) {
|
||||||
|
const lastDot = filename.lastIndexOf('.');
|
||||||
|
if (lastDot === -1) return '';
|
||||||
|
return filename.slice(lastDot + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodeMirrorMode(filename) {
|
||||||
|
const ext = getFileExtension(filename).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'js':
|
||||||
|
return 'javascript';
|
||||||
|
case 'json':
|
||||||
|
return {
|
||||||
|
name: "javascript",
|
||||||
|
json: true
|
||||||
|
};
|
||||||
|
case 'html':
|
||||||
|
case 'htm':
|
||||||
|
return 'htmlmixed';
|
||||||
|
case 'css':
|
||||||
|
return 'css';
|
||||||
|
case 'xml':
|
||||||
|
return 'xml';
|
||||||
|
case 'yml':
|
||||||
|
case 'yaml':
|
||||||
|
return 'yaml';
|
||||||
|
case 'properties':
|
||||||
|
return 'properties';
|
||||||
|
case 'log':
|
||||||
|
case 'txt':
|
||||||
|
return 'text/plain';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFileEditor(filePath) {
|
||||||
|
const fileExtension = getFileExtension(filePath);
|
||||||
|
const textFileExtensions = ['txt', 'log', 'json', 'yml', 'yaml', 'properties', 'html', 'css', 'js', 'xml', 'py'];
|
||||||
|
|
||||||
|
if (!textFileExtensions.includes(fileExtension.toLowerCase())) {
|
||||||
|
alert('Ten typ pliku nie może być edytowany bezpośrednio. Możesz go pobrać.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/content?username=${username}&path=${encodeURIComponent(filePath)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Server responded with status ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('file-list').style.display = 'none';
|
||||||
|
document.getElementById('drop-zone').style.display = 'none';
|
||||||
|
document.getElementById('file-editor-container').style.display = 'block';
|
||||||
|
|
||||||
|
document.getElementById('editing-filename').textContent = filePath.split('/').pop();
|
||||||
|
currentEditingFilePath = filePath;
|
||||||
|
|
||||||
|
if (!codeMirrorEditor) {
|
||||||
|
codeMirrorEditor = CodeMirror.fromTextArea(document.getElementById('file-editor'), {
|
||||||
|
lineNumbers: true,
|
||||||
|
theme: 'material-darker',
|
||||||
|
mode: getCodeMirrorMode(filePath),
|
||||||
|
indentUnit: 4,
|
||||||
|
tabSize: 4,
|
||||||
|
indentWithTabs: false,
|
||||||
|
lineWrapping: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
codeMirrorEditor.setValue(data.content);
|
||||||
|
codeMirrorEditor.setOption('mode', getCodeMirrorMode(filePath));
|
||||||
|
}
|
||||||
|
codeMirrorEditor.refresh();
|
||||||
|
} else {
|
||||||
|
alert('Błąd podczas ładowania pliku: ' + (data.error || 'Nieznany błąd.'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Błąd:', error);
|
||||||
|
alert('Wystąpił błąd podczas ładowania pliku: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFileContent() {
|
||||||
|
if (!codeMirrorEditor || !currentEditingFilePath) {
|
||||||
|
alert('Brak pliku do zapisania.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = codeMirrorEditor.getValue();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files/content', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
path: currentEditingFilePath,
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Server responded with status ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Plik został pomyślnie zapisany!');
|
||||||
|
} else {
|
||||||
|
alert('Błąd podczas zapisywania pliku: ' + (data.error || 'Nieznany błąd.'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Błąd:', error);
|
||||||
|
alert('Wystąpił błąd podczas zapisywania pliku: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFileEditor() {
|
||||||
|
document.getElementById('file-editor-container').style.display = 'none';
|
||||||
|
document.getElementById('file-list').style.display = 'block';
|
||||||
|
document.getElementById('drop-zone').style.display = 'block';
|
||||||
|
currentEditingFilePath = null;
|
||||||
|
if (codeMirrorEditor) {
|
||||||
|
codeMirrorEditor.setValue('');
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,25 +9,42 @@ function loadFileList(path = '') {
|
||||||
if (path) {
|
if (path) {
|
||||||
list.innerHTML += `<li><a class="non-link" href="#" onclick="loadFileList('${path.split('/').slice(0, -1).join('/')}')">⬅️ ..</a></li>`;
|
list.innerHTML += `<li><a class="non-link" href="#" onclick="loadFileList('${path.split('/').slice(0, -1).join('/')}')">⬅️ ..</a></li>`;
|
||||||
}
|
}
|
||||||
data.sort((a, b) => a.name.localeCompare(b.name));
|
data.sort((a, b) => {
|
||||||
|
if (a.is_dir === b.is_dir) {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
return a.is_dir ? -1 : 1;
|
||||||
|
});
|
||||||
data.forEach(entry => {
|
data.forEach(entry => {
|
||||||
const itemPath = path ? `${path}/${entry.name}` : entry.name;
|
const itemPath = path ? `${path}/${entry.name}` : entry.name;
|
||||||
const html = entry.is_dir
|
let html = '';
|
||||||
? `<li>📁 <a class="non-link" href="#" onclick="loadFileList('${itemPath}')">${entry.name}</a>
|
if (entry.is_dir) {
|
||||||
<a class="non-link" onclick="deleteItem('${itemPath}')">
|
html = `<li>📁 <a class="non-link" href="#" onclick="loadFileList('${itemPath}')">${entry.name}</a>
|
||||||
<i class="fa-solid fa-trash"></i>
|
<a class="non-link action-icon" onclick="deleteItem('${itemPath}')" title="Usuń folder">
|
||||||
</a>
|
|
||||||
</li>`
|
|
||||||
: `<li>📄 ${entry.name}
|
|
||||||
<a class="non-link" href="/api/files/download?username=${username}&path=${encodeURIComponent(itemPath)}" target="_blank">
|
|
||||||
<i class="fa-solid fa-download"></i>
|
|
||||||
</a>
|
|
||||||
<a class="non-link" onclick="deleteItem('${itemPath}')">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>`;
|
</li>`;
|
||||||
|
} else {
|
||||||
|
const fileExtension = getFileExtension(entry.name);
|
||||||
|
const editableExtensions = ['txt', 'log', 'json', 'yml', 'yaml', 'properties', 'html', 'css', 'js', 'xml', 'py'];
|
||||||
|
const isEditable = editableExtensions.includes(fileExtension.toLowerCase());
|
||||||
|
|
||||||
|
html = `<li>📄 ${entry.name}
|
||||||
|
${isEditable ? `
|
||||||
|
<a class="non-link action-icon" onclick="openFileEditor('${itemPath}')" title="Edytuj plik">
|
||||||
|
<i class="fa-solid fa-edit"></i>
|
||||||
|
</a>` : ''}
|
||||||
|
<a class="non-link action-icon" href="/api/files/download?username=${username}&path=${encodeURIComponent(itemPath)}" target="_blank" title="Pobierz plik">
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<a class="non-link action-icon" onclick="deleteItem('${itemPath}')" title="Usuń plik">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
list.innerHTML += html;
|
list.innerHTML += html;
|
||||||
});
|
});
|
||||||
|
closeFileEditor();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,7 +70,9 @@ function deleteItem(path) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('[onclick="showTab(\'files\')"]').addEventListener('click', () => loadFileList());
|
document.querySelector('[onclick="showTab(\'files\')"]').addEventListener('click', () => {
|
||||||
|
loadFileList(); // Load root directory when the tab is opened
|
||||||
|
});
|
||||||
|
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
|
|
|
@ -105,8 +105,9 @@ h2 {
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
.tabs {
|
.tabs {
|
||||||
|
/* Desktop default layout */
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr); /* Original desktop layout */
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 26px;
|
margin-bottom: 26px;
|
||||||
}
|
}
|
||||||
|
@ -121,6 +122,11 @@ h2 {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
transition: background-color 0.25s, transform 0.2s;
|
transition: background-color 0.25s, transform 0.2s;
|
||||||
box-shadow: 0 0 8px transparent;
|
box-shadow: 0 0 8px transparent;
|
||||||
|
/* Flexbox for text and icon alignment */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center; /* Center content horizontally */
|
||||||
|
gap: 8px; /* Space between text and icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button:hover {
|
.tab-button:hover {
|
||||||
|
@ -134,6 +140,16 @@ h2 {
|
||||||
box-shadow: 0 0 14px #4a5aef99;
|
box-shadow: 0 0 14px #4a5aef99;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-button .tab-icon {
|
||||||
|
font-size: 1.2em; /* Adjust icon size as needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button .tab-text {
|
||||||
|
/* By default, text is visible on desktop */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.square-button {
|
.square-button {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
|
@ -666,18 +682,28 @@ a.non-link:hover {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs on Mobile: Single row, icons only */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: grid;
|
display: flex; /* Use flexbox for a single row */
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: unset; /* Override grid for mobile */
|
||||||
gap: 10px;
|
justify-content: space-around; /* Distribute items evenly */
|
||||||
margin-bottom: 12px;
|
flex-wrap: nowrap; /* Keep tabs in a single row */
|
||||||
|
overflow-x: auto; /* Enable horizontal scrolling if buttons exceed screen width */
|
||||||
|
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||||
|
padding-bottom: 5px; /* Add some padding for scrollbar if present */
|
||||||
|
gap: 8px; /* Slightly smaller gap on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
padding: 12px;
|
flex-shrink: 0; /* Prevent buttons from shrinking */
|
||||||
font-size: 0.95rem;
|
padding: 10px 15px; /* Adjust padding for icon-only buttons */
|
||||||
width: 100%;
|
/* Center content for icon-only */
|
||||||
box-sizing: border-box;
|
justify-content: center;
|
||||||
|
width: auto; /* Allow buttons to size to content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button .tab-text {
|
||||||
|
display: none; /* Hide the text on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
.square-button {
|
.square-button {
|
||||||
|
|
|
@ -3,12 +3,24 @@
|
||||||
<h2>Twój serwer Minecraft</h2>
|
<h2>Twój serwer Minecraft</h2>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-button" onclick="showTab('controls')">Sterowanie 🎛️</button>
|
<button class="tab-button" onclick="showTab('controls')">
|
||||||
<button class="tab-button" onclick="showTab('console')">Konsola 📟</button>
|
<span class="tab-text">Sterowanie</span> <span class="tab-icon">🎛️</span>
|
||||||
<button class="tab-button" onclick="showTab('files')">Pliki 📁</button>
|
</button>
|
||||||
<button class="tab-button" onclick="showTab('config')">Konfiguracja 🛠️</button>
|
<button class="tab-button" onclick="showTab('console')">
|
||||||
<button class="tab-button" onclick="showTab('mods')">Mody/Pluginy 🧩</button>
|
<span class="tab-text">Konsola</span> <span class="tab-icon">📟</span>
|
||||||
<button class="tab-button" onclick="showTab('statistics')">Statystyki 📈</button>
|
</button>
|
||||||
|
<button class="tab-button" onclick="showTab('files')">
|
||||||
|
<span class="tab-text">Pliki</span> <span class="tab-icon">📁</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" onclick="showTab('config')">
|
||||||
|
<span class="tab-text">Konfiguracja</span> <span class="tab-icon">🛠️</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" onclick="showTab('mods')">
|
||||||
|
<span class="tab-text">Mody/Pluginy</span> <span class="tab-icon">🧩</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" onclick="showTab('statistics')">
|
||||||
|
<span class="tab-text">Statystyki</span> <span class="tab-icon">📈</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
@ -46,6 +58,13 @@
|
||||||
<br>
|
<br>
|
||||||
<h4>Lista plików:</h4>
|
<h4>Lista plików:</h4>
|
||||||
<ul id="file-list"></ul>
|
<ul id="file-list"></ul>
|
||||||
|
|
||||||
|
<div id="file-editor-container" style="display: none;">
|
||||||
|
<h3>Edytuj plik: <span id="editing-filename"></span></h3>
|
||||||
|
<textarea id="file-editor" style="width: 100%; height: 400px;"></textarea>
|
||||||
|
<button class="btn btn-primary" onclick="saveFileContent()">Zapisz zmiany</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeFileEditor()">Zamknij</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="config">
|
<div class="tab-panel" id="config">
|
||||||
|
@ -123,6 +142,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="/static/js/controls.js"></script>
|
<script src="/static/js/controls.js"></script>
|
||||||
<script src="/static/js/console.js"></script>
|
<script src="/static/js/console.js"></script>
|
||||||
|
@ -141,6 +162,11 @@
|
||||||
function showTab(id) {
|
function showTab(id) {
|
||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
$(id).classList.add('active');
|
$(id).classList.add('active');
|
||||||
|
// Close the mobile menu if it's open (optional, depending on your menu implementation)
|
||||||
|
const tabsContainer = document.querySelector('.tabs');
|
||||||
|
if (tabsContainer.classList.contains('mobile-active')) {
|
||||||
|
tabsContainer.classList.remove('mobile-active');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
|
@ -267,4 +293,16 @@
|
||||||
setInterval(checkServerStatus, 5000);
|
setInterval(checkServerStatus, 5000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
<!-- CSS do Edytora Plików -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/javascript/javascript.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/css/css.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/clike/clike.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/python/python.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/yaml/yaml.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/properties/properties.min.js"></script>
|
||||||
|
<script src="/static/js/file_editor.js"></script>
|
||||||
|
|
||||||
|
{% endblock %}
|
Loading…
Add table
Reference in a new issue