OrbitMC commited on
Commit
7e183ae
·
verified ·
1 Parent(s): 36c1a91

Update panel.py

Browse files
Files changed (1) hide show
  1. panel.py +390 -439
panel.py CHANGED
@@ -15,9 +15,6 @@ output_history = collections.deque(maxlen=300)
15
  connected_clients = set()
16
  BASE_DIR = os.path.abspath("/app")
17
 
18
- # -----------------
19
- # HTML FRONTEND (Ultra-Modern Black & Green UI)
20
- # -----------------
21
  HTML_CONTENT = """
22
  <!DOCTYPE html>
23
  <html lang="en" class="dark">
@@ -29,551 +26,505 @@ HTML_CONTENT = """
29
  <script src="https://unpkg.com/lucide@latest"></script>
30
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
31
  <style>
32
- :root {
33
- --bg: #000000; --panel: #0a0a0a; --panel-hover: #111111;
34
  --border: #1a1a1a; --text: #a1a1aa; --text-light: #e4e4e7;
35
- --accent: #22c55e; --accent-hover: #16a34a; --accent-glow: rgba(34, 197, 94, 0.15);
36
  }
37
- body { background-color: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; overflow: hidden; -webkit-font-smoothing: antialiased; }
 
 
38
  .font-mono { font-family: 'JetBrains Mono', monospace; }
39
-
40
- /* Custom Scrollbars */
41
  ::-webkit-scrollbar { width: 4px; height: 4px; }
42
  ::-webkit-scrollbar-track { background: transparent; }
43
  ::-webkit-scrollbar-thumb { background: #27272a; border-radius: 4px; }
44
  ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
45
 
46
- /* Sidebar & Layout */
47
- .sidebar { width: 45px; transition: all 0.3s ease; }
48
  .nav-btn { color: #52525b; transition: all 0.2s; position: relative; }
49
  .nav-btn:hover, .nav-btn.active { color: var(--accent); }
50
- .nav-btn.active::before { content: ''; position: absolute; left: -12px; top: 10%; height: 80%; width: 2px; background: var(--accent); border-radius: 4px; box-shadow: 0 0 8px var(--accent); }
51
-
52
- /* Terminal Mask & Animations */
53
- .term-mask { mask-image: linear-gradient(to bottom, transparent 0%, black 8%, black 100%); -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 8%, black 100%); }
54
- @keyframes fadeUpLine { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
55
- .log-line { animation: fadeUpLine 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; word-break: break-all; padding: 1px 0; }
56
-
57
- /* File Row */
58
- .file-row { border-bottom: 1px solid var(--border); transition: background 0.15s; }
59
- .file-row:hover { background: var(--panel-hover); }
60
- .file-row:last-child { border-bottom: none; }
61
-
62
- /* Modals & Inputs */
63
- input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-glow); }
64
- .modal-enter { animation: modalIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
65
- @keyframes modalIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
66
-
67
- /* Loader */
68
- .loader { animation: spin 1s linear infinite; }
69
- @keyframes spin { 100% { transform: rotate(360deg); } }
70
- .hidden-tab { display: none !important; }
 
 
 
 
71
  </style>
72
  </head>
73
- <body class="flex h-screen w-full select-none">
74
-
75
- <!-- Super Slim Sidebar (45px) -->
76
- <aside class="sidebar bg-[#050505] border-r border-[#1a1a1a] flex flex-col items-center py-6 gap-8 z-40 shrink-0">
77
- <div class="text-green-500 shadow-[0_0_15px_rgba(34,197,94,0.3)] rounded-full"><i data-lucide="server" class="w-5 h-5"></i></div>
78
- <nav class="flex flex-col gap-6 w-full items-center mt-4">
79
- <button onclick="switchTab('console')" id="nav-console" class="nav-btn active" title="Console"><i data-lucide="terminal-square" class="w-5 h-5"></i></button>
80
- <button onclick="switchTab('files')" id="nav-files" class="nav-btn" title="File Manager"><i data-lucide="folder-tree" class="w-5 h-5"></i></button>
81
- <button onclick="switchTab('config')" id="nav-config" class="nav-btn" title="Server Config"><i data-lucide="settings-2" class="w-5 h-5"></i></button>
82
- <button onclick="switchTab('plugins')" id="nav-plugins" class="nav-btn" title="Plugins"><i data-lucide="puzzle" class="w-5 h-5"></i></button>
83
- </nav>
84
- </aside>
85
-
86
- <!-- Main Workspace -->
87
- <main class="flex-1 relative bg-black flex flex-col overflow-hidden">
88
-
89
- <!-- ================= CONSOLE TAB ================= -->
90
- <div id="tab-console" class="absolute inset-0 flex flex-col p-3 sm:p-5">
91
- <div class="flex-1 bg-panel border border-[#1a1a1a] rounded-xl flex flex-col overflow-hidden relative shadow-2xl">
92
- <!-- Top Status Bar -->
93
- <div class="h-10 border-b border-[#1a1a1a] bg-[#050505] flex items-center px-4 justify-between">
94
- <div class="flex items-center gap-2">
95
- <div class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]"></div>
96
- <span class="text-xs font-mono text-zinc-400">engine-live-stream</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
- </div>
99
-
100
- <!-- Terminal Output -->
101
- <div id="terminal-output" class="flex-1 p-4 overflow-y-auto font-mono text-[13px] text-zinc-300 term-mask select-text">
102
- <!-- Logs injected here -->
103
- </div>
104
-
105
- <!-- Terminal Input -->
106
- <div class="h-14 border-t border-[#1a1a1a] bg-[#050505] flex items-center px-4 gap-3">
107
- <i data-lucide="chevron-right" class="w-4 h-4 text-green-500"></i>
108
- <input type="text" id="cmd-input" class="flex-1 bg-transparent border-none text-green-400 font-mono text-sm placeholder-zinc-700" placeholder="Execute command...">
109
  </div>
110
  </div>
111
- </div>
112
 
113
- <!-- ================= FILES TAB ================= -->
114
- <div id="tab-files" class="hidden-tab absolute inset-0 flex flex-col p-3 sm:p-5">
115
- <div class="flex-1 bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl flex flex-col overflow-hidden shadow-2xl">
116
- <!-- File Header -->
117
- <div class="bg-[#050505] border-b border-[#1a1a1a] px-4 py-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
118
- <div class="flex items-center gap-2 text-sm font-mono text-zinc-400 w-full sm:w-auto overflow-x-auto hide-scrollbar" id="breadcrumbs"></div>
119
-
120
- <div class="flex items-center gap-2 shrink-0">
121
- <input type="file" id="file-upload" class="hidden" onchange="uploadFile(event)">
122
- <button onclick="showCreateModal('file')" class="p-1.5 hover:bg-[#1a1a1a] hover:text-green-400 rounded transition-colors text-zinc-400" title="New File"><i data-lucide="file-plus" class="w-4 h-4"></i></button>
123
- <button onclick="showCreateModal('folder')" class="p-1.5 hover:bg-[#1a1a1a] hover:text-green-400 rounded transition-colors text-zinc-400" title="New Folder"><i data-lucide="folder-plus" class="w-4 h-4"></i></button>
124
- <button onclick="document.getElementById('file-upload').click()" class="p-1.5 hover:bg-[#1a1a1a] hover:text-green-400 rounded transition-colors text-zinc-400" title="Upload"><i data-lucide="upload" class="w-4 h-4"></i></button>
125
- <div class="w-px h-4 bg-[#222] mx-1"></div>
126
- <button onclick="loadFiles(currentPath)" class="p-1.5 hover:bg-[#1a1a1a] text-zinc-400 rounded transition-colors"><i data-lucide="rotate-cw" class="w-4 h-4"></i></button>
127
  </div>
 
 
 
 
 
 
128
  </div>
129
-
130
- <!-- File List Header -->
131
- <div class="hidden sm:grid grid-cols-12 gap-4 px-4 py-2 border-b border-[#1a1a1a] bg-[#080808] text-[11px] font-semibold text-zinc-600 uppercase tracking-widest">
132
- <div class="col-span-8">Filename</div>
133
- <div class="col-span-3 text-right">Size</div>
134
- <div class="col-span-1 text-right"></div>
135
- </div>
136
-
137
- <!-- File List -->
138
- <div class="flex-1 overflow-y-auto pb-10" id="file-list"></div>
139
  </div>
140
- </div>
141
 
142
- <!-- ================= CONFIG TAB ================= -->
143
- <div id="tab-config" class="hidden-tab absolute inset-0 flex flex-col p-3 sm:p-5">
144
- <div class="flex-1 bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl flex flex-col overflow-hidden shadow-2xl relative">
145
- <div class="h-12 border-b border-[#1a1a1a] bg-[#050505] flex items-center px-4 justify-between">
146
- <div class="flex items-center gap-2 text-sm font-mono text-zinc-300">
147
- <i data-lucide="sliders" class="w-4 h-4 text-green-500"></i> server.properties
 
 
 
 
148
  </div>
149
- <button onclick="saveConfig()" class="bg-green-600 hover:bg-green-500 text-black px-4 py-1.5 rounded text-xs font-bold transition-colors flex items-center gap-2">
150
- <i data-lucide="save" class="w-3.5 h-3.5"></i> Apply Details
151
- </button>
152
  </div>
153
- <textarea id="config-editor" class="flex-1 bg-transparent p-4 text-zinc-300 font-mono text-[13px] resize-none focus:outline-none leading-relaxed select-text w-full h-full" spellcheck="false"></textarea>
154
  </div>
155
- </div>
156
 
157
- <!-- ================= PLUGINS TAB ================= -->
158
- <div id="tab-plugins" class="hidden-tab absolute inset-0 flex items-center justify-center">
159
- <div class="text-center flex flex-col items-center gap-4 opacity-50">
160
- <div class="w-16 h-16 rounded-2xl border border-green-500/30 flex items-center justify-center bg-green-500/5 shadow-[0_0_30px_rgba(34,197,94,0.1)]">
161
- <i data-lucide="blocks" class="w-8 h-8 text-green-500"></i>
162
- </div>
163
- <div>
164
- <h2 class="text-xl font-bold text-white tracking-tight">Plugin Manager</h2>
165
- <p class="text-sm text-zinc-500 mt-1 font-mono">system.status = "COMING_SOON"</p>
 
166
  </div>
167
  </div>
168
- </div>
169
 
170
- </main>
171
-
172
- <!-- ================= CONTEXT MENU (•••) ================= -->
173
- <div id="context-menu" class="hidden absolute z-50 bg-[#0a0a0a] border border-[#222] rounded-md shadow-2xl py-1 w-36 overflow-hidden">
174
- <!-- Injected via JS -->
175
  </div>
176
 
177
- <!-- ================= CUSTOM MODALS OVERLAY ================= -->
178
- <div id="modal-overlay" class="hidden fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 opacity-0 transition-opacity duration-200">
179
-
180
- <!-- Input Modal (Create/Rename/Move) -->
181
- <div id="input-modal" class="hidden bg-[#0a0a0a] border border-[#222] rounded-xl w-full max-w-sm shadow-2xl flex-col overflow-hidden modal-enter">
182
- <div class="p-5 border-b border-[#1a1a1a]">
183
- <h3 id="input-modal-title" class="text-white font-medium text-sm">Action</h3>
184
- <p id="input-modal-desc" class="text-xs text-zinc-500 mt-1"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </div>
186
- <div class="p-5">
187
- <input type="text" id="input-modal-field" class="w-full bg-[#050505] border border-[#222] text-white text-sm rounded-lg px-3 py-2.5 focus:border-green-500 transition-colors font-mono" autocomplete="off">
 
188
  </div>
189
- <div class="px-5 py-3 bg-[#050505] border-t border-[#1a1a1a] flex justify-end gap-2">
190
- <button onclick="closeModal()" class="px-4 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancel</button>
191
- <button id="input-modal-submit" class="px-4 py-1.5 bg-green-600 hover:bg-green-500 text-black text-xs font-bold rounded transition-colors">Confirm</button>
192
  </div>
193
  </div>
194
 
195
- <!-- Confirm Modal (Delete) -->
196
- <div id="confirm-modal" class="hidden bg-[#0a0a0a] border border-[#222] rounded-xl w-full max-w-sm shadow-2xl flex-col overflow-hidden modal-enter">
197
- <div class="p-5 border-b border-[#1a1a1a] flex gap-3">
198
- <i data-lucide="alert-triangle" class="w-5 h-5 text-red-500 shrink-0 mt-0.5"></i>
 
199
  <div>
200
- <h3 id="confirm-modal-title" class="text-white font-medium text-sm">Delete File</h3>
201
- <p id="confirm-modal-msg" class="text-xs text-zinc-400 mt-1 leading-relaxed"></p>
202
  </div>
203
  </div>
204
- <div class="px-5 py-3 bg-[#050505] flex justify-end gap-2">
205
- <button onclick="closeModal()" class="px-4 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancel</button>
206
- <button id="confirm-modal-submit" class="px-4 py-1.5 bg-red-600 hover:bg-red-500 text-white text-xs font-bold rounded transition-colors">Delete Permanently</button>
207
  </div>
208
  </div>
209
 
210
  <!-- Editor Modal -->
211
- <div id="editor-modal" class="hidden bg-[#0a0a0a] border border-[#222] rounded-xl w-full max-w-4xl h-[85vh] shadow-2xl flex-col overflow-hidden modal-enter">
212
- <div class="px-4 py-3 border-b border-[#1a1a1a] bg-[#050505] flex justify-between items-center">
213
- <span id="editor-modal-title" class="text-sm font-mono text-green-400">editing...</span>
214
- <div class="flex gap-2">
215
- <button onclick="closeModal()" class="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Discard</button>
216
- <button id="editor-modal-submit" class="px-4 py-1.5 bg-green-600 hover:bg-green-500 text-black text-xs font-bold rounded transition-colors flex items-center gap-1.5">
217
- <i data-lucide="save" class="w-3.5 h-3.5"></i> Save File
 
218
  </button>
219
  </div>
220
  </div>
221
- <textarea id="editor-modal-content" class="flex-1 bg-transparent p-4 text-zinc-300 font-mono text-[13px] resize-none focus:outline-none w-full leading-relaxed select-text" spellcheck="false"></textarea>
222
  </div>
223
  </div>
224
 
225
- <!-- Toast Notifications -->
226
- <div id="toast-container" class="fixed bottom-5 right-5 z-[200] flex flex-col gap-3 pointer-events-none"></div>
227
 
228
  <script>
229
  lucide.createIcons();
230
 
231
- // ----------------- TOAST SYSTEM -----------------
232
  function showToast(msg, type = 'info') {
233
- const container = document.getElementById('toast-container');
234
- const toast = document.createElement('div');
235
- let color = type === 'error' ? 'text-red-500 border-red-500/20' : (type === 'success' ? 'text-green-500 border-green-500/20' : 'text-blue-400 border-blue-400/20');
236
- toast.className = `flex items-center gap-3 bg-[#0a0a0a] border ${color} px-4 py-3 rounded-lg shadow-2xl translate-y-4 opacity-0 transition-all duration-300 pointer-events-auto`;
237
- toast.innerHTML = `<span class="text-xs font-mono text-white">${msg}</span>`;
238
- container.appendChild(toast);
239
- requestAnimationFrame(() => toast.classList.remove('translate-y-4', 'opacity-0'));
240
- setTimeout(() => {
241
- toast.classList.add('translate-y-4', 'opacity-0');
242
- setTimeout(() => toast.remove(), 300);
243
- }, 3000);
244
  }
245
 
246
- // ----------------- NAVIGATION -----------------
247
  function switchTab(tab) {
248
- document.querySelectorAll('.tab-content, [id^="tab-"]').forEach(el => el.classList.add('hidden-tab'));
249
- document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
250
-
251
- document.getElementById('tab-' + tab).classList.remove('hidden-tab');
252
- document.getElementById('nav-' + tab).classList.add('active');
253
-
254
- if (tab === 'files' && !currentPathLoaded) { loadFiles(''); currentPathLoaded = true; }
255
- if (tab === 'config') { loadConfig(); }
256
- if (tab === 'console') { scrollToBottom(); }
257
  }
258
 
259
- // ----------------- MODAL SYSTEM -----------------
260
  const overlay = document.getElementById('modal-overlay');
261
- let currentModalAction = null;
262
-
263
  function openModalElement(id) {
264
- document.getElementById('input-modal').classList.add('hidden');
265
- document.getElementById('confirm-modal').classList.add('hidden');
266
- document.getElementById('editor-modal').classList.add('hidden');
267
-
 
268
  overlay.classList.remove('hidden');
269
- document.getElementById(id).classList.remove('hidden');
270
- requestAnimationFrame(() => overlay.classList.remove('opacity-0'));
 
 
 
271
  }
272
-
273
  function closeModal() {
274
- overlay.classList.add('opacity-0');
275
- setTimeout(() => overlay.classList.add('hidden'), 200);
276
- currentModalAction = null;
277
  }
 
278
 
279
- function showPrompt(title, desc, defaultVal, onConfirm) {
280
- document.getElementById('input-modal-title').innerText = title;
281
- document.getElementById('input-modal-desc').innerText = desc;
282
- const input = document.getElementById('input-modal-field');
283
- input.value = defaultVal;
284
  openModalElement('input-modal');
285
- input.focus();
286
-
287
- const submitBtn = document.getElementById('input-modal-submit');
288
- submitBtn.onclick = () => { onConfirm(input.value); closeModal(); };
289
- input.onkeydown = (e) => { if(e.key === 'Enter') submitBtn.click(); };
290
  }
291
-
292
- function showConfirm(title, msg, onConfirm) {
293
- document.getElementById('confirm-modal-title').innerText = title;
294
- document.getElementById('confirm-modal-msg').innerText = msg;
295
  openModalElement('confirm-modal');
296
- document.getElementById('confirm-modal-submit').onclick = () => { onConfirm(); closeModal(); };
297
  }
298
 
299
- // ----------------- CUSTOM TERMINAL LOGIC -----------------
300
- const termOut = document.getElementById('terminal-output');
301
- const cmdInput = document.getElementById('cmd-input');
302
-
303
  function parseANSI(str) {
304
- str = str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
305
- let res = '', styles = [];
306
- const chunks = str.split(/\\x1b\\[/);
307
- res += chunks[0];
308
-
309
- for (let i = 1; i < chunks.length; i++) {
310
- const match = chunks[i].match(/^([0-9;]*)m(.*)/s);
311
- if (match) {
312
- const codes = match[1].split(';');
313
- for(let code of codes) {
314
- if(code === '' || code === '0') styles = [];
315
- else if(code === '1') styles.push('font-weight:bold');
316
- else if(code === '31' || code === '91') styles.push('color:#ef4444');
317
- else if(code === '32' || code === '92') styles.push('color:#22c55e');
318
- else if(code === '33' || code === '93') styles.push('color:#eab308');
319
- else if(code === '34' || code === '94') styles.push('color:#3b82f6');
320
- else if(code === '35' || code === '95') styles.push('color:#d946ef');
321
- else if(code === '36' || code === '96') styles.push('color:#06b6d4');
322
- else if(code === '37' || code === '97') styles.push('color:#fafafa');
323
- else if(code === '90') styles.push('color:#71717a');
324
  }
325
- const styleStr = styles.length ? `style="${styles.join(';')}"` : '';
326
- res += styles.length ? `<span ${styleStr}>${match[2]}</span>` : match[2];
327
- } else {
328
- res += '\\x1b[' + chunks[i]; // Unhandled escape
329
- }
330
  }
331
- return res || '&nbsp;';
332
- }
333
-
334
- function scrollToBottom() {
335
- termOut.scrollTop = termOut.scrollHeight;
336
  }
337
-
338
- function appendLog(text) {
339
- const isAtBottom = termOut.scrollHeight - termOut.clientHeight <= termOut.scrollTop + 10;
340
- const div = document.createElement('div');
341
- div.className = 'log-line';
342
- div.innerHTML = parseANSI(text);
343
- termOut.appendChild(div);
344
-
345
- // Keep memory bound
346
- if(termOut.childElementCount > 400) termOut.removeChild(termOut.firstChild);
347
- if(isAtBottom) scrollToBottom();
348
  }
349
-
350
- const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
351
  let ws;
352
- function connectWS() {
353
- ws = new WebSocket(wsUrl);
354
- ws.onopen = () => appendLog('\\x1b[32m\\x1b[1m[Panel]\\x1b[0m Stream connected.');
355
- ws.onmessage = e => appendLog(e.data);
356
- ws.onclose = () => { appendLog('\\x1b[31m\\x1b[1m[Panel]\\x1b[0m Connection lost. Reconnecting...'); setTimeout(connectWS, 3000); };
357
  }
358
  connectWS();
359
-
360
- cmdInput.addEventListener('keypress', e => {
361
- if (e.key === 'Enter') {
362
- const val = cmdInput.value.trim();
363
- if(val && ws && ws.readyState === WebSocket.OPEN) {
364
- appendLog(`\\x1b[90m> ${val}\\x1b[0m`);
365
- ws.send(val);
366
- cmdInput.value = '';
367
  }
368
  }
369
  });
370
 
371
- // ----------------- FILE MANAGER LOGIC -----------------
372
- let currentPath = '';
373
- let currentPathLoaded = false;
374
- let menuTarget = null;
375
-
376
- document.addEventListener('click', () => {
377
- document.getElementById('context-menu').classList.add('hidden');
378
- });
379
-
380
- function openMenu(e, name, isDir) {
381
  e.stopPropagation();
382
- menuTarget = { name, isDir, path: (currentPath ? currentPath + '/' + name : name) };
383
- const menu = document.getElementById('context-menu');
384
-
385
- let html = '';
386
- if(!isDir) html += `<button onclick="editFile('${menuTarget.path}')" class="w-full text-left px-4 py-2 hover:bg-[#1a1a1a] hover:text-green-400 text-xs text-zinc-300 transition-colors flex gap-2 items-center"><i data-lucide="edit-3" class="w-3.5 h-3.5"></i> Edit</button>`;
387
-
388
- html += `<button onclick="initRename()" class="w-full text-left px-4 py-2 hover:bg-[#1a1a1a] hover:text-green-400 text-xs text-zinc-300 transition-colors flex gap-2 items-center"><i data-lucide="type" class="w-3.5 h-3.5"></i> Rename</button>
389
- <button onclick="initMove()" class="w-full text-left px-4 py-2 hover:bg-[#1a1a1a] hover:text-green-400 text-xs text-zinc-300 transition-colors flex gap-2 items-center"><i data-lucide="move" class="w-3.5 h-3.5"></i> Move</button>`;
390
-
391
- if(!isDir) html += `<button onclick="window.open('/api/fs/download?path=' + encodeURIComponent('${menuTarget.path}'))" class="w-full text-left px-4 py-2 hover:bg-[#1a1a1a] hover:text-green-400 text-xs text-zinc-300 transition-colors flex gap-2 items-center"><i data-lucide="download" class="w-3.5 h-3.5"></i> Download</button>`;
392
-
393
- html += `<div class="h-px w-full bg-[#1a1a1a] my-1"></div>
394
- <button onclick="initDelete()" class="w-full text-left px-4 py-2 hover:bg-red-500/10 hover:text-red-400 text-xs text-red-500 transition-colors flex gap-2 items-center"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete</button>`;
395
-
396
- menu.innerHTML = html;
397
  lucide.createIcons();
398
-
399
- menu.style.left = Math.min(e.pageX, window.innerWidth - 150) + 'px';
400
- menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
401
  menu.classList.remove('hidden');
402
  }
403
-
404
- async function loadFiles(path) {
405
- currentPath = path;
406
- renderBreadcrumbs(path);
407
- const list = document.getElementById('file-list');
408
- list.innerHTML = `<div class="flex justify-center py-10"><i data-lucide="loader-2" class="w-6 h-6 text-green-500 loader"></i></div>`;
409
  lucide.createIcons();
410
-
411
  try {
412
- const res = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
413
- const files = await res.json();
414
- list.innerHTML = '';
415
-
416
- if (path !== '') {
417
- const parent = path.split('/').slice(0, -1).join('/');
418
- list.innerHTML += `
419
- <div class="file-row flex items-center px-4 py-3 cursor-pointer group" onclick="loadFiles('${parent}')">
420
- <i data-lucide="corner-left-up" class="w-4 h-4 text-zinc-600 group-hover:text-green-400 mr-3 transition-colors"></i>
421
- <span class="text-xs font-mono text-zinc-500 group-hover:text-green-400 transition-colors">..</span>
422
- </div>`;
 
 
423
  }
424
-
425
- if(files.length === 0 && path === '') list.innerHTML += `<div class="text-center py-10 text-zinc-600 text-xs font-mono">Directory empty</div>`;
426
-
427
- files.forEach(f => {
428
- const icon = f.is_dir ? '<i data-lucide="folder" class="w-4 h-4 text-green-500"></i>' : '<i data-lucide="file" class="w-4 h-4 text-zinc-500"></i>';
429
- const sizeStr = f.is_dir ? '--' : (f.size > 1024*1024 ? (f.size/(1024*1024)).toFixed(1) + ' MB' : (f.size / 1024).toFixed(1) + ' KB');
430
- const clickAct = f.is_dir ? `onclick="loadFiles('${currentPath ? currentPath + '/' + f.name : f.name}')"` : '';
431
-
432
- list.innerHTML += `
433
- <div class="file-row flex flex-col sm:grid sm:grid-cols-12 items-start sm:items-center px-4 py-2.5 gap-2 group cursor-pointer" ${clickAct}>
434
- <div class="col-span-8 flex items-center gap-3 w-full truncate">
435
- ${icon}
436
- <span class="text-[13px] font-mono text-zinc-300 group-hover:text-white transition-colors truncate">${f.name}</span>
437
- </div>
438
- <div class="col-span-3 text-right text-[11px] text-zinc-600 font-mono hidden sm:block">${sizeStr}</div>
439
- <div class="col-span-1 flex justify-end w-full sm:w-auto mt-1 sm:mt-0">
440
- <button onclick="openMenu(event, '${f.name}', ${f.is_dir})" class="p-1 hover:bg-[#222] rounded text-zinc-500 hover:text-white transition-colors">
441
- <i data-lucide="more-horizontal" class="w-4 h-4"></i>
442
- </button>
443
- </div>
444
- </div>`;
445
  });
446
  lucide.createIcons();
447
- } catch { showToast("Error loading files", "error"); }
448
  }
449
-
450
- function renderBreadcrumbs(path) {
451
- const parts = path.split('/').filter(p => p);
452
- let html = `<button onclick="loadFiles('')" class="hover:text-green-400 transition-colors"><i data-lucide="home" class="w-4 h-4"></i></button>`;
453
- let build = '';
454
- parts.forEach((p, i) => {
455
- build += (build ? '/' : '') + p;
456
- html += `<span class="opacity-30 mx-1">/</span>`;
457
- if(i === parts.length - 1) html += `<span class="text-green-500">${p}</span>`;
458
- else html += `<button onclick="loadFiles('${build}')" class="hover:text-green-400 transition-colors">${p}</button>`;
459
  });
460
- document.getElementById('breadcrumbs').innerHTML = html;
461
  lucide.createIcons();
462
  }
463
-
464
- // --- CRUD ACTIONS ---
465
- function showCreateModal(type) {
466
- showPrompt(`New ${type === 'folder' ? 'Folder' : 'File'}`, 'Enter name:', '', async (val) => {
467
- if(!val) return;
468
- const path = currentPath ? `${currentPath}/${val}` : val;
469
- const endpoint = type === 'folder' ? '/api/fs/create_dir' : '/api/fs/create_file';
470
- const fd = new FormData(); fd.append('path', path);
471
- try {
472
- const r = await fetch(endpoint, { method: 'POST', body: fd });
473
- if(r.ok) { showToast('Created successfully', 'success'); loadFiles(currentPath); }
474
- else showToast('Failed to create', 'error');
475
- } catch { showToast('Network error', 'error'); }
476
  });
477
  }
478
-
479
- function initRename() {
480
- const t = menuTarget;
481
- showPrompt('Rename', `Enter new name for ${t.name}:`, t.name, async (val) => {
482
- if(!val || val === t.name) return;
483
- const fd = new FormData(); fd.append('old_path', t.path); fd.append('new_name', val);
484
- try {
485
- const r = await fetch('/api/fs/rename', { method: 'POST', body: fd });
486
- if(r.ok) { showToast('Renamed', 'success'); loadFiles(currentPath); }
487
- else showToast('Rename failed', 'error');
488
- } catch { showToast('Network error', 'error'); }
489
  });
490
  }
491
-
492
- function initMove() {
493
- const t = menuTarget;
494
- showPrompt('Move', `Enter destination folder path for ${t.name} (relative to root, leave blank for root):`, '', async (val) => {
495
- const destFolder = val.trim();
496
- const destPath = destFolder ? `${destFolder}/${t.name}` : t.name;
497
- if(destPath === t.path) return;
498
- const fd = new FormData(); fd.append('source', t.path); fd.append('dest', destPath);
499
- try {
500
- const r = await fetch('/api/fs/move', { method: 'POST', body: fd });
501
- if(r.ok) { showToast('Moved', 'success'); loadFiles(currentPath); }
502
- else showToast('Move failed', 'error');
503
- } catch { showToast('Network error', 'error'); }
504
  });
505
  }
506
-
507
- function initDelete() {
508
- const t = menuTarget;
509
- showConfirm('Delete Permanently', `Are you sure you want to delete ${t.name}? This action cannot be undone.`, async () => {
510
- const fd = new FormData(); fd.append('path', t.path);
511
- try {
512
- const r = await fetch('/api/fs/delete', { method: 'POST', body: fd });
513
- if(r.ok) { showToast('Deleted', 'success'); loadFiles(currentPath); }
514
- else showToast('Delete failed', 'error');
515
- } catch { showToast('Network error', 'error'); }
516
  });
517
  }
518
-
519
- async function uploadFile(e) {
520
- const file = e.target.files[0];
521
- if(!file) return;
522
  showToast(`Uploading ${file.name}...`);
523
- const fd = new FormData(); fd.append('path', currentPath); fd.append('file', file);
524
- try {
525
- const r = await fetch('/api/fs/upload', { method: 'POST', body: fd });
526
- if(r.ok) { showToast('Upload complete', 'success'); loadFiles(currentPath); }
527
- else showToast('Upload failed', 'error');
528
- } catch { showToast('Network error', 'error'); }
529
- e.target.value = '';
530
  }
531
-
532
- // --- EDITOR LOGIC ---
533
- let currentEditPath = '';
534
- async function editFile(path) {
535
- try {
536
- const r = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
537
- if(!r.ok) throw new Error();
538
- const text = await r.text();
539
- currentEditPath = path;
540
- document.getElementById('editor-modal-title').innerText = path;
541
- document.getElementById('editor-modal-content').value = text;
542
  openModalElement('editor-modal');
543
-
544
- document.getElementById('editor-modal-submit').onclick = async () => {
545
- const fd = new FormData(); fd.append('path', currentEditPath);
546
- fd.append('content', document.getElementById('editor-modal-content').value);
547
- const res = await fetch('/api/fs/write', { method: 'POST', body: fd });
548
- if(res.ok) { showToast('File saved', 'success'); closeModal(); }
549
- else showToast('Save failed', 'error');
550
  };
551
- } catch { showToast('Cannot open file (might be binary)', 'error'); }
552
  }
553
-
554
- // --- CONFIG TAB LOGIC ---
555
- async function loadConfig() {
556
- try {
557
- const r = await fetch(`/api/fs/read?path=server.properties`);
558
- if(r.ok) {
559
- document.getElementById('config-editor').value = await r.text();
560
- } else {
561
- document.getElementById('config-editor').value = "# server.properties not found yet.";
562
- }
563
- } catch { showToast('Failed to load config', 'error'); }
564
  }
565
-
566
- async function saveConfig() {
567
- const fd = new FormData();
568
- fd.append('path', 'server.properties');
569
- fd.append('content', document.getElementById('config-editor').value);
570
- try {
571
- const r = await fetch('/api/fs/write', { method: 'POST', body: fd });
572
- if(r.ok) showToast('Config applied successfully', 'success');
573
- else showToast('Failed to save', 'error');
574
- } catch { showToast('Network error', 'error'); }
575
  }
576
-
577
  </script>
578
  </body>
579
  </html>
 
15
  connected_clients = set()
16
  BASE_DIR = os.path.abspath("/app")
17
 
 
 
 
18
  HTML_CONTENT = """
19
  <!DOCTYPE html>
20
  <html lang="en" class="dark">
 
26
  <script src="https://unpkg.com/lucide@latest"></script>
27
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
28
  <style>
29
+ :root {
30
+ --bg: #000000; --panel: #0a0a0a; --panel-hover: #111111;
31
  --border: #1a1a1a; --text: #a1a1aa; --text-light: #e4e4e7;
32
+ --accent: #22c55e; --accent-hover: #16a34a; --accent-glow: rgba(34,197,94,0.15);
33
  }
34
+ *, *::before, *::after { box-sizing: border-box; }
35
+ html, body { height: 100%; overflow: hidden; margin: 0; }
36
+ body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
37
  .font-mono { font-family: 'JetBrains Mono', monospace; }
38
+
 
39
  ::-webkit-scrollbar { width: 4px; height: 4px; }
40
  ::-webkit-scrollbar-track { background: transparent; }
41
  ::-webkit-scrollbar-thumb { background: #27272a; border-radius: 4px; }
42
  ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
43
 
44
+ /* Desktop sidebar nav indicator */
 
45
  .nav-btn { color: #52525b; transition: all 0.2s; position: relative; }
46
  .nav-btn:hover, .nav-btn.active { color: var(--accent); }
47
+ .nav-btn.active::before { content:''; position:absolute; left:-12px; top:10%; height:80%; width:2px; background:var(--accent); border-radius:4px; box-shadow:0 0 8px var(--accent); }
48
+
49
+ /* Mobile bottom nav */
50
+ .bnav-btn { flex:1; display:flex; flex-direction:column; align-items:center; gap:3px; padding:8px 4px 6px; font-size:9px; color:#52525b; transition:color 0.2s; background:none; border:none; }
51
+ .bnav-btn.active { color:var(--accent); }
52
+ .bnav-dot { width:14px; height:2px; background:var(--accent); border-radius:2px; opacity:0; transition:opacity 0.2s; box-shadow:0 0 6px var(--accent); }
53
+ .bnav-btn.active .bnav-dot { opacity:1; }
54
+
55
+ /* Terminal */
56
+ .term-mask { mask-image:linear-gradient(to bottom,transparent 0%,black 8%,black 100%); -webkit-mask-image:linear-gradient(to bottom,transparent 0%,black 8%,black 100%); }
57
+ @keyframes fadeUpLine { from{opacity:0;transform:translateY(5px)} to{opacity:1;transform:translateY(0)} }
58
+ .log-line { animation:fadeUpLine 0.2s cubic-bezier(0.16,1,0.3,1) forwards; word-break:break-all; padding:0.5px 0; }
59
+
60
+ .file-row { border-bottom:1px solid var(--border); transition:background 0.15s; }
61
+ .file-row:hover { background:var(--panel-hover); }
62
+ .file-row:last-child { border-bottom:none; }
63
+
64
+ input:focus, textarea:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-glow); }
65
+
66
+ .modal-enter { animation:modalIn 0.3s cubic-bezier(0.16,1,0.3,1) forwards; }
67
+ @keyframes modalIn { from{opacity:0;transform:scale(0.95) translateY(10px)} to{opacity:1;transform:scale(1) translateY(0)} }
68
+
69
+ .loader { animation:spin 1s linear infinite; }
70
+ @keyframes spin { 100%{transform:rotate(360deg)} }
71
+ .hidden-tab { display:none !important; }
72
  </style>
73
  </head>
74
+ <body style="display:flex;flex-direction:column;height:100dvh;">
75
+
76
+ <!-- Main row: sidebar + content -->
77
+ <div style="display:flex;flex:1;overflow:hidden;">
78
+
79
+ <!-- Desktop sidebar (hidden on mobile) -->
80
+ <aside class="hidden sm:flex" style="width:45px;background:#050505;border-right:1px solid #1a1a1a;flex-direction:column;align-items:center;padding:24px 0;gap:32px;z-index:40;flex-shrink:0;">
81
+ <div style="color:#22c55e;filter:drop-shadow(0 0 8px rgba(34,197,94,0.4))"><i data-lucide="server" style="width:20px;height:20px;"></i></div>
82
+ <nav style="display:flex;flex-direction:column;gap:24px;align-items:center;">
83
+ <button onclick="switchTab('console')" id="nav-console" class="nav-btn active" title="Console"><i data-lucide="terminal-square" style="width:20px;height:20px;"></i></button>
84
+ <button onclick="switchTab('files')" id="nav-files" class="nav-btn" title="Files"><i data-lucide="folder-tree" style="width:20px;height:20px;"></i></button>
85
+ <button onclick="switchTab('config')" id="nav-config" class="nav-btn" title="Config"><i data-lucide="settings-2" style="width:20px;height:20px;"></i></button>
86
+ <button onclick="switchTab('plugins')" id="nav-plugins" class="nav-btn" title="Plugins"><i data-lucide="puzzle" style="width:20px;height:20px;"></i></button>
87
+ </nav>
88
+ </aside>
89
+
90
+ <!-- Content area -->
91
+ <main style="flex:1;position:relative;background:#000;overflow:hidden;">
92
+
93
+ <!-- CONSOLE TAB -->
94
+ <div id="tab-console" style="position:absolute;inset:0;display:flex;flex-direction:column;padding:8px;" class="sm:p-5">
95
+ <div style="flex:1;background:#0a0a0a;border:1px solid #1a1a1a;border-radius:12px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.9)">
96
+ <!-- Status bar -->
97
+ <div style="height:36px;border-bottom:1px solid #1a1a1a;background:#050505;display:flex;align-items:center;padding:0 12px;justify-content:space-between;flex-shrink:0;">
98
+ <div style="display:flex;align-items:center;gap:8px;">
99
+ <div style="width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.8);"></div>
100
+ <span style="font-size:10px;font-family:'JetBrains Mono',monospace;color:#71717a;">engine-live-stream</span>
101
+ </div>
102
+ </div>
103
+ <!-- Output — SMALLER TEXT -->
104
+ <div id="terminal-output" class="term-mask" style="flex:1;padding:8px 12px;overflow-y:auto;font-family:'JetBrains Mono',monospace;font-size:10px;line-height:1.6;color:#d4d4d8;" class="sm:text-[11px]"></div>
105
+ <!-- Input -->
106
+ <div style="height:46px;border-top:1px solid #1a1a1a;background:#050505;display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;">
107
+ <i data-lucide="chevron-right" style="width:14px;height:14px;color:#22c55e;flex-shrink:0;"></i>
108
+ <input type="text" id="cmd-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
109
+ style="flex:1;background:transparent;border:none;color:#4ade80;font-family:'JetBrains Mono',monospace;font-size:11px;min-width:0;"
110
+ placeholder="Execute command...">
111
  </div>
 
 
 
 
 
 
 
 
 
 
 
112
  </div>
113
  </div>
 
114
 
115
+ <!-- FILES TAB -->
116
+ <div id="tab-files" class="hidden-tab" style="position:absolute;inset:0;display:flex;flex-direction:column;padding:8px;" class="sm:p-5">
117
+ <div style="flex:1;background:#0a0a0a;border:1px solid #1a1a1a;border-radius:12px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.9)">
118
+ <!-- Header -->
119
+ <div style="background:#050505;border-bottom:1px solid #1a1a1a;padding:10px 12px;display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;flex-shrink:0;">
120
+ <div id="breadcrumbs" style="display:flex;align-items:center;gap:4px;font-size:11px;font-family:'JetBrains Mono',monospace;color:#71717a;overflow-x:auto;max-width:60%;"></div>
121
+ <div style="display:flex;align-items:center;gap:4px;">
122
+ <input type="file" id="file-upload" class="hidden" onchange="uploadFile(event)">
123
+ <button onclick="showCreateModal('file')" title="New File" style="padding:6px;border-radius:6px;color:#71717a;background:none;border:none;cursor:pointer;transition:all 0.15s;" onmouseover="this.style.color='#22c55e'" onmouseout="this.style.color='#71717a'"><i data-lucide="file-plus" style="width:16px;height:16px;"></i></button>
124
+ <button onclick="showCreateModal('folder')" title="New Folder" style="padding:6px;border-radius:6px;color:#71717a;background:none;border:none;cursor:pointer;transition:all 0.15s;" onmouseover="this.style.color='#22c55e'" onmouseout="this.style.color='#71717a'"><i data-lucide="folder-plus" style="width:16px;height:16px;"></i></button>
125
+ <button onclick="document.getElementById('file-upload').click()" title="Upload" style="padding:6px;border-radius:6px;color:#71717a;background:none;border:none;cursor:pointer;transition:all 0.15s;" onmouseover="this.style.color='#22c55e'" onmouseout="this.style.color='#71717a'"><i data-lucide="upload" style="width:16px;height:16px;"></i></button>
126
+ <div style="width:1px;height:16px;background:#222;margin:0 2px;"></div>
127
+ <button onclick="loadFiles(currentPath)" style="padding:6px;border-radius:6px;color:#71717a;background:none;border:none;cursor:pointer;transition:all 0.15s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#71717a'"><i data-lucide="rotate-cw" style="width:16px;height:16px;"></i></button>
128
+ </div>
129
  </div>
130
+ <!-- Column headers (desktop) -->
131
+ <div class="hidden sm:grid" style="grid-template-columns:1fr auto auto;gap:16px;padding:8px 16px;border-bottom:1px solid #1a1a1a;background:#080808;font-size:10px;font-weight:600;color:#3f3f46;text-transform:uppercase;letter-spacing:0.08em;flex-shrink:0;">
132
+ <div>Filename</div><div>Size</div><div></div>
133
+ </div>
134
+ <!-- List -->
135
+ <div id="file-list" style="flex:1;overflow-y:auto;"></div>
136
  </div>
 
 
 
 
 
 
 
 
 
 
137
  </div>
 
138
 
139
+ <!-- CONFIG TAB -->
140
+ <div id="tab-config" class="hidden-tab" style="position:absolute;inset:0;display:flex;flex-direction:column;padding:8px;" class="sm:p-5">
141
+ <div style="flex:1;background:#0a0a0a;border:1px solid #1a1a1a;border-radius:12px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.9)">
142
+ <div style="height:44px;border-bottom:1px solid #1a1a1a;background:#050505;display:flex;align-items:center;padding:0 12px;justify-content:space-between;flex-shrink:0;">
143
+ <div style="display:flex;align-items:center;gap:8px;font-size:12px;font-family:'JetBrains Mono',monospace;color:#d4d4d8;">
144
+ <i data-lucide="sliders" style="width:14px;height:14px;color:#22c55e;"></i> server.properties
145
+ </div>
146
+ <button onclick="saveConfig()" style="background:#16a34a;color:#000;padding:4px 12px;border-radius:6px;font-size:11px;font-weight:700;border:none;cursor:pointer;display:flex;align-items:center;gap:6px;transition:background 0.15s;" onmouseover="this.style.background='#22c55e'" onmouseout="this.style.background='#16a34a'">
147
+ <i data-lucide="save" style="width:12px;height:12px;"></i> Apply
148
+ </button>
149
  </div>
150
+ <textarea id="config-editor" style="flex:1;background:transparent;padding:12px;color:#d4d4d8;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;border:none;outline:none;line-height:1.6;" spellcheck="false"></textarea>
 
 
151
  </div>
 
152
  </div>
 
153
 
154
+ <!-- PLUGINS TAB -->
155
+ <div id="tab-plugins" class="hidden-tab" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;">
156
+ <div style="text-align:center;opacity:0.5;display:flex;flex-direction:column;align-items:center;gap:16px;">
157
+ <div style="width:64px;height:64px;border-radius:16px;border:1px solid rgba(34,197,94,0.3);display:flex;align-items:center;justify-content:center;background:rgba(34,197,94,0.05);box-shadow:0 0 30px rgba(34,197,94,0.1);">
158
+ <i data-lucide="blocks" style="width:32px;height:32px;color:#22c55e;"></i>
159
+ </div>
160
+ <div>
161
+ <h2 style="font-size:20px;font-weight:700;color:#fff;margin:0 0 4px;">Plugin Manager</h2>
162
+ <p style="font-size:11px;color:#52525b;font-family:'JetBrains Mono',monospace;margin:0;">system.status = "COMING_SOON"</p>
163
+ </div>
164
  </div>
165
  </div>
 
166
 
167
+ </main>
 
 
 
 
168
  </div>
169
 
170
+ <!-- Mobile bottom nav (hidden on desktop) -->
171
+ <nav class="flex sm:hidden" style="background:#050505;border-top:1px solid #1a1a1a;flex-shrink:0;padding-bottom:env(safe-area-inset-bottom,0);">
172
+ <button onclick="switchTab('console')" id="mnav-console" class="bnav-btn active">
173
+ <div class="bnav-dot"></div>
174
+ <i data-lucide="terminal-square" style="width:20px;height:20px;"></i>
175
+ <span>Console</span>
176
+ </button>
177
+ <button onclick="switchTab('files')" id="mnav-files" class="bnav-btn">
178
+ <div class="bnav-dot"></div>
179
+ <i data-lucide="folder-tree" style="width:20px;height:20px;"></i>
180
+ <span>Files</span>
181
+ </button>
182
+ <button onclick="switchTab('config')" id="mnav-config" class="bnav-btn">
183
+ <div class="bnav-dot"></div>
184
+ <i data-lucide="settings-2" style="width:20px;height:20px;"></i>
185
+ <span>Config</span>
186
+ </button>
187
+ <button onclick="switchTab('plugins')" id="mnav-plugins" class="bnav-btn">
188
+ <div class="bnav-dot"></div>
189
+ <i data-lucide="puzzle" style="width:20px;height:20px;"></i>
190
+ <span>Plugins</span>
191
+ </button>
192
+ </nav>
193
+
194
+ <!-- Context Menu -->
195
+ <div id="context-menu" class="hidden" style="position:fixed;z-index:50;background:#0a0a0a;border:1px solid #222;border-radius:8px;box-shadow:0 20px 40px rgba(0,0,0,0.8);padding:4px 0;width:144px;overflow:hidden;"></div>
196
+
197
+ <!-- Modal Overlay -->
198
+ <div id="modal-overlay" class="hidden" style="position:fixed;inset:0;z-index:100;background:rgba(0,0,0,0.65);backdrop-filter:blur(4px);display:flex;align-items:flex-end;justify-content:center;opacity:0;transition:opacity 0.2s;" class="sm:items-center sm:p-4">
199
+
200
+ <!-- Input Modal -->
201
+ <div id="input-modal" class="hidden modal-enter" style="background:#0a0a0a;border:1px solid #222;border-radius:16px 16px 0 0;width:100%;max-width:400px;overflow:hidden;display:flex;flex-direction:column;">
202
+ <div style="width:40px;height:4px;background:#333;border-radius:4px;margin:12px auto 4px;"></div>
203
+ <div style="padding:16px 20px;border-bottom:1px solid #1a1a1a;">
204
+ <h3 id="input-modal-title" style="color:#fff;font-size:14px;font-weight:500;margin:0 0 4px;">Action</h3>
205
+ <p id="input-modal-desc" style="color:#71717a;font-size:11px;margin:0;"></p>
206
  </div>
207
+ <div style="padding:16px 20px;">
208
+ <input type="text" id="input-modal-field" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
209
+ style="width:100%;background:#050505;border:1px solid #222;color:#fff;font-size:13px;border-radius:8px;padding:10px 12px;font-family:'JetBrains Mono',monospace;transition:border-color 0.15s;">
210
  </div>
211
+ <div style="padding:10px 20px;background:#050505;border-top:1px solid #1a1a1a;display:flex;justify-content:flex-end;gap:8px;">
212
+ <button onclick="closeModal()" style="padding:6px 16px;font-size:11px;color:#71717a;background:none;border:none;cursor:pointer;transition:color 0.15s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#71717a'">Cancel</button>
213
+ <button id="input-modal-submit" style="padding:6px 16px;background:#16a34a;color:#000;font-size:11px;font-weight:700;border-radius:6px;border:none;cursor:pointer;transition:background 0.15s;" onmouseover="this.style.background='#22c55e'" onmouseout="this.style.background='#16a34a'">Confirm</button>
214
  </div>
215
  </div>
216
 
217
+ <!-- Confirm Modal -->
218
+ <div id="confirm-modal" class="hidden modal-enter" style="background:#0a0a0a;border:1px solid #222;border-radius:16px 16px 0 0;width:100%;max-width:400px;overflow:hidden;display:flex;flex-direction:column;">
219
+ <div style="width:40px;height:4px;background:#333;border-radius:4px;margin:12px auto 4px;"></div>
220
+ <div style="padding:16px 20px;border-bottom:1px solid #1a1a1a;display:flex;gap:12px;">
221
+ <i data-lucide="alert-triangle" style="width:20px;height:20px;color:#ef4444;flex-shrink:0;margin-top:2px;"></i>
222
  <div>
223
+ <h3 id="confirm-modal-title" style="color:#fff;font-size:14px;font-weight:500;margin:0 0 4px;">Delete</h3>
224
+ <p id="confirm-modal-msg" style="color:#a1a1aa;font-size:11px;margin:0;line-height:1.5;"></p>
225
  </div>
226
  </div>
227
+ <div style="padding:10px 20px;background:#050505;display:flex;justify-content:flex-end;gap:8px;">
228
+ <button onclick="closeModal()" style="padding:6px 16px;font-size:11px;color:#71717a;background:none;border:none;cursor:pointer;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#71717a'">Cancel</button>
229
+ <button id="confirm-modal-submit" style="padding:6px 16px;background:#dc2626;color:#fff;font-size:11px;font-weight:700;border-radius:6px;border:none;cursor:pointer;transition:background 0.15s;" onmouseover="this.style.background='#ef4444'" onmouseout="this.style.background='#dc2626'">Delete</button>
230
  </div>
231
  </div>
232
 
233
  <!-- Editor Modal -->
234
+ <div id="editor-modal" class="hidden modal-enter" style="background:#0a0a0a;border:1px solid #222;border-radius:16px 16px 0 0;width:100%;max-width:800px;height:85vh;overflow:hidden;display:flex;flex-direction:column;">
235
+ <div style="width:40px;height:4px;background:#333;border-radius:4px;margin:12px auto 4px;flex-shrink:0;"></div>
236
+ <div style="padding:10px 16px;border-bottom:1px solid #1a1a1a;background:#050505;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
237
+ <span id="editor-modal-title" style="font-size:11px;font-family:'JetBrains Mono',monospace;color:#4ade80;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%;">editing...</span>
238
+ <div style="display:flex;gap:8px;flex-shrink:0;">
239
+ <button onclick="closeModal()" style="padding:5px 12px;font-size:11px;color:#71717a;background:none;border:none;cursor:pointer;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#71717a'">Discard</button>
240
+ <button id="editor-modal-submit" style="padding:5px 12px;background:#16a34a;color:#000;font-size:11px;font-weight:700;border-radius:6px;border:none;cursor:pointer;display:flex;align-items:center;gap:4px;" onmouseover="this.style.background='#22c55e'" onmouseout="this.style.background='#16a34a'">
241
+ <i data-lucide="save" style="width:12px;height:12px;"></i> Save
242
  </button>
243
  </div>
244
  </div>
245
+ <textarea id="editor-modal-content" style="flex:1;background:transparent;padding:12px;color:#d4d4d8;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;border:none;outline:none;line-height:1.6;" spellcheck="false"></textarea>
246
  </div>
247
  </div>
248
 
249
+ <!-- Toasts -->
250
+ <div id="toast-container" style="position:fixed;bottom:80px;right:16px;z-index:200;display:flex;flex-direction:column;gap:8px;pointer-events:none;" class="sm:bottom-5"></div>
251
 
252
  <script>
253
  lucide.createIcons();
254
 
255
+ // Toast
256
  function showToast(msg, type = 'info') {
257
+ const c = document.getElementById('toast-container');
258
+ const t = document.createElement('div');
259
+ const col = type==='error'?'#ef4444':type==='success'?'#22c55e':'#60a5fa';
260
+ t.style.cssText = `display:flex;align-items:center;gap:8px;background:#0a0a0a;border:1px solid ${col}33;padding:10px 14px;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,0.6);transform:translateY(12px);opacity:0;transition:all 0.3s;pointer-events:auto;max-width:280px;`;
261
+ t.innerHTML = `<div style="width:6px;height:6px;border-radius:50%;background:${col};flex-shrink:0;"></div><span style="font-size:11px;font-family:'JetBrains Mono',monospace;color:#e4e4e7;">${msg}</span>`;
262
+ c.appendChild(t);
263
+ requestAnimationFrame(()=>{t.style.transform='translateY(0)';t.style.opacity='1';});
264
+ setTimeout(()=>{t.style.transform='translateY(12px)';t.style.opacity='0';setTimeout(()=>t.remove(),300);},3000);
 
 
 
265
  }
266
 
267
+ // Navigation
268
  function switchTab(tab) {
269
+ document.querySelectorAll('[id^="tab-"]').forEach(el=>el.classList.add('hidden-tab'));
270
+ document.querySelectorAll('.nav-btn').forEach(el=>el.classList.remove('active'));
271
+ document.querySelectorAll('.bnav-btn').forEach(el=>el.classList.remove('active'));
272
+ document.getElementById('tab-'+tab).classList.remove('hidden-tab');
273
+ const d=document.getElementById('nav-'+tab); if(d) d.classList.add('active');
274
+ const m=document.getElementById('mnav-'+tab); if(m) m.classList.add('active');
275
+ if(tab==='files'&&!currentPathLoaded){loadFiles('');currentPathLoaded=true;}
276
+ if(tab==='config') loadConfig();
277
+ if(tab==='console') scrollToBottom();
278
  }
279
 
280
+ // Modals
281
  const overlay = document.getElementById('modal-overlay');
 
 
282
  function openModalElement(id) {
283
+ ['input-modal','confirm-modal','editor-modal'].forEach(m=>{
284
+ const el=document.getElementById(m);
285
+ el.classList.add('hidden');
286
+ el.style.display='none';
287
+ });
288
  overlay.classList.remove('hidden');
289
+ overlay.style.display='flex';
290
+ const el=document.getElementById(id);
291
+ el.classList.remove('hidden');
292
+ el.style.display='flex';
293
+ requestAnimationFrame(()=>overlay.style.opacity='1');
294
  }
 
295
  function closeModal() {
296
+ overlay.style.opacity='0';
297
+ setTimeout(()=>{overlay.classList.add('hidden');overlay.style.display='';},200);
 
298
  }
299
+ overlay.addEventListener('click', e=>{if(e.target===overlay)closeModal();});
300
 
301
+ function showPrompt(title,desc,def,onConfirm) {
302
+ document.getElementById('input-modal-title').innerText=title;
303
+ document.getElementById('input-modal-desc').innerText=desc;
304
+ const inp=document.getElementById('input-modal-field');
305
+ inp.value=def;
306
  openModalElement('input-modal');
307
+ setTimeout(()=>inp.focus(),150);
308
+ const btn=document.getElementById('input-modal-submit');
309
+ btn.onclick=()=>{onConfirm(inp.value);closeModal();};
310
+ inp.onkeydown=e=>{if(e.key==='Enter')btn.click();};
 
311
  }
312
+ function showConfirm(title,msg,onConfirm) {
313
+ document.getElementById('confirm-modal-title').innerText=title;
314
+ document.getElementById('confirm-modal-msg').innerText=msg;
 
315
  openModalElement('confirm-modal');
316
+ document.getElementById('confirm-modal-submit').onclick=()=>{onConfirm();closeModal();};
317
  }
318
 
319
+ // Terminal
320
+ const termOut=document.getElementById('terminal-output');
321
+ const cmdInput=document.getElementById('cmd-input');
 
322
  function parseANSI(str) {
323
+ str=str.replace(/</g,'&lt;').replace(/>/g,'&gt;');
324
+ let res='',styles=[];
325
+ const chunks=str.split(/\\x1b\\[/);
326
+ res+=chunks[0];
327
+ for(let i=1;i<chunks.length;i++){
328
+ const m=chunks[i].match(/^([0-9;]*)m(.*)/s);
329
+ if(m){
330
+ const codes=m[1].split(';');
331
+ for(let c of codes){
332
+ if(c===''||c==='0')styles=[];
333
+ else if(c==='1')styles.push('font-weight:bold');
334
+ else if(c==='31'||c==='91')styles.push('color:#ef4444');
335
+ else if(c==='32'||c==='92')styles.push('color:#22c55e');
336
+ else if(c==='33'||c==='93')styles.push('color:#eab308');
337
+ else if(c==='34'||c==='94')styles.push('color:#3b82f6');
338
+ else if(c==='35'||c==='95')styles.push('color:#d946ef');
339
+ else if(c==='36'||c==='96')styles.push('color:#06b6d4');
340
+ else if(c==='37'||c==='97')styles.push('color:#fafafa');
341
+ else if(c==='90')styles.push('color:#71717a');
 
342
  }
343
+ const s=styles.length?`style="${styles.join(';')}"`:'' ;
344
+ res+=styles.length?`<span ${s}>${m[2]}</span>`:m[2];
345
+ } else { res+='\\x1b['+chunks[i]; }
 
 
346
  }
347
+ return res||'&nbsp;';
 
 
 
 
348
  }
349
+ function scrollToBottom(){termOut.scrollTop=termOut.scrollHeight;}
350
+ function appendLog(text){
351
+ const atBottom=termOut.scrollHeight-termOut.clientHeight<=termOut.scrollTop+10;
352
+ const d=document.createElement('div');
353
+ d.className='log-line';
354
+ d.innerHTML=parseANSI(text);
355
+ termOut.appendChild(d);
356
+ if(termOut.childElementCount>400)termOut.removeChild(termOut.firstChild);
357
+ if(atBottom)scrollToBottom();
 
 
358
  }
359
+ const wsUrl=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws';
 
360
  let ws;
361
+ function connectWS(){
362
+ ws=new WebSocket(wsUrl);
363
+ ws.onopen=()=>appendLog('\\x1b[32m\\x1b[1m[Panel]\\x1b[0m Stream connected.');
364
+ ws.onmessage=e=>appendLog(e.data);
365
+ ws.onclose=()=>{appendLog('\\x1b[31m\\x1b[1m[Panel]\\x1b[0m Connection lost. Reconnecting...');setTimeout(connectWS,3000);};
366
  }
367
  connectWS();
368
+ cmdInput.addEventListener('keypress',e=>{
369
+ if(e.key==='Enter'){
370
+ const v=cmdInput.value.trim();
371
+ if(v&&ws&&ws.readyState===WebSocket.OPEN){
372
+ appendLog(`\\x1b[90m> ${v}\\x1b[0m`);
373
+ ws.send(v);cmdInput.value='';
 
 
374
  }
375
  }
376
  });
377
 
378
+ // Files
379
+ let currentPath='',currentPathLoaded=false,menuTarget=null;
380
+ document.addEventListener('click',()=>document.getElementById('context-menu').classList.add('hidden'));
381
+ function openMenu(e,name,isDir){
 
 
 
 
 
 
382
  e.stopPropagation();
383
+ menuTarget={name,isDir,path:(currentPath?currentPath+'/'+name:name)};
384
+ const menu=document.getElementById('context-menu');
385
+ const btnStyle=`style="width:100%;text-align:left;padding:10px 16px;font-size:11px;background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:8px;color:#d4d4d8;transition:all 0.15s;"`;
386
+ let html='';
387
+ if(!isDir) html+=`<button ${btnStyle} onclick="editFile('${menuTarget.path}')" onmouseover="this.style.color='#22c55e';this.style.background='#1a1a1a'" onmouseout="this.style.color='#d4d4d8';this.style.background='none'"><i data-lucide="edit-3" style="width:14px;height:14px;"></i> Edit</button>`;
388
+ html+=`<button ${btnStyle} onclick="initRename()" onmouseover="this.style.color='#22c55e';this.style.background='#1a1a1a'" onmouseout="this.style.color='#d4d4d8';this.style.background='none'"><i data-lucide="type" style="width:14px;height:14px;"></i> Rename</button>
389
+ <button ${btnStyle} onclick="initMove()" onmouseover="this.style.color='#22c55e';this.style.background='#1a1a1a'" onmouseout="this.style.color='#d4d4d8';this.style.background='none'"><i data-lucide="move" style="width:14px;height:14px;"></i> Move</button>`;
390
+ if(!isDir) html+=`<button ${btnStyle} onclick="window.open('/api/fs/download?path='+encodeURIComponent('${menuTarget.path}'))" onmouseover="this.style.color='#22c55e';this.style.background='#1a1a1a'" onmouseout="this.style.color='#d4d4d8';this.style.background='none'"><i data-lucide="download" style="width:14px;height:14px;"></i> Download</button>`;
391
+ html+=`<div style="height:1px;background:#1a1a1a;margin:4px 0;"></div>
392
+ <button style="width:100%;text-align:left;padding:10px 16px;font-size:11px;background:none;border:none;cursor:pointer;display:flex;align-items:center;gap:8px;color:#ef4444;transition:all 0.15s;" onclick="initDelete()" onmouseover="this.style.background='rgba(239,68,68,0.1)'" onmouseout="this.style.background='none'"><i data-lucide="trash-2" style="width:14px;height:14px;"></i> Delete</button>`;
393
+ menu.innerHTML=html;
 
 
 
 
394
  lucide.createIcons();
395
+ const mw=148,mh=180;
396
+ menu.style.left=Math.min(e.pageX,window.innerWidth-mw-8)+'px';
397
+ menu.style.top=Math.min(e.pageY,window.innerHeight-mh-8)+'px';
398
  menu.classList.remove('hidden');
399
  }
400
+ async function loadFiles(path){
401
+ currentPath=path;renderBreadcrumbs(path);
402
+ const list=document.getElementById('file-list');
403
+ list.innerHTML=`<div style="display:flex;justify-content:center;padding:40px;"><i data-lucide="loader-2" style="width:24px;height:24px;color:#22c55e;" class="loader"></i></div>`;
 
 
404
  lucide.createIcons();
 
405
  try {
406
+ const res=await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
407
+ const files=await res.json();
408
+ list.innerHTML='';
409
+ if(path!==''){
410
+ const parent=path.split('/').slice(0,-1).join('/');
411
+ const row=document.createElement('div');
412
+ row.className='file-row';
413
+ row.style.cssText='display:flex;align-items:center;padding:10px 12px;cursor:pointer;gap:10px;';
414
+ row.onclick=()=>loadFiles(parent);
415
+ row.onmouseover=()=>row.style.background='#111';
416
+ row.onmouseout=()=>row.style.background='';
417
+ row.innerHTML=`<i data-lucide="corner-left-up" style="width:16px;height:16px;color:#3f3f46;flex-shrink:0;"></i><span style="font-size:11px;font-family:'JetBrains Mono',monospace;color:#52525b;">..</span>`;
418
+ list.appendChild(row);
419
  }
420
+ if(files.length===0&&path==='') list.innerHTML+=`<div style="text-align:center;padding:40px;color:#3f3f46;font-size:11px;font-family:'JetBrains Mono',monospace;">Directory empty</div>`;
421
+ files.forEach(f=>{
422
+ const icon=f.is_dir?'<i data-lucide="folder" style="width:16px;height:16px;color:#22c55e;flex-shrink:0;"></i>':'<i data-lucide="file" style="width:16px;height:16px;color:#52525b;flex-shrink:0;"></i>';
423
+ const sz=f.is_dir?'--':(f.size>1048576?(f.size/1048576).toFixed(1)+' MB':(f.size/1024).toFixed(1)+' KB');
424
+ const row=document.createElement('div');
425
+ row.className='file-row';
426
+ row.style.cssText='display:flex;align-items:center;padding:8px 12px;cursor:pointer;gap:8px;';
427
+ if(f.is_dir){row.onclick=()=>loadFiles(currentPath?currentPath+'/'+f.name:f.name);}
428
+ row.onmouseover=()=>row.style.background='#111';
429
+ row.onmouseout=()=>row.style.background='';
430
+ row.innerHTML=`
431
+ ${icon}
432
+ <span style="font-size:12px;font-family:'JetBrains Mono',monospace;color:#d4d4d8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f.name}</span>
433
+ <span style="font-size:10px;color:#52525b;font-family:'JetBrains Mono',monospace;flex-shrink:0;display:none;" class="sm:inline-block">${sz}</span>
434
+ <button onclick="openMenu(event,'${f.name}',${f.is_dir})" style="padding:4px;border-radius:4px;background:none;border:none;cursor:pointer;color:#52525b;flex-shrink:0;transition:all 0.15s;" onmouseover="this.style.color='#fff';this.style.background='#222'" onmouseout="this.style.color='#52525b';this.style.background='none'">
435
+ <i data-lucide="more-horizontal" style="width:16px;height:16px;"></i>
436
+ </button>`;
437
+ list.appendChild(row);
 
 
 
438
  });
439
  lucide.createIcons();
440
+ } catch {showToast('Error loading files','error');}
441
  }
442
+ function renderBreadcrumbs(path){
443
+ const parts=path.split('/').filter(p=>p);
444
+ let html=`<button onclick="loadFiles('')" style="background:none;border:none;cursor:pointer;color:#52525b;padding:2px;transition:color 0.15s;" onmouseover="this.style.color='#22c55e'" onmouseout="this.style.color='#52525b'"><i data-lucide="home" style="width:14px;height:14px;display:block;"></i></button>`;
445
+ let build='';
446
+ parts.forEach((p,i)=>{
447
+ build+=(build?'/':'')+p;
448
+ html+=`<span style="opacity:0.3;margin:0 2px;">/</span>`;
449
+ if(i===parts.length-1) html+=`<span style="color:#22c55e;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p}</span>`;
450
+ else html+=`<button onclick="loadFiles('${build}')" style="background:none;border:none;cursor:pointer;color:#52525b;transition:color 0.15s;display:none;" class="sm:inline" onmouseover="this.style.color='#22c55e'" onmouseout="this.style.color='#52525b'">${p}</button>`;
 
451
  });
452
+ document.getElementById('breadcrumbs').innerHTML=html;
453
  lucide.createIcons();
454
  }
455
+ function showCreateModal(type){
456
+ showPrompt(`New ${type==='folder'?'Folder':'File'}`,'Enter name:','',async(val)=>{
457
+ if(!val)return;
458
+ const path=currentPath?`${currentPath}/${val}`:val;
459
+ const ep=type==='folder'?'/api/fs/create_dir':'/api/fs/create_file';
460
+ const fd=new FormData();fd.append('path',path);
461
+ try{const r=await fetch(ep,{method:'POST',body:fd});if(r.ok){showToast('Created','success');loadFiles(currentPath);}else showToast('Failed','error');}
462
+ catch{showToast('Network error','error');}
 
 
 
 
 
463
  });
464
  }
465
+ function initRename(){
466
+ const t=menuTarget;
467
+ showPrompt('Rename',`New name for ${t.name}:`,t.name,async(val)=>{
468
+ if(!val||val===t.name)return;
469
+ const fd=new FormData();fd.append('old_path',t.path);fd.append('new_name',val);
470
+ try{const r=await fetch('/api/fs/rename',{method:'POST',body:fd});if(r.ok){showToast('Renamed','success');loadFiles(currentPath);}else showToast('Failed','error');}
471
+ catch{showToast('Network error','error');}
 
 
 
 
472
  });
473
  }
474
+ function initMove(){
475
+ const t=menuTarget;
476
+ showPrompt('Move',`Destination for "${t.name}" (blank = root):`, '',async(val)=>{
477
+ const dest=(val.trim()?`${val.trim()}/${t.name}`:t.name);
478
+ if(dest===t.path)return;
479
+ const fd=new FormData();fd.append('source',t.path);fd.append('dest',dest);
480
+ try{const r=await fetch('/api/fs/move',{method:'POST',body:fd});if(r.ok){showToast('Moved','success');loadFiles(currentPath);}else showToast('Failed','error');}
481
+ catch{showToast('Network error','error');}
 
 
 
 
 
482
  });
483
  }
484
+ function initDelete(){
485
+ const t=menuTarget;
486
+ showConfirm('Delete Permanently',`Delete "${t.name}"? Cannot be undone.`,async()=>{
487
+ const fd=new FormData();fd.append('path',t.path);
488
+ try{const r=await fetch('/api/fs/delete',{method:'POST',body:fd});if(r.ok){showToast('Deleted','success');loadFiles(currentPath);}else showToast('Failed','error');}
489
+ catch{showToast('Network error','error');}
 
 
 
 
490
  });
491
  }
492
+ async function uploadFile(e){
493
+ const file=e.target.files[0];if(!file)return;
 
 
494
  showToast(`Uploading ${file.name}...`);
495
+ const fd=new FormData();fd.append('path',currentPath);fd.append('file',file);
496
+ try{const r=await fetch('/api/fs/upload',{method:'POST',body:fd});if(r.ok){showToast('Upload complete','success');loadFiles(currentPath);}else showToast('Failed','error');}
497
+ catch{showToast('Network error','error');}
498
+ e.target.value='';
 
 
 
499
  }
500
+ let currentEditPath='';
501
+ async function editFile(path){
502
+ try{
503
+ const r=await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
504
+ if(!r.ok)throw new Error();
505
+ const text=await r.text();
506
+ currentEditPath=path;
507
+ document.getElementById('editor-modal-title').innerText=path;
508
+ document.getElementById('editor-modal-content').value=text;
 
 
509
  openModalElement('editor-modal');
510
+ document.getElementById('editor-modal-submit').onclick=async()=>{
511
+ const fd=new FormData();fd.append('path',currentEditPath);fd.append('content',document.getElementById('editor-modal-content').value);
512
+ const res=await fetch('/api/fs/write',{method:'POST',body:fd});
513
+ if(res.ok){showToast('Saved','success');closeModal();}else showToast('Save failed','error');
 
 
 
514
  };
515
+ }catch{showToast('Cannot open file (binary?)','error');}
516
  }
517
+ async function loadConfig(){
518
+ try{
519
+ const r=await fetch('/api/fs/read?path=server.properties');
520
+ document.getElementById('config-editor').value=r.ok?await r.text():'# server.properties not found yet.';
521
+ }catch{showToast('Failed to load config','error');}
 
 
 
 
 
 
522
  }
523
+ async function saveConfig(){
524
+ const fd=new FormData();fd.append('path','server.properties');fd.append('content',document.getElementById('config-editor').value);
525
+ try{const r=await fetch('/api/fs/write',{method:'POST',body:fd});if(r.ok)showToast('Config applied','success');else showToast('Failed','error');}
526
+ catch{showToast('Network error','error');}
 
 
 
 
 
 
527
  }
 
528
  </script>
529
  </body>
530
  </html>