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

Create panel.py

Browse files
Files changed (1) hide show
  1. panel.py +382 -0
panel.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")