OrbitMC commited on
Commit
e27e38e
·
verified ·
1 Parent(s): 2c951e9

Update panel.py

Browse files
Files changed (1) hide show
  1. panel.py +391 -294
panel.py CHANGED
@@ -1,382 +1,479 @@
1
- import os, asyncio, collections, shutil, urllib.request, json
2
  from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException
3
- from fastapi.responses import HTMLResponse, Response, FileResponse
4
  from fastapi.middleware.cors import CORSMiddleware
5
  import uvicorn
6
 
 
7
  app = FastAPI()
8
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
9
-
10
  BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app"))
 
11
  mc_process = None
12
  output_history = collections.deque(maxlen=300)
13
  connected_clients = set()
14
 
 
15
  HTML_CONTENT = """
16
- <!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
17
- <title>Server Engine</title><script src="https://cdn.tailwindcss.com"></script><script src="https://unpkg.com/lucide@latest"></script>
18
  <style>
19
- :root{--bg:#000;--panel:#0a0a0a;--border:#1a1a1a;--accent:#22c55e;}
20
- body{background:var(--bg);color:#a1a1aa;font-family:ui-sans-serif,system-ui,sans-serif;margin:0;height:100vh;display:flex;flex-direction:column;overflow:hidden;}
21
- ::-webkit-scrollbar{width:4px;height:4px;} ::-webkit-scrollbar-thumb{background:#27272a;border-radius:4px;} ::-webkit-scrollbar-thumb:hover{background:var(--accent);}
22
- .tab-content{display:none;position:absolute;inset:0;padding:8px;} .tab-content.active{display:flex;flex-direction:column;}
23
- .nav-btn{color:#52525b;transition:.2s;} .nav-btn:hover,.nav-btn.active{color:var(--accent);}
24
- .log-line{word-break:break-all;padding:0.5px 0;font-family:monospace;font-size:11px;}
25
- .modal{display:none;position:fixed;inset:0;background:#000a;backdrop-filter:blur(4px);z-index:50;align-items:center;justify-content:center;} .modal.active{display:flex;}
26
- input:focus,textarea:focus{outline:none;border-color:var(--accent);}
 
 
27
  </style></head>
28
  <body>
 
29
  <div class="flex flex-1 overflow-hidden">
30
- <!-- Sidebar -->
31
- <aside class="w-12 bg-[#050505] border-r border-[#1a1a1a] flex flex-col items-center py-6 gap-8 z-40 shrink-0 hidden sm:flex">
32
- <div class="text-green-500 drop-shadow-[0_0_8px_rgba(34,197,94,0.4)]"><i data-lucide="server" class="w-5 h-5"></i></div>
33
- <nav class="flex flex-col gap-6 items-center">
34
- <button onclick="switchTab('console')" id="nav-console" class="nav-btn active"><i data-lucide="terminal-square" class="w-5 h-5"></i></button>
35
- <button onclick="switchTab('files')" id="nav-files" class="nav-btn"><i data-lucide="folder-tree" class="w-5 h-5"></i></button>
36
- <button onclick="switchTab('config')" id="nav-config" class="nav-btn"><i data-lucide="settings-2" class="w-5 h-5"></i></button>
37
- <button onclick="switchTab('plugins')" id="nav-plugins" class="nav-btn"><i data-lucide="puzzle" class="w-5 h-5"></i></button>
38
  </nav>
39
  </aside>
40
 
41
- <main class="flex-1 relative bg-black overflow-hidden sm:p-2">
 
42
  <!-- CONSOLE -->
43
- <div id="tab-console" class="tab-content active">
44
- <div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
45
- <div class="h-9 border-b border-border bg-[#050505] flex items-center px-3 gap-2 shrink-0">
46
- <div class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]"></div><span class="text-[10px] font-mono text-zinc-500">engine-live-stream</span>
47
  </div>
48
- <div id="terminal-output" class="flex-1 p-2 overflow-y-auto text-zinc-300"></div>
49
- <div class="h-11 border-t border-border bg-[#050505] flex items-center px-3 gap-2 shrink-0">
50
- <i data-lucide="chevron-right" class="w-3.5 h-3.5 text-green-500 shrink-0"></i>
51
- <input type="text" id="cmd-input" class="flex-1 bg-transparent border-none text-green-400 font-mono text-xs" placeholder="Execute command..." autocomplete="off">
52
  </div>
53
  </div>
54
  </div>
55
 
56
  <!-- FILES -->
57
- <div id="tab-files" class="tab-content">
58
- <div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
59
- <div class="bg-[#050505] border-b border-border p-2 flex justify-between items-center gap-2 shrink-0">
60
- <div id="breadcrumbs" class="flex items-center gap-1 text-[11px] font-mono text-zinc-500 overflow-x-auto"></div>
61
- <div class="flex items-center gap-1">
62
- <input type="file" id="file-upload" class="hidden" onchange="uploadFile(event)">
63
- <button onclick="document.getElementById('file-upload').click()" class="p-1.5 rounded-md text-zinc-500 hover:text-green-500"><i data-lucide="upload" class="w-4 h-4"></i></button>
64
- <button onclick="loadFiles(currentPath)" class="p-1.5 rounded-md text-zinc-500 hover:text-white"><i data-lucide="rotate-cw" class="w-4 h-4"></i></button>
65
- </div>
66
  </div>
67
  <div id="file-list" class="flex-1 overflow-y-auto"></div>
68
  </div>
69
  </div>
70
 
71
- <!-- CONFIG -->
72
- <div id="tab-config" class="tab-content">
73
- <div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
74
- <div class="h-10 border-b border-border bg-[#050505] flex items-center justify-between px-3 shrink-0">
75
- <span class="text-xs font-mono text-zinc-300 flex items-center gap-2"><i data-lucide="sliders" class="w-3.5 h-3.5 text-green-500"></i> server.properties</span>
76
- <button onclick="saveConfig()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 rounded text-[11px] font-bold flex items-center gap-1"><i data-lucide="save" class="w-3 h-3"></i> Apply</button>
77
- </div>
78
- <textarea id="config-editor" class="flex-1 bg-transparent p-3 text-zinc-300 font-mono text-[11px] resize-none border-none" spellcheck="false"></textarea>
79
- </div>
80
- </div>
81
-
82
- <!-- PLUGINS -->
83
- <div id="tab-plugins" class="tab-content">
84
- <div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
85
- <div class="bg-[#050505] border-b border-border p-2 flex items-center justify-between shrink-0 gap-2">
86
- <div class="flex items-center gap-2">
87
- <span class="text-[10px] text-zinc-500 font-mono">MC Ver:</span>
88
- <input type="text" id="mc-version" value="1.20.4" class="bg-[#111] border border-[#222] text-zinc-300 text-[11px] px-2 py-1 rounded w-16 text-center font-mono">
 
 
89
  </div>
90
- <div class="flex bg-[#111] rounded p-0.5">
91
- <button id="pbtn-installed" onclick="switchPluginView('installed')" class="px-3 py-1 text-[10px] font-bold rounded bg-[#222] text-white">Installed</button>
92
- <button id="pbtn-browser" onclick="switchPluginView('browser')" class="px-3 py-1 text-[10px] font-bold rounded text-zinc-500 hover:text-white">Browser</button>
 
 
 
 
93
  </div>
94
- <div class="flex items-center gap-2" id="plugin-search-container" style="display:none;">
95
- <input type="text" id="plugin-search" class="bg-[#111] border border-[#222] text-zinc-300 text-[11px] px-2 py-1 rounded w-32 font-mono" placeholder="Search..." onkeydown="if(event.key==='Enter') searchPlugins()">
96
- <button onclick="searchPlugins()" class="text-green-500 hover:text-green-400"><i data-lucide="search" class="w-4 h-4"></i></button>
 
 
 
 
97
  </div>
98
  </div>
99
- <div id="plugin-list" class="flex-1 overflow-y-auto p-2 grid grid-cols-1 sm:grid-cols-2 gap-2 content-start"></div>
100
  </div>
101
  </div>
102
  </main>
103
-
104
- <!-- Mobile Nav -->
105
- <nav class="flex sm:hidden bg-[#050505] border-t border-border shrink-0 pb-[env(safe-area-inset-bottom,0)]">
106
- <button onclick="switchTab('console')" id="mnav-console" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn active"><i data-lucide="terminal-square" class="w-5 h-5"></i> Console</button>
107
- <button onclick="switchTab('files')" id="mnav-files" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="folder-tree" class="w-5 h-5"></i> Files</button>
108
- <button onclick="switchTab('config')" id="mnav-config" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="settings-2" class="w-5 h-5"></i> Config</button>
109
- <button onclick="switchTab('plugins')" id="mnav-plugins" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="puzzle" class="w-5 h-5"></i> Plugins</button>
110
- </nav>
111
  </div>
112
 
113
- <!-- Modal -->
114
- <div id="editor-modal" class="modal">
115
- <div class="bg-panel border border-[#222] rounded-t-xl sm:rounded-xl w-full max-w-3xl h-[85vh] flex flex-col self-end sm:self-center">
116
- <div class="p-2 border-b border-border bg-[#050505] flex justify-between items-center shrink-0">
117
- <span id="editor-title" class="text-[11px] font-mono text-green-400 truncate w-2/3"></span>
118
- <div class="flex gap-2">
119
- <button onclick="document.getElementById('editor-modal').classList.remove('active')" class="px-3 py-1 text-[11px] text-zinc-500 hover:text-white">Discard</button>
120
- <button onclick="saveFile()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 text-[11px] font-bold rounded flex items-center gap-1"><i data-lucide="save" class="w-3 h-3"></i> Save</button>
121
- </div>
122
- </div>
123
- <textarea id="editor-content" class="flex-1 bg-transparent p-3 text-zinc-300 font-mono text-[11px] resize-none border-none" spellcheck="false"></textarea>
124
- </div>
125
- </div>
126
 
127
- <div id="toast-container" class="fixed bottom-16 sm:bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div>
 
128
 
129
  <script>
 
 
 
 
 
 
 
 
 
130
  lucide.createIcons();
131
- const showToast = (msg, type='info') => {
132
- const c = document.getElementById('toast-container'), t = document.createElement('div');
133
- const col = type==='error'?'#ef4444':type==='success'?'#22c55e':'#60a5fa';
134
- t.className = `flex items-center gap-2 bg-[#0a0a0a] border border-[${col}33] p-2.5 rounded-lg shadow-xl text-[11px] font-mono text-zinc-200 transition-all duration-300 translate-y-4 opacity-0`;
135
- t.innerHTML = `<div class="w-1.5 h-1.5 rounded-full" style="background:${col}"></div>${msg}`;
136
- c.appendChild(t); requestAnimationFrame(() => { t.classList.remove('translate-y-4','opacity-0'); });
137
- setTimeout(() => { t.classList.add('opacity-0'); setTimeout(()=>t.remove(),300); }, 3000);
138
- };
139
-
140
- // Navigation
141
- function switchTab(t) {
142
- document.querySelectorAll('.tab-content').forEach(el=>el.classList.remove('active'));
143
- document.querySelectorAll('.nav-btn').forEach(el=>el.classList.remove('active'));
144
- document.getElementById('tab-'+t).classList.add('active');
145
- if(document.getElementById('nav-'+t)) document.getElementById('nav-'+t).classList.add('active');
146
- if(document.getElementById('mnav-'+t)) document.getElementById('mnav-'+t).classList.add('active');
147
- if(t==='files'&&!currentPathLoaded){loadFiles(''); currentPathLoaded=true;}
148
- if(t==='config') loadConfig();
149
- if(t==='plugins'&&Object.keys(pluginsJson).length===0) initPlugins();
150
- if(t==='console') termOut.scrollTop=termOut.scrollHeight;
151
- }
152
 
153
- // Console
154
- const termOut = document.getElementById('terminal-output'), cmdInput = document.getElementById('cmd-input');
155
- function appendLog(txt) {
156
- const d = document.createElement('div'); d.className='log-line';
157
- d.innerHTML = txt.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\x1b\\[([0-9;]*)m(.*?)(?=\\x1b|$)/gs, (m,g1,g2) => {
158
- let s=''; if(g1.includes('31'))s='color:#ef4444'; else if(g1.includes('32'))s='color:#22c55e'; else if(g1.includes('33'))s='color:#eab308'; else if(g1.includes('36'))s='color:#06b6d4';
159
- return `<span style="${s}">${g2}</span>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  });
161
- const bot = termOut.scrollHeight - termOut.clientHeight <= termOut.scrollTop + 10;
162
- termOut.appendChild(d); if(termOut.childElementCount>300) termOut.removeChild(termOut.firstChild);
163
- if(bot) termOut.scrollTop = termOut.scrollHeight;
164
- }
165
- const ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
166
- ws.onmessage = e => appendLog(e.data);
167
- cmdInput.onkeypress = e => { if(e.key==='Enter'&&cmdInput.value.trim()){ ws.send(cmdInput.value.trim()); cmdInput.value=''; } };
168
-
169
- // Files
170
- let currentPath = '', currentPathLoaded = false, currentEditPath = '';
171
- async function loadFiles(p) {
172
- currentPath = p; document.getElementById('breadcrumbs').innerHTML = `<button onclick="loadFiles('')" class="hover:text-green-500"><i data-lucide="home" class="w-3.5 h-3.5"></i></button> ` + p.split('/').filter(x=>x).map((x,i,a)=>`<span class="opacity-30 mx-1">/</span><span class="${i===a.length-1?'text-green-500':''}">${x}</span>`).join('');
173
- lucide.createIcons(); document.getElementById('file-list').innerHTML='<div class="p-4 text-center text-xs text-zinc-500 font-mono">Loading...</div>';
174
- try {
175
- const res = await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`); const files = await res.json();
176
- let html = p?`<div class="flex items-center p-2 border-b border-[#1a1a1a] cursor-pointer hover:bg-[#111]" onclick="loadFiles('${p.split('/').slice(0,-1).join('/')}')"><i data-lucide="corner-left-up" class="w-4 h-4 text-zinc-500 mr-2"></i><span class="text-[11px] font-mono text-zinc-500">..</span></div>`:'';
177
- if(!files.length&&!p) html+='<div class="p-4 text-center text-xs text-zinc-500 font-mono">Empty Directory</div>';
178
- files.forEach(f => {
179
- const ic = f.is_dir ? '<i data-lucide="folder" class="w-4 h-4 text-green-500 shrink-0"></i>' : '<i data-lucide="file" class="w-4 h-4 text-zinc-500 shrink-0"></i>';
180
- const pth = p?`${p}/${f.name}`:f.name;
181
- html += `<div class="flex items-center p-2 border-b border-[#1a1a1a] cursor-pointer hover:bg-[#111] gap-2" onclick="${f.is_dir?`loadFiles('${pth}')`:`editFile('${pth}')`}">${ic}<span class="text-[11px] font-mono text-zinc-300 flex-1 truncate">${f.name}</span>
182
- <button onclick="event.stopPropagation(); deleteFile('${pth}')" class="p-1 hover:text-red-500 text-zinc-600"><i data-lucide="trash" class="w-3.5 h-3.5"></i></button></div>`;
183
- });
184
- document.getElementById('file-list').innerHTML = html; lucide.createIcons();
185
- } catch { showToast('Load failed', 'error'); }
186
- }
187
- async function deleteFile(p) {
188
- if(!confirm(`Delete ${p}?`)) return;
189
- const fd=new FormData(); fd.append('path',p);
190
- if((await fetch('/api/fs/delete',{method:'POST',body:fd})).ok) { showToast('Deleted','success'); loadFiles(currentPath); }
191
- }
192
- async function uploadFile(e) {
193
- if(!e.target.files[0]) return;
194
- const fd=new FormData(); fd.append('path',currentPath); fd.append('file',e.target.files[0]);
195
- showToast('Uploading...'); if((await fetch('/api/fs/upload',{method:'POST',body:fd})).ok) { showToast('Uploaded','success'); loadFiles(currentPath); }
196
- e.target.value='';
197
- }
198
- async function editFile(p) {
199
- try {
200
- const r=await fetch(`/api/fs/read?path=${encodeURIComponent(p)}`); if(!r.ok) throw new Error();
201
- currentEditPath=p; document.getElementById('editor-title').innerText=p; document.getElementById('editor-content').value=await r.text();
202
- document.getElementById('editor-modal').classList.add('active');
203
- } catch { showToast('Cannot read file','error'); }
204
- }
205
- async function saveFile() {
206
- const fd=new FormData(); fd.append('path',currentEditPath); fd.append('content',document.getElementById('editor-content').value);
207
- if((await fetch('/api/fs/write',{method:'POST',body:fd})).ok) { showToast('Saved','success'); document.getElementById('editor-modal').classList.remove('active'); }
208
- }
209
 
210
- // Config
211
- async function loadConfig() { try{ document.getElementById('config-editor').value = await(await fetch('/api/fs/read?path=server.properties')).text(); }catch{} }
212
- async function saveConfig() {
213
- const fd=new FormData(); fd.append('path','server.properties'); fd.append('content',document.getElementById('config-editor').value);
214
- if((await fetch('/api/fs/write',{method:'POST',body:fd})).ok) showToast('Config applied','success');
215
- }
 
 
 
 
 
216
 
217
- // Plugins (Modrinth)
218
- let pluginsJson = {}, currentPView = 'installed';
219
- async function initPlugins() {
220
- try { const r = await fetch('/api/fs/read?path=plugins/plugins.json'); if(r.ok) pluginsJson = JSON.parse(await r.text()); } catch{}
221
- renderInstalledPlugins(); checkUpdates();
222
- }
223
- async function savePluginsState() {
224
- const fd=new FormData(); fd.append('path','plugins/plugins.json'); fd.append('content',JSON.stringify(pluginsJson,null,2));
225
- await fetch('/api/fs/write',{method:'POST',body:fd});
226
- }
227
- function switchPluginView(v) {
228
- currentPView = v; document.getElementById('pbtn-installed').className = `px-3 py-1 text-[10px] font-bold rounded ${v==='installed'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
229
- document.getElementById('pbtn-browser').className = `px-3 py-1 text-[10px] font-bold rounded ${v==='browser'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
230
- document.getElementById('plugin-search-container').style.display = v==='browser'?'flex':'none';
231
- v==='installed' ? renderInstalledPlugins() : document.getElementById('plugin-list').innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">Search above to find plugins.</div>';
232
- }
233
- function renderInstalledPlugins() {
234
- const c = document.getElementById('plugin-list'); c.innerHTML='';
235
- const keys = Object.keys(pluginsJson); if(!keys.length) return c.innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">No plugins installed via engine.</div>';
236
- keys.forEach(k => {
237
- const p = pluginsJson[k];
238
- c.innerHTML += `<div class="bg-[#050505] border border-border rounded p-3 flex flex-col gap-2">
239
- <div class="flex justify-between items-start">
240
- <div><h3 class="text-[12px] font-bold text-white">${p.name||k}</h3><p class="text-[10px] font-mono text-zinc-500 break-all">${p.filename}</p></div>
241
- ${p.has_update ? `<button onclick="installPlugin('${k}', true)" class="bg-blue-600 hover:bg-blue-500 text-white px-2 py-1 rounded text-[10px] font-bold">Update</button>` : `<span class="bg-[#111] px-2 py-0.5 rounded text-[10px] text-zinc-500 border border-[#222]">Up to date</span>`}
 
 
 
 
 
242
  </div>
243
- </div>`;
 
 
 
 
 
 
244
  });
 
 
 
245
  }
246
- async function searchPlugins() {
247
- const q = document.getElementById('plugin-search').value.trim(); const v = document.getElementById('mc-version').value.trim(); if(!q||!v)return;
248
- document.getElementById('plugin-list').innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">Searching Modrinth...</div>';
249
- try {
250
- const res = await fetch(`https://api.modrinth.com/v2/search?query=${q}&facets=[["project_type:plugin"],["versions:${v}"]]`).then(r=>r.json());
251
- const c = document.getElementById('plugin-list'); c.innerHTML='';
252
- if(!res.hits.length) return c.innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">No results found for this version.</div>';
253
- res.hits.forEach(h => {
254
- c.innerHTML += `<div class="bg-[#050505] border border-border rounded p-3 flex flex-col justify-between gap-3">
255
- <div><h3 class="text-[12px] font-bold text-green-400 mb-1">${h.title}</h3><p class="text-[10px] text-zinc-400 leading-snug">${h.description}</p></div>
256
- <button onclick="installPlugin('${h.project_id}')" class="bg-[#1a1a1a] hover:bg-green-600 hover:text-black transition-colors text-white px-3 py-1.5 rounded text-[10px] font-bold w-full">Install Latest</button>
257
- </div>`;
258
- });
259
- } catch { showToast('Search failed','error'); }
260
- }
261
- async function checkUpdates() {
262
- const v = document.getElementById('mc-version').value.trim(); if(!v) return;
263
- for (let pid in pluginsJson) {
264
- try {
265
- const res = await fetch(`https://api.modrinth.com/v2/project/${pid}/version?game_versions=["${v}"]`).then(r=>r.json());
266
- if(res.length && res[0].id !== pluginsJson[pid].version_id) pluginsJson[pid].has_update = true;
267
- else pluginsJson[pid].has_update = false;
268
- } catch{}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  }
270
- if(currentPView==='installed') renderInstalledPlugins();
 
 
 
271
  }
272
- async function installPlugin(pid, isUpdate=false) {
273
- showToast(isUpdate?'Updating...':'Installing...');
274
- const v = document.getElementById('mc-version').value.trim();
275
- try {
276
- const res = await fetch(`https://api.modrinth.com/v2/project/${pid}/version?game_versions=["${v}"]`).then(r=>r.json());
277
- if(!res.length) return showToast('No compatible version found', 'error');
278
- const file = res[0].files.find(f=>f.primary) || res[0].files[0];
279
- const fd = new FormData(); fd.append('url', file.url); fd.append('filename', file.filename);
280
- if((await fetch('/api/plugins/install', {method:'POST', body:fd})).ok) {
281
- const proj = await fetch(`https://api.modrinth.com/v2/project/${pid}`).then(r=>r.json());
282
- pluginsJson[pid] = { project_id: pid, version_id: res[0].id, filename: file.filename, name: proj.title };
283
- await savePluginsState(); showToast('Success', 'success'); if(currentPView==='installed') renderInstalledPlugins();
284
- } else showToast('Download failed', 'error');
285
- } catch { showToast('Network error', 'error'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  }
 
287
  </script></body></html>
288
  """
289
 
290
- def get_safe_path(subpath: str):
291
- p = os.path.abspath(os.path.join(BASE_DIR, (subpath or "").strip("/")))
292
- if not p.startswith(BASE_DIR): raise HTTPException(403, "Access denied")
293
- return p
 
294
 
295
- async def read_stream(stream, prefix=""):
296
  while True:
297
- try:
298
- line = await stream.readline()
299
- if not line: break
300
- await broadcast(prefix + line.decode('utf-8', errors='replace').rstrip('\r\n'))
301
- except: break
302
-
303
- async def broadcast(msg: str):
304
- output_history.append(msg)
305
- for c in list(connected_clients):
306
- try: await c.send_text(msg)
307
- except: connected_clients.remove(c)
308
-
309
- async def start_minecraft():
310
  global mc_process
311
- jar_path = os.path.join(BASE_DIR, "purpur.jar")
312
- if not os.path.exists(jar_path): await broadcast("\x1b[33m[Panel] Missing purpur.jar in app directory.\x1b[0m")
 
 
 
 
313
  mc_process = await asyncio.create_subprocess_exec(
314
- "java", "-Xmx4G", "-Dfile.encoding=UTF-8", "-jar", "purpur.jar", "--nogui",
315
  stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=BASE_DIR
316
  )
317
- asyncio.create_task(read_stream(mc_process.stdout))
318
 
319
  @app.on_event("startup")
320
- async def startup_event():
321
- os.makedirs(BASE_DIR, exist_ok=True)
322
- asyncio.create_task(start_minecraft())
323
 
324
  @app.get("/")
325
- def get_panel(): return HTMLResponse(content=HTML_CONTENT)
326
 
327
  @app.websocket("/ws")
328
- async def websocket_endpoint(ws: WebSocket):
329
- await ws.accept(); connected_clients.add(ws)
 
330
  for l in output_history: await ws.send_text(l)
331
  try:
332
  while True:
333
  cmd = await ws.receive_text()
334
- if mc_process and mc_process.stdin: mc_process.stdin.write((cmd + "\n").encode()); await mc_process.stdin.drain()
335
- except: connected_clients.discard(ws)
 
 
336
 
 
337
  @app.get("/api/fs/list")
338
- def fs_list(path: str = ""):
339
- t = get_safe_path(path)
340
  if not os.path.exists(t): return []
341
- return sorted([{"name": f, "is_dir": os.path.isdir(os.path.join(t, f))} for f in os.listdir(t)], key=lambda x: (not x["is_dir"], x["name"].lower()))
342
-
343
- @app.get("/api/fs/read")
344
- def fs_read(path: str):
345
- try:
346
- with open(get_safe_path(path), 'r', encoding='utf-8') as f: return Response(content=f.read(), media_type="text/plain")
347
- except: raise HTTPException(400, "File is binary or unreadable")
348
-
349
- @app.post("/api/fs/write")
350
- def fs_write(path: str = Form(...), content: str = Form(...)):
351
- t = get_safe_path(path); os.makedirs(os.path.dirname(t), exist_ok=True)
352
- with open(t, 'w', encoding='utf-8') as f: f.write(content)
353
- return {"status": "ok"}
354
 
355
  @app.post("/api/fs/upload")
356
- async def fs_upload(path: str = Form(""), file: UploadFile = File(...)):
357
- d = get_safe_path(path); os.makedirs(d, exist_ok=True)
358
- with open(os.path.join(d, file.filename), "wb") as b: shutil.copyfileobj(file.file, b)
359
- return {"status": "ok"}
 
360
 
361
  @app.post("/api/fs/delete")
362
- def fs_delete(path: str = Form(...)):
363
- t = get_safe_path(path)
364
  if os.path.isdir(t): shutil.rmtree(t)
365
  else: os.remove(t)
366
- return {"status": "ok"}
 
 
 
 
 
 
367
 
 
368
  @app.post("/api/plugins/install")
369
- def install_plugin(url: str = Form(...), filename: str = Form(...)):
370
  try:
371
- pd = get_safe_path("plugins")
372
- os.makedirs(pd, exist_ok=True)
373
- req = urllib.request.Request(url, headers={'User-Agent': 'HF-Minecraft-Panel/1.0'})
374
- with urllib.request.urlopen(req) as response, open(os.path.join(pd, filename), 'wb') as out_file:
375
- shutil.copyfileobj(response, out_file)
376
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  except Exception as e:
378
- raise HTTPException(400, str(e))
379
 
380
  if __name__ == "__main__":
381
- port = int(os.environ.get("PORT", 7860))
382
- uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
 
1
+ import os, asyncio, collections, shutil, urllib.request, json, time
2
  from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException
3
+ from fastapi.responses import HTMLResponse, Response
4
  from fastapi.middleware.cors import CORSMiddleware
5
  import uvicorn
6
 
7
+ # --- CONFIG ---
8
  app = FastAPI()
9
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
 
10
  BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app"))
11
+ PLUGINS_DIR = os.path.join(BASE_DIR, "plugins")
12
  mc_process = None
13
  output_history = collections.deque(maxlen=300)
14
  connected_clients = set()
15
 
16
+ # --- HTML GUI ---
17
  HTML_CONTENT = """
18
+ <!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no"><title>Server Engine</title>
19
+ <script src="https://cdn.tailwindcss.com"></script><script src="https://unpkg.com/lucide@latest"></script>
20
  <style>
21
+ :root{--bg:#050505;--panel:#0a0a0a;--border:#1a1a1a;--accent:#22c55e;--text:#a1a1aa;}
22
+ body{background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,sans-serif;height:100dvh;display:flex;flex-direction:column;overflow:hidden;user-select:none;}
23
+ ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-thumb{background:#27272a;border-radius:2px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}
24
+ .tab-pane{display:none;flex:1;flex-direction:column;overflow:hidden;position:relative;animation:fadeIn 0.2s ease-out} .tab-pane.active{display:flex}
25
+ @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
26
+ .nav-btn{transition:all 0.2s} .nav-btn.active{color:var(--accent)} .nav-btn.active::after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--accent);box-shadow:0 -1px 4px var(--accent)}
27
+ .log-line{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.4;word-break:break-all;padding:1px 0}
28
+ input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px rgba(34,197,94,0.1)}
29
+ .loader{border:2px solid #222;border-top:2px solid var(--accent);border-radius:50%;width:16px;height:16px;animation:spin .6s linear infinite}
30
+ @keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
31
  </style></head>
32
  <body>
33
+ <!-- MAIN LAYOUT -->
34
  <div class="flex flex-1 overflow-hidden">
35
+ <!-- DESKTOP SIDEBAR -->
36
+ <aside class="hidden sm:flex flex-col w-14 bg-black border-r border-[#1a1a1a] items-center py-6 gap-6 z-20">
37
+ <div class="text-green-500 drop-shadow-md"><i data-lucide="cpu" class="w-6 h-6"></i></div>
38
+ <nav class="flex flex-col gap-6 w-full items-center">
39
+ <button onclick="tab('console')" id="d-console" class="nav-btn active p-2 hover:text-white" title="Console"><i data-lucide="terminal-square" class="w-5 h-5"></i></button>
40
+ <button onclick="tab('files')" id="d-files" class="nav-btn p-2 hover:text-white" title="Files"><i data-lucide="folder-tree" class="w-5 h-5"></i></button>
41
+ <button onclick="tab('plugins')" id="d-plugins" class="nav-btn p-2 hover:text-white" title="Plugins"><i data-lucide="package-search" class="w-5 h-5"></i></button>
 
42
  </nav>
43
  </aside>
44
 
45
+ <main class="flex-1 flex flex-col relative bg-[#050505] overflow-hidden">
46
+
47
  <!-- CONSOLE -->
48
+ <div id="tab-console" class="tab-pane active p-2 sm:p-4">
49
+ <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden shadow-2xl">
50
+ <div class="h-8 bg-[#0a0a0a] border-b border-[#1a1a1a] flex items-center px-3 gap-2">
51
+ <div class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_6px_#22c55e]"></div><span class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">Live Stream</span>
52
  </div>
53
+ <div id="logs" class="flex-1 overflow-y-auto p-3 text-zinc-300 scroll-smooth"></div>
54
+ <div class="p-2 bg-[#0a0a0a] border-t border-[#1a1a1a] flex gap-2">
55
+ <input id="cmd" type="text" class="flex-1 bg-[#050505] border border-[#222] rounded text-xs px-3 py-2 font-mono text-green-400 placeholder-zinc-700" placeholder="Type a command..." autocomplete="off">
56
+ <button onclick="sendCmd()" class="bg-[#1a1a1a] hover:bg-[#222] text-white p-2 rounded border border-[#222]"><i data-lucide="send-horizontal" class="w-4 h-4"></i></button>
57
  </div>
58
  </div>
59
  </div>
60
 
61
  <!-- FILES -->
62
+ <div id="tab-files" class="tab-pane p-2 sm:p-4">
63
+ <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden">
64
+ <div class="p-3 border-b border-[#1a1a1a] flex items-center gap-2 bg-[#0a0a0a]">
65
+ <div id="path-bread" class="flex-1 flex items-center gap-1 text-[11px] font-mono overflow-x-auto whitespace-nowrap mask-linear"></div>
66
+ <button onclick="document.getElementById('up').click()" class="hover:text-white"><i data-lucide="upload-cloud" class="w-4 h-4"></i></button>
67
+ <button onclick="refreshFiles()" class="hover:text-white"><i data-lucide="refresh-cw" class="w-4 h-4"></i></button>
68
+ <input type="file" id="up" class="hidden" onchange="uploadFile()">
 
 
69
  </div>
70
  <div id="file-list" class="flex-1 overflow-y-auto"></div>
71
  </div>
72
  </div>
73
 
74
+ <!-- PLUGINS (BROWSER & INSTALLED) -->
75
+ <div id="tab-plugins" class="tab-pane p-2 sm:p-4">
76
+ <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden">
77
+ <!-- Plugin Header/Controls -->
78
+ <div class="p-3 border-b border-[#1a1a1a] bg-[#0a0a0a] flex flex-col gap-3 shrink-0">
79
+ <div class="flex gap-2 w-full">
80
+ <div class="flex bg-[#111] rounded border border-[#222] p-0.5 shrink-0">
81
+ <button onclick="setPView('browser')" id="pv-browser" class="px-3 py-1 text-[10px] font-bold rounded bg-[#222] text-white transition-all">Browse</button>
82
+ <button onclick="setPView('installed')" id="pv-installed" class="px-3 py-1 text-[10px] font-bold rounded text-zinc-500 hover:text-white transition-all">Installed</button>
83
+ </div>
84
+ <div class="h-full w-[1px] bg-[#222] mx-1"></div>
85
+ <!-- Configuration -->
86
+ <select id="pl-loader" class="bg-[#111] border border-[#222] text-zinc-300 text-[10px] px-2 rounded focus:ring-0 w-24">
87
+ <option value="paper">Paper/Spigot</option>
88
+ <option value="purpur">Purpur</option>
89
+ <option value="velocity">Velocity</option>
90
+ <option value="waterfall">Waterfall</option>
91
+ <option value="fabric">Fabric</option>
92
+ </select>
93
+ <input type="text" id="pl-version" value="1.20.4" class="bg-[#111] border border-[#222] text-zinc-300 text-[10px] px-2 rounded w-16 text-center" placeholder="Ver">
94
  </div>
95
+ <!-- Search Bar -->
96
+ <div id="search-box" class="flex gap-2">
97
+ <div class="relative flex-1">
98
+ <i data-lucide="search" class="absolute left-2.5 top-2 w-3.5 h-3.5 text-zinc-500"></i>
99
+ <input type="text" id="pl-query" class="w-full bg-[#050505] border border-[#222] rounded text-[11px] pl-8 pr-3 py-1.5 text-white placeholder-zinc-700" placeholder="Search Modrinth (e.g. LuckPerms)..." onkeydown="if(event.key==='Enter') searchPlugins()">
100
+ </div>
101
+ <button onclick="searchPlugins()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 rounded text-[10px] font-bold">Search</button>
102
  </div>
103
+ </div>
104
+
105
+ <!-- Results Area -->
106
+ <div id="pl-list" class="flex-1 overflow-y-auto p-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 content-start">
107
+ <div class="col-span-full flex flex-col items-center justify-center text-zinc-600 h-64 gap-2">
108
+ <i data-lucide="search-code" class="w-8 h-8 opacity-20"></i>
109
+ <span class="text-xs">Select loader & version, then search.</span>
110
  </div>
111
  </div>
 
112
  </div>
113
  </div>
114
  </main>
 
 
 
 
 
 
 
 
115
  </div>
116
 
117
+ <!-- MOBILE NAV -->
118
+ <nav class="sm:hidden flex bg-black border-t border-[#1a1a1a] pb-[env(safe-area-inset-bottom,0)]">
119
+ <button onclick="tab('console')" id="m-console" class="nav-btn active flex-1 py-3 flex justify-center text-zinc-500"><i data-lucide="terminal-square" class="w-5 h-5"></i></button>
120
+ <button onclick="tab('files')" id="m-files" class="nav-btn flex-1 py-3 flex justify-center text-zinc-500"><i data-lucide="folder-tree" class="w-5 h-5"></i></button>
121
+ <button onclick="tab('plugins')" id="m-plugins" class="nav-btn flex-1 py-3 flex justify-center text-zinc-500"><i data-lucide="package-search" class="w-5 h-5"></i></button>
122
+ </nav>
 
 
 
 
 
 
 
123
 
124
+ <!-- TOASTS -->
125
+ <div id="toasts" class="fixed bottom-16 sm:bottom-6 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div>
126
 
127
  <script>
128
+ lucide.createIcons();
129
+ let curPath = "", curView = "browser";
130
+
131
+ // --- UTILS ---
132
+ const toast = (msg, err=false) => {
133
+ const d = document.createElement("div");
134
+ d.className = `flex items-center gap-3 px-4 py-3 rounded-lg border shadow-xl backdrop-blur-md transform transition-all duration-300 translate-y-8 opacity-0 pointer-events-auto ${err ? 'bg-red-950/90 border-red-900 text-red-200' : 'bg-zinc-900/90 border-zinc-800 text-zinc-200'}`;
135
+ d.innerHTML = `<i data-lucide="${err?'alert-circle':'check-circle-2'}" class="w-4 h-4 ${err?'text-red-500':'text-green-500'}"></i><span class="text-[11px] font-medium">${msg}</span>`;
136
+ document.getElementById("toasts").appendChild(d);
137
  lucide.createIcons();
138
+ requestAnimationFrame(() => d.classList.remove("translate-y-8", "opacity-0"));
139
+ setTimeout(() => { d.classList.add("translate-y-4", "opacity-0"); setTimeout(() => d.remove(), 300); }, 3000);
140
+ };
141
+
142
+ function tab(id) {
143
+ document.querySelectorAll(".tab-pane").forEach(e => e.classList.remove("active"));
144
+ document.querySelectorAll(".nav-btn").forEach(e => e.classList.remove("active"));
145
+ document.getElementById("tab-" + id).classList.add("active");
146
+ if(document.getElementById("d-" + id)) document.getElementById("d-" + id).classList.add("active");
147
+ if(document.getElementById("m-" + id)) document.getElementById("m-" + id).classList.add("active");
148
+ if(id === "files" && !curPath) refreshFiles();
149
+ if(id === "plugins" && curView === "installed") loadInstalled();
150
+ }
 
 
 
 
 
 
 
 
151
 
152
+ // --- CONSOLE ---
153
+ const logs = document.getElementById("logs");
154
+ const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
155
+ ws.onmessage = e => {
156
+ const l = document.createElement("div"); l.className = "log-line";
157
+ // Basic ANSI color parsing
158
+ l.innerHTML = e.data.replace(/</g, "&lt;").replace(/\x1b\[31m/g, '<span class="text-red-400">').replace(/\x1b\[32m/g, '<span class="text-green-400">').replace(/\x1b\[33m/g, '<span class="text-yellow-400">').replace(/\x1b\[36m/g, '<span class="text-cyan-400">').replace(/\x1b\[0m/g, '</span>');
159
+ logs.appendChild(l);
160
+ if(logs.children.length > 300) logs.removeChild(logs.firstChild);
161
+ if(logs.scrollHeight - logs.scrollTop < logs.clientHeight + 50) logs.scrollTop = logs.scrollHeight;
162
+ };
163
+ function sendCmd() {
164
+ const i = document.getElementById("cmd"); if(!i.value.trim()) return;
165
+ ws.send(i.value); i.value = "";
166
+ }
167
+
168
+ // --- FILES ---
169
+ async function refreshFiles(p = curPath) {
170
+ curPath = p;
171
+ document.getElementById("path-bread").innerHTML = `<button onclick="refreshFiles('')" class="hover:text-green-400"><i data-lucide="home" class="w-3 h-3"></i></button>` + p.split("/").filter(Boolean).map((x,i,a) => `<span class="opacity-25">/</span><button onclick="refreshFiles('${a.slice(0,i+1).join("/")}')" class="hover:text-white">${x}</button>`).join("");
172
+ lucide.createIcons();
173
+ const l = document.getElementById("file-list"); l.innerHTML = `<div class="p-4 flex justify-center"><div class="loader"></div></div>`;
174
+ try {
175
+ const r = await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`);
176
+ const d = await r.json();
177
+ l.innerHTML = "";
178
+ if(p) d.unshift({name:"..", is_dir:true, parent:true});
179
+ if(d.length === 0) l.innerHTML = `<div class="p-8 text-center text-xs text-zinc-600">Empty Directory</div>`;
180
+ d.forEach(f => {
181
+ const row = document.createElement("div");
182
+ row.className = "flex items-center gap-3 p-2 border-b border-[#111] hover:bg-[#111] cursor-pointer group";
183
+ if(f.parent) {
184
+ row.onclick = () => refreshFiles(p.split("/").slice(0,-1).join("/"));
185
+ row.innerHTML = `<i data-lucide="corner-left-up" class="w-4 h-4 text-zinc-500"></i><span class="text-xs text-zinc-500">Back</span>`;
186
+ } else {
187
+ row.onclick = () => f.is_dir ? refreshFiles((p?p+"/":"")+f.name) : null;
188
+ row.innerHTML = `
189
+ <i data-lucide="${f.is_dir?'folder':'file'}" class="w-4 h-4 ${f.is_dir?'text-green-500':'text-zinc-500'}"></i>
190
+ <span class="flex-1 text-xs font-mono text-zinc-300 truncate">${f.name}</span>
191
+ <button onclick="event.stopPropagation(); delFile('${(p?p+"/":"")+f.name}')" class="opacity-0 group-hover:opacity-100 p-1 hover:text-red-500 transition-opacity"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
192
+ `;
193
+ }
194
+ l.appendChild(row);
195
  });
196
+ lucide.createIcons();
197
+ } catch(e) { toast("Failed to load files", true); }
198
+ }
199
+ async function uploadFile() {
200
+ const f = document.getElementById("up").files[0]; if(!f) return;
201
+ const fd = new FormData(); fd.append("path", curPath); fd.append("file", f);
202
+ toast("Uploading...");
203
+ if((await fetch("/api/fs/upload", {method:"POST", body:fd})).ok) { toast("Uploaded"); refreshFiles(); } else toast("Upload failed", true);
204
+ }
205
+ async function delFile(p) {
206
+ if(!confirm("Delete " + p + "?")) return;
207
+ const fd = new FormData(); fd.append("path", p);
208
+ if((await fetch("/api/fs/delete", {method:"POST", body:fd})).ok) { toast("Deleted"); refreshFiles(); }
209
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
+ // --- PLUGINS (MODRINTH) ---
212
+ function setPView(v) {
213
+ curView = v;
214
+ document.getElementById("pv-browser").className = `px-3 py-1 text-[10px] font-bold rounded transition-all ${v==='browser'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
215
+ document.getElementById("pv-installed").className = `px-3 py-1 text-[10px] font-bold rounded transition-all ${v==='installed'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
216
+ document.getElementById("search-box").style.display = v === 'browser' ? 'flex' : 'none';
217
+ if(v === 'browser') {
218
+ document.getElementById("pl-list").innerHTML = `<div class="col-span-full flex flex-col items-center justify-center text-zinc-600 h-64 gap-2"><i data-lucide="search" class="w-8 h-8 opacity-20"></i><span class="text-xs">Ready to search.</span></div>`;
219
+ lucide.createIcons();
220
+ } else loadInstalled();
221
+ }
222
 
223
+ async function searchPlugins() {
224
+ const q = document.getElementById("pl-query").value.trim();
225
+ if(!q) return;
226
+ const list = document.getElementById("pl-list");
227
+ list.innerHTML = `<div class="col-span-full flex justify-center py-10"><div class="loader"></div></div>`;
228
+
229
+ // We map user selection to generic facets for broader results, then filter versions strictly on install click
230
+ try {
231
+ const res = await fetch(`https://api.modrinth.com/v2/search?query=${encodeURIComponent(q)}&facets=[["project_type:plugin"]]&limit=20`);
232
+ const data = await res.json();
233
+ list.innerHTML = "";
234
+
235
+ if(data.hits.length === 0) {
236
+ list.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No results found on Modrinth.</div>`;
237
+ return;
238
+ }
239
+
240
+ data.hits.forEach(p => {
241
+ const card = document.createElement("div");
242
+ card.className = "bg-[#080808] border border-[#1a1a1a] rounded p-3 flex flex-col gap-2 hover:border-[#333] transition-colors";
243
+ card.innerHTML = `
244
+ <div class="flex gap-3">
245
+ <img src="${p.icon_url || 'https://cdn.modrinth.com/assets/unknown_icon.png'}" class="w-8 h-8 rounded bg-[#111]" onerror="this.src='https://placehold.co/32x32/111/444?text=?'">
246
+ <div class="flex-1 min-w-0">
247
+ <div class="flex justify-between items-start">
248
+ <h3 class="text-xs font-bold text-zinc-200 truncate pr-2" title="${p.title}">${p.title}</h3>
249
+ <span class="text-[9px] bg-[#111] text-zinc-500 px-1 rounded border border-[#222]">${p.downloads.toLocaleString()} dl</span>
250
+ </div>
251
+ <p class="text-[10px] text-zinc-500 line-clamp-2 leading-tight mt-0.5">${p.description}</p>
252
+ </div>
253
  </div>
254
+ <div class="mt-auto pt-2 border-t border-[#1a1a1a]">
255
+ <button onclick="resolveInstall('${p.project_id}', '${p.title.replace(/'/g, "")}')" id="btn-${p.project_id}" class="w-full bg-[#111] hover:bg-green-600 hover:text-black text-zinc-400 text-[10px] font-bold py-1.5 rounded transition-colors flex items-center justify-center gap-1">
256
+ <i data-lucide="download" class="w-3 h-3"></i> Install
257
+ </button>
258
+ </div>
259
+ `;
260
+ list.appendChild(card);
261
  });
262
+ lucide.createIcons();
263
+ } catch(e) {
264
+ list.innerHTML = `<div class="col-span-full text-center text-xs text-red-400 py-8">Error connecting to Modrinth API.</div>`;
265
  }
266
+ }
267
+
268
+ async function resolveInstall(id, name) {
269
+ const loaderRaw = document.getElementById("pl-loader").value;
270
+ const version = document.getElementById("pl-version").value.trim();
271
+ const btn = document.getElementById(`btn-${id}`);
272
+
273
+ // UI Loading State
274
+ const ogHtml = btn.innerHTML;
275
+ btn.innerHTML = `<div class="loader w-3 h-3 border-zinc-400 border-t-transparent"></div> Checking...`;
276
+ btn.disabled = true;
277
+
278
+ // Smart Loader Mapping: Purpur/Waterfall usually support Spigot/Paper plugins
279
+ let loaders = [loaderRaw];
280
+ if(loaderRaw === 'purpur') loaders = ['paper', 'spigot', 'purpur'];
281
+ if(loaderRaw === 'paper') loaders = ['paper', 'spigot'];
282
+ if(loaderRaw === 'waterfall') loaders = ['bungeecord', 'waterfall'];
283
+
284
+ try {
285
+ // Construct array string for API: '["paper", "spigot"]'
286
+ const lQuery = JSON.stringify(loaders);
287
+ const vQuery = JSON.stringify([version]);
288
+
289
+ const res = await fetch(`https://api.modrinth.com/v2/project/${id}/version?loaders=${lQuery}&game_versions=${vQuery}`);
290
+ const versions = await res.json();
291
+
292
+ if(!versions.length) {
293
+ toast(`No version found for ${loaderRaw} ${version}`, true);
294
+ btn.innerHTML = `<span class="text-red-400">Incompatible</span>`;
295
+ setTimeout(() => { btn.innerHTML = ogHtml; btn.disabled = false; }, 2000);
296
+ return;
297
+ }
298
+
299
+ // Install the first match
300
+ const file = versions[0].files.find(f => f.primary) || versions[0].files[0];
301
+ btn.innerHTML = `Downloading...`;
302
+
303
+ const fd = new FormData();
304
+ fd.append("url", file.url);
305
+ fd.append("filename", file.filename);
306
+ fd.append("project_id", id);
307
+ fd.append("version_id", versions[0].id);
308
+ fd.append("name", name);
309
+
310
+ const dl = await fetch("/api/plugins/install", {method: "POST", body: fd});
311
+ if(dl.ok) {
312
+ toast(`Installed ${name}`);
313
+ btn.className = "w-full bg-green-600 text-black text-[10px] font-bold py-1.5 rounded flex items-center justify-center gap-1 cursor-default";
314
+ btn.innerHTML = `<i data-lucide="check" class="w-3 h-3"></i> Installed`;
315
+ lucide.createIcons();
316
+ } else {
317
+ throw new Error("Server error");
318
  }
319
+ } catch(e) {
320
+ toast("Installation failed", true);
321
+ btn.innerHTML = `<span class="text-red-400">Error</span>`;
322
+ setTimeout(() => { btn.innerHTML = ogHtml; btn.disabled = false; }, 2000);
323
  }
324
+ }
325
+
326
+ async function loadInstalled() {
327
+ const l = document.getElementById("pl-list");
328
+ l.innerHTML = `<div class="col-span-full flex justify-center py-10"><div class="loader"></div></div>`;
329
+ try {
330
+ const r = await fetch("/api/fs/read?path=plugins/plugins.json");
331
+ if(!r.ok) throw new Error();
332
+ const json = await r.json();
333
+ l.innerHTML = "";
334
+
335
+ if(Object.keys(json).length === 0) {
336
+ l.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No plugins installed via Panel.</div>`;
337
+ return;
338
+ }
339
+
340
+ for(const [pid, data] of Object.entries(json)) {
341
+ const card = document.createElement("div");
342
+ card.className = "bg-[#080808] border border-[#1a1a1a] rounded p-3 flex flex-col gap-2";
343
+ card.innerHTML = `
344
+ <div class="flex justify-between items-start">
345
+ <h3 class="text-xs font-bold text-zinc-200">${data.name}</h3>
346
+ <button onclick="delFile('plugins/${data.filename}')" class="text-zinc-600 hover:text-red-500"><i data-lucide="trash" class="w-3 h-3"></i></button>
347
+ </div>
348
+ <div class="text-[10px] text-zinc-500 font-mono truncate">${data.filename}</div>
349
+ <div class="mt-auto flex gap-2">
350
+ <button class="flex-1 bg-[#111] text-zinc-500 text-[9px] py-1 rounded cursor-not-allowed">Installed</button>
351
+ <!-- Future: Check update logic here -->
352
+ </div>
353
+ `;
354
+ l.appendChild(card);
355
+ }
356
+ lucide.createIcons();
357
+ } catch(e) {
358
+ l.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No plugins.json record found.</div>`;
359
  }
360
+ }
361
  </script></body></html>
362
  """
363
 
364
+ # --- BACKEND LOGIC ---
365
+ def get_path(p: str):
366
+ safe = os.path.abspath(os.path.join(BASE_DIR, (p or "").strip("/")))
367
+ if not safe.startswith(BASE_DIR): raise HTTPException(403, "Access Denied")
368
+ return safe
369
 
370
+ async def stream_output(pipe):
371
  while True:
372
+ line = await pipe.readline()
373
+ if not line: break
374
+ txt = line.decode('utf-8', errors='replace').rstrip()
375
+ output_history.append(txt)
376
+ dead = set()
377
+ for c in connected_clients:
378
+ try: await c.send_text(txt)
379
+ except: dead.add(c)
380
+ connected_clients.difference_update(dead)
381
+
382
+ async def boot_mc():
 
 
383
  global mc_process
384
+ jar = os.path.join(BASE_DIR, "purpur.jar")
385
+ if not os.path.exists(jar):
386
+ output_history.append("\x1b[33m[System] purpur.jar not found in /app. Please upload it via Files tab.\x1b[0m")
387
+ return
388
+
389
+ # Low resource flags
390
  mc_process = await asyncio.create_subprocess_exec(
391
+ "java", "-Xmx4G", "-Xms1G", "-Dfile.encoding=UTF-8", "-jar", jar, "--nogui",
392
  stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=BASE_DIR
393
  )
394
+ asyncio.create_task(stream_output(mc_process.stdout))
395
 
396
  @app.on_event("startup")
397
+ async def start():
398
+ os.makedirs(PLUGINS_DIR, exist_ok=True)
399
+ asyncio.create_task(boot_mc())
400
 
401
  @app.get("/")
402
+ def index(): return HTMLResponse(HTML_CONTENT)
403
 
404
  @app.websocket("/ws")
405
+ async def ws_end(ws: WebSocket):
406
+ await ws.accept()
407
+ connected_clients.add(ws)
408
  for l in output_history: await ws.send_text(l)
409
  try:
410
  while True:
411
  cmd = await ws.receive_text()
412
+ if mc_process and mc_process.stdin:
413
+ mc_process.stdin.write((cmd + "\n").encode())
414
+ await mc_process.stdin.drain()
415
+ except: connected_clients.remove(ws)
416
 
417
+ # FS API
418
  @app.get("/api/fs/list")
419
+ def list_fs(path: str=""):
420
+ t = get_path(path)
421
  if not os.path.exists(t): return []
422
+ res = []
423
+ for x in os.listdir(t):
424
+ fp = os.path.join(t, x)
425
+ res.append({"name": x, "is_dir": os.path.isdir(fp)})
426
+ return sorted(res, key=lambda k: (not k["is_dir"], k["name"].lower()))
 
 
 
 
 
 
 
 
427
 
428
  @app.post("/api/fs/upload")
429
+ async def upload(path: str=Form(""), file: UploadFile=File(...)):
430
+ t = get_path(path)
431
+ os.makedirs(t, exist_ok=True)
432
+ with open(os.path.join(t, file.filename), "wb") as f: shutil.copyfileobj(file.file, f)
433
+ return "ok"
434
 
435
  @app.post("/api/fs/delete")
436
+ def delete(path: str=Form(...)):
437
+ t = get_path(path)
438
  if os.path.isdir(t): shutil.rmtree(t)
439
  else: os.remove(t)
440
+ return "ok"
441
+
442
+ @app.get("/api/fs/read")
443
+ def read(path: str):
444
+ try:
445
+ with open(get_path(path), "r", encoding="utf-8") as f: return json.load(f) if path.endswith(".json") else Response(f.read())
446
+ except: raise HTTPException(404)
447
 
448
+ # PLUGIN INSTALLER
449
  @app.post("/api/plugins/install")
450
+ def install_pl(url: str=Form(...), filename: str=Form(...), project_id: str=Form(...), version_id: str=Form(...), name: str=Form(...)):
451
  try:
452
+ # Download
453
+ dest = os.path.join(PLUGINS_DIR, filename)
454
+ req = urllib.request.Request(url, headers={'User-Agent': 'HF-Panel/1.0'})
455
+ with urllib.request.urlopen(req) as r, open(dest, 'wb') as f:
456
+ shutil.copyfileobj(r, f)
457
+
458
+ # Update JSON Record
459
+ j_path = os.path.join(PLUGINS_DIR, "plugins.json")
460
+ data = {}
461
+ if os.path.exists(j_path):
462
+ try:
463
+ with open(j_path, 'r') as f: data = json.load(f)
464
+ except: pass
465
+
466
+ data[project_id] = {
467
+ "name": name,
468
+ "filename": filename,
469
+ "version_id": version_id,
470
+ "installed_at": time.time()
471
+ }
472
+
473
+ with open(j_path, 'w') as f: json.dump(data, f, indent=2)
474
+ return "ok"
475
  except Exception as e:
476
+ raise HTTPException(500, str(e))
477
 
478
  if __name__ == "__main__":
479
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), log_level="error")