Compare commits
17 commits
Author | SHA1 | Date | |
---|---|---|---|
b01a6b5034 | |||
a661f8222d | |||
07c9522444 | |||
60e2f73008 | |||
384335c52b | |||
137177efc4 | |||
b873e71385 | |||
8ea8fd3567 | |||
694134ccec | |||
efa85c5070 | |||
ee189b7e72 | |||
8ee15c1928 | |||
3b689196a0 | |||
dfb6f5f370 | |||
2b9845ac85 | |||
b0e84a95eb | |||
bb4fc9e488 |
21 changed files with 1141 additions and 327 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -128,3 +128,4 @@ dmypy.json
|
||||||
client_secrets.json
|
client_secrets.json
|
||||||
|
|
||||||
ports.json
|
ports.json
|
||||||
|
/servers/
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
# Hosting
|
|
194
app/api.py
194
app/api.py
|
@ -4,7 +4,8 @@ import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
from flask import Blueprint, jsonify, request, send_from_directory, abort
|
import requests
|
||||||
|
from flask import Blueprint, jsonify, request, send_from_directory, abort, Response
|
||||||
|
|
||||||
from .auth import oidc
|
from .auth import oidc
|
||||||
from .docker_utils import start_server, stop_server, restart_server, get_logs, delete_server, save_server_info, \
|
from .docker_utils import start_server, stop_server, restart_server, get_logs, delete_server, save_server_info, \
|
||||||
|
@ -17,6 +18,7 @@ client = docker.from_env()
|
||||||
|
|
||||||
# Server Deployment
|
# Server Deployment
|
||||||
@api.route('/setup', methods=['POST'])
|
@api.route('/setup', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def setup_server():
|
def setup_server():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
username = oidc.user_getfield('preferred_username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
|
@ -31,9 +33,9 @@ def setup_server():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/delete', methods=['POST'])
|
@api.route('/delete', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def delete():
|
def delete():
|
||||||
data = request.get_json()
|
username = oidc.user_getfield('preferred_username')
|
||||||
username = data.get("username")
|
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
return jsonify({"error": "Brak nazwy użytkownika"}), 400
|
return jsonify({"error": "Brak nazwy użytkownika"}), 400
|
||||||
|
@ -44,8 +46,9 @@ def delete():
|
||||||
|
|
||||||
# Server Controls
|
# Server Controls
|
||||||
@api.route('/start', methods=['POST'])
|
@api.route('/start', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def start():
|
def start():
|
||||||
username = request.json['username']
|
username = oidc.user_getfield('preferred_username')
|
||||||
setup_file_path = f"./servers/mc-{username}/server_info.json"
|
setup_file_path = f"./servers/mc-{username}/server_info.json"
|
||||||
|
|
||||||
if not os.path.exists(setup_file_path):
|
if not os.path.exists(setup_file_path):
|
||||||
|
@ -66,22 +69,25 @@ def start():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/stop', methods=['POST'])
|
@api.route('/stop', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def stop():
|
def stop():
|
||||||
username = request.json['username']
|
username = oidc.user_getfield('preferred_username')
|
||||||
stop_server(username)
|
stop_server(username)
|
||||||
return jsonify({"status": "stopped"})
|
return jsonify({"status": "stopped"})
|
||||||
|
|
||||||
|
|
||||||
@api.route('/restart', methods=['POST'])
|
@api.route('/restart', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def restart():
|
def restart():
|
||||||
username = request.json['username']
|
username = oidc.user_getfield('preferred_username')
|
||||||
restart_server(username)
|
restart_server(username)
|
||||||
return jsonify({"status": "restarted"})
|
return jsonify({"status": "restarted"})
|
||||||
|
|
||||||
|
|
||||||
@api.route('/logs', methods=['GET'])
|
@api.route('/logs', methods=['GET'])
|
||||||
|
@oidc.require_login
|
||||||
def logs():
|
def logs():
|
||||||
username = request.args.get('username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
return jsonify({"logs": get_logs(username)})
|
return jsonify({"logs": get_logs(username)})
|
||||||
|
|
||||||
|
|
||||||
|
@ -92,6 +98,9 @@ def send_command():
|
||||||
username = data['username']
|
username = data['username']
|
||||||
command = data['command']
|
command = data['command']
|
||||||
|
|
||||||
|
if username != oidc.user_getfield('preferred_username'):
|
||||||
|
return jsonify({"error": "Unauthorized request."}), 403
|
||||||
|
|
||||||
container_name = f"mc-{username}"
|
container_name = f"mc-{username}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -107,8 +116,9 @@ def send_command():
|
||||||
|
|
||||||
# Files APIs (Upload, download, delete)
|
# Files APIs (Upload, download, delete)
|
||||||
@api.route('/files', methods=['GET'])
|
@api.route('/files', methods=['GET'])
|
||||||
|
@oidc.require_login
|
||||||
def list_files():
|
def list_files():
|
||||||
username = request.args.get('username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
path = request.args.get('path', '')
|
path = request.args.get('path', '')
|
||||||
|
|
||||||
base_path = os.path.abspath(f'./servers/mc-{username}')
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
|
@ -134,8 +144,9 @@ def list_files():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/files/download', methods=['GET'])
|
@api.route('/files/download', methods=['GET'])
|
||||||
|
@oidc.require_login
|
||||||
def download_file():
|
def download_file():
|
||||||
username = request.args.get('username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
path = request.args.get('path')
|
path = request.args.get('path')
|
||||||
|
|
||||||
base_path = os.path.abspath(f'./servers/mc-{username}')
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
|
@ -150,8 +161,9 @@ def download_file():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/files/upload', methods=['POST'])
|
@api.route('/files/upload', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def upload_file():
|
def upload_file():
|
||||||
username = request.form.get('username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
path = request.form.get('path', '')
|
path = request.form.get('path', '')
|
||||||
file = request.files['files']
|
file = request.files['files']
|
||||||
base_path = os.path.abspath(f'./servers/mc-{username}')
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
|
@ -167,12 +179,17 @@ def upload_file():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/files/delete', methods=['POST'])
|
@api.route('/files/delete', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def delete_file_or_folder():
|
def delete_file_or_folder():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
|
if username != oidc.user_getfield('preferred_username'):
|
||||||
|
return jsonify({"error": "Unauthorized request."}), 403
|
||||||
|
|
||||||
path = data.get('path')
|
path = data.get('path')
|
||||||
base_path = os.path.abspath(f'./servers/mc-{username}')
|
base_path = os.path.abspath(f'./servers/mc-{username}')
|
||||||
target_path = os.path.abspath(os.path.join(base_path, path))
|
target_path = os.path.abspath(os.path.join(base_path, path))
|
||||||
|
|
||||||
if not target_path.startswith(base_path):
|
if not target_path.startswith(base_path):
|
||||||
return abort(403)
|
return abort(403)
|
||||||
if not os.path.exists(target_path):
|
if not os.path.exists(target_path):
|
||||||
|
@ -187,23 +204,74 @@ 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
|
||||||
def get_config():
|
def get_config():
|
||||||
username = request.args.get('username')
|
username = oidc.user_getfield('preferred_username')
|
||||||
server_info_path = f'./servers/mc-{username}/server_info.json'
|
server_info_path = f'./servers/mc-{username}/server_info.json'
|
||||||
if not os.path.exists(server_info_path):
|
if not os.path.exists(server_info_path):
|
||||||
return jsonify({"success": False, "message": "Server config not found"})
|
return jsonify({"success": False, "message": "Server config not found"})
|
||||||
with open(server_info_path, 'r') as f:
|
with open(server_info_path, 'r') as f:
|
||||||
server_info = json.load(f)
|
server_info = json.load(f)
|
||||||
|
|
||||||
return jsonify({"success": True, "config": server_info["config"], "version": server_info["version"], "type": server_info["type"]})
|
return jsonify({"success": True, "config": server_info["config"], "version": server_info["version"],
|
||||||
|
"type": server_info["type"]})
|
||||||
|
|
||||||
|
|
||||||
@api.route('/config', methods=['POST'])
|
@api.route('/config', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
def update_config():
|
def update_config():
|
||||||
data = request.json
|
data = request.json
|
||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
|
if username != oidc.user_getfield('preferred_username'):
|
||||||
|
return jsonify({"error": "Unauthorized request."}), 403
|
||||||
|
|
||||||
incoming_config = data.get('config', {})
|
incoming_config = data.get('config', {})
|
||||||
server_info_path = f'./servers/mc-{username}/server_info.json'
|
server_info_path = f'./servers/mc-{username}/server_info.json'
|
||||||
if os.path.exists(server_info_path):
|
if os.path.exists(server_info_path):
|
||||||
|
@ -211,9 +279,11 @@ def update_config():
|
||||||
server_info = json.load(f)
|
server_info = json.load(f)
|
||||||
else:
|
else:
|
||||||
server_info = DEFAULT_CONFIG.copy()
|
server_info = DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
for key in ["type", "version"]:
|
for key in ["type", "version"]:
|
||||||
if key in incoming_config:
|
if key in incoming_config:
|
||||||
server_info[key] = incoming_config.pop(key)
|
server_info[key] = incoming_config.pop(key)
|
||||||
|
|
||||||
formatted_config = {key.replace('_', '-'): value for key, value in incoming_config.items()}
|
formatted_config = {key.replace('_', '-'): value for key, value in incoming_config.items()}
|
||||||
server_info["config"] = formatted_config
|
server_info["config"] = formatted_config
|
||||||
with open(server_info_path, 'w') as f:
|
with open(server_info_path, 'w') as f:
|
||||||
|
@ -235,8 +305,9 @@ def status():
|
||||||
|
|
||||||
|
|
||||||
@api.route('/stats', methods=['GET'])
|
@api.route('/stats', methods=['GET'])
|
||||||
|
@oidc.require_login
|
||||||
def stats():
|
def stats():
|
||||||
username = request.args.get("username")
|
username = oidc.user_getfield("preferred_username")
|
||||||
container_name = f"mc-{username}"
|
container_name = f"mc-{username}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -268,3 +339,100 @@ def stats():
|
||||||
})
|
})
|
||||||
except docker.errors.NotFound:
|
except docker.errors.NotFound:
|
||||||
return jsonify({"error": "Container not found"}), 404
|
return jsonify({"error": "Container not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# Modrinth
|
||||||
|
@api.route('/modrinth/search')
|
||||||
|
def modrinth_search():
|
||||||
|
query = request.args.get("query")
|
||||||
|
server_type = request.args.get("type") # 'fabric', 'paper', etc.
|
||||||
|
version = request.args.get("version")
|
||||||
|
|
||||||
|
categories = {
|
||||||
|
'fabric': 'mod',
|
||||||
|
'paper': 'plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
category = categories.get(server_type, 'mod') # fallback
|
||||||
|
response = requests.get(
|
||||||
|
f"https://api.modrinth.com/v2/search",
|
||||||
|
params={
|
||||||
|
"query": query,
|
||||||
|
"facets": f'[["project_type:{category}"],["categories:{server_type}"],["versions:{version}"]]',
|
||||||
|
"limit": 10
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return jsonify(response.json())
|
||||||
|
|
||||||
|
|
||||||
|
@api.route('/modrinth/download', methods=['POST'])
|
||||||
|
@oidc.require_login
|
||||||
|
def download_mod():
|
||||||
|
data = request.json
|
||||||
|
project_id = data['project_id']
|
||||||
|
username = data['username']
|
||||||
|
server_type = data['type']
|
||||||
|
server_version = data['version']
|
||||||
|
|
||||||
|
base_path = f"./servers/mc-{username}/{'plugins' if server_type == 'paper' else 'mods'}"
|
||||||
|
os.makedirs(base_path, exist_ok=True)
|
||||||
|
|
||||||
|
downloaded_files = []
|
||||||
|
visited_versions = set()
|
||||||
|
|
||||||
|
def download_project(pid, version_id=None):
|
||||||
|
if version_id:
|
||||||
|
version_data = requests.get(f"https://api.modrinth.com/v2/version/{version_id}").json()
|
||||||
|
else:
|
||||||
|
all_versions = requests.get(f"https://api.modrinth.com/v2/project/{pid}/version").json()
|
||||||
|
version_data = next(
|
||||||
|
(v for v in all_versions
|
||||||
|
if server_version in v['game_versions']
|
||||||
|
and server_type in v['loaders']
|
||||||
|
and v.get('server_side') != 'unsupported'),
|
||||||
|
None)
|
||||||
|
if not version_data:
|
||||||
|
print(f"[SKIP] No compatible version found for {pid}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if version_data["id"] in visited_versions:
|
||||||
|
return
|
||||||
|
visited_versions.add(version_data["id"])
|
||||||
|
|
||||||
|
if version_data.get('server_side') == 'unsupported':
|
||||||
|
print(f"[SKIP] {version_data.get('name', 'Nieznana nazwa')} is client-only")
|
||||||
|
return
|
||||||
|
|
||||||
|
file = next((f for f in version_data['files'] if f.get('primary', True)), version_data['files'][0])
|
||||||
|
file_path = os.path.join(base_path, file['filename'])
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"[DOWNLOAD] {file['filename']}")
|
||||||
|
with requests.get(file['url'], stream=True) as r:
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
downloaded_files.append({
|
||||||
|
"filename": file['filename'],
|
||||||
|
"name": version_data.get("name") or version_data.get("version_number") or "Nieznana wersja",
|
||||||
|
"project_id": version_data["project_id"],
|
||||||
|
"version_id": version_data["id"]
|
||||||
|
})
|
||||||
|
|
||||||
|
for dep in version_data.get('dependencies', []):
|
||||||
|
if dep.get('version_id'):
|
||||||
|
try:
|
||||||
|
download_project(dep['project_id'], dep['version_id'])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to fetch dep version {dep['version_id']}: {e}")
|
||||||
|
elif dep.get('project_id'):
|
||||||
|
try:
|
||||||
|
download_project(dep['project_id'])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to fetch dep project {dep['project_id']}: {e}")
|
||||||
|
|
||||||
|
download_project(project_id)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "downloaded": downloaded_files})
|
||||||
|
|
||||||
|
|
|
@ -55,8 +55,6 @@ def get_server_config(username):
|
||||||
server_info["config"] = server_config
|
server_info["config"] = server_config
|
||||||
with open(server_info_path, "w") as f:
|
with open(server_info_path, "w") as f:
|
||||||
json.dump(server_info, f, indent=4)
|
json.dump(server_info, f, indent=4)
|
||||||
|
|
||||||
print(f"Loaded config: {server_config}")
|
|
||||||
return server_config, server_type, server_version
|
return server_config, server_type, server_version
|
||||||
|
|
||||||
|
|
||||||
|
@ -91,6 +89,7 @@ def start_server(username):
|
||||||
environment = {
|
environment = {
|
||||||
"EULA": "TRUE",
|
"EULA": "TRUE",
|
||||||
"SERVER_PORT": ports[0],
|
"SERVER_PORT": ports[0],
|
||||||
|
"ENABLE_RCON": "TRUE",
|
||||||
"MOTD": f"Serwer użytkownika §9{username}",
|
"MOTD": f"Serwer użytkownika §9{username}",
|
||||||
"TYPE": server_type,
|
"TYPE": server_type,
|
||||||
"VERSION": server_version,
|
"VERSION": server_version,
|
||||||
|
@ -116,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"}
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,22 +1,27 @@
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template, jsonify
|
||||||
from .auth import oidc
|
from .auth import oidc
|
||||||
from .port_utils import get_user_ports
|
from .port_utils import get_user_ports
|
||||||
|
from .docker_utils import client
|
||||||
|
from docker.errors import NotFound
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
main = Blueprint('main', __name__)
|
main = Blueprint('main', __name__, static_folder='static')
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
@main.route("/")
|
@main.route("/")
|
||||||
@oidc.require_login
|
|
||||||
def home():
|
def home():
|
||||||
|
is_logged_in = oidc.user_loggedin
|
||||||
|
has_server = False
|
||||||
|
if is_logged_in:
|
||||||
username = oidc.user_getfield("preferred_username")
|
username = oidc.user_getfield("preferred_username")
|
||||||
server_path = f"./servers/mc-{username}"
|
server_path = f"./servers/mc-{username}"
|
||||||
has_server = os.path.exists(server_path)
|
has_server = os.path.exists(server_path)
|
||||||
return render_template("home.html", has_server=has_server)
|
return render_template("home.html", has_server=has_server, is_logged_in=is_logged_in)
|
||||||
|
|
||||||
|
|
||||||
@main.route('/setup')
|
@main.route('/setup')
|
||||||
|
@ -33,7 +38,55 @@ def dashboard():
|
||||||
has_server = os.path.exists(server_path)
|
has_server = os.path.exists(server_path)
|
||||||
ip = os.getenv("SERVER_IP")
|
ip = os.getenv("SERVER_IP")
|
||||||
ports = get_user_ports(username)
|
ports = get_user_ports(username)
|
||||||
if (has_server):
|
|
||||||
|
if has_server:
|
||||||
return render_template('dashboard.html', username=username, ip=ip, ports=ports)
|
return render_template('dashboard.html', username=username, ip=ip, ports=ports)
|
||||||
else:
|
else:
|
||||||
return render_template('setup.html', username=username, ip=ip, ports=ports)
|
return render_template('setup.html', username=username, ip=ip, ports=ports)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/donate')
|
||||||
|
def donate():
|
||||||
|
return render_template('donate.html')
|
||||||
|
|
||||||
|
|
||||||
|
@main.route("/ads/list")
|
||||||
|
def list_ads():
|
||||||
|
ads_dir = os.path.join(main.static_folder, "ads")
|
||||||
|
files = [
|
||||||
|
f"/static/ads/{f}"
|
||||||
|
for f in os.listdir(ads_dir)
|
||||||
|
if f.lower().endswith(".png")
|
||||||
|
]
|
||||||
|
return jsonify(files)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route("/status")
|
||||||
|
def servers_status():
|
||||||
|
servers_dir = "./servers"
|
||||||
|
server_dirs = [
|
||||||
|
d for d in os.listdir(servers_dir)
|
||||||
|
if os.path.isdir(os.path.join(servers_dir, d)) and d.startswith('mc-')
|
||||||
|
]
|
||||||
|
|
||||||
|
server_data = []
|
||||||
|
|
||||||
|
for server_dir in server_dirs:
|
||||||
|
server_name = server_dir
|
||||||
|
server_status = "Offline"
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = client.containers.get(server_name)
|
||||||
|
if container.status == "running":
|
||||||
|
server_status = "Online"
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server_data.append({"name": server_name, "status": server_status})
|
||||||
|
|
||||||
|
return render_template('servers_list.html', servers=server_data)
|
||||||
|
|
||||||
|
|
||||||
|
@main.app_errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
return render_template("404.html"), 404
|
||||||
|
|
BIN
app/static/ads/giantKitten.png
Normal file
BIN
app/static/ads/giantKitten.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 MiB |
BIN
app/static/ads/iPhoneAd.png
Normal file
BIN
app/static/ads/iPhoneAd.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 211 KiB |
52
app/static/js/config.js
Normal file
52
app/static/js/config.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
function loadConfig() {
|
||||||
|
fetch(`/api/config?username=${username}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const cfg = data.config;
|
||||||
|
document.getElementById("server-type").value = data.type;
|
||||||
|
document.getElementById("server-version").value = data.version;
|
||||||
|
document.getElementById("max-players").value = cfg["max-players"];
|
||||||
|
document.getElementById("pvp").checked = cfg["pvp"];
|
||||||
|
document.getElementById("difficulty").value = cfg["difficulty"];
|
||||||
|
document.getElementById("online-mode").checked = cfg["online-mode"];
|
||||||
|
document.getElementById("spawn-monsters").checked = cfg["spawn-monsters"];
|
||||||
|
document.getElementById("spawn-animals").checked = cfg["spawn-animals"];
|
||||||
|
document.getElementById("allow-nether").checked = cfg["allow-nether"];
|
||||||
|
document.getElementById("max-build-height").value = cfg["max-build-height"];
|
||||||
|
document.getElementById("view-distance").value = cfg["view-distance"];
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Błąd wczytywania konfiguracji: ", err);
|
||||||
|
alert("Nie udało się załadować konfiguracji serwera.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('[onclick="showTab(\'config\')"]').addEventListener('click', loadConfig);
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
const config = {
|
||||||
|
type: document.getElementById("server-type").value,
|
||||||
|
version: document.getElementById("server-version").value,
|
||||||
|
max_players: document.getElementById("max-players").value,
|
||||||
|
pvp: document.getElementById("pvp").checked,
|
||||||
|
difficulty: document.getElementById("difficulty").value,
|
||||||
|
online_mode: document.getElementById("online-mode").checked,
|
||||||
|
spawn_monsters: document.getElementById("spawn-monsters").checked,
|
||||||
|
spawn_animals: document.getElementById("spawn-animals").checked,
|
||||||
|
allow_nether: document.getElementById("allow-nether").checked,
|
||||||
|
max_build_height: document.getElementById("max-build-height").value,
|
||||||
|
view_distance: document.getElementById("view-distance").value
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, config })
|
||||||
|
}).then(res => res.json()).then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert("Konfiguracja została zapisana!");
|
||||||
|
} else {
|
||||||
|
alert("Błąd zapisywania konfiguracji.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
23
app/static/js/console.js
Normal file
23
app/static/js/console.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
function sendCommand() {
|
||||||
|
const input = $("console-command");
|
||||||
|
const command = input.value.trim();
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
fetch('/api/command', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, command })
|
||||||
|
}).then(res => {
|
||||||
|
if (!res.ok) throw new Error("Nie udało się wysłać komendy.");
|
||||||
|
input.value = '';
|
||||||
|
}).catch(err => {
|
||||||
|
alert("Błąd wysyłania komendy.");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLogs() {
|
||||||
|
fetch(`/api/logs?username=${username}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { $("console-log").textContent = data.logs; });
|
||||||
|
}
|
19
app/static/js/controls.js
vendored
Normal file
19
app/static/js/controls.js
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
function sendAction(action) {
|
||||||
|
fetch(`/api/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username })
|
||||||
|
}).then(r => r.json()).then(console.log);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteServer() {
|
||||||
|
if (!confirm("Na pewno chcesz usunąć swój serwer? Tej operacji nie można cofnąć!")) return;
|
||||||
|
fetch('/api/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username })
|
||||||
|
})
|
||||||
|
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
|
||||||
|
.then(data => { alert(data.message); location.reload(); })
|
||||||
|
.catch(err => { alert("Błąd usuwania serwera."); console.error(err); });
|
||||||
|
}
|
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('');
|
||||||
|
}
|
||||||
|
}
|
142
app/static/js/files.js
Normal file
142
app/static/js/files.js
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
function loadFileList(path = '') {
|
||||||
|
currentPath = path;
|
||||||
|
$("current-path").textContent = "/" + path;
|
||||||
|
fetch(`/api/files?username=${username}&path=${encodeURIComponent(path)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const list = $("file-list");
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (path) {
|
||||||
|
list.innerHTML += `<li><a class="non-link" href="#" onclick="loadFileList('${path.split('/').slice(0, -1).join('/')}')">⬅️ ..</a></li>`;
|
||||||
|
}
|
||||||
|
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 => {
|
||||||
|
const itemPath = path ? `${path}/${entry.name}` : entry.name;
|
||||||
|
let html = '';
|
||||||
|
if (entry.is_dir) {
|
||||||
|
html = `<li>📁 <a class="non-link" href="#" onclick="loadFileList('${itemPath}')">${entry.name}</a>
|
||||||
|
<a class="non-link action-icon" onclick="deleteItem('${itemPath}')" title="Usuń folder">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</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;
|
||||||
|
});
|
||||||
|
closeFileEditor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItem(path) {
|
||||||
|
if (!confirm(`Czy na pewno chcesz usunąć: ${path}?`)) return;
|
||||||
|
|
||||||
|
fetch('/api/files/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, path })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
loadFileList(currentPath);
|
||||||
|
} else {
|
||||||
|
alert("Błąd podczas usuwania: " + (data.error || "Nieznany"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert("Błąd połączenia z serwerem.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('[onclick="showTab(\'files\')"]').addEventListener('click', () => {
|
||||||
|
loadFileList(); // Load root directory when the tab is opened
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressWrapper = document.getElementById('upload-progress');
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragleave', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
handleFiles(fileInput.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
formData.append('username', username);
|
||||||
|
formData.append('path', currentPath);
|
||||||
|
|
||||||
|
progressWrapper.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', `/api/files/upload?username=${username}&path=${encodeURIComponent(currentPath)}`);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', e => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = (e.loaded / e.total) * 100;
|
||||||
|
progressBar.style.width = `${percent}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
progressWrapper.classList.add('hidden');
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
loadFileList(currentPath);
|
||||||
|
} else {
|
||||||
|
alert("Upload się nie udał.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
progressWrapper.classList.add('hidden');
|
||||||
|
alert("Upload się nie udał.");
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
68
app/static/js/mods.js
Normal file
68
app/static/js/mods.js
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
function searchMods() {
|
||||||
|
const query = document.getElementById("mod-search").value;
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(configData => {
|
||||||
|
console.log(configData);
|
||||||
|
if (configData.success) {
|
||||||
|
const serverType = configData.type;
|
||||||
|
const serverVersion = configData.version;
|
||||||
|
if (serverType && serverVersion) {
|
||||||
|
fetch(`/api/modrinth/search?query=${encodeURIComponent(query)}&type=${serverType}&version=${serverVersion}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const results = document.getElementById("mod-results");
|
||||||
|
results.innerHTML = "";
|
||||||
|
data.hits.forEach(mod => {
|
||||||
|
const modEl = document.createElement("div");
|
||||||
|
modEl.innerHTML = `
|
||||||
|
<strong>${mod.title}</strong> - <a onclick="installMod('${mod.project_id}')">Zainstaluj</a><hr>`;
|
||||||
|
results.appendChild(modEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Server type or version not available');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Server config not found');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error fetching server config:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMod(projectId) {
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(configData => {
|
||||||
|
if (configData.success) {
|
||||||
|
const serverType = configData.type;
|
||||||
|
const serverVersion = configData.version;
|
||||||
|
if (serverType && serverVersion) {
|
||||||
|
fetch('/api/modrinth/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project_id: projectId, username, type: serverType, version: serverVersion }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const modFiles = data.downloaded.map(mod => "- " + mod.filename).join("\n");
|
||||||
|
alert(`Zainstalowano:\n${modFiles}`);
|
||||||
|
loadFileList();
|
||||||
|
} else {
|
||||||
|
alert("Nie udało się zainstalować moda.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Server type or version not available');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Server config not found');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error fetching server config:', err);
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
/* Imports */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
|
||||||
|
|
||||||
/* Reset & Base Styles */
|
/* Reset & Base Styles */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -86,10 +89,25 @@ h2 {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ads */
|
||||||
|
.ad-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-banner img {
|
||||||
|
width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
.tabs {
|
.tabs {
|
||||||
|
/* Desktop default layout */
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
grid-template-columns: repeat(3, 1fr); /* Original desktop layout */
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 26px;
|
margin-bottom: 26px;
|
||||||
}
|
}
|
||||||
|
@ -104,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 {
|
||||||
|
@ -117,9 +140,19 @@ 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: 100px;
|
width: 200px;
|
||||||
background-color: #1a1a2e;
|
background-color: #1a1a2e;
|
||||||
border: 2px solid #2c2c3c;
|
border: 2px solid #2c2c3c;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
@ -195,6 +228,7 @@ h2 {
|
||||||
.btn-primary { background: #4a5aef; color: white; box-shadow: 0 0 10px #4a5aef99; }
|
.btn-primary { background: #4a5aef; color: white; box-shadow: 0 0 10px #4a5aef99; }
|
||||||
.btn-success { background: #2ecc71; color: white; box-shadow: 0 0 10px #2ecc7199; }
|
.btn-success { background: #2ecc71; color: white; box-shadow: 0 0 10px #2ecc7199; }
|
||||||
.btn-danger { background: #e74c3c; color: white; box-shadow: 0 0 10px #e74c3c99; }
|
.btn-danger { background: #e74c3c; color: white; box-shadow: 0 0 10px #e74c3c99; }
|
||||||
|
.btn-worsedanger { background: #8b0000; color: white; box-shadow: 0 0 10px #8b000099; }
|
||||||
.btn-warning { background: #f39c12; color: black; box-shadow: 0 0 10px #f39c1288; }
|
.btn-warning { background: #f39c12; color: black; box-shadow: 0 0 10px #f39c1288; }
|
||||||
.btn-info { background: #3498db; color: white; box-shadow: 0 0 10px #3498db99; }
|
.btn-info { background: #3498db; color: white; box-shadow: 0 0 10px #3498db99; }
|
||||||
|
|
||||||
|
@ -242,18 +276,7 @@ h2 {
|
||||||
background-color: #666;
|
background-color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart Container */
|
body > *:not(.cursor-glow) {
|
||||||
.chart-container {
|
|
||||||
background: #12121c;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 14px;
|
|
||||||
margin-top: 30px;
|
|
||||||
border: 1px solid #232334;
|
|
||||||
box-shadow: 0 0 12px #1a1a2a;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > *:not(script):not(style):not(.cursor-glow) {
|
|
||||||
animation: growIn 0.6s ease-out;
|
animation: growIn 0.6s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -514,22 +537,246 @@ a.non-link:hover {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 404 Error */
|
||||||
|
.error-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch-title {
|
||||||
|
font-size: 6rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ff0066;
|
||||||
|
text-shadow: 2px 2px #00ffff, -2px -2px #ff00ff;
|
||||||
|
animation: glitch 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch {
|
||||||
|
0% { text-shadow: 2px 2px #00ffff, -2px -2px #ff00ff; }
|
||||||
|
20% { text-shadow: -2px -2px #00ffff, 2px 2px #ff00ff; }
|
||||||
|
40% { text-shadow: 2px -2px #00ffff, -2px 2px #ff00ff; }
|
||||||
|
60% { text-shadow: -2px 2px #00ffff, 2px -2px #ff00ff; }
|
||||||
|
80% { text-shadow: 2px 2px #00ffff, -2px -2px #ff00ff; }
|
||||||
|
100% { text-shadow: -2px -2px #00ffff, 2px 2px #ff00ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: #222;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Servers Status */
|
||||||
|
.server-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(14, 14, 26, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid #1a1a2a;
|
||||||
|
box-shadow: 0 0 24px rgba(74, 90, 239, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
color: #f0f0f0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table thead {
|
||||||
|
background: linear-gradient(145deg, #1a1a2f, #23233a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #b9c5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table th,
|
||||||
|
.server-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table th:first-child,
|
||||||
|
.server-table td:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table th:last-child,
|
||||||
|
.server-table td:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-table tbody tr {
|
||||||
|
box-shadow: inset 0 0 0 rgba(0, 0, 0, 0);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.server-table tbody tr:hover {
|
||||||
|
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4em 0.75em;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.online {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.offline {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile Tweaks */
|
/* Mobile Tweaks */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: clamp(15px, 4vw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 24px 12px;
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs on Mobile: Single row, icons only */
|
||||||
|
.tabs {
|
||||||
|
display: flex; /* Use flexbox for a single row */
|
||||||
|
grid-template-columns: unset; /* Override grid for mobile */
|
||||||
|
justify-content: space-around; /* Distribute items evenly */
|
||||||
|
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 {
|
||||||
|
flex-shrink: 0; /* Prevent buttons from shrinking */
|
||||||
|
padding: 10px 15px; /* Adjust padding for icon-only buttons */
|
||||||
|
/* Center content for icon-only */
|
||||||
|
justify-content: center;
|
||||||
|
width: auto; /* Allow buttons to size to content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button .tab-text {
|
||||||
|
display: none; /* Hide the text on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.square-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square-button img {
|
||||||
|
width: 32px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-output {
|
form {
|
||||||
height: 180px;
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-row {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="number"], select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#drop-zone {
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Universal Fixes */
|
||||||
|
* {
|
||||||
|
max-width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
26
app/templates/404.html
Normal file
26
app/templates/404.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}404 - Zgubiłeś się w multiversum{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-container">
|
||||||
|
<h1 class="glitch-title">404</h1>
|
||||||
|
<p id="error-message" class="error-message"></p>
|
||||||
|
|
||||||
|
<a href="/" class="back-button">Powrót do bezpiecznego świata</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const messages = [
|
||||||
|
"Właśnie próbowałeś wejść na Peron 9 i ¾ bez rozpędu. Niestety, ściana nie puściła.",
|
||||||
|
"Strona zniknęła w glitchu Matrixa. Może weź czerwoną pigułkę?",
|
||||||
|
"Demogorgon pożarł zawartość strony. Sorki...",
|
||||||
|
"Strona zniknęła w anomalii czasoprzestrzennej. Też nie wiemy, co się stało.",
|
||||||
|
"Thanos pstryknął palcami i ta strona zniknęła.",
|
||||||
|
"Ups. Gandalf powiedział: \"Nie przejdziesz!\" – i strona się rozpadła.",
|
||||||
|
"Uwaga! Ta strona została zawieszona przez Biuro Czasu TVA.",
|
||||||
|
"Błąd jak z Simsów - ktoś wyjął drabinkę z basenu tej strony. Ona już nie wróci."
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
document.getElementById('error-message').textContent = randomMessage;
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -1,14 +1,26 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<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('statistics')">Statystyki 📈</button>
|
<span class="tab-text">Konsola</span> <span class="tab-icon">📟</span>
|
||||||
|
</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">
|
||||||
|
@ -22,7 +34,7 @@
|
||||||
<button class="btn btn-success" onclick="sendAction('start')">Start</button>
|
<button class="btn btn-success" onclick="sendAction('start')">Start</button>
|
||||||
<button class="btn btn-warning" onclick="sendAction('restart')">Restart</button>
|
<button class="btn btn-warning" onclick="sendAction('restart')">Restart</button>
|
||||||
<button class="btn btn-danger" onclick="sendAction('stop')">Stop</button><br>
|
<button class="btn btn-danger" onclick="sendAction('stop')">Stop</button><br>
|
||||||
<button class="btn btn-danger" onclick="deleteServer()">Usuń Serwer</button>
|
<button class="btn btn-worsedanger" onclick="deleteServer()">Usuń Serwer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="console">
|
<div class="tab-panel" id="console">
|
||||||
|
@ -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">
|
||||||
|
@ -109,15 +128,28 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="mods">
|
||||||
|
<h4>Zainstaluj mody/pluginy z <span style="color: #2ecc71;">Modrinth</span></h4>
|
||||||
|
<input type="text" id="mod-search" placeholder="Wyszukaj mod/plugin..." oninput="searchMods()">
|
||||||
|
<div id="mod-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="statistics">
|
<div class="tab-panel" id="statistics">
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
<div class="chart-container"><canvas id="cpuChart"></canvas></div>
|
<div class="chart-container"><canvas id="cpuChart"></canvas></div>
|
||||||
<div class="chart-container"><canvas id="ramChart"></canvas></div>
|
<div class="chart-container"><canvas id="ramChart"></canvas></div>
|
||||||
<div class="chart-container"><canvas id="diskChart"></canvas></div>
|
|
||||||
</div>
|
</div>
|
||||||
</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="/static/js/controls.js"></script>
|
||||||
|
<script src="/static/js/console.js"></script>
|
||||||
|
<script src="/static/js/files.js"></script>
|
||||||
|
<script src="/static/js/config.js"></script>
|
||||||
|
<script src="/static/js/mods.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const username = "{{ username }}";
|
const username = "{{ username }}";
|
||||||
|
|
||||||
|
@ -130,227 +162,35 @@
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAction(action) {
|
|
||||||
fetch(`/api/${action}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username })
|
|
||||||
}).then(r => r.json()).then(console.log);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCommand() {
|
|
||||||
const input = $("console-command");
|
|
||||||
const command = input.value.trim();
|
|
||||||
if (!command) return;
|
|
||||||
|
|
||||||
fetch('/api/command', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, command })
|
|
||||||
}).then(res => {
|
|
||||||
if (!res.ok) throw new Error("Nie udało się wysłać komendy.");
|
|
||||||
input.value = '';
|
|
||||||
}).catch(err => {
|
|
||||||
alert("Błąd wysyłania komendy.");
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteServer() {
|
|
||||||
if (!confirm("Na pewno chcesz usunąć swój serwer? Tej operacji nie można cofnąć!")) return;
|
|
||||||
fetch('/api/delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username })
|
|
||||||
})
|
|
||||||
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
|
|
||||||
.then(data => { alert(data.message); location.reload(); })
|
|
||||||
.catch(err => { alert("Błąd usuwania serwera."); console.error(err); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLogs() {
|
|
||||||
fetch(`/api/logs?username=${username}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => { $("console-log").textContent = data.logs; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
|
|
||||||
function loadFileList(path = '') {
|
function checkServerStatus() {
|
||||||
currentPath = path;
|
fetch(`/api/status?username=${username}`)
|
||||||
$("current-path").textContent = "/" + path;
|
|
||||||
fetch(`/api/files?username=${username}&path=${encodeURIComponent(path)}`)
|
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const list = $("file-list");
|
const statusEl = $("server-status");
|
||||||
list.innerHTML = '';
|
|
||||||
if (path) {
|
|
||||||
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.forEach(entry => {
|
|
||||||
const itemPath = path ? `${path}/${entry.name}` : entry.name;
|
|
||||||
const html = entry.is_dir
|
|
||||||
? `<li>📁 <a class="non-link" href="#" onclick="loadFileList('${itemPath}')">${entry.name}</a>
|
|
||||||
<a class="non-link" onclick="deleteItem('${itemPath}')">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</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>
|
|
||||||
</a>
|
|
||||||
</li>`;
|
|
||||||
list.innerHTML += html;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteItem(path) {
|
if (data.running) {
|
||||||
if (!confirm(`Czy na pewno chcesz usunąć: ${path}?`)) return;
|
statusEl.textContent = "Online";
|
||||||
|
statusEl.style.color = "#2ecc71";
|
||||||
fetch('/api/files/delete', {
|
if (currentStatus !== true) {
|
||||||
method: 'POST',
|
startStats();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
currentStatus = true;
|
||||||
body: JSON.stringify({ username, path })
|
}
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
loadFileList(currentPath);
|
|
||||||
} else {
|
} else {
|
||||||
alert("Błąd podczas usuwania: " + (data.error || "Nieznany"));
|
statusEl.textContent = "Offline";
|
||||||
|
statusEl.style.color = "#e74c3c";
|
||||||
|
if (currentStatus !== false) {
|
||||||
|
stopStats();
|
||||||
|
currentStatus = false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
alert("Błąd połączenia z serwerem.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('[onclick="showTab(\'files\')"]').addEventListener('click', () => loadFileList());
|
|
||||||
|
|
||||||
const dropZone = document.getElementById('drop-zone');
|
|
||||||
const fileInput = document.getElementById('file-input');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressWrapper = document.getElementById('upload-progress');
|
|
||||||
|
|
||||||
dropZone.addEventListener('click', () => fileInput.click());
|
|
||||||
|
|
||||||
dropZone.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
dropZone.classList.add('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropZone.addEventListener('dragleave', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
dropZone.classList.remove('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
dropZone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
dropZone.classList.remove('dragover');
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', () => {
|
|
||||||
handleFiles(fileInput.files);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleFiles(files) {
|
|
||||||
if (!files.length) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', file);
|
|
||||||
}
|
|
||||||
formData.append('username', username);
|
|
||||||
formData.append('path', currentPath);
|
|
||||||
|
|
||||||
progressWrapper.classList.remove('hidden');
|
|
||||||
progressBar.style.width = '0%';
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', `/api/files/upload?username=${username}&path=${encodeURIComponent(currentPath)}`);
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', e => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const percent = (e.loaded / e.total) * 100;
|
|
||||||
progressBar.style.width = `${percent}%`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
progressWrapper.classList.add('hidden');
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
loadFileList(currentPath);
|
|
||||||
} else {
|
|
||||||
alert("Upload failed.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
progressWrapper.classList.add('hidden');
|
|
||||||
alert("Upload failed.");
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadConfig() {
|
|
||||||
fetch(`/api/config?username=${username}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const cfg = data.config;
|
|
||||||
document.getElementById("server-type").value = data.type;
|
|
||||||
document.getElementById("server-version").value = data.version;
|
|
||||||
document.getElementById("max-players").value = cfg["max-players"];
|
|
||||||
document.getElementById("pvp").checked = cfg["pvp"];
|
|
||||||
document.getElementById("difficulty").value = cfg["difficulty"];
|
|
||||||
document.getElementById("online-mode").checked = cfg["online-mode"];
|
|
||||||
document.getElementById("spawn-monsters").checked = cfg["spawn-monsters"];
|
|
||||||
document.getElementById("spawn-animals").checked = cfg["spawn-animals"];
|
|
||||||
document.getElementById("allow-nether").checked = cfg["allow-nether"];
|
|
||||||
document.getElementById("max-build-height").value = cfg["max-build-height"];
|
|
||||||
document.getElementById("view-distance").value = cfg["view-distance"];
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Błąd wczytywania konfiguracji: ", err);
|
|
||||||
alert("Nie udało się załadować konfiguracji serwera.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('[onclick="showTab(\'config\')"]').addEventListener('click', loadConfig);
|
|
||||||
|
|
||||||
function saveConfig() {
|
|
||||||
const config = {
|
|
||||||
type: document.getElementById("server-type").value,
|
|
||||||
version: document.getElementById("server-version").value,
|
|
||||||
max_players: document.getElementById("max-players").value,
|
|
||||||
pvp: document.getElementById("pvp").checked,
|
|
||||||
difficulty: document.getElementById("difficulty").value,
|
|
||||||
online_mode: document.getElementById("online-mode").checked,
|
|
||||||
spawn_monsters: document.getElementById("spawn-monsters").checked,
|
|
||||||
spawn_animals: document.getElementById("spawn-animals").checked,
|
|
||||||
allow_nether: document.getElementById("allow-nether").checked,
|
|
||||||
max_build_height: document.getElementById("max-build-height").value,
|
|
||||||
view_distance: document.getElementById("view-distance").value
|
|
||||||
};
|
|
||||||
|
|
||||||
fetch('/api/config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, config })
|
|
||||||
}).then(res => res.json()).then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
alert("Konfiguracja została zapisana!");
|
|
||||||
} else {
|
|
||||||
alert("Błąd zapisywania konfiguracji.");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -394,33 +234,6 @@
|
||||||
chart.data.datasets[0].data.push(values[i]);
|
chart.data.datasets[0].data.push(values[i]);
|
||||||
chart.update();
|
chart.update();
|
||||||
});
|
});
|
||||||
|
|
||||||
diskChart.data.datasets[0].data[0] = data.disk;
|
|
||||||
diskChart.update();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkServerStatus() {
|
|
||||||
fetch(`/api/status?username=${username}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
const statusEl = $("server-status");
|
|
||||||
|
|
||||||
if (data.running) {
|
|
||||||
statusEl.textContent = "Online";
|
|
||||||
statusEl.style.color = "#2ecc71";
|
|
||||||
if (currentStatus !== true) {
|
|
||||||
startStats();
|
|
||||||
currentStatus = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = "Offline";
|
|
||||||
statusEl.style.color = "#e74c3c";
|
|
||||||
if (currentStatus !== false) {
|
|
||||||
stopStats();
|
|
||||||
currentStatus = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -476,26 +289,20 @@
|
||||||
options: chartOptions
|
options: chartOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
const diskChart = new Chart($("diskChart"), {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['Dysk (GB)'],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Dysk (GB)',
|
|
||||||
data: [0],
|
|
||||||
backgroundColor: '#f39c12',
|
|
||||||
borderColor: '#f39c12',
|
|
||||||
borderWidth: 1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
...chartOptions,
|
|
||||||
scales: { ...chartOptions.scales, y: { min: 0, max: 15, ticks: { color: '#aaa' } } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
checkServerStatus();
|
checkServerStatus();
|
||||||
setInterval(checkServerStatus, 5000);
|
setInterval(checkServerStatus, 5000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- 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 %}
|
{% endblock %}
|
17
app/templates/donate.html
Normal file
17
app/templates/donate.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<h2>Donate</h2>
|
||||||
|
<h3>Dlaczego nawet mały donate ma znaczenie:</h3>
|
||||||
|
<p>Hosting Minecrafta jest całkowicie darmowy - robię to z pasji i dla frajdy.</p>
|
||||||
|
<p>Serwery działają obecnie na darmowym <a href="https://oracle.com/cloud">Oracle Cloud Free Tier</a>,
|
||||||
|
ale pracuję też nad rozwojem tego panelu (MCPanel) żeby zarządzanie serwerami <br>było jeszcze wygodniejsze i bardziej funkcjonalne.</p><br>
|
||||||
|
<p>W przyszłości backupy Waszych serwerów będą przechowywane na moim prywatnym serwerze <a href="https://www.netcup.com/en/server/arm-server/vps-2000-arm-g11-iv-mnz">NetCup</a>,
|
||||||
|
<br>za który już płacę z własnej kieszeni. A donate, nawet najmniejszy, to dla mnie:</p>
|
||||||
|
<ul style="list-style-position: inside; display: inline-block; text-align: left;">
|
||||||
|
<li><b>Motywacja</b> do dalszego rozwijania panelu</li>
|
||||||
|
<li><b>Wsparcie</b> w opłacaniu serwera na backupy</li>
|
||||||
|
</ul>
|
||||||
|
<p>Donate możesz wysłać <a href="https://ko-fi.com/andusdev">tutaj</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -2,20 +2,30 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="logo-wrapper">
|
<div class="logo-wrapper">
|
||||||
<h2 class="logo-text">MCPanel</h2>
|
<h2 class="logo-text">MCPanel</h2>
|
||||||
<p id="quote" class="splash-text">Jesteśmy 1.5x lepsi od Aternosa!</p>
|
<p id="quote" class="splash-text"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
{% if is_logged_in %}
|
||||||
{% if has_server %}
|
{% if has_server %}
|
||||||
<a class="tab-button" href="{{ url_for('main.dashboard') }}">Idź do panelu</a>
|
<a class="tab-button" href="{{ url_for('main.dashboard') }}">Idź do panelu</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="tab-button" href="{{ url_for('main.setup') }}">Ustaw własny serwer</a>
|
<a class="tab-button" href="{{ url_for('main.setup') }}">Ustaw własny serwer</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a class="tab-button" href="{{ url_for('main.dashboard') }}">Zaloguj się</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="tab-button" href="#news">Wiadomości</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="news">
|
||||||
|
<!-- Z Tagami jak: WERSJA PANELU, PROBLEM -->
|
||||||
|
<h3>Wiadomości:</h3>
|
||||||
|
<p>Soon™</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const quotes = [
|
const quotes = [
|
||||||
"Jesteśmy 2x lepsi od Aternosa!",
|
|
||||||
"Twój serwer, Twoje zasady!",
|
"Twój serwer, Twoje zasady!",
|
||||||
"Stabilność? Nie znam takiego słowa!",
|
"Stabilność? Nie znam takiego słowa!",
|
||||||
"Zero kolejki, tylko gra!",
|
"Zero kolejki, tylko gra!",
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>MCPanel</title>
|
<title>MCPanel</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
@ -19,23 +20,24 @@
|
||||||
<div id="particle-background"></div>
|
<div id="particle-background"></div>
|
||||||
|
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<div class="nav-logo">MCPanel <h6>BETA</h6></div>
|
<div class="nav-logo"><a href="/">MCPanel <h6>BETA</h6></a></div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/">Strona Główna</a>
|
|
||||||
<a href="/dashboard">Panel</a>
|
<a href="/dashboard">Panel</a>
|
||||||
<a href="/logout">Wyloguj</a>
|
<a href="/status">Statusy</a>
|
||||||
|
<a style="color: cornflowerblue;" href="/donate">Donate</a>
|
||||||
|
<a style="color: red;" href="/logout">Wyloguj</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="dashboard-card">
|
<div class="dashboard-card">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<p>Wersja: v0.1</p>
|
<div id="ad-banner" class="ad-banner"></div>
|
||||||
|
<p>Wersja: v0.3</p>
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener('dragover', e => e.preventDefault());
|
window.addEventListener('dragover', e => e.preventDefault());
|
||||||
window.addEventListener('drop', e => e.preventDefault());
|
window.addEventListener('drop', e => e.preventDefault());
|
||||||
|
|
||||||
|
|
||||||
const hoverSound = new Audio("/static/sounds/hover.wav");
|
const hoverSound = new Audio("/static/sounds/hover.wav");
|
||||||
//const clickSound = new Audio("/static/sounds/click.wav");
|
//const clickSound = new Audio("/static/sounds/click.wav");
|
||||||
|
|
||||||
|
@ -108,6 +110,21 @@
|
||||||
}
|
}
|
||||||
setInterval(createParticle, 100);
|
setInterval(createParticle, 100);
|
||||||
|
|
||||||
|
fetch("/ads/list")
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(images => {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
const randomImage = images[Math.floor(Math.random() * images.length)];
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = randomImage;
|
||||||
|
img.alt = "Ad Banner";
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.height = "auto";
|
||||||
|
img.style.borderRadius = "8px";
|
||||||
|
img.style.boxShadow = "0 0 12px rgba(0,0,0,0.3)";
|
||||||
|
document.getElementById("ad-banner").appendChild(img);
|
||||||
|
});
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
32
app/templates/servers_list.html
Normal file
32
app/templates/servers_list.html
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="page-title">Wszystkie Serwery</h1>
|
||||||
|
|
||||||
|
<div class="server-table-container">
|
||||||
|
<table class="server-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for server in servers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ server.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-label {{ 'online' if server.status == 'Online' else 'offline' }}">
|
||||||
|
{{ server.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="empty-row">Nie znaleziono serwerów</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; gap: 2rem; margin-bottom: 2rem;">
|
<div style="display: flex; justify-content: center; gap: 2rem; margin-bottom: 2rem;">
|
||||||
<button class="square-button" onclick="selectType(this, 'paper')">
|
<button class="square-button" onclick="selectType(this, 'paper')">
|
||||||
<img src="https://docs.papermc.io/assets/images/papermc-logomark-512-f125384f3367cd4d9291ca983fcb7334.png" alt="Paper">
|
<img src="https://assets.papermc.io/brand/papermc_logo.min.svg" alt="Paper">
|
||||||
<b>Paper</b>
|
<b>Paper</b>
|
||||||
<br>
|
<br>
|
||||||
<p>Zoptymalizowany serwer Minecraft oferujący lepszą wydajność.</p>
|
<p>Zoptymalizowany serwer Minecraft oferujący lepszą wydajność.</p>
|
||||||
|
|
Loading…
Add table
Reference in a new issue