OrbitMC commited on
Commit
0851e07
Β·
verified Β·
1 Parent(s): 9f0d1c9

Update panel.py

Browse files
Files changed (1) hide show
  1. panel.py +688 -350
panel.py CHANGED
@@ -17,358 +17,663 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
17
  BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app"))
18
  PLUGINS_DIR = os.path.join(BASE_DIR, "plugins")
19
  mc_process = None
20
- output_history = collections.deque(maxlen=300)
21
  connected_clients = set()
22
 
23
- # --- HTML GUI ---
24
- HTML_CONTENT = """
25
- <!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>
26
- <script src="https://cdn.tailwindcss.com"></script><script src="https://unpkg.com/lucide@latest"></script>
 
 
 
 
 
 
 
 
27
  <style>
28
- :root{--bg:#050505;--panel:#0a0a0a;--border:#1a1a1a;--accent:#22c55e;--text:#a1a1aa;}
29
- 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;}
30
- ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-thumb{background:#27272a;border-radius:2px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}
31
- .tab-pane{display:none;flex:1;flex-direction:column;overflow:hidden;position:relative;animation:fadeIn 0.2s ease-out} .tab-pane.active{display:flex}
32
- @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
33
- .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)}
34
- .log-line{font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.4;word-break:break-all;padding:1px 0}
35
- input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 1px rgba(34,197,94,0.1)}
36
- .loader{border:2px solid #222;border-top:2px solid var(--accent);border-radius:50%;width:16px;height:16px;animation:spin .6s linear infinite}
37
- @keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
38
- </style></head>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  <body>
40
- <!-- MAIN LAYOUT -->
41
- <div class="flex flex-1 overflow-hidden">
42
- <!-- DESKTOP SIDEBAR -->
43
- <aside class="hidden sm:flex flex-col w-14 bg-black border-r border-[#1a1a1a] items-center py-6 gap-6 z-20">
44
- <div class="text-green-500 drop-shadow-md"><i data-lucide="cpu" class="w-6 h-6"></i></div>
45
- <nav class="flex flex-col gap-6 w-full items-center">
46
- <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>
47
- <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>
48
- <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>
49
- </nav>
50
- </aside>
51
-
52
- <main class="flex-1 flex flex-col relative bg-[#050505] overflow-hidden">
53
-
54
- <!-- CONSOLE -->
55
- <div id="tab-console" class="tab-pane active p-2 sm:p-4">
56
- <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden shadow-2xl">
57
- <div class="h-8 bg-[#0a0a0a] border-b border-[#1a1a1a] flex items-center px-3 gap-2">
58
- <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>
59
- </div>
60
- <div id="logs" class="flex-1 overflow-y-auto p-3 text-zinc-300 scroll-smooth"></div>
61
- <div class="p-2 bg-[#0a0a0a] border-t border-[#1a1a1a] flex gap-2">
62
- <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">
63
- <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>
64
- </div>
65
- </div>
66
- </div>
67
 
68
- <!-- FILES -->
69
- <div id="tab-files" class="tab-pane p-2 sm:p-4">
70
- <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden">
71
- <div class="p-3 border-b border-[#1a1a1a] flex items-center gap-2 bg-[#0a0a0a]">
72
- <div id="path-bread" class="flex-1 flex items-center gap-1 text-[11px] font-mono overflow-x-auto whitespace-nowrap mask-linear"></div>
73
- <button onclick="document.getElementById('up').click()" class="hover:text-white"><i data-lucide="upload-cloud" class="w-4 h-4"></i></button>
74
- <button onclick="refreshFiles()" class="hover:text-white"><i data-lucide="refresh-cw" class="w-4 h-4"></i></button>
75
- <input type="file" id="up" class="hidden" onchange="uploadFile()">
76
- </div>
77
- <div id="file-list" class="flex-1 overflow-y-auto"></div>
78
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  </div>
80
-
81
- <!-- PLUGINS (BROWSER & INSTALLED) -->
82
- <div id="tab-plugins" class="tab-pane p-2 sm:p-4">
83
- <div class="flex-1 bg-black border border-[#1a1a1a] rounded-lg flex flex-col overflow-hidden">
84
- <!-- Plugin Header/Controls -->
85
- <div class="p-3 border-b border-[#1a1a1a] bg-[#0a0a0a] flex flex-col gap-3 shrink-0">
86
- <div class="flex gap-2 w-full">
87
- <div class="flex bg-[#111] rounded border border-[#222] p-0.5 shrink-0">
88
- <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>
89
- <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>
90
- </div>
91
- <div class="h-full w-[1px] bg-[#222] mx-1"></div>
92
- <!-- Configuration -->
93
- <select id="pl-loader" class="bg-[#111] border border-[#222] text-zinc-300 text-[10px] px-2 rounded focus:ring-0 w-24">
94
- <option value="paper">Paper/Spigot</option>
95
- <option value="purpur">Purpur</option>
96
- <option value="velocity">Velocity</option>
97
- <option value="waterfall">Waterfall</option>
98
- <option value="fabric">Fabric</option>
99
- </select>
100
- <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">
101
- </div>
102
- <!-- Search Bar -->
103
- <div id="search-box" class="flex gap-2">
104
- <div class="relative flex-1">
105
- <i data-lucide="search" class="absolute left-2.5 top-2 w-3.5 h-3.5 text-zinc-500"></i>
106
- <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()">
107
- </div>
108
- <button onclick="searchPlugins()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 rounded text-[10px] font-bold">Search</button>
109
- </div>
110
- </div>
111
-
112
- <!-- Results Area -->
113
- <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">
114
- <div class="col-span-full flex flex-col items-center justify-center text-zinc-600 h-64 gap-2">
115
- <i data-lucide="search-code" class="w-8 h-8 opacity-20"></i>
116
- <span class="text-xs">Select loader & version, then search.</span>
117
- </div>
118
- </div>
 
 
 
 
 
 
 
 
 
119
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </div>
121
- </main>
 
 
122
  </div>
123
 
124
  <!-- MOBILE NAV -->
125
- <nav class="sm:hidden flex bg-black border-t border-[#1a1a1a] pb-[env(safe-area-inset-bottom,0)]">
126
- <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>
127
- <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>
128
- <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>
 
 
 
 
 
 
 
 
 
 
 
129
  </nav>
130
 
131
- <!-- TOASTS -->
132
- <div id="toasts" class="fixed bottom-16 sm:bottom-6 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div>
133
 
134
  <script>
135
- lucide.createIcons();
136
- let curPath = "", curView = "browser";
137
-
138
- // --- UTILS ---
139
- const toast = (msg, err=false) => {
140
- const d = document.createElement("div");
141
- 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'}`;
142
- 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>`;
143
- document.getElementById("toasts").appendChild(d);
144
- lucide.createIcons();
145
- requestAnimationFrame(() => d.classList.remove("translate-y-8", "opacity-0"));
146
- setTimeout(() => { d.classList.add("translate-y-4", "opacity-0"); setTimeout(() => d.remove(), 300); }, 3000);
147
- };
148
-
149
- function tab(id) {
150
- document.querySelectorAll(".tab-pane").forEach(e => e.classList.remove("active"));
151
- document.querySelectorAll(".nav-btn").forEach(e => e.classList.remove("active"));
152
- document.getElementById("tab-" + id).classList.add("active");
153
- if(document.getElementById("d-" + id)) document.getElementById("d-" + id).classList.add("active");
154
- if(document.getElementById("m-" + id)) document.getElementById("m-" + id).classList.add("active");
155
- if(id === "files" && !curPath) refreshFiles();
156
- if(id === "plugins" && curView === "installed") loadInstalled();
 
 
 
 
 
 
 
157
  }
158
 
159
- // --- CONSOLE ---
160
- const logs = document.getElementById("logs");
161
- const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
162
- ws.onmessage = e => {
163
- const l = document.createElement("div"); l.className = "log-line";
164
- // Basic ANSI color parsing
165
- 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>');
166
- logs.appendChild(l);
167
- if(logs.children.length > 300) logs.removeChild(logs.firstChild);
168
- if(logs.scrollHeight - logs.scrollTop < logs.clientHeight + 50) logs.scrollTop = logs.scrollHeight;
169
- };
170
- function sendCmd() {
171
- const i = document.getElementById("cmd"); if(!i.value.trim()) return;
172
- ws.send(i.value); i.value = "";
173
  }
174
 
175
- // --- FILES ---
176
- async function refreshFiles(p = curPath) {
177
- curPath = p;
178
- 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("");
179
- lucide.createIcons();
180
- const l = document.getElementById("file-list"); l.innerHTML = `<div class="p-4 flex justify-center"><div class="loader"></div></div>`;
181
- try {
182
- const r = await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`);
183
- const d = await r.json();
184
- l.innerHTML = "";
185
- if(p) d.unshift({name:"..", is_dir:true, parent:true});
186
- if(d.length === 0) l.innerHTML = `<div class="p-8 text-center text-xs text-zinc-600">Empty Directory</div>`;
187
- d.forEach(f => {
188
- const row = document.createElement("div");
189
- row.className = "flex items-center gap-3 p-2 border-b border-[#111] hover:bg-[#111] cursor-pointer group";
190
- if(f.parent) {
191
- row.onclick = () => refreshFiles(p.split("/").slice(0,-1).join("/"));
192
- 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>`;
193
- } else {
194
- row.onclick = () => f.is_dir ? refreshFiles((p?p+"/":"")+f.name) : null;
195
- row.innerHTML = `
196
- <i data-lucide="${f.is_dir?'folder':'file'}" class="w-4 h-4 ${f.is_dir?'text-green-500':'text-zinc-500'}"></i>
197
- <span class="flex-1 text-xs font-mono text-zinc-300 truncate">${f.name}</span>
198
- <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>
199
- `;
200
- }
201
- l.appendChild(row);
202
- });
203
- lucide.createIcons();
204
- } catch(e) { toast("Failed to load files", true); }
205
  }
206
- async function uploadFile() {
207
- const f = document.getElementById("up").files[0]; if(!f) return;
208
- const fd = new FormData(); fd.append("path", curPath); fd.append("file", f);
209
- toast("Uploading...");
210
- if((await fetch("/api/fs/upload", {method:"POST", body:fd})).ok) { toast("Uploaded"); refreshFiles(); } else toast("Upload failed", true);
 
 
211
  }
212
- async function delFile(p) {
213
- if(!confirm("Delete " + p + "?")) return;
214
- const fd = new FormData(); fd.append("path", p);
215
- if((await fetch("/api/fs/delete", {method:"POST", body:fd})).ok) { toast("Deleted"); refreshFiles(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  }
 
217
 
218
- // --- PLUGINS (MODRINTH) ---
219
- function setPView(v) {
220
- curView = v;
221
- 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'}`;
222
- 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'}`;
223
- document.getElementById("search-box").style.display = v === 'browser' ? 'flex' : 'none';
224
- if(v === 'browser') {
225
- 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>`;
226
- lucide.createIcons();
227
- } else loadInstalled();
228
  }
229
 
230
- async function searchPlugins() {
231
- const q = document.getElementById("pl-query").value.trim();
232
- if(!q) return;
233
- const list = document.getElementById("pl-list");
234
- list.innerHTML = `<div class="col-span-full flex justify-center py-10"><div class="loader"></div></div>`;
235
-
236
- // We map user selection to generic facets for broader results, then filter versions strictly on install click
237
- try {
238
- const res = await fetch(`https://api.modrinth.com/v2/search?query=${encodeURIComponent(q)}&facets=[["project_type:plugin"]]&limit=20`);
239
- const data = await res.json();
240
- list.innerHTML = "";
241
-
242
- if(data.hits.length === 0) {
243
- list.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No results found on Modrinth.</div>`;
244
- return;
245
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
- data.hits.forEach(p => {
248
- const card = document.createElement("div");
249
- card.className = "bg-[#080808] border border-[#1a1a1a] rounded p-3 flex flex-col gap-2 hover:border-[#333] transition-colors";
250
- card.innerHTML = `
251
- <div class="flex gap-3">
252
- <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=?'">
253
- <div class="flex-1 min-w-0">
254
- <div class="flex justify-between items-start">
255
- <h3 class="text-xs font-bold text-zinc-200 truncate pr-2" title="${p.title}">${p.title}</h3>
256
- <span class="text-[9px] bg-[#111] text-zinc-500 px-1 rounded border border-[#222]">${p.downloads.toLocaleString()} dl</span>
257
- </div>
258
- <p class="text-[10px] text-zinc-500 line-clamp-2 leading-tight mt-0.5">${p.description}</p>
259
- </div>
260
- </div>
261
- <div class="mt-auto pt-2 border-t border-[#1a1a1a]">
262
- <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">
263
- <i data-lucide="download" class="w-3 h-3"></i> Install
264
- </button>
265
- </div>
266
- `;
267
- list.appendChild(card);
268
- });
269
- lucide.createIcons();
270
- } catch(e) {
271
- list.innerHTML = `<div class="col-span-full text-center text-xs text-red-400 py-8">Error connecting to Modrinth API.</div>`;
272
- }
273
  }
274
 
275
- async function resolveInstall(id, name) {
276
- const loaderRaw = document.getElementById("pl-loader").value;
277
- const version = document.getElementById("pl-version").value.trim();
278
- const btn = document.getElementById(`btn-${id}`);
279
-
280
- // UI Loading State
281
- const ogHtml = btn.innerHTML;
282
- btn.innerHTML = `<div class="loader w-3 h-3 border-zinc-400 border-t-transparent"></div> Checking...`;
283
- btn.disabled = true;
284
-
285
- // Smart Loader Mapping: Purpur/Waterfall usually support Spigot/Paper plugins
286
- let loaders = [loaderRaw];
287
- if(loaderRaw === 'purpur') loaders = ['paper', 'spigot', 'purpur'];
288
- if(loaderRaw === 'paper') loaders = ['paper', 'spigot'];
289
- if(loaderRaw === 'waterfall') loaders = ['bungeecord', 'waterfall'];
290
-
291
- try {
292
- // Construct array string for API: '["paper", "spigot"]'
293
- const lQuery = JSON.stringify(loaders);
294
- const vQuery = JSON.stringify([version]);
295
-
296
- const res = await fetch(`https://api.modrinth.com/v2/project/${id}/version?loaders=${lQuery}&game_versions=${vQuery}`);
297
- const versions = await res.json();
298
-
299
- if(!versions.length) {
300
- toast(`No version found for ${loaderRaw} ${version}`, true);
301
- btn.innerHTML = `<span class="text-red-400">Incompatible</span>`;
302
- setTimeout(() => { btn.innerHTML = ogHtml; btn.disabled = false; }, 2000);
303
- return;
304
- }
305
 
306
- // Install the first match
307
- const file = versions[0].files.find(f => f.primary) || versions[0].files[0];
308
- btn.innerHTML = `Downloading...`;
309
-
310
- const fd = new FormData();
311
- fd.append("url", file.url);
312
- fd.append("filename", file.filename);
313
- fd.append("project_id", id);
314
- fd.append("version_id", versions[0].id);
315
- fd.append("name", name);
316
-
317
- const dl = await fetch("/api/plugins/install", {method: "POST", body: fd});
318
- if(dl.ok) {
319
- toast(`Installed ${name}`);
320
- 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";
321
- btn.innerHTML = `<i data-lucide="check" class="w-3 h-3"></i> Installed`;
322
- lucide.createIcons();
323
- } else {
324
- throw new Error("Server error");
325
- }
326
- } catch(e) {
327
- toast("Installation failed", true);
328
- btn.innerHTML = `<span class="text-red-400">Error</span>`;
329
- setTimeout(() => { btn.innerHTML = ogHtml; btn.disabled = false; }, 2000);
330
- }
331
  }
332
 
333
- async function loadInstalled() {
334
- const l = document.getElementById("pl-list");
335
- l.innerHTML = `<div class="col-span-full flex justify-center py-10"><div class="loader"></div></div>`;
336
- try {
337
- const r = await fetch("/api/fs/read?path=plugins/plugins.json");
338
- if(!r.ok) throw new Error();
339
- const json = await r.json();
340
- l.innerHTML = "";
341
-
342
- if(Object.keys(json).length === 0) {
343
- l.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No plugins installed via Panel.</div>`;
344
- return;
345
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
- for(const [pid, data] of Object.entries(json)) {
348
- const card = document.createElement("div");
349
- card.className = "bg-[#080808] border border-[#1a1a1a] rounded p-3 flex flex-col gap-2";
350
- card.innerHTML = `
351
- <div class="flex justify-between items-start">
352
- <h3 class="text-xs font-bold text-zinc-200">${data.name}</h3>
353
- <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>
354
- </div>
355
- <div class="text-[10px] text-zinc-500 font-mono truncate">${data.filename}</div>
356
- <div class="mt-auto flex gap-2">
357
- <button class="flex-1 bg-[#111] text-zinc-500 text-[9px] py-1 rounded cursor-not-allowed">Installed</button>
358
- <!-- Future: Check update logic here -->
359
- </div>
360
- `;
361
- l.appendChild(card);
362
- }
363
- lucide.createIcons();
364
- } catch(e) {
365
- l.innerHTML = `<div class="col-span-full text-center text-xs text-zinc-500 py-8">No plugins.json record found.</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }
 
367
  }
368
- </script></body></html>
 
 
369
  """
370
 
371
- # --- BACKEND LOGIC ---
 
 
372
  def get_path(p: str):
373
  safe = os.path.abspath(os.path.join(BASE_DIR, (p or "").strip("/")))
374
  if not safe.startswith(BASE_DIR): raise HTTPException(403, "Access Denied")
@@ -389,17 +694,45 @@ async def stream_output(pipe):
389
  async def boot_mc():
390
  global mc_process
391
  jar = os.path.join(BASE_DIR, "purpur.jar")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  if not os.path.exists(jar):
393
- output_history.append("\x1b[33m[System] purpur.jar not found in /app. Please upload it via Files tab.\x1b[0m")
394
  return
395
-
396
- # Low resource flags
397
  mc_process = await asyncio.create_subprocess_exec(
398
- "java", "-Xmx4G", "-Xms1G", "-Dfile.encoding=UTF-8", "-jar", jar, "--nogui",
399
- stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=BASE_DIR
 
 
 
 
 
 
 
400
  )
401
  asyncio.create_task(stream_output(mc_process.stdout))
402
 
 
 
 
403
  @app.get("/")
404
  def index(): return HTMLResponse(HTML_CONTENT)
405
 
@@ -407,35 +740,37 @@ def index(): return HTMLResponse(HTML_CONTENT)
407
  async def ws_end(ws: WebSocket):
408
  await ws.accept()
409
  connected_clients.add(ws)
410
- for l in output_history: await ws.send_text(l)
411
  try:
412
  while True:
413
  cmd = await ws.receive_text()
414
  if mc_process and mc_process.stdin:
415
  mc_process.stdin.write((cmd + "\n").encode())
416
  await mc_process.stdin.drain()
417
- except: connected_clients.remove(ws)
 
 
 
 
 
418
 
419
- # FS API
420
  @app.get("/api/fs/list")
421
- def list_fs(path: str=""):
422
  t = get_path(path)
423
  if not os.path.exists(t): return []
424
- res = []
425
- for x in os.listdir(t):
426
- fp = os.path.join(t, x)
427
- res.append({"name": x, "is_dir": os.path.isdir(fp)})
428
  return sorted(res, key=lambda k: (not k["is_dir"], k["name"].lower()))
429
 
430
  @app.post("/api/fs/upload")
431
- async def upload(path: str=Form(""), file: UploadFile=File(...)):
432
  t = get_path(path)
433
  os.makedirs(t, exist_ok=True)
434
- with open(os.path.join(t, file.filename), "wb") as f: shutil.copyfileobj(file.file, f)
 
435
  return "ok"
436
 
437
  @app.post("/api/fs/delete")
438
- def delete(path: str=Form(...)):
439
  t = get_path(path)
440
  if os.path.isdir(t): shutil.rmtree(t)
441
  else: os.remove(t)
@@ -444,38 +779,41 @@ def delete(path: str=Form(...)):
444
  @app.get("/api/fs/read")
445
  def read(path: str):
446
  try:
447
- with open(get_path(path), "r", encoding="utf-8") as f: return json.load(f) if path.endswith(".json") else Response(f.read())
448
- except: raise HTTPException(404)
 
 
449
 
450
- # PLUGIN INSTALLER
451
  @app.post("/api/plugins/install")
452
- def install_pl(url: str=Form(...), filename: str=Form(...), project_id: str=Form(...), version_id: str=Form(...), name: str=Form(...)):
 
 
 
 
453
  try:
454
- # Download
455
  dest = os.path.join(PLUGINS_DIR, filename)
456
- req = urllib.request.Request(url, headers={'User-Agent': 'HF-Panel/1.0'})
457
- with urllib.request.urlopen(req) as r, open(dest, 'wb') as f:
458
  shutil.copyfileobj(r, f)
459
-
460
- # Update JSON Record
461
  j_path = os.path.join(PLUGINS_DIR, "plugins.json")
462
  data = {}
463
  if os.path.exists(j_path):
464
  try:
465
- with open(j_path, 'r') as f: data = json.load(f)
466
  except: pass
467
-
468
  data[project_id] = {
469
- "name": name,
470
- "filename": filename,
471
- "version_id": version_id,
472
- "installed_at": time.time()
473
  }
474
-
475
- with open(j_path, 'w') as f: json.dump(data, f, indent=2)
476
  return "ok"
477
  except Exception as e:
478
  raise HTTPException(500, str(e))
479
 
480
  if __name__ == "__main__":
481
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), log_level="error")
 
 
 
 
 
 
17
  BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app"))
18
  PLUGINS_DIR = os.path.join(BASE_DIR, "plugins")
19
  mc_process = None
20
+ output_history = collections.deque(maxlen=500)
21
  connected_clients = set()
22
 
23
+ # ─────────────────────────────────────────────
24
+ # HTML GUI β€” macOS / Apple-style UI
25
+ # Raw string (r"""…""") so backslashes in JS
26
+ # regex patterns pass through unchanged.
27
+ # ─────────────────────────────────────────────
28
+ HTML_CONTENT = r"""<!DOCTYPE html>
29
+ <html lang="en" data-theme="dark">
30
+ <head>
31
+ <meta charset="UTF-8">
32
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
33
+ <title>MC Panel</title>
34
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
35
  <style>
36
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
37
+ :root{
38
+ --f:-apple-system,BlinkMacSystemFont,'SF Pro Display','SF Pro Text',system-ui,sans-serif;
39
+ --mono:'JetBrains Mono','SF Mono','Cascadia Code',monospace;
40
+ --r:12px;--r-sm:8px;--r-lg:16px;--dur:0.18s
41
+ }
42
+ [data-theme=dark]{
43
+ --bg:#000;--s1:#1C1C1E;--s2:#2C2C2E;--s3:#3A3A3C;
44
+ --bd:rgba(255,255,255,.08);
45
+ --t1:#fff;--t2:rgba(255,255,255,.55);--t3:rgba(255,255,255,.22);
46
+ --acc:#32D74B;--acc-bg:rgba(50,215,75,.12);
47
+ --red:#FF453A;--yel:#FFD60A;--blu:#0A84FF;
48
+ --glass:rgba(28,28,30,.8);
49
+ --sh:0 8px 40px rgba(0,0,0,.7),0 2px 8px rgba(0,0,0,.4);
50
+ --sh-sm:0 2px 12px rgba(0,0,0,.5)
51
+ }
52
+ [data-theme=light]{
53
+ --bg:#EBEBEB;--s1:#fff;--s2:#F5F5F7;--s3:#E5E5EA;
54
+ --bd:rgba(0,0,0,.09);
55
+ --t1:#1C1C1E;--t2:rgba(0,0,0,.5);--t3:rgba(0,0,0,.22);
56
+ --acc:#28CD41;--acc-bg:rgba(40,205,65,.1);
57
+ --red:#FF3B30;--yel:#FF9F0A;--blu:#007AFF;
58
+ --glass:rgba(255,255,255,.85);
59
+ --sh:0 8px 40px rgba(0,0,0,.12),0 2px 8px rgba(0,0,0,.06);
60
+ --sh-sm:0 2px 12px rgba(0,0,0,.1)
61
+ }
62
+ html,body{height:100%;height:100dvh}
63
+ body{font-family:var(--f);background:var(--bg);color:var(--t1);
64
+ display:flex;flex-direction:column;overflow:hidden;
65
+ -webkit-font-smoothing:antialiased;transition:background var(--dur),color var(--dur)}
66
+ ::-webkit-scrollbar{width:5px;height:5px}
67
+ ::-webkit-scrollbar-track{background:transparent}
68
+ ::-webkit-scrollbar-thumb{background:var(--s3);border-radius:99px}
69
+ ::-webkit-scrollbar-thumb:hover{background:var(--t3)}
70
+
71
+ /* TOOLBAR */
72
+ .toolbar{height:52px;min-height:52px;background:var(--glass);
73
+ backdrop-filter:blur(24px) saturate(180%);-webkit-backdrop-filter:blur(24px) saturate(180%);
74
+ border-bottom:1px solid var(--bd);display:flex;align-items:center;
75
+ padding:0 16px;gap:14px;position:relative;z-index:100;flex-shrink:0;user-select:none}
76
+ .tl-wrap{display:flex;gap:6px;align-items:center;flex-shrink:0}
77
+ .tl{width:12px;height:12px;border-radius:50%;transition:filter var(--dur);cursor:default}
78
+ .tl:hover{filter:brightness(1.25)}
79
+ .tl-r{background:var(--red)}.tl-y{background:var(--yel)}.tl-g{background:var(--acc)}
80
+ .tb-title{position:absolute;left:50%;transform:translateX(-50%);
81
+ font-size:13px;font-weight:600;letter-spacing:-.3px;color:var(--t1);
82
+ pointer-events:none;display:flex;align-items:center;gap:7px;white-space:nowrap}
83
+ .pip{width:7px;height:7px;border-radius:50%;background:var(--acc);
84
+ box-shadow:0 0 6px var(--acc);animation:pip 2.5s ease-in-out infinite}
85
+ @keyframes pip{0%,100%{opacity:1}50%{opacity:.3}}
86
+ .tb-actions{margin-left:auto;display:flex;align-items:center;gap:6px}
87
+ .icon-btn{width:30px;height:30px;border-radius:var(--r-sm);border:1px solid var(--bd);
88
+ background:var(--s1);color:var(--t2);display:flex;align-items:center;justify-content:center;
89
+ cursor:pointer;transition:all var(--dur)}
90
+ .icon-btn:hover{color:var(--t1);background:var(--s2)}
91
+ .icon-btn svg{width:15px;height:15px;pointer-events:none}
92
+
93
+ /* LAYOUT */
94
+ .app-body{flex:1;display:flex;overflow:hidden;min-height:0}
95
+
96
+ /* SIDEBAR */
97
+ .sidebar{width:192px;flex-shrink:0;background:var(--glass);
98
+ backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
99
+ border-right:1px solid var(--bd);display:flex;flex-direction:column;
100
+ padding:10px 8px 16px;gap:2px}
101
+ @media(max-width:640px){.sidebar{display:none}}
102
+ .sb-label{font-size:10px;font-weight:700;text-transform:uppercase;
103
+ letter-spacing:.6px;color:var(--t3);padding:10px 10px 4px}
104
+ .nav-item{display:flex;align-items:center;gap:9px;padding:8px 10px;
105
+ border-radius:var(--r-sm);font-size:13px;font-weight:500;color:var(--t2);
106
+ cursor:pointer;transition:all var(--dur);border:none;background:none;width:100%;text-align:left}
107
+ .nav-item:hover{background:var(--s2);color:var(--t1)}
108
+ .nav-item.active{background:var(--acc-bg);color:var(--acc)}
109
+ .nav-item svg{width:16px;height:16px;flex-shrink:0}
110
+
111
+ /* MAIN */
112
+ .main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0}
113
+ .tab-pane{display:none;flex:1;flex-direction:column;overflow:hidden;padding:14px;min-height:0}
114
+ .tab-pane.active{display:flex;animation:fu var(--dur) ease-out}
115
+ @keyframes fu{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
116
+
117
+ /* WINDOW */
118
+ .win{flex:1;display:flex;flex-direction:column;background:var(--s1);
119
+ border:1px solid var(--bd);border-radius:var(--r-lg);overflow:hidden;
120
+ box-shadow:var(--sh);min-height:0;transition:background var(--dur),border-color var(--dur);
121
+ position:relative}
122
+ .win-bar{height:40px;min-height:40px;background:var(--s2);border-bottom:1px solid var(--bd);
123
+ display:flex;align-items:center;padding:0 14px;gap:10px;flex-shrink:0}
124
+ .win-bar-title{flex:1;text-align:center;font-size:12px;font-weight:600;color:var(--t2)}
125
+ .live-dot{width:6px;height:6px;border-radius:50%;background:var(--acc);box-shadow:0 0 5px var(--acc)}
126
+
127
+ /* CONSOLE */
128
+ .log-out{flex:1;overflow-y:auto;padding:12px 14px;font-family:var(--mono);
129
+ font-size:11.5px;line-height:1.65;color:var(--t2);min-height:0}
130
+ .log-line{word-break:break-all;padding:.5px 0}
131
+ .cmd-bar{display:flex;align-items:center;gap:8px;padding:8px 10px;
132
+ background:var(--s2);border-top:1px solid var(--bd);flex-shrink:0}
133
+ .cmd-prompt{font-family:var(--mono);font-size:13px;color:var(--acc);flex-shrink:0}
134
+ .cmd-in{flex:1;background:var(--s1);border:1px solid var(--bd);border-radius:var(--r-sm);
135
+ padding:6px 12px;font-family:var(--mono);font-size:12px;color:var(--t1);
136
+ outline:none;transition:border-color var(--dur),box-shadow var(--dur)}
137
+ .cmd-in:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-bg)}
138
+ .cmd-in::placeholder{color:var(--t3)}
139
+ .send-btn{width:32px;height:32px;flex-shrink:0;background:var(--acc);border:none;
140
+ border-radius:var(--r-sm);color:#fff;display:flex;align-items:center;justify-content:center;
141
+ cursor:pointer;transition:opacity var(--dur),transform var(--dur)}
142
+ .send-btn:hover{opacity:.85;transform:scale(.95)}
143
+ .send-btn svg{width:14px;height:14px}
144
+
145
+ /* FILES */
146
+ .fm-bar{height:44px;min-height:44px;background:var(--s2);border-bottom:1px solid var(--bd);
147
+ display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0}
148
+ .breadcrumb{flex:1;display:flex;align-items:center;gap:3px;font-size:12px;
149
+ overflow-x:auto;white-space:nowrap}
150
+ .breadcrumb button{background:none;border:none;color:var(--t2);cursor:pointer;
151
+ padding:3px 5px;border-radius:5px;font-size:12px;font-family:var(--f)}
152
+ .breadcrumb button:hover{color:var(--acc);background:var(--acc-bg)}
153
+ .breadcrumb .sep{color:var(--t3);font-size:10px;margin:0 1px}
154
+ .file-list{flex:1;overflow-y:auto}
155
+ .file-row{display:flex;align-items:center;gap:10px;padding:9px 16px;
156
+ border-bottom:1px solid var(--bd);cursor:pointer;transition:background var(--dur)}
157
+ .file-row:hover{background:var(--s2)}
158
+ .file-row:last-child{border-bottom:none}
159
+ .file-name{flex:1;font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
160
+ .file-del{opacity:0;background:none;border:none;color:var(--red);cursor:pointer;padding:4px;
161
+ border-radius:6px;display:flex;align-items:center;transition:opacity var(--dur),background var(--dur)}
162
+ .file-row:hover .file-del{opacity:1}
163
+ .file-del:hover{background:rgba(255,59,48,.1)}
164
+ .drag-ov{position:absolute;inset:0;background:rgba(50,215,75,.06);
165
+ border:2px dashed var(--acc);border-radius:var(--r-lg);
166
+ display:none;align-items:center;justify-content:center;
167
+ font-size:15px;font-weight:600;color:var(--acc);z-index:50}
168
+ .drag-ov.on{display:flex}
169
+
170
+ /* PLUGINS */
171
+ .pl-hd{padding:12px 14px;background:var(--s2);border-bottom:1px solid var(--bd);
172
+ flex-shrink:0;display:flex;flex-direction:column;gap:10px}
173
+ .segment{display:inline-flex;background:var(--s3);border-radius:var(--r-sm);padding:2px}
174
+ .seg-btn{padding:5px 16px;border-radius:6px;border:none;background:none;
175
+ color:var(--t2);font-size:12px;font-weight:500;cursor:pointer;
176
+ transition:all var(--dur);font-family:var(--f)}
177
+ .seg-btn.active{background:var(--s1);color:var(--t1);box-shadow:var(--sh-sm)}
178
+ .pl-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
179
+ .sf-sel,.sf-ver{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r-sm);
180
+ padding:6px 10px;font-size:12px;color:var(--t1);outline:none;
181
+ transition:border-color var(--dur);font-family:var(--f)}
182
+ .sf-sel:focus,.sf-ver:focus{border-color:var(--blu)}
183
+ .sf-ver{width:70px;text-align:center}
184
+ .srch-wrap{position:relative;flex:1;min-width:160px}
185
+ .srch-wrap svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);
186
+ width:13px;height:13px;color:var(--t3);pointer-events:none}
187
+ .sf-srch{width:100%;background:var(--s1);border:1px solid var(--bd);border-radius:var(--r-sm);
188
+ padding:7px 10px 7px 32px;font-size:12px;color:var(--t1);outline:none;
189
+ transition:border-color var(--dur),box-shadow var(--dur);font-family:var(--f)}
190
+ .sf-srch:focus{border-color:var(--blu);box-shadow:0 0 0 3px rgba(10,132,255,.15)}
191
+ .sf-srch::placeholder{color:var(--t3)}
192
+ .srch-btn{padding:7px 14px;background:var(--blu);border:none;border-radius:var(--r-sm);
193
+ color:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:opacity var(--dur);
194
+ flex-shrink:0;font-family:var(--f)}
195
+ .srch-btn:hover{opacity:.85}
196
+ .pl-grid{flex:1;overflow-y:auto;padding:14px;
197
+ display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;align-content:start}
198
+ @media(max-width:640px){.pl-grid{grid-template-columns:1fr}}
199
+ .pl-card{background:var(--s2);border:1px solid var(--bd);border-radius:var(--r);padding:14px;
200
+ display:flex;flex-direction:column;gap:10px;transition:border-color var(--dur),box-shadow var(--dur)}
201
+ .pl-card:hover{border-color:var(--acc);box-shadow:0 0 0 1px var(--acc-bg)}
202
+ .pl-head{display:flex;gap:10px}
203
+ .pl-ico{width:36px;height:36px;border-radius:8px;flex-shrink:0;object-fit:cover;background:var(--s3)}
204
+ .pl-meta{flex:1;min-width:0}
205
+ .pl-name{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
206
+ .pl-desc{font-size:11px;color:var(--t2);line-height:1.5;overflow:hidden;
207
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
208
+ .pl-dl{font-size:10px;color:var(--t3);background:var(--s3);padding:2px 6px;
209
+ border-radius:4px;white-space:nowrap;flex-shrink:0;align-self:flex-start}
210
+ .inst-btn{width:100%;padding:7px;border-radius:var(--r-sm);border:none;
211
+ background:var(--acc);color:#fff;font-size:12px;font-weight:600;cursor:pointer;
212
+ transition:opacity var(--dur);display:flex;align-items:center;justify-content:center;
213
+ gap:5px;font-family:var(--f)}
214
+ .inst-btn:hover{opacity:.85}.inst-btn:disabled{opacity:.4;cursor:not-allowed}
215
+ .pl-empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;
216
+ justify-content:center;gap:10px;color:var(--t3);padding:60px 0}
217
+ .pl-empty svg{width:40px;height:40px;opacity:.25}
218
+ .pl-empty p{font-size:13px}
219
+
220
+ /* SPINNER */
221
+ .spin{width:16px;height:16px;border-radius:50%;border:2px solid var(--bd);
222
+ border-top-color:var(--acc);animation:sp .6s linear infinite;flex-shrink:0}
223
+ @keyframes sp{to{transform:rotate(360deg)}}
224
+
225
+ /* TOAST */
226
+ .toasts{position:fixed;bottom:72px;right:14px;z-index:9999;
227
+ display:flex;flex-direction:column;gap:8px;pointer-events:none}
228
+ @media(min-width:641px){.toasts{bottom:20px}}
229
+ .toast{background:var(--glass);backdrop-filter:blur(20px) saturate(180%);
230
+ -webkit-backdrop-filter:blur(20px) saturate(180%);
231
+ border:1px solid var(--bd);border-radius:var(--r);padding:10px 14px;
232
+ display:flex;align-items:center;gap:9px;font-size:13px;font-weight:500;
233
+ box-shadow:var(--sh);pointer-events:auto;
234
+ transform:translateY(10px);opacity:0;transition:transform .3s ease,opacity .3s ease}
235
+ .toast.show{transform:translateY(0);opacity:1}
236
+
237
+ /* MOBILE NAV */
238
+ .mob-nav{display:none;background:var(--glass);
239
+ backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);
240
+ border-top:1px solid var(--bd);padding-bottom:env(safe-area-inset-bottom,0px);flex-shrink:0}
241
+ @media(max-width:640px){.mob-nav{display:block}}
242
+ .mob-inner{display:flex}
243
+ .mob-btn{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
244
+ gap:3px;padding:9px 4px;border:none;background:none;color:var(--t3);
245
+ font-size:10px;font-weight:500;cursor:pointer;transition:color var(--dur);font-family:var(--f)}
246
+ .mob-btn.active{color:var(--acc)}
247
+ .mob-btn svg{width:22px;height:22px}
248
+ </style>
249
+ </head>
250
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
+ <!-- TOOLBAR -->
253
+ <header class="toolbar">
254
+ <div class="tl-wrap">
255
+ <div class="tl tl-r"></div>
256
+ <div class="tl tl-y"></div>
257
+ <div class="tl tl-g"></div>
258
+ </div>
259
+ <div class="tb-title">
260
+ <div class="pip" id="pip"></div>
261
+ <span>Minecraft Panel</span>
262
+ </div>
263
+ <div class="tb-actions">
264
+ <button class="icon-btn" onclick="toggleTheme()" id="theme-btn" title="Toggle theme">
265
+ <svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
266
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
267
+ </svg>
268
+ </button>
269
+ </div>
270
+ </header>
271
+
272
+ <div class="app-body">
273
+ <!-- SIDEBAR -->
274
+ <aside class="sidebar">
275
+ <div class="sb-label">Navigation</div>
276
+ <button class="nav-item active" id="d-console" onclick="tab('console')">
277
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>
278
+ Console
279
+ </button>
280
+ <button class="nav-item" id="d-files" onclick="tab('files')">
281
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
282
+ Files
283
+ </button>
284
+ <button class="nav-item" id="d-plugins" onclick="tab('plugins')">
285
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>
286
+ Plugins
287
+ </button>
288
+ </aside>
289
+
290
+ <main class="main">
291
+ <!-- CONSOLE -->
292
+ <div class="tab-pane active" id="tab-console">
293
+ <div class="win">
294
+ <div class="win-bar">
295
+ <div class="live-dot"></div>
296
+ <div class="win-bar-title">Live Console</div>
297
+ <button class="icon-btn" onclick="clearLog()" title="Clear log">
298
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
299
+ </button>
300
  </div>
301
+ <div class="log-out" id="logs"></div>
302
+ <div class="cmd-bar">
303
+ <span class="cmd-prompt">β†’</span>
304
+ <input class="cmd-in" id="cmd" type="text" placeholder="Type a command…" autocomplete="off"
305
+ onkeydown="if(event.key==='Enter')sendCmd()">
306
+ <button class="send-btn" onclick="sendCmd()" title="Send">
307
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
308
+ </button>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <!-- FILES -->
314
+ <div class="tab-pane" id="tab-files">
315
+ <div class="win" id="drop-zone">
316
+ <div class="drag-ov" id="drag-ov">Drop file to upload</div>
317
+ <div class="fm-bar">
318
+ <div class="breadcrumb" id="path-bread"></div>
319
+ <button class="icon-btn" onclick="document.getElementById('up-in').click()" title="Upload">
320
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>
321
+ </button>
322
+ <button class="icon-btn" onclick="refreshFiles()" title="Refresh">
323
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
324
+ </button>
325
+ <input type="file" id="up-in" style="display:none" onchange="uploadFile()">
326
+ </div>
327
+ <div class="file-list" id="file-list"></div>
328
+ </div>
329
+ </div>
330
+
331
+ <!-- PLUGINS -->
332
+ <div class="tab-pane" id="tab-plugins">
333
+ <div class="win">
334
+ <div class="pl-hd">
335
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
336
+ <div class="segment">
337
+ <button class="seg-btn active" id="pv-browser" onclick="setPView('browser')">Browse</button>
338
+ <button class="seg-btn" id="pv-installed" onclick="setPView('installed')">Installed</button>
339
+ </div>
340
+ <div class="pl-row" id="pl-ctrl">
341
+ <select class="sf-sel" id="pl-loader">
342
+ <option value="paper">Paper / Spigot</option>
343
+ <option value="purpur">Purpur</option>
344
+ <option value="velocity">Velocity</option>
345
+ <option value="waterfall">Waterfall</option>
346
+ <option value="fabric">Fabric</option>
347
+ </select>
348
+ <input class="sf-ver" id="pl-ver" type="text" value="1.20.4" placeholder="1.x.x">
349
  </div>
350
+ </div>
351
+ <div id="srch-row" style="display:flex;gap:8px">
352
+ <div class="srch-wrap">
353
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
354
+ <input class="sf-srch" id="pl-q" type="text" placeholder="Search Modrinth (e.g. LuckPerms)…"
355
+ onkeydown="if(event.key==='Enter')searchPlugins()">
356
+ </div>
357
+ <button class="srch-btn" onclick="searchPlugins()">Search</button>
358
+ </div>
359
+ </div>
360
+ <div class="pl-grid" id="pl-list">
361
+ <div class="pl-empty">
362
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
363
+ <p>Select loader &amp; version, then search.</p>
364
+ </div>
365
  </div>
366
+ </div>
367
+ </div>
368
+ </main>
369
  </div>
370
 
371
  <!-- MOBILE NAV -->
372
+ <nav class="mob-nav">
373
+ <div class="mob-inner">
374
+ <button class="mob-btn active" id="m-console" onclick="tab('console')">
375
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>
376
+ Console
377
+ </button>
378
+ <button class="mob-btn" id="m-files" onclick="tab('files')">
379
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
380
+ Files
381
+ </button>
382
+ <button class="mob-btn" id="m-plugins" onclick="tab('plugins')">
383
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>
384
+ Plugins
385
+ </button>
386
+ </div>
387
  </nav>
388
 
389
+ <div class="toasts" id="toasts"></div>
 
390
 
391
  <script>
392
+ // ── THEME ──────────────────────────────────────────
393
+ const html=document.documentElement;
394
+ const MOON=`<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>`;
395
+ const SUN=`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>`;
396
+ function applyTheme(t){
397
+ html.dataset.theme=t;
398
+ localStorage.setItem('mc-theme',t);
399
+ document.querySelector('#theme-icon').innerHTML=t==='dark'?MOON:SUN;
400
+ }
401
+ function toggleTheme(){applyTheme(html.dataset.theme==='dark'?'light':'dark');}
402
+ (function(){
403
+ const s=localStorage.getItem('mc-theme');
404
+ if(s)applyTheme(s);
405
+ else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme:light)').matches)applyTheme('light');
406
+ else applyTheme('dark');
407
+ })();
408
+
409
+ // ── TOAST ──────────────────────────────────────────
410
+ function toast(msg,err=false){
411
+ const c=document.getElementById('toasts');
412
+ const d=document.createElement('div');d.className='toast';
413
+ const col=err?'var(--red)':'var(--acc)';
414
+ const ic=err
415
+ ?`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="${col}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`
416
+ :`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="${col}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
417
+ d.innerHTML=ic+`<span>${msg}</span>`;
418
+ c.appendChild(d);
419
+ requestAnimationFrame(()=>requestAnimationFrame(()=>d.classList.add('show')));
420
+ setTimeout(()=>{d.classList.remove('show');setTimeout(()=>d.remove(),350);},3000);
421
  }
422
 
423
+ // ── TABS ────────────────────────────────────────────
424
+ let curTab='console';
425
+ function tab(id){
426
+ curTab=id;
427
+ document.querySelectorAll('.tab-pane').forEach(e=>e.classList.remove('active'));
428
+ document.querySelectorAll('.nav-item,.mob-btn').forEach(e=>e.classList.remove('active'));
429
+ document.getElementById('tab-'+id).classList.add('active');
430
+ ['d-','m-'].forEach(p=>{const el=document.getElementById(p+id);if(el)el.classList.add('active');});
431
+ if(id==='files'&&!curPath)refreshFiles();
432
+ if(id==='plugins'&&curView==='installed')loadInstalled();
 
 
 
 
433
  }
434
 
435
+ // ── CONSOLE ─────────────────────────────────────────
436
+ const logs=document.getElementById('logs');
437
+ let ws,wsRetries=0;
438
+
439
+ function ansiToHtml(raw){
440
+ let s=raw.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
441
+ // \x1b is the ESC character in JS β€” matches actual ANSI escape sequences
442
+ s=s.replace(/\x1b\[(\d+(?:;\d+)*)m/g,(_,codes)=>{
443
+ let o='';
444
+ for(const n of codes.split(';').map(Number)){
445
+ if(n===0)o+='</span>';
446
+ else if(n===1)o+='<span style="font-weight:700">';
447
+ else if(n===2)o+='<span style="opacity:.55">';
448
+ else if(n===3)o+='<span style="font-style:italic">';
449
+ else if(n===30)o+='<span style="color:#555">';
450
+ else if(n===31)o+='<span style="color:#FF6B6B">';
451
+ else if(n===32)o+='<span style="color:#51CF66">';
452
+ else if(n===33)o+='<span style="color:#FFD43B">';
453
+ else if(n===34)o+='<span style="color:#74C0FC">';
454
+ else if(n===35)o+='<span style="color:#CC5DE8">';
455
+ else if(n===36)o+='<span style="color:#22D3EE">';
456
+ else if(n===37)o+='<span style="color:#F8F9FA">';
457
+ else if(n===90)o+='<span style="color:#666">';
458
+ }
459
+ return o;
460
+ });
461
+ s=s.replace(/\x1b\[[^m]*m/g,''); // strip unknown sequences
462
+ return s;
 
 
463
  }
464
+
465
+ function appendLog(text){
466
+ const l=document.createElement('div');l.className='log-line';
467
+ l.innerHTML=ansiToHtml(text);
468
+ logs.appendChild(l);
469
+ if(logs.children.length>500)logs.removeChild(logs.firstChild);
470
+ if(logs.scrollHeight-logs.scrollTop<logs.clientHeight+80)logs.scrollTop=logs.scrollHeight;
471
  }
472
+ function clearLog(){logs.innerHTML='';}
473
+
474
+ function connectWS(){
475
+ const proto=location.protocol==='https:'?'wss:':'ws:';
476
+ ws=new WebSocket(`${proto}//${location.host}/ws`);
477
+ ws.onopen=()=>{
478
+ wsRetries=0;
479
+ document.getElementById('pip').style.background='var(--acc)';
480
+ document.getElementById('pip').style.boxShadow='0 0 6px var(--acc)';
481
+ };
482
+ ws.onmessage=e=>appendLog(e.data);
483
+ ws.onclose=()=>{
484
+ document.getElementById('pip').style.background='var(--red)';
485
+ document.getElementById('pip').style.boxShadow='none';
486
+ const delay=Math.min(1000*(2**wsRetries),30000);wsRetries++;
487
+ appendLog(`\x1b[33m[Panel] Disconnected β€” reconnecting in ${Math.round(delay/1000)}s\x1b[0m`);
488
+ setTimeout(connectWS,delay);
489
+ };
490
+ ws.onerror=()=>ws.close();
491
  }
492
+ connectWS();
493
 
494
+ function sendCmd(){
495
+ const i=document.getElementById('cmd'),v=i.value.trim();if(!v)return;
496
+ if(ws&&ws.readyState===WebSocket.OPEN){ws.send(v);i.value='';}
497
+ else toast('Not connected to server',true);
 
 
 
 
 
 
498
  }
499
 
500
+ // ── FILES ───────────────────────────────────────────
501
+ let curPath='';
502
+ function buildBread(p){
503
+ const el=document.getElementById('path-bread');
504
+ const parts=p.split('/').filter(Boolean);
505
+ let h=`<button onclick="refreshFiles('')"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></button>`;
506
+ parts.forEach((x,i,a)=>{
507
+ const p2=a.slice(0,i+1).join('/');
508
+ h+=`<span class="sep">β€Ί</span><button onclick="refreshFiles('${p2}')">${x}</button>`;
509
+ });
510
+ el.innerHTML=h;
511
+ }
512
+ async function refreshFiles(p=curPath){
513
+ curPath=p;buildBread(p);
514
+ const l=document.getElementById('file-list');
515
+ l.innerHTML=`<div style="display:flex;justify-content:center;padding:32px"><div class="spin"></div></div>`;
516
+ try{
517
+ const r=await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`);
518
+ const d=await r.json();l.innerHTML='';
519
+ if(p)d.unshift({name:'..',is_dir:true,parent:true});
520
+ if(!d.length){l.innerHTML=`<div style="padding:48px;text-align:center;font-size:13px;color:var(--t3)">Empty directory</div>`;return;}
521
+ d.forEach(f=>{
522
+ const row=document.createElement('div');row.className='file-row';
523
+ const fp=(p?p+'/':'')+f.name;
524
+ if(f.parent){
525
+ row.onclick=()=>refreshFiles(p.split('/').slice(0,-1).join('/'));
526
+ row.innerHTML=`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--t3)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg><span class="file-name" style="color:var(--t2)">Back</span>`;
527
+ }else{
528
+ row.onclick=()=>f.is_dir?refreshFiles(fp):null;
529
+ const ico=f.is_dir
530
+ ?`<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--acc)" stroke="none" opacity=".85"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`
531
+ :`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--t3)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
532
+ row.innerHTML=`${ico}<span class="file-name">${f.name}</span>
533
+ <button class="file-del" onclick="event.stopPropagation();delFile('${fp}')" title="Delete">
534
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
535
+ </button>`;
536
+ }
537
+ l.appendChild(row);
538
+ });
539
+ }catch(e){toast('Failed to load files',true);}
540
+ }
541
 
542
+ async function uploadFile(){
543
+ const f=document.getElementById('up-in').files[0];if(!f)return;
544
+ const fd=new FormData();fd.append('path',curPath);fd.append('file',f);
545
+ toast('Uploading…');
546
+ const r=await fetch('/api/fs/upload',{method:'POST',body:fd});
547
+ r.ok?(toast('Uploaded βœ“'),refreshFiles()):toast('Upload failed',true);
548
+ document.getElementById('up-in').value='';
549
+ }
550
+ async function delFile(p){
551
+ if(!confirm('Delete '+p+'?'))return;
552
+ const fd=new FormData();fd.append('path',p);
553
+ const r=await fetch('/api/fs/delete',{method:'POST',body:fd});
554
+ r.ok?(toast('Deleted'),refreshFiles()):toast('Delete failed',true);
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  }
556
 
557
+ // Drag & drop
558
+ const dz=document.getElementById('drop-zone'),dov=document.getElementById('drag-ov');
559
+ if(dz){
560
+ dz.addEventListener('dragover',e=>{e.preventDefault();dov.classList.add('on');});
561
+ dz.addEventListener('dragleave',()=>dov.classList.remove('on'));
562
+ dz.addEventListener('drop',async e=>{
563
+ e.preventDefault();dov.classList.remove('on');
564
+ const file=e.dataTransfer.files[0];if(!file)return;
565
+ const fd=new FormData();fd.append('path',curPath);fd.append('file',file);
566
+ toast('Uploading…');
567
+ const r=await fetch('/api/fs/upload',{method:'POST',body:fd});
568
+ r.ok?(toast('Uploaded βœ“'),refreshFiles()):toast('Upload failed',true);
569
+ });
570
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
+ // ── PLUGINS ─────────────────────────────────────────
573
+ let curView='browser';
574
+ function setPView(v){
575
+ curView=v;
576
+ document.getElementById('pv-browser').className='seg-btn'+(v==='browser'?' active':'');
577
+ document.getElementById('pv-installed').className='seg-btn'+(v==='installed'?' active':'');
578
+ document.getElementById('srch-row').style.display=v==='browser'?'flex':'none';
579
+ document.getElementById('pl-ctrl').style.display=v==='browser'?'flex':'none';
580
+ if(v==='browser'){
581
+ document.getElementById('pl-list').innerHTML=`<div class="pl-empty">
582
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
583
+ <p>Ready to search.</p></div>`;
584
+ }else loadInstalled();
 
 
 
 
 
 
 
 
 
 
 
 
585
  }
586
 
587
+ async function searchPlugins(){
588
+ const q=document.getElementById('pl-q').value.trim();if(!q)return;
589
+ const list=document.getElementById('pl-list');
590
+ list.innerHTML=`<div style="grid-column:1/-1;display:flex;justify-content:center;padding:40px"><div class="spin"></div></div>`;
591
+ try{
592
+ const res=await fetch(`https://api.modrinth.com/v2/search?query=${encodeURIComponent(q)}&facets=[["project_type:plugin"]]&limit=20`);
593
+ const data=await res.json();list.innerHTML='';
594
+ if(!data.hits.length){list.innerHTML=`<div class="pl-empty"><p>No results on Modrinth.</p></div>`;return;}
595
+ data.hits.forEach(p=>{
596
+ const card=document.createElement('div');card.className='pl-card';
597
+ card.innerHTML=`
598
+ <div class="pl-head">
599
+ <img class="pl-ico" src="${p.icon_url||''}" onerror="this.src='https://placehold.co/36x36/3A3A3C/888?text=?'" alt="">
600
+ <div class="pl-meta">
601
+ <div style="display:flex;justify-content:space-between;gap:4px;align-items:flex-start">
602
+ <div class="pl-name" title="${p.title}">${p.title}</div>
603
+ <div class="pl-dl">${p.downloads.toLocaleString()} dl</div>
604
+ </div>
605
+ <div class="pl-desc">${p.description}</div>
606
+ </div>
607
+ </div>
608
+ <button class="inst-btn" id="btn-${p.project_id}"
609
+ onclick="resolveInstall('${p.project_id}','${p.title.replace(/'/g,'')}')">
610
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
611
+ Install
612
+ </button>`;
613
+ list.appendChild(card);
614
+ });
615
+ }catch(e){list.innerHTML=`<div class="pl-empty" style="color:var(--red)"><p>Error connecting to Modrinth.</p></div>`;}
616
+ }
617
 
618
+ async function resolveInstall(id,name){
619
+ const loaderRaw=document.getElementById('pl-loader').value;
620
+ const version=document.getElementById('pl-ver').value.trim();
621
+ const btn=document.getElementById('btn-'+id);
622
+ const ogHtml=btn.innerHTML;
623
+ btn.innerHTML=`<div class="spin" style="border-top-color:#fff;width:13px;height:13px"></div> Checking…`;
624
+ btn.disabled=true;
625
+ let loaders=[loaderRaw];
626
+ if(loaderRaw==='purpur')loaders=['paper','spigot','purpur'];
627
+ if(loaderRaw==='paper')loaders=['paper','spigot'];
628
+ if(loaderRaw==='waterfall')loaders=['bungeecord','waterfall'];
629
+ try{
630
+ const res=await fetch(`https://api.modrinth.com/v2/project/${id}/version?loaders=${JSON.stringify(loaders)}&game_versions=${JSON.stringify([version])}`);
631
+ const versions=await res.json();
632
+ if(!versions.length){toast(`No version for ${loaderRaw} ${version}`,true);setTimeout(()=>{btn.innerHTML=ogHtml;btn.disabled=false;},2000);return;}
633
+ const file=versions[0].files.find(f=>f.primary)||versions[0].files[0];
634
+ btn.innerHTML=`Downloading…`;
635
+ const fd=new FormData();
636
+ fd.append('url',file.url);fd.append('filename',file.filename);
637
+ fd.append('project_id',id);fd.append('version_id',versions[0].id);fd.append('name',name);
638
+ const dl=await fetch('/api/plugins/install',{method:'POST',body:fd});
639
+ if(dl.ok){
640
+ toast(`Installed ${name} βœ“`);
641
+ btn.style.cssText='background:var(--s3);color:var(--acc)';
642
+ btn.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Installed`;
643
+ }else throw new Error();
644
+ }catch(e){toast('Installation failed',true);setTimeout(()=>{btn.innerHTML=ogHtml;btn.disabled=false;},2000);}
645
+ }
646
+
647
+ async function loadInstalled(){
648
+ const l=document.getElementById('pl-list');
649
+ l.innerHTML=`<div style="grid-column:1/-1;display:flex;justify-content:center;padding:40px"><div class="spin"></div></div>`;
650
+ try{
651
+ const r=await fetch('/api/fs/read?path=plugins/plugins.json');if(!r.ok)throw new Error();
652
+ const data=await r.json();l.innerHTML='';
653
+ if(!Object.keys(data).length){l.innerHTML=`<div class="pl-empty"><p>No plugins installed via Panel.</p></div>`;return;}
654
+ for(const[pid,d]of Object.entries(data)){
655
+ const card=document.createElement('div');card.className='pl-card';
656
+ card.innerHTML=`
657
+ <div style="display:flex;justify-content:space-between;align-items:flex-start">
658
+ <div class="pl-name">${d.name}</div>
659
+ <button onclick="delFile('plugins/${d.filename}')" style="background:none;border:none;color:var(--t3);cursor:pointer;padding:2px;border-radius:4px" title="Remove">
660
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
661
+ </button>
662
+ </div>
663
+ <div style="font-family:var(--mono);font-size:10px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.filename}</div>
664
+ <div style="background:var(--s3);color:var(--t2);font-size:11px;padding:5px 10px;border-radius:var(--r-sm);text-align:center">Installed</div>`;
665
+ l.appendChild(card);
666
  }
667
+ }catch(e){l.innerHTML=`<div class="pl-empty"><p>No plugins.json record found.</p></div>`;}
668
  }
669
+ </script>
670
+ </body>
671
+ </html>
672
  """
673
 
674
+ # ─────────────────────────────────────────────
675
+ # BACKEND
676
+ # ─────────────────────────────────────────────
677
  def get_path(p: str):
678
  safe = os.path.abspath(os.path.join(BASE_DIR, (p or "").strip("/")))
679
  if not safe.startswith(BASE_DIR): raise HTTPException(403, "Access Denied")
 
694
  async def boot_mc():
695
  global mc_process
696
  jar = os.path.join(BASE_DIR, "purpur.jar")
697
+
698
+ # ── Wait for background world download (started by start.sh) ──────────
699
+ # start.sh runs: (python3 download_world.py; touch /tmp/world_dl_done) &
700
+ # Without this wait, Minecraft would start before the world is copied in.
701
+ if os.environ.get("FOLDER_URL"):
702
+ output_history.append("\u23f3 [Panel] World download is running in background, waiting\u2026")
703
+ for i in range(600): # up to 10 min
704
+ if os.path.exists("/tmp/world_dl_done"):
705
+ output_history.append("\u2705 [Panel] World download finished! Starting Minecraft\u2026")
706
+ break
707
+ if i > 0 and i % 30 == 0:
708
+ output_history.append(f"\u23f3 [Panel] Still waiting\u2026 ({i}s elapsed)")
709
+ await asyncio.sleep(1)
710
+ else:
711
+ output_history.append("\u26a0 [Panel] Download wait timed out. Starting Minecraft anyway\u2026")
712
+ else:
713
+ open("/tmp/world_dl_done", "w").close() # mark done immediately
714
+
715
  if not os.path.exists(jar):
716
+ output_history.append("\u26a0 [Panel] purpur.jar not found in /app \u2014 upload it via the Files tab.")
717
  return
718
+
719
+ output_history.append("\U0001f680 [Panel] Starting Minecraft server\u2026")
720
  mc_process = await asyncio.create_subprocess_exec(
721
+ "java", "-Xmx4G", "-Xms1G",
722
+ "-Dfile.encoding=UTF-8",
723
+ "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",
724
+ "-XX:MaxGCPauseMillis=200",
725
+ "-jar", jar, "--nogui",
726
+ stdin=asyncio.subprocess.PIPE,
727
+ stdout=asyncio.subprocess.PIPE,
728
+ stderr=asyncio.subprocess.STDOUT,
729
+ cwd=BASE_DIR
730
  )
731
  asyncio.create_task(stream_output(mc_process.stdout))
732
 
733
+ # ─────────────────────────────────────────────
734
+ # ROUTES
735
+ # ─────────────────────────────────────────────
736
  @app.get("/")
737
  def index(): return HTMLResponse(HTML_CONTENT)
738
 
 
740
  async def ws_end(ws: WebSocket):
741
  await ws.accept()
742
  connected_clients.add(ws)
743
+ for line in output_history: await ws.send_text(line)
744
  try:
745
  while True:
746
  cmd = await ws.receive_text()
747
  if mc_process and mc_process.stdin:
748
  mc_process.stdin.write((cmd + "\n").encode())
749
  await mc_process.stdin.drain()
750
+ except:
751
+ connected_clients.discard(ws)
752
+
753
+ @app.get("/api/status")
754
+ def api_status():
755
+ return {"running": mc_process is not None and mc_process.returncode is None}
756
 
 
757
  @app.get("/api/fs/list")
758
+ def list_fs(path: str = ""):
759
  t = get_path(path)
760
  if not os.path.exists(t): return []
761
+ res = [{"name": x, "is_dir": os.path.isdir(os.path.join(t, x))} for x in os.listdir(t)]
 
 
 
762
  return sorted(res, key=lambda k: (not k["is_dir"], k["name"].lower()))
763
 
764
  @app.post("/api/fs/upload")
765
+ async def upload(path: str = Form(""), file: UploadFile = File(...)):
766
  t = get_path(path)
767
  os.makedirs(t, exist_ok=True)
768
+ with open(os.path.join(t, file.filename), "wb") as f:
769
+ shutil.copyfileobj(file.file, f)
770
  return "ok"
771
 
772
  @app.post("/api/fs/delete")
773
+ def delete(path: str = Form(...)):
774
  t = get_path(path)
775
  if os.path.isdir(t): shutil.rmtree(t)
776
  else: os.remove(t)
 
779
  @app.get("/api/fs/read")
780
  def read(path: str):
781
  try:
782
+ with open(get_path(path), "r", encoding="utf-8") as f:
783
+ return json.load(f) if path.endswith(".json") else Response(f.read())
784
+ except:
785
+ raise HTTPException(404)
786
 
 
787
  @app.post("/api/plugins/install")
788
+ def install_pl(
789
+ url: str = Form(...), filename: str = Form(...),
790
+ project_id: str = Form(...), version_id: str = Form(...),
791
+ name: str = Form(...)
792
+ ):
793
  try:
 
794
  dest = os.path.join(PLUGINS_DIR, filename)
795
+ req = urllib.request.Request(url, headers={"User-Agent": "HF-Panel/1.0"})
796
+ with urllib.request.urlopen(req) as r, open(dest, "wb") as f:
797
  shutil.copyfileobj(r, f)
 
 
798
  j_path = os.path.join(PLUGINS_DIR, "plugins.json")
799
  data = {}
800
  if os.path.exists(j_path):
801
  try:
802
+ with open(j_path, "r") as f: data = json.load(f)
803
  except: pass
 
804
  data[project_id] = {
805
+ "name": name, "filename": filename,
806
+ "version_id": version_id, "installed_at": time.time()
 
 
807
  }
808
+ with open(j_path, "w") as f: json.dump(data, f, indent=2)
 
809
  return "ok"
810
  except Exception as e:
811
  raise HTTPException(500, str(e))
812
 
813
  if __name__ == "__main__":
814
+ uvicorn.run(
815
+ app,
816
+ host="0.0.0.0",
817
+ port=int(os.environ.get("PORT", 7860)),
818
+ log_level="error"
819
+ )