Files
panel/public/js/app.js

2183 lines
82 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
let apiKey = localStorage.getItem('apiKey') || '';
let ws = null;
const pendingRequests = new Map();
let responseTimeout = null;
let terminal = null;
let fitAddon = null;
let memoryMeter = null;
let cpuMeter = null;
let currentPage = 1;
let totalResults = 0;
const resultsPerPage = 10;
let modListCurrentPage = 1;
let modListSearchQuery = '';
let allProperties = {};
let sftpCredentials = null;
let isSftpOnline = false;
let hasAttemptedSftpConnect = false;
const filteredSettings = [
'bug-report-link',
'query.port',
'rcon.password',
'region-file-compression',
'rcon.port',
'server-port',
'level-type',
'enable-rcon',
'enable-status'
];
const elements = {
loginPage: document.getElementById('loginPage'),
loginApiKey: document.getElementById('loginApiKey'),
loginBtn: document.getElementById('loginBtn'),
loginError: document.getElementById('loginError'),
authControls: document.getElementById('authControls'),
apiKeyInput: document.getElementById('apiKey'),
user: document.getElementById('user'),
serverStatus: document.getElementById('serverStatus'),
memoryPercent: document.getElementById('memoryPercent'),
cpuPercent: document.getElementById('cpuPercent'),
keyExpiry: document.getElementById('keyExpiry'),
playerList: document.getElementById('playerList'),
modList: document.getElementById('modList'),
logUrl: document.getElementById('logUrl'),
websiteUrl: document.getElementById('websiteUrl'),
mapUrl: document.getElementById('mapUrl'),
myLink: document.getElementById('myLink'),
geyserLink: document.getElementById('geyserLink'),
sftpLink: document.getElementById('sftpLink'),
holesailHash: document.getElementById('holesailHash'),
geyserHash: document.getElementById('geyserHash'),
sftpHash: document.getElementById('sftpHash'),
modResults: document.getElementById('modResults'),
consoleOutput: document.getElementById('consoleOutput'),
consoleInput: document.getElementById('consoleInput'),
dockerLogsTerminal: document.getElementById('dockerLogsTerminal'),
mainContent: document.getElementById('mainContent'),
notificationContainer: document.getElementById('notificationContainer'),
generateMyLinkBtn: document.getElementById('generateMyLinkBtn'),
generateGeyserLinkBtn: document.getElementById('generateGeyserLinkBtn'),
generateSftpLinkBtn: document.getElementById('generateSftpLinkBtn'),
pagination: document.getElementById('pagination'),
modSearch: document.getElementById('modSearch'),
closeSearchBtn: document.getElementById('closeSearchBtn'),
tellModal: document.getElementById('tellModal'),
tellPlayerName: document.getElementById('tellPlayerName'),
tellMessage: document.getElementById('tellMessage'),
tellForm: document.getElementById('tellForm'),
giveModal: document.getElementById('giveModal'),
givePlayerName: document.getElementById('givePlayerName'),
loadoutSelect: document.getElementById('loadoutSelect'),
customGiveFields: document.getElementById('customGiveFields'),
giveForm: document.getElementById('giveForm'),
addItemBtn: document.getElementById('addItemBtn'),
itemList: document.getElementById('itemList'),
editPropertiesBtn: document.getElementById('editPropertiesBtn'),
editPropertiesModal: document.getElementById('editPropertiesModal'),
propertiesFields: document.getElementById('propertiesFields'),
editPropertiesForm: document.getElementById('editPropertiesForm'),
updateModsBtn: document.getElementById('updateModsBtn'),
updateModsModal: document.getElementById('updateModsModal'),
updateModsOutput: document.getElementById('updateModsOutput'),
closeUpdateModsBtn: document.getElementById('closeUpdateModsBtn'),
stopBtn: document.getElementById('stopBtn'),
restartBtn: document.getElementById('restartBtn'),
connectionStatus: document.getElementById('connectionStatus'),
geyserStatus: document.getElementById('geyserStatus'),
sftpStatus: document.getElementById('sftpStatus'),
backupBtn: document.getElementById('backupBtn'),
modListSearch: document.getElementById('modListSearch'),
clearModListSearch: document.getElementById('clearModListSearch'),
modListPagination: document.getElementById('modListPagination'),
teleportModal: document.getElementById('teleportModal'),
teleportPlayerName: document.getElementById('teleportPlayerName'),
teleportDestination: document.getElementById('teleportDestination'),
teleportForm: document.getElementById('teleportForm'),
effectModal: document.getElementById('effectModal'),
effectPlayerName: document.getElementById('effectPlayerName'),
effectSelect: document.getElementById('effectSelect'),
effectForm: document.getElementById('effectForm'),
sftpBtn: document.getElementById('sftpBtn'),
sftpBrowserSection: document.getElementById('sftpBrowserSection'),
sftpIframe: document.getElementById('sftpIframe')
};
const loadouts = {
starter: [
{ item: 'torch', amount: 16 },
{ item: 'cooked_beef', amount: 8 },
{ item: 'stone_pickaxe', amount: 1 }
],
builder: [
{ item: 'stone', amount: 64 },
{ item: 'oak_planks', amount: 64 },
{ item: 'glass', amount: 32 },
{ item: 'ladder', amount: 16 }
],
combat: [
{ item: 'iron_sword', amount: 1 },
{ item: 'leather_chestplate', amount: 1 },
{ item: 'shield', amount: 1 },
{ item: 'arrow', amount: 32 }
],
miner: [
{ item: 'iron_pickaxe', amount: 1, enchantments: [{ name: 'efficiency', level: 2 }] },
{ item: 'torch', amount: 64 },
{ item: 'iron_shovel', amount: 1 },
{ item: 'bucket', amount: 1 }
],
adventurer: [
{ item: 'bow', amount: 1, enchantments: [{ name: 'power', level: 1 }] },
{ item: 'arrow', amount: 64 },
{ item: 'compass', amount: 1 },
{ item: 'map', amount: 1 }
],
alchemist: [
{ item: 'brewing_stand', amount: 1 },
{ item: 'potion', amount: 3, type: 'healing' },
{ item: 'potion', amount: 2, type: 'swiftness' },
{ item: 'nether_wart', amount: 16 }
],
enchanter: [
{ item: 'enchanting_table', amount: 1 },
{ item: 'book', amount: 10 },
{ item: 'lapis_lazuli', amount: 32 },
{ item: 'experience_bottle', amount: 16 }
],
farmer: [
{ item: 'wheat_seeds', amount: 32 },
{ item: 'iron_hoe', amount: 1 },
{ item: 'bone_meal', amount: 16 },
{ item: 'carrot', amount: 8 }
],
nether: [
{ item: 'potion', amount: 2, type: 'fire_resistance' },
{ item: 'obsidian', amount: 10 },
{ item: 'flint_and_steel', amount: 1 },
{ item: 'golden_apple', amount: 1 }
],
end: [
{ item: 'ender_pearl', amount: 16 },
{ item: 'blaze_rod', amount: 8 },
{ item: 'diamond_sword', amount: 1, enchantments: [{ name: 'sharpness', level: 2 }] },
{ item: 'pumpkin', amount: 1 }
]
};
let state = {
user: '',
serverStatus: '',
memoryPercent: 0,
cpuPercent: 0,
keyExpiry: '',
playerList: '',
modListHtml: '',
logUrl: '',
websiteUrl: '',
mapUrl: '',
myLink: '',
geyserLink: '',
sftpLink: '',
holesailHash: '',
geyserHash: '',
sftpHash: '',
hasShownStartNotification: false,
connectionStatus: '',
geyserStatus: '',
sftpStatus: '',
activeNotifications: new Map(),
allMods: [],
currentPlayers: []
};
function showNotification(message, type = 'loading', key = null) {
if (key && state.activeNotifications.has(key)) {
const existing = state.activeNotifications.get(key);
existing.notification.style.opacity = '0';
setTimeout(() => existing.notification.remove(), 300);
state.activeNotifications.delete(key);
}
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
${type === 'loading' ? '<div class="spinner"></div>' : ''}
<span>${message}</span>
`;
elements.notificationContainer.appendChild(notification);
if (type !== 'loading') {
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
notification.remove();
if (key) state.activeNotifications.delete(key);
}, 300);
}, 3000);
}
if (key) {
state.activeNotifications.set(key, { notification, message, type });
}
return notification;
}
function updateNotification(notification, message, type, key = null) {
if (key && state.activeNotifications.has(key) && state.activeNotifications.get(key).notification !== notification) {
const existing = state.activeNotifications.get(key);
existing.notification.style.opacity = '0';
setTimeout(() => existing.notification.remove(), 300);
}
notification.className = `notification ${type}`;
notification.innerHTML = `<span>${message}</span>`;
if (type !== 'loading') {
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
notification.remove();
if (key) state.activeNotifications.delete(key);
}, 300);
}, 3000);
}
if (key) {
state.activeNotifications.set(key, { notification, message, type });
}
}
function initializeCharts() {
if (memoryMeter || cpuMeter) {
return;
}
const memoryCanvas = document.getElementById('memoryMeter');
if (!memoryCanvas) {
console.error('Memory Meter canvas not found');
showNotification('Unable to display memory usage chart: Canvas element missing', 'error');
return;
}
const memoryCtx = memoryCanvas.getContext('2d');
if (!memoryCtx) {
console.error('Failed to acquire 2D context for Memory Meter');
showNotification('Unable to display memory usage chart: Invalid canvas context', 'error');
return;
}
memoryMeter = new Chart(memoryCtx, {
type: 'doughnut',
data: {
datasets: [{
data: [0, 100],
backgroundColor: ['#EF4444', '#4B5563'],
borderWidth: 0,
circumference: 180,
rotation: 270
}]
},
options: {
cutout: '80%',
responsive: false,
plugins: { title: { display: false }, tooltip: { enabled: false } }
}
});
const cpuCanvas = document.getElementById('cpuMeter');
if (!cpuCanvas) {
console.error('CPU Meter canvas not found');
showNotification('Unable to display CPU usage chart: Canvas element missing', 'error');
return;
}
const cpuCtx = cpuCanvas.getContext('2d');
if (!cpuCtx) {
console.error('Failed to acquire 2D context for CPU Meter');
showNotification('Unable to display CPU usage chart: Invalid canvas context', 'error');
return;
}
cpuMeter = new Chart(cpuCtx, {
type: 'doughnut',
data: {
datasets: [{
data: [0, 600],
backgroundColor: ['#3B82F6', '#4B5563'],
borderWidth: 0,
circumference: 180,
rotation: 270
}]
},
options: {
cutout: '80%',
responsive: false,
plugins: { title: { display: false }, tooltip: { enabled: false } }
}
});
}
function initializeTerminal() {
if (terminal) {
terminal.clear();
} else {
terminal = new Terminal({
rows: 8,
fontSize: 14,
fontFamily: 'monospace',
theme: {
background: '#1f2937',
foreground: '#ffffff',
cursor: '#ffffff'
},
scrollback: 1000,
rendererType: 'canvas'
});
fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(elements.dockerLogsTerminal);
terminal.element.style.border = 'none';
}
fitAddon.fit();
window.addEventListener('resize', () => {
if (fitAddon && terminal) {
fitAddon.fit();
}
});
}
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) {
return;
}
ws = new WebSocket(`wss://${window.location.host}/ws?apiKey=${encodeURIComponent(apiKey)}`);
ws.onopen = () => {
showMainContent();
ws.send(JSON.stringify({
type: 'subscribe',
endpoints: [
'docker',
'docker-logs',
'hello',
'time',
'list-players',
'mod-list',
'log',
'website',
'map',
'my-link-cache',
'my-geyser-cache',
'my-sftp-cache',
'backup',
'sftp-status',
'holesail-hashes'
]
}));
responseTimeout = setTimeout(() => {
showNotification('No response from server. Please check your connection or API key.', 'error', 'ws-timeout');
handleLogout();
}, 5000);
};
ws.onmessage = (event) => {
try {
clearTimeout(responseTimeout);
const message = JSON.parse(event.data);
if (message.requestId && pendingRequests.has(message.requestId)) {
const { resolve, reject, notification, key } = pendingRequests.get(message.requestId);
pendingRequests.delete(message.requestId);
if (message.error) {
updateNotification(notification, `Error: ${message.error}`, 'error', key);
if (message.error.includes('Missing token') || message.error.includes('HTTP 403')) {
showNotification('Invalid or expired API key. Please log in again.', 'error', 'auth-error');
elements.loginError.classList.remove('hidden');
elements.loginError.textContent = 'Invalid API key. Please try again.';
handleLogout();
}
reject(new Error(message.error));
} else {
updateNotification(notification, message.message || 'Action completed successfully', 'success', key);
resolve(message.data || message);
}
} else {
updateUI(message);
}
} catch (error) {
console.error('WebSocket message parsing error:', error);
showNotification('Error processing server data. Please try again.', 'error', 'ws-parse-error');
}
};
ws.onclose = () => {
showLoginPage();
clearTimeout(responseTimeout);
if (terminal) {
terminal.clear();
}
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
showNotification('Failed to connect to server. Please check your network.', 'error', 'ws-error');
};
}
function wsRequest(endpoint, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showNotification('Not connected to server. Please log in.', 'error', 'ws-disconnected');
reject(new Error('WebSocket not connected'));
return;
}
const requestId = crypto.randomUUID();
const action = endpoint.replace('/', '').replace('-', ' ');
const key = `action-${action}`;
const notification = showNotification(`Processing ${action}...`, 'loading', key);
pendingRequests.set(requestId, { resolve, reject, notification, key });
ws.send(JSON.stringify({ type: 'request', requestId, endpoint, method, body }));
});
}
function updateUI(message) {
if (message.type === 'docker') {
updateDockerUI(message);
} else if (message.type === 'docker-logs') {
updateDockerLogsUI(message);
} else if (message.type === 'connection-status') {
updateConnectionStatusUI(message);
} else if (message.type === 'geyser-status') {
updateGeyserStatusUI(message);
} else if (message.type === 'sftp-status') {
updateSftpStatusUI(message);
} else if (message.type === 'my-sftp-cache') {
updateSftpCacheUI(message);
} else if (message.type === 'update-mods') {
updateModsUI(message);
} else if (message.type === 'backup') {
console.log('Received backup message:', message);
} else if (message.type === 'holesail-hashes') {
updateHolesailHashesUI(message);
} else {
updateNonDockerUI(message);
}
}
function updateDockerUI(message) {
if (message.error) {
if (elements.serverStatus) elements.serverStatus.textContent = 'Not Running';
toggleSections('Not Running');
return;
}
const memoryPercent = parseFloat(message.data?.memory?.percent) || 0;
if (state.memoryPercent !== memoryPercent && elements.memoryPercent && memoryMeter) {
memoryMeter.data.datasets[0].data = [memoryPercent, 100 - memoryPercent];
memoryMeter.update();
elements.memoryPercent.textContent = `${memoryPercent.toFixed(1)}%`;
state.memoryPercent = memoryPercent;
}
const cpuPercent = parseFloat(message.data?.cpu) || 0;
if (state.cpuPercent !== cpuPercent && elements.cpuPercent && cpuMeter) {
const scaledCpuPercent = Math.min((cpuPercent / 600) * 100, 100);
cpuMeter.data.datasets[0].data = [scaledCpuPercent, 100 - scaledCpuPercent];
cpuMeter.update();
elements.cpuPercent.textContent = `${cpuPercent.toFixed(1)}%`;
state.cpuPercent = cpuPercent;
}
const status = message.data?.status || 'Unknown';
if (elements.serverStatus) {
elements.serverStatus.textContent = status;
state.serverStatus = status;
toggleSections(status);
}
}
function toggleSections(status) {
const sections = document.querySelectorAll('.bg-gray-800.p-6.rounded-lg.shadow-lg.mb-6');
const serverStatusSection = document.querySelector('.bg-gray-800.p-6.rounded-lg.shadow-lg.mb-6[data-section="server-status"]');
const editPropertiesBtn = elements.editPropertiesBtn;
const updateModsBtn = elements.updateModsBtn;
const backupBtn = elements.backupBtn;
const sftpBtn = elements.sftpBtn;
const startBtn = document.getElementById('startBtn');
const stopBtn = elements.stopBtn;
const restartBtn = elements.restartBtn;
const sftpBrowserSection = elements.sftpBrowserSection;
if (startBtn) {
if (status.toLowerCase() === 'running') {
startBtn.disabled = true;
startBtn.classList.add('disabled-btn');
} else {
startBtn.disabled = false;
startBtn.classList.remove('disabled-btn');
}
}
if (status.toLowerCase() !== 'running') {
sections.forEach(section => {
if (section !== serverStatusSection) {
section.classList.add('hidden');
}
});
if (editPropertiesBtn) {
editPropertiesBtn.classList.add('hidden');
}
if (updateModsBtn) {
updateModsBtn.classList.add('hidden');
}
if (backupBtn) {
backupBtn.classList.add('hidden');
}
if (sftpBtn) {
sftpBtn.classList.add('hidden');
}
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.classList.add('disabled-btn');
}
if (restartBtn) {
restartBtn.disabled = true;
restartBtn.classList.add('disabled-btn');
}
if (sftpBrowserSection) {
sftpBrowserSection.style.display = 'none';
}
if (!state.hasShownStartNotification) {
showNotification('Server is stopped. Click "Start" to enable all features.', 'error', 'server-stopped');
state.hasShownStartNotification = true;
}
} else {
sections.forEach(section => {
section.classList.remove('hidden');
});
if (editPropertiesBtn) {
editPropertiesBtn.classList.remove('hidden');
}
if (updateModsBtn) {
updateModsBtn.classList.remove('hidden');
}
if (backupBtn) {
backupBtn.classList.remove('hidden');
}
if (sftpBtn) {
sftpBtn.classList.remove('hidden');
}
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.classList.remove('disabled-btn');
}
if (restartBtn) {
restartBtn.disabled = false;
restartBtn.classList.remove('disabled-btn');
}
if (sftpBrowserSection) {
sftpBrowserSection.style.display = 'block';
}
state.hasShownStartNotification = false;
}
}
function updateDockerLogsUI(message) {
if (message.error) {
return;
}
if (message.data?.log && terminal) {
const logLines = message.data.log.split('\n');
const cleanedLog = logLines
.map(line =>
line
.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*/, '')
.replace(/\u001b\[32m0\|MineCraft Server\s*\|\s*\u001b\[39m/, '')
)
.join('\n');
terminal.write(cleanedLog);
const lines = terminal.buffer.active.length;
if (lines > 8) {
terminal.scrollToLine(lines - 8);
}
if (fitAddon) {
fitAddon.fit();
}
}
}
function updateConnectionStatusUI(message) {
if (message.data?.isOnline !== undefined && elements.connectionStatus) {
const statusIcon = message.data.isOnline ? '✅' : '❌';
if (state.connectionStatus !== statusIcon) {
elements.connectionStatus.textContent = statusIcon;
state.connectionStatus = statusIcon;
}
}
}
function updateGeyserStatusUI(message) {
if (message.data?.isOnline !== undefined && elements.geyserStatus) {
const statusIcon = message.data.isOnline ? '✅' : '❌';
if (state.geyserStatus !== statusIcon) {
elements.geyserStatus.textContent = statusIcon;
state.geyserStatus = statusIcon;
}
}
}
function updateSftpStatusUI(message) {
if (message.data?.isOnline !== undefined && elements.sftpStatus) {
const statusIcon = message.data.isOnline ? '✅' : '❌';
isSftpOnline = message.data.isOnline;
if (state.sftpStatus !== statusIcon) {
elements.sftpStatus.textContent = statusIcon;
state.sftpStatus = statusIcon;
}
}
}
function updateSftpCacheUI(message) {
if (message.data?.hostname && message.data?.port && message.data?.user && message.data?.password) {
sftpCredentials = {
hostname: state.user,
port: 22,
user: message.data.user,
password: message.data.password
};
const sftpLinkText = `${message.data.hostname}:${message.data.port}`;
if (state.sftpLink !== sftpLinkText && elements.sftpLink) {
elements.sftpLink.textContent = sftpLinkText;
state.sftpLink = sftpLinkText;
}
if (!hasAttemptedSftpConnect) {
hasAttemptedSftpConnect = true;
connectSftp();
}
}
}
function updateHolesailHashesUI(message) {
if (message.error) {
showNotification(`Failed to load Holesail hashes: ${message.error}`, 'error', 'holesail-hashes-error');
return;
}
if (message.data?.myHash && elements.holesailHash) {
const hashText = message.data.myHash;
if (state.holesailHash !== hashText) {
elements.holesailHash.textContent = hashText;
state.holesailHash = hashText;
}
}
if (message.data?.geyserHash && elements.geyserHash) {
const hashText = message.data.geyserHash;
if (state.geyserHash !== hashText) {
elements.geyserHash.textContent = hashText;
state.geyserHash = hashText;
}
}
if (message.data?.sftpHash && elements.sftpHash) {
const hashText = message.data.sftpHash;
if (state.sftpHash !== hashText) {
elements.sftpHash.textContent = hashText;
state.sftpHash = hashText;
}
}
if (message.data?.errors?.length > 0) {
message.data.errors.forEach(error => {
showNotification(error, 'error', `holesail-hashes-error-${error}`);
});
}
}
function updateModsUI(message) {
if (message.error) {
elements.updateModsOutput.textContent = `Error: ${message.error}`;
} else if (message.output) {
elements.updateModsOutput.textContent = message.output;
}
elements.updateModsModal.classList.remove('hidden');
}
function updateNonDockerUI(message) {
if (message.error) {
if (message.error.includes('Missing token') || message.error.includes('HTTP 403')) {
showNotification('Invalid or expired API key. Please log in again.', 'error', 'auth-error');
elements.loginError.classList.remove('hidden');
elements.loginError.textContent = 'Invalid API key. Please try again.';
handleLogout();
}
return;
}
if (message.type === 'hello' && message.data?.message) {
const user = message.data.message.split(', ')[1]?.replace('!', '').trim() || 'Unknown';
if (state.user !== user && elements.user) {
elements.user.textContent = user;
state.user = user;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'updateUser', user }));
}
}
}
if (message.type === 'time' && message.data?.keyexpireString) {
if (state.keyExpiry !== message.data.keyexpireString && elements.keyExpiry) {
const expiryDate = new Date(message.data.keyexpireString);
const formattedDate = expiryDate.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZoneName: 'short'
});
elements.keyExpiry.textContent = formattedDate;
state.keyExpiry = message.data.keyexpireString;
}
}
if (message.type === 'list-players' && message.data?.players) {
const players = message.data.players || [];
state.currentPlayers = players;
const isSinglePlayer = players.length <= 1;
const playerListHtml = players.length > 0
? players.map(player => `
<div class="flex items-center space-x-4 mb-2">
<span class="w-32">${player}</span>
<div class="flex flex-wrap gap-2">
<button class="tell-player bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm" data-player="${player}">Tell</button>
<button class="give-player bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-sm" data-player="${player}">Give</button>
<button class="teleport-player bg-cyan-600 hover:bg-cyan-700 px-2 py-1 rounded text-sm ${isSinglePlayer ? 'opacity-50 cursor-not-allowed' : ''}" data-player="${player}" ${isSinglePlayer ? 'disabled' : ''}>Teleport</button>
<button class="effect-player bg-teal-600 hover:bg-teal-700 px-2 py-1 rounded text-sm" data-player="${player}">Effect</button>
<button class="op-player bg-purple-600 hover:bg-purple-700 px-2 py-1 rounded text-sm" data-player="${player}">Op</button>
<button class="deop-player bg-purple-600 hover:bg-purple-700 px-2 py-1 rounded text-sm" data-player="${player}">Deop</button>
<button class="kick-player bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm" data-player="${player}">Kick</button>
<button class="ban-player bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm" data-player="${player}">Ban</button>
</div>
</div>
`).join('')
: 'None';
if (state.playerList !== playerListHtml && elements.playerList) {
elements.playerList.innerHTML = playerListHtml;
state.playerList = playerListHtml;
document.querySelectorAll('.tell-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
elements.tellPlayerName.textContent = player;
elements.tellMessage.value = '';
elements.tellModal.classList.remove('hidden');
});
});
document.querySelectorAll('.give-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
elements.givePlayerName.textContent = player;
elements.loadoutSelect.value = 'custom';
elements.customGiveFields.classList.remove('hidden');
resetItemFields();
elements.giveModal.classList.remove('hidden');
});
});
document.querySelectorAll('.teleport-player').forEach(button => {
if (!button.disabled) {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
elements.teleportPlayerName.textContent = player;
elements.teleportDestination.innerHTML = state.currentPlayers
.filter(p => p !== player)
.map(p => `<option value="${p}">${p}</option>`)
.join('');
elements.teleportModal.classList.remove('hidden');
});
}
});
document.querySelectorAll('.effect-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
elements.effectPlayerName.textContent = player;
elements.effectSelect.value = 'speed:30:1';
elements.effectModal.classList.remove('hidden');
});
});
document.querySelectorAll('.kick-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
try {
const requestId = crypto.randomUUID();
const key = `action-kick-player-${player}`;
const notification = showNotification(`Kicking player ${player}...`, 'loading', key);
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Player ${player} kicked successfully`, 'success', key);
wsRequest('/list-players').then(response => {
updateNonDockerUI({ type: 'list-players', data: response });
});
},
reject: (error) => {
updateNotification(notification, `Failed to kick player ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({ type: 'kick-player', requestId, player }));
} catch (error) {
console.error(`Kick player ${player} error:`, error);
showNotification(`Failed to kick player ${player}: ${error.message}`, 'error');
}
});
});
document.querySelectorAll('.ban-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
try {
const requestId = crypto.randomUUID();
const key = `action-ban-player-${player}`;
const notification = showNotification(`Banning player ${player}...`, 'loading', key);
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Player ${player} banned successfully`, 'success', key);
wsRequest('/list-players').then(response => {
updateNonDockerUI({ type: 'list-players', data: response });
});
},
reject: (error) => {
updateNotification(notification, `Failed to ban player ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({ type: 'ban-player', requestId, player }));
} catch (error) {
console.error(`Ban player ${player} error:`, error);
showNotification(`Failed to ban player ${player}: ${error.message}`, 'error');
}
});
});
document.querySelectorAll('.op-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
try {
const requestId = crypto.randomUUID();
const key = `action-op-player-${player}`;
const notification = showNotification(`Granting operator status to ${player}...`, 'loading', key);
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Operator status granted to ${player}`, 'success', key);
wsRequest('/list-players').then(response => {
updateNonDockerUI({ type: 'list-players', data: response });
});
},
reject: (error) => {
updateNotification(notification, `Failed to grant operator status to ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({ type: 'op-player', requestId, player }));
} catch (error) {
console.error(`Op player ${player} error:`, error);
showNotification(`Failed to grant operator status to ${player}: ${error.message}`, 'error');
}
});
});
document.querySelectorAll('.deop-player').forEach(button => {
button.addEventListener('click', () => {
const player = button.getAttribute('data-player').trim();
if (!player) {
showNotification('Invalid player name.', 'error');
return;
}
try {
const requestId = crypto.randomUUID();
const key = `action-deop-player-${player}`;
const notification = showNotification(`Removing operator status from ${player}...`, 'loading', key);
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Operator status removed from ${player}`, 'success', key);
wsRequest('/list-players').then(response => {
updateNonDockerUI({ type: 'list-players', data: response });
});
},
reject: (error) => {
updateNotification(notification, `Failed to remove operator status from ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({ type: 'deop-player', requestId, player }));
} catch (error) {
console.error(`Deop player ${player} error:`, error);
showNotification(`Failed to remove operator status from ${player}: ${error.message}`, 'error');
}
});
});
}
}
if (message.type === 'mod-list' && message.data?.mods) {
state.allMods = message.data.mods || [];
renderModList();
}
if (message.type === 'log' && message.data?.message) {
if (state.logUrl !== message.data.message && elements.logUrl) {
elements.logUrl.href = message.data.message;
elements.logUrl.textContent = message.data.message;
state.logUrl = message.data.message;
}
}
if (message.type === 'website' && message.data?.message) {
if (state.websiteUrl !== message.data.message && elements.websiteUrl) {
elements.websiteUrl.href = message.data.message;
elements.websiteUrl.textContent = message.data.message;
state.websiteUrl = message.data.message;
}
}
if (message.type === 'map' && message.data?.message) {
if (state.mapUrl !== message.data.message && elements.mapUrl) {
elements.mapUrl.href = message.data.message;
elements.mapUrl.textContent = message.data.message;
state.mapUrl = message.data.message;
}
}
if (message.type === 'my-link-cache' && message.data?.hostname && message.data?.port) {
const myLinkText = `${message.data.hostname}:${message.data.port}`;
if (state.myLink !== myLinkText && elements.myLink) {
elements.myLink.textContent = myLinkText;
state.myLink = myLinkText;
}
}
if (message.type === 'my-geyser-cache' && message.data?.hostname && message.data?.port) {
const geyserLinkText = `${message.data.hostname}:${message.data.port}`;
if (state.geyserLink !== geyserLinkText && elements.geyserLink) {
elements.geyserLink.textContent = geyserLinkText;
state.geyserLink = geyserLinkText;
}
}
if (message.type === 'my-link' && message.data?.hostname && message.data?.port) {
const myLinkText = `${message.data.hostname}:${message.data.port}`;
if (state.myLink !== myLinkText && elements.myLink) {
elements.myLink.textContent = myLinkText;
state.myLink = myLinkText;
}
}
if (message.type === 'my-geyser-link' && message.data?.hostname && message.data?.port) {
const geyserLinkText = `${message.data.hostname}:${message.data.port}`;
if (state.geyserLink !== geyserLinkText && elements.geyserLink) {
elements.geyserLink.textContent = geyserLinkText;
state.geyserLink = geyserLinkText;
}
}
if (message.type === 'my-sftp' && message.data?.hostname && message.data?.port && message.data?.user) {
const sftpLinkText = `${message.data.hostname}:${message.data.port} (User: ${message.data.user})`;
if (state.sftpLink !== sftpLinkText && elements.sftpLink) {
elements.sftpLink.textContent = sftpLinkText;
state.sftpLink = sftpLinkText;
}
}
}
function showLoginPage() {
if (!elements.loginPage || !elements.mainContent) {
console.error('Required elements not found:', {
loginPage: elements.loginPage,
mainContent: elements.mainContent
});
showNotification('Page error: Essential elements missing. Please refresh the page.', 'error', 'page-error');
return;
}
elements.loginPage.classList.remove('hidden');
elements.mainContent.classList.add('hidden');
elements.authControls.innerHTML = '<input id="apiKey" type="text" placeholder="Enter API Key" class="bg-gray-700 px-4 py-2 rounded text-white">';
elements.apiKeyInput = document.getElementById('apiKey');
elements.apiKeyInput.addEventListener('change', handleApiKeyChange);
if (ws) {
ws.close();
ws = null;
}
if (memoryMeter) {
memoryMeter.destroy();
memoryMeter = null;
}
if (cpuMeter) {
cpuMeter.destroy();
cpuMeter = null;
}
if (terminal) {
terminal.clear();
}
}
function showMainContent() {
if (!elements.loginPage || !elements.mainContent) {
console.error('Required elements not found:', {
loginPage: elements.loginPage,
mainContent: elements.mainContent
});
showNotification('Page error: Essential elements missing. Please refresh the page.', 'error', 'page-error');
return;
}
elements.loginPage.classList.add('hidden');
elements.mainContent.classList.remove('hidden');
elements.authControls.innerHTML = '<button id="logoutBtn" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded">Logout</button>';
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogout);
} else {
console.error('Logout button not found after insertion');
}
initializeCharts();
initializeTerminal();
}
function handleApiKeyChange(e) {
apiKey = e.target.value.trim();
if (apiKey) {
localStorage.setItem('apiKey', apiKey);
elements.loginError.classList.add('hidden');
connectWebSocket();
}
}
function handleLogout() {
apiKey = '';
localStorage.removeItem('apiKey');
showLoginPage();
}
function updatePagination() {
const totalPages = Math.max(1, Math.ceil(totalResults / resultsPerPage));
elements.pagination.innerHTML = '';
const createPageButton = (page, text, disabled = false) => {
const button = document.createElement('button');
button.textContent = text;
button.className = `px-3 py-1 rounded ${disabled || page === currentPage
? 'bg-gray-600 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`;
if (!disabled && page !== currentPage) {
button.addEventListener('click', () => {
currentPage = page;
searchMods();
});
}
elements.pagination.appendChild(button);
};
if (totalResults > 0) {
createPageButton(currentPage - 1, 'Previous', currentPage === 1);
const maxButtons = 5;
const startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
const endPage = Math.min(totalPages, startPage + maxButtons - 1);
for (let i = startPage; i <= endPage; i++) {
createPageButton(i, i.toString());
}
createPageButton(currentPage + 1, 'Next', currentPage === totalPages);
}
}
function updateModListPagination() {
const filteredMods = state.allMods.filter(mod =>
mod.name.toLowerCase().includes(modListSearchQuery.toLowerCase())
);
const totalModPages = Math.max(1, Math.ceil(filteredMods.length / resultsPerPage));
elements.modListPagination.innerHTML = '';
const createPageButton = (page, text, disabled = false) => {
const button = document.createElement('button');
button.textContent = text;
button.className = `px-3 py-1 rounded ${disabled || page === modListCurrentPage
? 'bg-gray-600 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`;
if (!disabled && page !== modListCurrentPage) {
button.addEventListener('click', () => {
modListCurrentPage = page;
renderModList();
});
}
elements.modListPagination.appendChild(button);
};
if (filteredMods.length > 0) {
createPageButton(modListCurrentPage - 1, 'Previous', modListCurrentPage === 1);
const maxButtons = 5;
const startPage = Math.max(1, modListCurrentPage - Math.floor(maxButtons / 2));
const endPage = Math.min(totalModPages, startPage + maxButtons - 1);
for (let i = startPage; i <= endPage; i++) {
createPageButton(i, i.toString());
}
createPageButton(modListCurrentPage + 1, 'Next', modListCurrentPage === totalModPages);
}
}
function closeSearch() {
elements.modSearch.value = '';
elements.modResults.innerHTML = '';
elements.pagination.innerHTML = '';
elements.closeSearchBtn.classList.add('hidden');
currentPage = 1;
totalResults = 0;
}
function clearModListSearch() {
elements.modListSearch.value = '';
modListSearchQuery = '';
modListCurrentPage = 1;
elements.clearModListSearch.classList.add('hidden');
renderModList();
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
async function searchMods(page = currentPage) {
currentPage = page;
const mod = elements.modSearch.value.trim();
if (mod) {
try {
const offset = (currentPage - 1) * resultsPerPage;
const key = `action-search-mods-${mod}`;
const notification = showNotification(`Searching for mods matching "${mod}"...`, 'loading', key);
const response = await wsRequest('/search', 'POST', { mod, offset });
if (elements.modResults) {
totalResults = response.totalHits || response.results?.length || 0;
elements.modResults.innerHTML = response.results?.length > 0 ? response.results.map(result => `
<div class="bg-gray-700 p-4 rounded">
<p><strong>${result.title}</strong></p>
<p>${result.description}</p>
<p>Downloads: ${result.downloads}</p>
<button class="install-mod bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded mt-2" data-mod-id="${result.installID}">Install</button>
</div>
`).join('') : '<p class="text-gray-400">No results found.</p>';
updatePagination();
elements.closeSearchBtn.classList.remove('hidden');
updateNotification(notification, `Found ${totalResults} mod${totalResults === 1 ? '' : 's'} for "${mod}"`, 'success', key);
document.querySelectorAll('.install-mod').forEach(button => {
button.addEventListener('click', async () => {
const modId = button.getAttribute('data-mod-id');
const installKey = `action-install-mod-${modId}`;
try {
await wsRequest('/install', 'POST', { mod: modId });
const modResponse = await wsRequest('/mod-list');
updateNonDockerUI({ type: 'mod-list', data: modResponse });
showNotification(`Mod ${modId} installed successfully`, 'success', installKey);
} catch (error) {
console.error('Install mod error:', error);
showNotification(`Failed to install mod ${modId}: ${error.message}`, 'error', installKey);
}
});
});
}
} catch (error) {
console.error('Search mods error:', error);
showNotification(`Failed to search mods: ${error.message}`, 'error', key);
}
} else {
showNotification('Please enter a mod name to search.', 'error', 'search-mods-error');
}
}
function renderModList() {
const filteredMods = state.allMods.filter(mod =>
mod.name.toLowerCase().includes(modListSearchQuery.toLowerCase())
);
const startIndex = (modListCurrentPage - 1) * resultsPerPage;
const endIndex = startIndex + resultsPerPage;
const paginatedMods = filteredMods.slice(startIndex, endIndex);
const modListHtml = paginatedMods.length > 0
? paginatedMods.map(mod => `
<div class="bg-gray-700 p-4 rounded">
<p><strong>${mod.name}</strong> (${mod.version})</p>
<p>ID: ${mod.id}</p>
<button class="uninstall-mod bg-red-600 hover:bg-red-700 px-2 py-1 rounded mt-2" data-mod-id="${mod.id}">Uninstall</button>
</div>
`).join('')
: '<p class="text-gray-400">No mods found.</p>';
if (state.modListHtml !== modListHtml && elements.modList) {
elements.modList.innerHTML = modListHtml;
state.modListHtml = modListHtml;
document.querySelectorAll('.uninstall-mod').forEach(button => {
button.addEventListener('click', async () => {
const modId = button.getAttribute('data-mod-id');
const key = `action-uninstall-mod-${modId}`;
try {
await wsRequest('/uninstall', 'POST', { mod: modId });
const response = await wsRequest('/mod-list');
updateNonDockerUI({ type: 'mod-list', data: response });
showNotification(`Mod ${modId} uninstalled successfully`, 'success', key);
} catch (error) {
console.error('Uninstall mod error:', error);
showNotification(`Failed to uninstall mod ${modId}: ${error.message}`, 'error', key);
}
});
});
}
updateModListPagination();
}
async function sendConsoleCommand() {
const command = elements.consoleInput.value.trim();
if (command) {
try {
const key = `action-console-command`;
const notification = showNotification(`Executing command "${command}"...`, 'loading', key);
const response = await wsRequest('/console', 'POST', { command });
if (elements.consoleOutput) {
elements.consoleOutput.textContent += `> ${command}\n${response.message}\n`;
elements.consoleInput.value = '';
updateNotification(notification, `Command "${command}" executed successfully`, 'success', key);
}
} catch (error) {
if (elements.consoleOutput) {
elements.consoleOutput.textContent += `> ${command}\nError: ${error.message}\n`;
}
console.error('Console command error:', error);
showNotification(`Failed to execute command "${command}": ${error.message}`, 'error', 'console-error');
}
}
}
async function sendTellMessage() {
const player = elements.tellPlayerName.textContent.trim();
const message = elements.tellMessage.value.trim();
if (!player || !message) {
showNotification('Player name and message are required.', 'error', 'tell-error');
return;
}
try {
const requestId = crypto.randomUUID();
const key = `action-tell-player-${player}`;
const notification = showNotification(`Sending message to ${player}...`, 'loading', key);
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Message sent to ${player} successfully`, 'success', key);
elements.tellModal.classList.add('hidden');
},
reject: (error) => {
updateNotification(notification, `Failed to send message to ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({ type: 'tell-player', requestId, player, message }));
} catch (error) {
console.error(`Send message to ${player} error:`, error);
showNotification(`Failed to send message to ${player}: ${error.message}`, 'error', 'tell-error');
}
}
async function sendTeleportCommand() {
const player = elements.teleportPlayerName.textContent.trim();
const destination = elements.teleportDestination.value.trim();
if (!player || !destination) {
showNotification('Source and destination players are required.', 'error', 'teleport-error');
return;
}
try {
const command = `tp ${player} ${destination}`;
const key = `action-teleport-player-${player}`;
const notification = showNotification(`Teleporting ${player} to ${destination}...`, 'loading', key);
const response = await wsRequest('/console', 'POST', { command });
updateNotification(notification, `Teleported ${player} to ${destination} successfully`, 'success', key);
elements.teleportModal.classList.add('hidden');
} catch (error) {
console.error(`Teleport ${player} error:`, error);
showNotification(`Failed to teleport ${player}: ${error.message}`, 'error', 'teleport-error');
}
}
async function sendEffectCommand() {
const player = elements.effectPlayerName.textContent.trim();
const effectData = elements.effectSelect.value.split(':');
const effect = effectData[0];
const duration = parseInt(effectData[1], 10);
const amplifier = parseInt(effectData[2], 10);
if (!player || !effect) {
showNotification('Player name and effect are required.', 'error', 'effect-error');
return;
}
try {
const command = `effect give ${player} minecraft:${effect} ${duration} ${amplifier}`;
const key = `action-effect-player-${player}`;
const notification = showNotification(`Applying ${effect} to ${player}...`, 'loading', key);
const response = await wsRequest('/console', 'POST', { command });
updateNotification(notification, `Applied ${effect} to ${player} successfully`, 'success', key);
elements.effectModal.classList.add('hidden');
} catch (error) {
console.error(`Apply effect to ${player} error:`, error);
showNotification(`Failed to apply effect to ${player}: ${error.message}`, 'error', 'effect-error');
}
}
function addItemField() {
const itemList = elements.itemList;
const itemEntry = document.createElement('div');
itemEntry.className = 'item-entry flex space-x-2 items-center';
itemEntry.innerHTML = `
<input type="text" placeholder="e.g., torch" class="item-name bg-gray-700 px-4 py-2 rounded text-white flex-grow">
<input type="number" min="1" value="1" class="item-amount bg-gray-700 px-4 py-2 rounded text-white w-20">
<button type="button" class="remove-item bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm">Remove</button>
`;
itemList.appendChild(itemEntry);
itemEntry.querySelector('.remove-item').addEventListener('click', () => {
itemEntry.remove();
});
}
function resetItemFields() {
elements.itemList.innerHTML = '';
addItemField();
}
async function sendGiveCommand() {
const player = elements.givePlayerName.textContent.trim();
const loadout = elements.loadoutSelect.value;
let items = [];
if (loadout === 'custom') {
const itemEntries = document.querySelectorAll('.item-entry');
items = Array.from(itemEntries).map(entry => {
const item = entry.querySelector('.item-name').value.trim();
const amount = parseInt(entry.querySelector('.item-amount').value, 10);
return { item, amount };
}).filter(itemData => itemData.item && itemData.amount > 0);
if (items.length === 0) {
showNotification('At least one valid item and quantity are required.', 'error', 'give-error');
return;
}
} else {
items = loadouts[loadout] || [];
}
try {
const key = `action-give-player-${player}`;
const notification = showNotification(`Giving items to ${player}...`, 'loading', key);
for (const itemData of items) {
const { item, amount, enchantments, type } = itemData;
const requestId = crypto.randomUUID();
pendingRequests.set(requestId, {
resolve: () => {
updateNotification(notification, `Gave ${amount} ${item}${type ? ` (${type})` : ''} to ${player}`, 'success', key);
},
reject: (error) => {
updateNotification(notification, `Failed to give ${item} to ${player}: ${error.message}`, 'error', key);
},
notification,
key
});
ws.send(JSON.stringify({
type: 'give-player',
requestId,
player,
item,
amount,
enchantments: enchantments || undefined,
potionType: type || undefined
}));
}
elements.giveModal.classList.add('hidden');
} catch (error) {
console.error(`Give items to ${player} error:`, error);
showNotification(`Failed to give items to ${player}: ${error.message}`, 'error', 'give-error');
}
}
let displayProperties = {};
function parseServerProperties(content) {
const properties = {};
const lines = content.split('\n');
lines.forEach(line => {
if (line.trim() && !line.trim().startsWith('#')) {
const [key, value] = line.split('=', 2).map(part => part.trim());
if (key && value !== undefined) {
properties[key] = value;
}
}
});
return properties;
}
function renderPropertiesFields(properties, filter = '') {
const fieldsContainer = elements.propertiesFields;
let searchContainer = fieldsContainer.querySelector('#searchContainer');
if (!searchContainer) {
searchContainer = document.createElement('div');
searchContainer.id = 'searchContainer';
searchContainer.className = 'mb-4';
searchContainer.style.display = 'block';
const searchLabel = document.createElement('label');
searchLabel.textContent = 'Search Properties';
searchLabel.className = 'block text-sm font-medium mb-1 text-white';
searchLabel.setAttribute('for', 'propertiesSearch');
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.id = 'propertiesSearch';
searchInput.placeholder = 'Search properties...';
searchInput.className = 'bg-gray-700 px-4 py-2 rounded text-white w-full';
searchInput.setAttribute('aria-label', 'Search server properties');
searchContainer.appendChild(searchLabel);
searchContainer.appendChild(searchInput);
const addButton = document.createElement('button');
addButton.textContent = 'Add Property';
addButton.type = 'button';
addButton.className = 'mt-2 bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded text-xs';
addButton.addEventListener('click', showCustomPropertyForm);
searchContainer.appendChild(addButton);
fieldsContainer.appendChild(searchContainer);
searchInput.addEventListener('input', (e) => {
renderPropertiesList(displayProperties, e.target.value);
});
elements.searchInput = searchInput;
}
let propertiesList = fieldsContainer.querySelector('#propertiesList');
if (!propertiesList) {
propertiesList = document.createElement('div');
propertiesList.id = 'propertiesList';
propertiesList.className = 'space-y-2';
fieldsContainer.appendChild(propertiesList);
}
renderPropertiesList(properties, filter);
const closeButton = elements.editPropertiesModal.querySelector('.close-button');
if (closeButton && !closeButton.dataset.closeHandlerAdded) {
closeButton.addEventListener('click', () => {
elements.searchInput.value = '';
renderPropertiesList(displayProperties, '');
elements.editPropertiesModal.classList.add('hidden');
hideCustomPropertyForm();
});
closeButton.dataset.closeHandlerAdded = 'true';
}
}
function showCustomPropertyForm() {
let formContainer = elements.propertiesFields.querySelector('#customPropertyForm');
if (formContainer) {
formContainer.remove();
}
formContainer = document.createElement('div');
formContainer.id = 'customPropertyForm';
formContainer.className = 'mb-4 p-4 bg-gray-800 rounded';
const form = document.createElement('form');
form.addEventListener('submit', (e) => e.preventDefault());
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.placeholder = 'Property name';
keyInput.className = 'bg-gray-700 px-4 py-2 rounded text-white w-full mb-2';
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.placeholder = 'Property value';
valueInput.className = 'bg-gray-700 px-4 py-2 rounded text-white w-full mb-2';
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.textContent = 'Add';
addButton.className = 'bg-green-600 hover:bg-green-700 text-white px-2 py-1 rounded text-xs mr-2';
addButton.addEventListener('click', () => {
const key = keyInput.value.trim();
const value = valueInput.value.trim();
if (key && value) {
displayProperties[key] = value;
allProperties[key] = value;
renderPropertiesList(displayProperties, elements.searchInput.value);
formContainer.remove();
} else {
showNotification('Please enter both property name and value', 'error', 'custom-property-error');
}
});
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.textContent = 'Cancel';
cancelButton.className = 'bg-gray-600 hover:bg-gray-700 text-white px-2 py-1 rounded text-xs';
cancelButton.addEventListener('click', hideCustomPropertyForm);
form.appendChild(keyInput);
form.appendChild(valueInput);
form.appendChild(addButton);
form.appendChild(cancelButton);
formContainer.appendChild(form);
elements.propertiesFields.insertBefore(formContainer, elements.propertiesFields.querySelector('#propertiesList'));
}
function hideCustomPropertyForm() {
const formContainer = elements.propertiesFields.querySelector('#customPropertyForm');
if (formContainer) {
formContainer.remove();
}
}
function showDeleteConfirmationModal(key) {
const modal = document.createElement('div');
modal.id = 'deleteConfirmationModal';
modal.className = 'absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
const modalContent = document.createElement('div');
modalContent.className = 'bg-gray-800 p-6 rounded-lg max-w-md w-full';
const header = document.createElement('h3');
header.className = 'text-lg font-medium text-white mb-4';
header.textContent = 'Confirm Deletion';
const message = document.createElement('p');
message.className = 'text-white mb-6';
message.textContent = `Are you sure you want to delete the property "${key}"? This action cannot be undone.`;
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex justify-end space-x-2';
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.className = 'bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded';
cancelButton.textContent = 'Cancel';
cancelButton.addEventListener('click', () => modal.remove());
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => {
delete displayProperties[key];
delete allProperties[key];
renderPropertiesList(displayProperties, elements.searchInput.value);
modal.remove();
showNotification(`Property "${key}" deleted`, 'success', 'delete-property-success');
});
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(deleteButton);
modalContent.appendChild(header);
modalContent.appendChild(message);
modalContent.appendChild(buttonContainer);
modal.appendChild(modalContent);
elements.editPropertiesModal.appendChild(modal);
}
function renderPropertiesList(properties, filter = '') {
const propertiesList = elements.propertiesFields.querySelector('#propertiesList');
propertiesList.innerHTML = '';
const filteredProperties = Object.entries(properties).filter(([key]) =>
key.toLowerCase().includes(filter.toLowerCase())
);
filteredProperties.forEach(([key, value]) => {
if (filteredSettings.includes(key)) {
return;
}
console.log(`Rendering field for ${key}: ${value}`);
const fieldDiv = document.createElement('div');
fieldDiv.className = 'flex items-center space-x-2';
fieldDiv.style.display = 'flex';
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'text-red-500 hover:text-red-700';
deleteButton.innerHTML = '✕';
deleteButton.title = `Delete ${key}`;
deleteButton.addEventListener('click', () => showDeleteConfirmationModal(key));
let inputType = 'text';
let isBoolean = value.toLowerCase() === 'true' || value.toLowerCase() === 'false';
if (isBoolean) {
inputType = 'switch';
} else if (/^\d+$/.test(value) && !isNaN(parseInt(value))) {
inputType = 'number';
}
const label = document.createElement('label');
label.textContent = key;
label.className = 'w-1/3 text-sm font-medium text-white';
label.setAttribute('for', `prop-${key}`);
if (inputType === 'switch') {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'text';
hiddenInput.id = `prop-${key}`;
hiddenInput.name = key;
hiddenInput.value = value.toLowerCase();
hiddenInput.style.display = 'none';
const switchContainer = document.createElement('div');
switchContainer.className = 'relative inline-block';
switchContainer.setAttribute('role', 'switch');
switchContainer.setAttribute('aria-checked', value.toLowerCase());
switchContainer.setAttribute('tabindex', '0');
switchContainer.dataset.name = key;
switchContainer.style.width = '40px';
switchContainer.style.height = '24px';
switchContainer.style.display = 'inline-block';
switchContainer.style.position = 'relative';
switchContainer.style.cursor = 'pointer';
switchContainer.style.zIndex = '10';
const switchTrack = document.createElement('div');
switchTrack.className = 'block w-full h-full rounded-full';
switchTrack.style.backgroundColor = value.toLowerCase() === 'true' ? '#10B981' : '#4B5563';
switchTrack.style.transition = 'background-color 0.2s ease-in-out';
const switchHandle = document.createElement('div');
switchHandle.className = 'absolute rounded-full bg-white';
switchHandle.style.width = '16px';
switchHandle.style.height = '16px';
switchHandle.style.top = '4px';
switchHandle.style.left = value.toLowerCase() === 'true' ? '20px' : '4px';
switchHandle.style.transition = 'left 0.2s ease-in-out';
switchHandle.style.position = 'absolute';
switchHandle.style.zIndex = '11';
const toggleSwitch = () => {
const currentValue = hiddenInput.value === 'true';
const newValue = !currentValue;
hiddenInput.value = newValue.toString();
switchContainer.setAttribute('aria-checked', newValue.toString());
switchTrack.style.backgroundColor = newValue ? '#10B981' : '#4B5563';
switchHandle.style.left = newValue ? '20px' : '4px';
};
switchContainer.addEventListener('click', toggleSwitch);
switchContainer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSwitch();
}
});
switchContainer.appendChild(switchTrack);
switchContainer.appendChild(switchHandle);
fieldDiv.appendChild(deleteButton);
fieldDiv.appendChild(label);
fieldDiv.appendChild(hiddenInput);
fieldDiv.appendChild(switchContainer);
} else {
const input = document.createElement('input');
input.id = `prop-${key}`;
input.name = key;
input.className = 'bg-gray-700 px-4 py-2 rounded text-white w-2/3';
input.type = inputType;
input.value = value;
if (inputType === 'number') {
input.min = '0';
}
fieldDiv.appendChild(deleteButton);
fieldDiv.appendChild(label);
fieldDiv.appendChild(input);
}
propertiesList.appendChild(fieldDiv);
});
}
function propertiesToString(properties) {
let header = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
return header + Object.entries(properties)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
async function fetchServerProperties() {
try {
const key = `action-fetch-properties`;
const notification = showNotification('Fetching server properties...', 'loading', key);
const response = await wsRequest('/server-properties', 'GET');
if (response.error) {
updateNotification(notification, `Failed to load server properties: ${response.error}`, 'error', key);
return;
}
if (response.content && response.content.length > 4000) {
updateNotification(notification, `Server properties file is too large to edit (${response.content.length} characters, max 4000)`, 'error', key);
return;
}
allProperties = parseServerProperties(response.content || '');
displayProperties = Object.fromEntries(
Object.entries(allProperties).filter(([key]) => !filteredSettings.includes(key))
);
renderPropertiesFields(displayProperties);
elements.editPropertiesModal.classList.remove('hidden');
updateNotification(notification, 'Server properties loaded successfully', 'success', key);
} catch (error) {
console.error('Fetch server properties error:', error);
showNotification(`Failed to load server properties: ${error.message}`, 'error', 'fetch-properties-error');
}
}
async function saveServerProperties() {
try {
const key = `action-save-properties`;
const notification = showNotification('Saving server properties...', 'loading', key);
const properties = {};
const inputs = elements.propertiesFields.querySelectorAll('input:not(#propertiesSearch):not([id="customPropertyKey"]):not([id="customPropertyValue"])');
inputs.forEach(input => {
const key = input.name;
let value = input.value.trim();
if (value !== '') {
properties[key] = value;
}
});
const fullProperties = { ...allProperties, ...properties };
const content = propertiesToString(fullProperties);
const response = await wsRequest('/server-properties', 'POST', { content });
if (response.error) {
updateNotification(notification, `Failed to save server properties: ${response.error}`, 'error', key);
return;
}
if (elements.searchInput) {
elements.searchInput.value = '';
renderPropertiesList(displayProperties, '');
}
elements.editPropertiesModal.classList.add('hidden');
updateNotification(notification, 'Server properties saved successfully', 'success', key);
} catch (error) {
console.error('Save server properties error:', error);
showNotification(`Failed to save server properties: ${error.message}`, 'error', 'save-properties-error');
}
}
async function updateMods() {
try {
const key = `action-update-mods`;
const notification = showNotification('Updating mods...', 'loading', key);
const response = await wsRequest('/update-mods', 'POST');
if (response.error) {
elements.updateModsOutput.textContent = `Error: ${response.error}`;
showNotification(`Failed to update mods: ${response.error}`, 'error', key);
} else {
const output = response.output || 'No output from mod update.';
elements.updateModsOutput.textContent = output;
showNotification('Mods updated successfully', 'success', key);
}
elements.updateModsModal.classList.remove('hidden');
} catch (error) {
console.error('Update mods error:', error);
elements.updateModsOutput.textContent = `Error: ${error.message}`;
elements.updateModsModal.classList.remove('hidden');
showNotification(`Failed to update mods: ${error.message}`, 'error', 'update-mods-error');
}
}
async function createBackup() {
try {
const key = `action-backup`;
const notification = showNotification('Creating backup... Download will start when ready.', 'loading', key);
const response = await wsRequest('/backup', 'POST');
if (response.error) {
updateNotification(notification, `Failed to create backup: ${response.error}`, 'error', key);
return;
}
const downloadURL = response.downloadURL;
if (downloadURL) {
const link = document.createElement('a');
link.href = downloadURL;
link.download = '';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
updateNotification(notification, 'Backup created and download started', 'success', key);
} else {
updateNotification(notification, 'Backup created but download URL unavailable', 'error', key);
}
} catch (error) {
console.error('Create backup error:', error);
showNotification(`Failed to create backup: ${error.message}`, 'error', 'backup-error');
}
}
async function connectSftp() {
if (!isSftpOnline) {
showNotification('SFTP is offline. Please try again later.', 'error', 'sftp-offline');
return;
}
if (!sftpCredentials) {
showNotification('SFTP credentials not available.', 'error', 'sftp-credentials');
return;
}
try {
const key = `action-sftp-connect`;
const notification = showNotification('Connecting to SFTP...', 'loading', key);
const response = await fetch('https://sftp.my-mc.link/auto-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
host: sftpCredentials.hostname.replace('sftp://', ''),
port: sftpCredentials.port,
username: sftpCredentials.user,
password: sftpCredentials.password,
}),
});
const result = await response.json();
if (result.success && result.connectionUrl) {
elements.sftpIframe.src = result.connectionUrl;
elements.sftpBrowserSection.style.display = 'block';
updateNotification(notification, 'SFTP connected successfully', 'success', key);
} else {
updateNotification(notification, 'Failed to establish SFTP connection.', 'error', key);
}
} catch (error) {
console.error('SFTP connection error:', error);
showNotification(`Failed to connect to SFTP: ${error.message}`, 'error', 'sftp-connect-error');
}
}
elements.loginBtn.addEventListener('click', () => {
apiKey = elements.loginApiKey.value.trim();
if (apiKey) {
localStorage.setItem('apiKey', apiKey);
elements.loginError.classList.add('hidden');
connectWebSocket();
} else {
elements.loginError.classList.remove('hidden');
elements.loginError.textContent = 'Please enter an API key.';
}
});
elements.apiKeyInput.addEventListener('change', handleApiKeyChange);
elements.generateMyLinkBtn.addEventListener('click', async () => {
try {
const key = `action-my-link`;
await wsRequest('/my-link');
showNotification('Connection link generated successfully', 'success', key);
} catch (error) {
console.error('Generate connection link error:', error);
showNotification(`Failed to generate connection link: ${error.message}`, 'error', 'my-link-error');
}
});
elements.generateGeyserLinkBtn.addEventListener('click', async () => {
try {
const key = `action-my-geyser-link`;
await wsRequest('/my-geyser-link');
showNotification('Geyser link generated successfully', 'success', key);
} catch (error) {
console.error('Generate geyser link error:', error);
showNotification(`Failed to generate Geyser link: ${error.message}`, 'error', 'geyser-link-error');
}
});
elements.generateSftpLinkBtn.addEventListener('click', async () => {
try {
const key = `action-my-sftp`;
await wsRequest('/my-sftp');
showNotification('SFTP link generated successfully', 'success', key);
} catch (error) {
console.error('Generate SFTP link error:', error);
showNotification(`Failed to generate SFTP link: ${error.message}`, 'error', 'sftp-link-error');
}
});
elements.sftpBtn.addEventListener('click', connectSftp);
document.getElementById('refresh').addEventListener('click', async () => {
if (ws && ws.readyState === WebSocket.OPEN) {
const key = `action-refresh`;
const notification = showNotification('Refreshing server data...', 'loading', key);
ws.send(JSON.stringify({ type: 'refresh' }));
initializeTerminal();
setTimeout(() => updateNotification(notification, 'Server data refreshed successfully', 'success', key), 1000);
} else {
showNotification('Not connected to server. Please log in.', 'error', 'ws-disconnected');
}
});
document.getElementById('startBtn').addEventListener('click', async () => {
try {
const key = `action-start`;
const notification = showNotification('Starting server...', 'loading', key);
await wsRequest('/start');
initializeTerminal();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'subscribe', endpoints: ['docker', 'docker-logs'] }));
const messageHandler = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'docker') {
state.serverStatus = message.data?.status || 'Unknown';
elements.serverStatus.textContent = state.serverStatus;
toggleSections(state.serverStatus);
if (message.data?.status === 'running') {
updateNotification(notification, 'Server started successfully', 'success', key);
ws.removeEventListener('message', messageHandler);
}
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.addEventListener('message', messageHandler);
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.removeEventListener('message', messageHandler);
if (state.serverStatus !== 'running') {
updateNotification(notification, 'Failed to start server. Please try again.', 'error', key);
toggleSections(state.serverStatus);
}
}
}, 30000);
} else {
updateNotification(notification, 'Not connected to server. Please log in.', 'error', key);
}
} catch (error) {
console.error('Start server error:', error);
showNotification(`Failed to start server: ${error.message}`, 'error', 'start-error');
}
});
document.getElementById('stopBtn').addEventListener('click', async () => {
try {
const key = `action-stop`;
const notification = showNotification('Stopping server...', 'loading', key);
await wsRequest('/stop');
updateNotification(notification, 'Server stopped successfully', 'success', key);
} catch (error) {
console.error('Stop server error:', error);
showNotification(`Failed to stop server: ${error.message}`, 'error', 'stop-error');
}
});
document.getElementById('restartBtn').addEventListener('click', async () => {
try {
const key = `action-restart`;
const notification = showNotification('Restarting server...', 'loading', key);
await wsRequest('/restart');
initializeTerminal();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'subscribe', endpoints: ['docker', 'docker-logs'] }));
const messageHandler = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'docker') {
state.serverStatus = message.data?.status || 'Unknown';
elements.serverStatus.textContent = state.serverStatus;
toggleSections(state.serverStatus);
if (message.data?.status === 'running') {
updateNotification(notification, 'Server restarted successfully', 'success', key);
ws.removeEventListener('message', messageHandler);
}
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.addEventListener('message', messageHandler);
setTimeout(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.removeEventListener('message', messageHandler);
if (state.serverStatus !== 'running') {
updateNotification(notification, 'Failed to restart server. Please try again.', 'error', key);
toggleSections(state.serverStatus);
}
}
}, 60000);
} else {
updateNotification(notification, 'Not connected to server. Please log in.', 'error', key);
}
} catch (error) {
console.error('Restart server error:', error);
showNotification(`Failed to restart server: ${error.message}`, 'error', 'restart-error');
}
});
elements.updateModsBtn.addEventListener('click', updateMods);
elements.closeUpdateModsBtn.addEventListener('click', () => {
elements.updateModsModal.classList.add('hidden');
elements.updateModsOutput.textContent = '';
});
elements.updateModsModal.querySelector('.modal-close').addEventListener('click', () => {
elements.updateModsModal.classList.add('hidden');
elements.updateModsOutput.textContent = '';
});
elements.backupBtn.addEventListener('click', createBackup);
document.getElementById('searchBtn').addEventListener('click', () => {
searchMods(1);
});
document.getElementById('sendConsole').addEventListener('click', sendConsoleCommand);
elements.closeSearchBtn.addEventListener('click', closeSearch);
elements.tellModal.querySelector('.modal-close').addEventListener('click', () => {
elements.tellModal.classList.add('hidden');
});
elements.giveModal.querySelector('.modal-close').addEventListener('click', () => {
elements.giveModal.classList.add('hidden');
});
elements.teleportModal.querySelector('.modal-close').addEventListener('click', () => {
elements.teleportModal.classList.add('hidden');
});
elements.effectModal.querySelector('.modal-close').addEventListener('click', () => {
elements.effectModal.classList.add('hidden');
});
elements.loadoutSelect.addEventListener('change', () => {
const isCustom = elements.loadoutSelect.value === 'custom';
elements.customGiveFields.classList.toggle('hidden', !isCustom);
if (isCustom) {
resetItemFields();
}
});
elements.tellMessage.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendTellMessage();
}
});
elements.itemList.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target.classList.contains('item-name') || e.target.classList.contains('item-amount'))) {
e.preventDefault();
sendGiveCommand();
}
});
elements.teleportForm.addEventListener('submit', (e) => {
e.preventDefault();
sendTeleportCommand();
});
elements.effectForm.addEventListener('submit', (e) => {
e.preventDefault();
sendEffectCommand();
});
elements.tellForm.addEventListener('submit', (e) => {
e.preventDefault();
sendTellMessage();
});
elements.giveForm.addEventListener('submit', (e) => {
e.preventDefault();
sendGiveCommand();
});
elements.addItemBtn.addEventListener('click', addItemField);
elements.editPropertiesBtn.addEventListener('click', fetchServerProperties);
elements.editPropertiesModal.querySelector('.modal-close').addEventListener('click', () => {
elements.editPropertiesModal.classList.add('hidden');
});
elements.editPropertiesForm.addEventListener('submit', (e) => {
e.preventDefault();
saveServerProperties();
});
elements.clearModListSearch.addEventListener('click', clearModListSearch);
const debouncedModListSearch = debounce((query) => {
modListSearchQuery = query.trim();
modListCurrentPage = 1;
elements.clearModListSearch.classList.toggle('hidden', !modListSearchQuery);
renderModList();
}, 300);
elements.modListSearch.addEventListener('input', (e) => {
debouncedModListSearch(e.target.value);
});
if (apiKey) {
connectWebSocket();
} else {
showLoginPage();
}
});