kokokoasd commited on
Commit
8e4fdb0
·
verified ·
1 Parent(s): fa5b587

Upload 19 files

Browse files
Files changed (3) hide show
  1. static/app.js +446 -940
  2. static/index.html +354 -259
  3. static/style.css +66 -1274
static/app.js CHANGED
@@ -1,951 +1,457 @@
1
- // ═══════════════════════════════════════════════
2
- // HugPanel — Frontend Controller
3
- // ═══════════════════════════════════════════════
4
-
5
- // ── State ─────────────────────────────────────
6
- let currentZone = null;
7
- let currentPath = "";
8
- let currentEditFile = null;
9
- let cmEditor = null; // CodeMirror instance
10
- let term = null;
11
- let termSocket = null;
12
- let fitAddon = null;
13
- let termDataDisposable = null;
14
- let termResizeDisposable = null;
15
- let termCurrentZone = null; // tracks which zone the terminal is connected to
16
- let promptResolve = null;
17
- let confirmResolve = null;
18
- const mobileQuery = "(max-width: 768px), (pointer: coarse)";
19
- let isMobile = window.matchMedia(mobileQuery).matches;
20
- let currentMobileTab = "files";
21
-
22
- // ── Init ──────────────────────────────────────
23
- document.addEventListener("DOMContentLoaded", () => {
24
- lucide.createIcons();
25
- loadZones();
26
- initResizers();
27
- bindEvents();
28
-
29
- // Track mobile state on resize / input change
30
- const mq = window.matchMedia(mobileQuery);
31
- mq.addEventListener("change", (e) => {
32
- isMobile = e.matches;
33
- document.body.classList.toggle("is-mobile", isMobile);
34
- if (!isMobile) {
35
- // Exiting mobile: reset panel visibility
36
- toggleSidebar(false);
37
- document.getElementById("panel-files").classList.remove("m-active");
38
- document.getElementById("pane-editor").classList.remove("m-active");
39
- document.getElementById("pane-terminal").classList.remove("m-active");
40
- document.getElementById("panel-right").classList.remove("m-active");
41
- } else if (currentZone) {
42
- switchMobileTab(currentMobileTab);
43
- }
44
- });
45
-
46
- // Set initial class
47
- document.body.classList.toggle("is-mobile", isMobile);
48
-
49
- // Apply initial mobile tab if on mobile
50
- if (isMobile && currentZone) {
51
- switchMobileTab("files");
52
- }
53
- });
54
-
55
- // ── API ───────────────────────────────────────
56
- async function api(url, opts = {}) {
57
- const res = await fetch(url, opts);
58
- if (!res.ok) {
59
- const err = await res.json().catch(() => ({ detail: res.statusText }));
60
- throw new Error(err.detail || "API Error");
61
- }
62
- return res.json();
63
- }
64
-
65
- // ── Toast ─────────────────────────────────────
66
- function toast(message, type = "info") {
67
- const container = document.getElementById("toast-container");
68
- const icons = {
69
- success: "check-circle-2",
70
- error: "alert-circle",
71
- info: "info",
72
- };
73
- const el = document.createElement("div");
74
- el.className = `toast toast-${type}`;
75
- el.innerHTML = `<i data-lucide="${icons[type] || 'info'}"></i><span>${escapeHtml(message)}</span>`;
76
- container.appendChild(el);
77
- lucide.createIcons({ nodes: [el] });
78
- setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 3000);
79
- }
80
-
81
- // ── Zone Management ──────────────────────────
82
- async function loadZones() {
83
- const zones = await api("/api/zones");
84
-
85
- // Sidebar zone list
86
- const list = document.getElementById("zone-list");
87
- if (zones.length === 0) {
88
- list.innerHTML = `<li class="empty-hint" style="color:var(--text-3);font-size:12px;padding:8px 10px;cursor:default;opacity:0.6">No zones yet</li>`;
89
- } else {
90
- list.innerHTML = zones.map(z => `
91
- <li data-zone="${escapeAttr(z.name)}" class="${currentZone === z.name ? 'active' : ''}">
92
- <span class="zone-icon"><i data-lucide="box"></i></span>
93
- <span class="zone-name">${escapeHtml(z.name)}</span>
94
- </li>
95
- `).join("");
96
- lucide.createIcons({ nodes: [list] });
97
- }
98
-
99
- // Welcome page zone cards (so users can pick zone without sidebar)
100
- const welcomeZones = document.getElementById("welcome-zones");
101
- if (welcomeZones) {
102
- if (zones.length === 0) {
103
- welcomeZones.innerHTML = `<p class="welcome-hint">No zones yet — create one to get started</p>`;
104
- } else {
105
- welcomeZones.innerHTML = `<div class="zone-grid">${zones.map(z => `
106
- <button class="zone-card" data-zone="${escapeAttr(z.name)}">
107
- <i data-lucide="box"></i>
108
- <span>${escapeHtml(z.name)}</span>
109
- </button>
110
- `).join("")}</div>`;
111
- lucide.createIcons({ nodes: [welcomeZones] });
112
- }
113
- }
114
- }
115
-
116
- async function openZone(name) {
117
- currentZone = name;
118
- currentPath = "";
119
- currentEditFile = null;
120
-
121
- document.getElementById("zone-title").textContent = name;
122
- document.getElementById("welcome").classList.remove("active");
123
- document.getElementById("workspace").classList.add("active");
124
-
125
- // Reset editor
126
- const editorContainer = document.getElementById("editor-container");
127
- editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>Tap a file to open</p></div>`;
128
- document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
129
- lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
130
- cmEditor = null;
131
-
132
- // Highlight
133
- document.querySelectorAll(".zone-list li").forEach(li => {
134
- li.classList.toggle("active", li.dataset.zone === name);
135
- });
136
-
137
- await loadFiles();
138
- loadPorts();
139
-
140
- // Mobile: close sidebar, show files tab
141
- if (isMobile) {
142
- toggleSidebar(false);
143
- switchMobileTab("files");
144
- }
145
-
146
- // Auto-connect terminal
147
- setTimeout(() => connectTerminal(), 200);
148
- }
149
-
150
- async function createZone() {
151
- const name = document.getElementById("input-zone-name").value.trim();
152
- const desc = document.getElementById("input-zone-desc").value.trim();
153
- if (!name) return;
154
- const form = new FormData();
155
- form.append("name", name);
156
- form.append("description", desc);
157
- try {
158
- await api("/api/zones", { method: "POST", body: form });
159
- closeModal("modal-overlay");
160
- document.getElementById("input-zone-name").value = "";
161
- document.getElementById("input-zone-desc").value = "";
162
- toast(`Zone "${name}" created`, "success");
163
- await loadZones();
164
- openZone(name);
165
- } catch (e) {
166
- toast(e.message, "error");
167
- }
168
- }
169
-
170
- async function deleteZone() {
171
- if (!currentZone) return;
172
- const ok = await customConfirm(`Delete zone "${currentZone}"?`, "All data will be lost!");
173
- if (!ok) return;
174
- try {
175
- await api(`/api/zones/${currentZone}`, { method: "DELETE" });
176
- disconnectTerminal();
177
- toast(`Zone "${currentZone}" deleted`, "info");
178
- currentZone = null;
179
- document.getElementById("workspace").classList.remove("active");
180
- document.getElementById("welcome").classList.add("active");
181
- await loadZones();
182
- } catch (e) {
183
- toast(e.message, "error");
184
- }
185
- }
186
-
187
- // ── File Manager ─────────────────────────────
188
- async function loadFiles() {
189
- if (!currentZone) return;
190
- const files = await api(`/api/zones/${currentZone}/files?path=${encodeURIComponent(currentPath)}`);
191
- renderBreadcrumb();
192
- renderFiles(files);
193
- }
194
-
195
- function renderBreadcrumb() {
196
- const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
197
- let html = `<span data-nav="">~</span>`;
198
- let path = "";
199
- for (const part of parts) {
200
- path += (path ? "/" : "") + part;
201
- html += `<span class="sep">/</span>`;
202
- html += `<span data-nav="${escapeAttr(path)}">${escapeHtml(part)}</span>`;
203
- }
204
- // Update both desktop and mobile breadcrumbs
205
- document.getElementById("breadcrumb").innerHTML = html;
206
- const mbc = document.getElementById("breadcrumb-mobile");
207
- if (mbc) mbc.innerHTML = html;
208
- }
209
-
210
- function renderFiles(files) {
211
- const list = document.getElementById("file-list");
212
- if (files.length === 0 && !currentPath) {
213
- list.innerHTML = `<div class="empty-state"><i data-lucide="folder-open"></i><p>Empty zone — create a file or upload</p></div>`;
214
- lucide.createIcons({ nodes: [list] });
215
- return;
216
- }
217
- let html = "";
218
- if (currentPath) {
219
- html += `<div class="file-item" data-action="up">
220
- <span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
221
- <span class="fi-name">..</span>
222
- </div>`;
223
- }
224
- const sorted = [...files].sort((a, b) => {
225
- if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
226
- return a.name.localeCompare(b.name);
227
- });
228
- for (const f of sorted) {
229
- const relPath = currentPath ? `${currentPath}/${f.name}` : f.name;
230
- const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
231
- const iconName = f.is_dir ? "folder" : "file-text";
232
- const size = f.is_dir ? "" : formatSize(f.size);
233
- const action = f.is_dir ? "navigate" : "edit";
234
-
235
- html += `<div class="file-item" data-action="${action}" data-path="${escapeAttr(relPath)}">
236
- <span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
237
- <span class="fi-name">${escapeHtml(f.name)}</span>
238
- <span class="fi-size">${size}</span>
239
- <span class="fi-actions">
240
- ${!f.is_dir ? `<button data-action="download" data-path="${escapeAttr(relPath)}" title="Download"><i data-lucide="download"></i></button>` : ''}
241
- <button data-action="rename" data-path="${escapeAttr(relPath)}" data-name="${escapeAttr(f.name)}" title="Rename"><i data-lucide="pencil"></i></button>
242
- <button data-action="delete" data-path="${escapeAttr(relPath)}" class="fi-del" title="Delete"><i data-lucide="trash-2"></i></button>
243
- </span>
244
- </div>`;
245
- }
246
- list.innerHTML = html;
247
- lucide.createIcons({ nodes: [list] });
248
- }
249
-
250
- function navigateTo(path) { currentPath = path; loadFiles(); }
251
-
252
- function navigateUp() {
253
- const parts = currentPath.split("/").filter(Boolean);
254
- parts.pop();
255
- currentPath = parts.join("/");
256
- loadFiles();
257
- }
258
-
259
- // ── Editor (CodeMirror) ──────────────────────
260
- function getMode(filename) {
261
- const ext = filename.split(".").pop().toLowerCase();
262
- const modes = {
263
- js: "javascript", mjs: "javascript", jsx: "javascript",
264
- ts: "text/typescript", tsx: "text/typescript",
265
- py: "python",
266
- go: "go",
267
- rs: "rust",
268
- html: "htmlmixed", htm: "htmlmixed",
269
- css: "css", scss: "css", less: "css",
270
- json: { name: "javascript", json: true },
271
- md: "markdown",
272
- sh: "shell", bash: "shell", zsh: "shell",
273
- yml: "yaml", yaml: "yaml",
274
- toml: "toml",
275
- xml: "xml", svg: "xml",
276
- dockerfile: "dockerfile",
277
- };
278
- // Special filename matches
279
- if (filename.toLowerCase() === "dockerfile") return "dockerfile";
280
- return modes[ext] || "text/plain";
281
- }
282
-
283
- async function editFile(relPath) {
284
- try {
285
- const data = await api(`/api/zones/${currentZone}/files/read?path=${encodeURIComponent(relPath)}`);
286
- currentEditFile = relPath;
287
- const filename = relPath.split("/").pop();
288
-
289
- // Update tab
290
- document.getElementById("editor-tabs").innerHTML = `
291
- <span class="pane-tab">
292
- <i data-lucide="file-text"></i>
293
- <span class="tab-name">${escapeHtml(filename)}</span>
294
- <span class="tab-dot" id="editor-modified" style="display:none"></span>
295
- </span>`;
296
- lucide.createIcons({ nodes: [document.getElementById("editor-tabs")] });
297
-
298
- // Init CodeMirror
299
- const container = document.getElementById("editor-container");
300
- container.innerHTML = "";
301
- cmEditor = CodeMirror(container, {
302
- value: data.content,
303
- mode: getMode(filename),
304
- theme: "material-darker",
305
- lineNumbers: true,
306
- lineWrapping: isMobile,
307
- indentWithTabs: false,
308
- indentUnit: 4,
309
- tabSize: 4,
310
- matchBrackets: true,
311
- autoCloseBrackets: true,
312
- styleActiveLine: true,
313
- extraKeys: {
314
- "Ctrl-S": () => saveFile(),
315
- "Cmd-S": () => saveFile(),
316
- "Tab": (cm) => {
317
- if (cm.somethingSelected()) cm.indentSelection("add");
318
- else cm.replaceSelection(" ", "end");
319
- },
320
- }
321
  });
322
-
323
- // Track modifications
324
- cmEditor.on("change", () => {
325
- const dot = document.getElementById("editor-modified");
326
- if (dot) dot.style.display = "block";
327
- const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
328
- if (mTab) mTab.classList.add('has-dot');
 
 
 
 
329
  });
 
330
 
331
- // Auto-switch to editor tab on mobile
332
- if (isMobile) switchMobileTab('editor');
333
-
334
- // Focus editor
335
- setTimeout(() => cmEditor.refresh(), 50);
336
- } catch (e) {
337
- toast(e.message, "error");
338
- }
339
- }
340
-
341
- async function saveFile() {
342
- if (!currentEditFile || !currentZone || !cmEditor) return;
343
- const content = cmEditor.getValue();
344
- const form = new FormData();
345
- form.append("path", currentEditFile);
346
- form.append("content", content);
347
- try {
348
- await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
349
- const dot = document.getElementById("editor-modified");
350
- if (dot) dot.style.display = "none";
351
- const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
352
- if (mTab) mTab.classList.remove('has-dot');
353
- toast("File saved", "success");
354
- } catch (e) {
355
- toast("Save failed: " + e.message, "error");
356
- }
357
- }
358
-
359
- function downloadFile(relPath) {
360
- window.open(`/api/zones/${currentZone}/files/download?path=${encodeURIComponent(relPath)}`);
361
- }
362
-
363
- async function deleteFile(relPath) {
364
- const ok = await customConfirm(`Delete "${relPath}"?`);
365
- if (!ok) return;
366
- try {
367
- await api(`/api/zones/${currentZone}/files?path=${encodeURIComponent(relPath)}`, { method: "DELETE" });
368
- toast("Deleted", "info");
369
- await loadFiles();
370
- } catch (e) {
371
- toast(e.message, "error");
372
- }
373
- }
374
-
375
- async function renameFile(oldPath, oldName) {
376
- const newName = await promptUser("Rename", oldName);
377
- if (!newName || newName === oldName) return;
378
- const form = new FormData();
379
- form.append("old_path", oldPath);
380
- form.append("new_name", newName);
381
- try {
382
- await api(`/api/zones/${currentZone}/files/rename`, { method: "POST", body: form });
383
- toast("Renamed", "success");
384
- await loadFiles();
385
- } catch (e) {
386
- toast(e.message, "error");
387
- }
388
- }
389
-
390
- async function createNewFile() {
391
- const name = await promptUser("New file name", "");
392
- if (!name) return;
393
- const path = currentPath ? `${currentPath}/${name}` : name;
394
- const form = new FormData();
395
- form.append("path", path);
396
- form.append("content", "");
397
- try {
398
- await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
399
- toast("File created", "success");
400
- await loadFiles();
401
- } catch (e) {
402
- toast(e.message, "error");
403
- }
404
- }
405
-
406
- async function createNewFolder() {
407
- const name = await promptUser("New folder name", "");
408
- if (!name) return;
409
- const path = currentPath ? `${currentPath}/${name}` : name;
410
- const form = new FormData();
411
- form.append("path", path);
412
- try {
413
- await api(`/api/zones/${currentZone}/files/mkdir`, { method: "POST", body: form });
414
- toast("Folder created", "success");
415
- await loadFiles();
416
- } catch (e) {
417
- toast(e.message, "error");
418
- }
419
- }
420
-
421
- function uploadFiles() {
422
- document.getElementById("file-upload-input").click();
423
- }
424
-
425
- async function handleUpload(event) {
426
- const files = event.target.files;
427
- if (!files.length) return;
428
- for (const file of files) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const form = new FormData();
430
- form.append("path", currentPath);
431
- form.append("file", file);
432
  try {
433
- await api(`/api/zones/${currentZone}/files/upload`, { method: "POST", body: form });
434
- toast(`Uploaded ${file.name}`, "success");
435
- } catch (e) {
436
- toast(`Upload ${file.name} failed: ${e.message}`, "error");
 
 
 
 
 
 
 
 
 
 
 
 
437
  }
438
- }
439
- event.target.value = "";
440
- await loadFiles();
441
- }
442
-
443
- // ── Terminal ─────────────────────────────────
444
- function connectTerminal() {
445
- if (!currentZone) return;
446
-
447
- if (!term) {
448
- term = new window.Terminal({
449
- cursorBlink: true,
450
- fontSize: isMobile ? 11 : 13,
451
- fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
452
- theme: {
453
- background: "#09090b",
454
- foreground: "#fafafa",
455
- cursor: "#3b82f6",
456
- selectionBackground: "rgba(59,130,246,0.3)",
457
- black: "#27272a",
458
- red: "#ef4444",
459
- green: "#22c55e",
460
- yellow: "#f59e0b",
461
- blue: "#3b82f6",
462
- magenta: "#a855f7",
463
- cyan: "#06b6d4",
464
- white: "#e4e4e7",
465
- brightBlack: "#52525b",
466
- brightRed: "#f87171",
467
- brightGreen: "#4ade80",
468
- brightYellow: "#fbbf24",
469
- brightBlue: "#60a5fa",
470
- brightMagenta: "#c084fc",
471
- brightCyan: "#22d3ee",
472
- brightWhite: "#fafafa",
473
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  });
475
- fitAddon = new window.FitAddon.FitAddon();
476
- term.loadAddon(fitAddon);
477
- term.loadAddon(new window.WebLinksAddon.WebLinksAddon());
478
- const container = document.getElementById("terminal-container");
479
- container.innerHTML = "";
480
- term.open(container);
481
- fitAddon.fit();
482
- }
483
-
484
- if (termSocket) { termSocket.close(); termSocket = null; }
485
- if (termDataDisposable) { termDataDisposable.dispose(); termDataDisposable = null; }
486
- if (termResizeDisposable) { termResizeDisposable.dispose(); termResizeDisposable = null; }
487
-
488
- // Only clear the terminal when switching to a different zone
489
- const switchingZone = termCurrentZone !== currentZone;
490
- if (switchingZone) {
491
- term.reset();
492
- termCurrentZone = currentZone;
493
- }
494
-
495
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
496
- termSocket = new WebSocket(`${proto}//${location.host}/ws/terminal/${currentZone}`);
497
- termSocket.binaryType = "arraybuffer";
498
-
499
- termSocket.onopen = () => {
500
- termSocket.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols }));
501
- };
502
-
503
- termSocket.onmessage = (event) => {
504
- if (event.data instanceof ArrayBuffer) term.write(new Uint8Array(event.data));
505
- else term.write(event.data);
506
- };
507
-
508
- termSocket.onclose = () => {
509
- term.writeln("\r\n\x1b[2m── disconnected ──\x1b[0m");
510
- };
511
-
512
- termSocket.onerror = () => {
513
- term.writeln("\r\n\x1b[31m── connection error ──\x1b[0m");
514
- };
515
-
516
- termDataDisposable = term.onData((data) => {
517
- if (termSocket && termSocket.readyState === WebSocket.OPEN) {
518
- termSocket.send(JSON.stringify({ type: "input", data }));
519
  }
520
- });
521
 
522
- termResizeDisposable = term.onResize(({ rows, cols }) => {
523
- if (termSocket && termSocket.readyState === WebSocket.OPEN) {
524
- termSocket.send(JSON.stringify({ type: "resize", rows, cols }));
525
- }
526
- });
527
- }
528
-
529
- function disconnectTerminal() {
530
- if (termSocket) { termSocket.close(); termSocket = null; }
531
- }
532
-
533
- function reconnectTerminal() {
534
- disconnectTerminal();
535
- connectTerminal();
536
- }
537
-
538
- // ── Resizable Panels ─────────────────────────
539
- function initResizers() {
540
- // Vertical resizer (file panel width)
541
- const rv = document.getElementById("resizer-v");
542
- const panelFiles = document.getElementById("panel-files");
543
- if (rv && panelFiles) {
544
- let startX, startW;
545
- rv.addEventListener("mousedown", (e) => {
546
- e.preventDefault();
547
- startX = e.clientX;
548
- startW = panelFiles.offsetWidth;
549
- rv.classList.add("active");
550
- const onMove = (e) => {
551
- const w = Math.max(180, Math.min(500, startW + e.clientX - startX));
552
- panelFiles.style.width = w + "px";
553
- };
554
- const onUp = () => {
555
- rv.classList.remove("active");
556
- document.removeEventListener("mousemove", onMove);
557
- document.removeEventListener("mouseup", onUp);
558
- if (fitAddon) fitAddon.fit();
559
- if (cmEditor) cmEditor.refresh();
560
- };
561
- document.addEventListener("mousemove", onMove);
562
- document.addEventListener("mouseup", onUp);
563
- });
564
- }
565
-
566
- // Horizontal resizer (terminal height)
567
- const rh = document.getElementById("resizer-h");
568
- const paneTerminal = document.getElementById("pane-terminal");
569
- if (rh && paneTerminal) {
570
- let startY, startH;
571
- rh.addEventListener("mousedown", (e) => {
572
- e.preventDefault();
573
- startY = e.clientY;
574
- startH = paneTerminal.offsetHeight;
575
- rh.classList.add("active");
576
- const onMove = (e) => {
577
- const h = Math.max(100, Math.min(600, startH - (e.clientY - startY)));
578
- paneTerminal.style.height = h + "px";
579
- paneTerminal.style.flex = "none";
580
- };
581
- const onUp = () => {
582
- rh.classList.remove("active");
583
- document.removeEventListener("mousemove", onMove);
584
- document.removeEventListener("mouseup", onUp);
585
- if (fitAddon) fitAddon.fit();
586
- if (cmEditor) cmEditor.refresh();
587
- };
588
- document.addEventListener("mousemove", onMove);
589
- document.addEventListener("mouseup", onUp);
590
- });
591
- }
592
- }
593
-
594
- // ── Modal / Prompt / Confirm ─────────────────
595
- function showModal(id) {
596
- document.getElementById(id).classList.remove("hidden");
597
- lucide.createIcons({ nodes: [document.getElementById(id)] });
598
- }
599
-
600
- function closeModal(id) {
601
- document.getElementById(id).classList.add("hidden");
602
- }
603
-
604
- function promptUser(title, defaultValue) {
605
- return new Promise((resolve) => {
606
- document.getElementById("prompt-title").textContent = title;
607
- document.getElementById("prompt-input").value = defaultValue || "";
608
- showModal("modal-prompt");
609
- setTimeout(() => document.getElementById("prompt-input").focus(), 100);
610
- promptResolve = resolve;
611
- });
612
- }
613
-
614
- function customConfirm(title, message) {
615
- return new Promise((resolve) => {
616
- document.getElementById("confirm-title").textContent = title || "Confirm";
617
- document.getElementById("confirm-message").textContent = message || title || "Are you sure?";
618
- showModal("modal-confirm");
619
- confirmResolve = resolve;
620
- });
621
- }
622
-
623
- function resolveConfirm(value) {
624
- closeModal("modal-confirm");
625
- if (confirmResolve) { confirmResolve(value); confirmResolve = null; }
626
- }
627
-
628
- // ── Port Management ──────────────────────────
629
- async function loadPorts() {
630
- if (!currentZone) return;
631
- try {
632
- const ports = await api(`/api/zones/${currentZone}/ports`);
633
- renderPorts(ports);
634
- const badge = document.getElementById("port-count");
635
- if (ports.length > 0) {
636
- badge.textContent = ports.length;
637
- badge.style.display = "inline";
638
  } else {
639
- badge.style.display = "none";
640
- }
641
- } catch (e) {
642
- // Zone might not exist yet
643
- }
644
- }
645
-
646
- function renderPorts(ports) {
647
- const list = document.getElementById("port-list");
648
- if (ports.length === 0) {
649
- list.innerHTML = `<div class="port-empty">No ports mapped yet</div>`;
650
- return;
651
- }
652
- list.innerHTML = ports.map(p => `
653
- <div class="port-item">
654
- <span class="pi-port">:${p.port}</span>
655
- <span class="pi-label">${escapeHtml(p.label)}</span>
656
- <span class="pi-actions">
657
- <button title="Open in new tab" data-action="open-port" data-port="${p.port}"><i data-lucide="external-link"></i></button>
658
- <button title="Copy URL" data-action="copy-port" data-port="${p.port}"><i data-lucide="copy"></i></button>
659
- <button class="pi-del" title="Remove" data-action="remove-port" data-port="${p.port}"><i data-lucide="trash-2"></i></button>
660
- </span>
661
- </div>
662
- `).join("");
663
- lucide.createIcons({ nodes: [list] });
664
- }
665
-
666
- function togglePortPanel() {
667
- const panel = document.getElementById("port-panel");
668
- if (panel.classList.contains("hidden")) {
669
- // Position panel below the button
670
- const btn = document.getElementById("btn-toggle-ports");
671
- const rect = btn.getBoundingClientRect();
672
- panel.style.top = (rect.bottom + 6) + "px";
673
- panel.classList.remove("hidden");
674
- lucide.createIcons({ nodes: [panel] });
675
- loadPorts();
676
- } else {
677
- panel.classList.add("hidden");
678
- }
679
- }
680
-
681
- async function addPort() {
682
- const portInput = document.getElementById("input-port-number");
683
- const labelInput = document.getElementById("input-port-label");
684
- const port = parseInt(portInput.value);
685
- const label = labelInput.value.trim();
686
- if (!port) return;
687
-
688
- const form = new FormData();
689
- form.append("port", port);
690
- form.append("label", label);
691
- try {
692
- await api(`/api/zones/${currentZone}/ports`, { method: "POST", body: form });
693
- closeModal("modal-add-port");
694
- portInput.value = "";
695
- labelInput.value = "";
696
- toast(`Port ${port} mapped`, "success");
697
- await loadPorts();
698
- } catch (e) {
699
- toast(e.message, "error");
700
- }
701
- }
702
-
703
- async function removePort(port) {
704
- const ok = await customConfirm(`Remove port ${port} mapping?`);
705
- if (!ok) return;
706
- try {
707
- await api(`/api/zones/${currentZone}/ports/${port}`, { method: "DELETE" });
708
- toast(`Port ${port} removed`, "info");
709
- await loadPorts();
710
- } catch (e) {
711
- toast(e.message, "error");
712
- }
713
- }
714
-
715
- function openPort(port) {
716
- window.open(`/port/${currentZone}/${port}/`, "_blank");
717
- }
718
-
719
- function copyPortUrl(port) {
720
- const url = `${location.origin}/port/${currentZone}/${port}/`;
721
- navigator.clipboard.writeText(url).then(() => {
722
- toast("URL copied", "success");
723
- }).catch(() => {
724
- toast(url, "info");
725
- });
726
- }
727
-
728
- // ── Utility ──────────────────────────────────
729
- function escapeHtml(str) {
730
- const div = document.createElement("div");
731
- div.textContent = str;
732
- return div.innerHTML;
733
- }
734
-
735
- function escapeAttr(str) {
736
- return str.replace(/'/g, "\\'").replace(/"/g, "&quot;");
737
- }
738
-
739
- function formatSize(bytes) {
740
- if (bytes === 0) return "0 B";
741
- const units = ["B", "KB", "MB", "GB"];
742
- const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
743
- return (bytes / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + units[i];
744
- }
745
-
746
- function fileIconClass(name) {
747
- const ext = name.split(".").pop().toLowerCase();
748
- const classes = {
749
- js: "fi-icon-js", mjs: "fi-icon-js", jsx: "fi-icon-js",
750
- ts: "fi-icon-ts", tsx: "fi-icon-ts",
751
- py: "fi-icon-py",
752
- go: "fi-icon-go",
753
- html: "fi-icon-html", htm: "fi-icon-html",
754
- css: "fi-icon-css", scss: "fi-icon-css",
755
- json: "fi-icon-json",
756
- md: "fi-icon-md",
757
- png: "fi-icon-img", jpg: "fi-icon-img", jpeg: "fi-icon-img", gif: "fi-icon-img", svg: "fi-icon-img",
758
- yml: "fi-icon-config", yaml: "fi-icon-config", toml: "fi-icon-config",
759
- };
760
- return classes[ext] || "fi-icon-file";
761
- }
762
-
763
- // ── Mobile: Sidebar ─────────────────────────
764
- function toggleSidebar(open) {
765
- const sidebar = document.getElementById("sidebar");
766
- const backdrop = document.getElementById("sidebar-backdrop");
767
- const hamburger = document.getElementById("btn-hamburger");
768
- if (open) {
769
- sidebar.classList.add("open");
770
- backdrop.classList.add("open");
771
- if (hamburger) hamburger.style.display = "none";
772
- } else {
773
- sidebar.classList.remove("open");
774
- backdrop.classList.remove("open");
775
- if (hamburger && isMobile) hamburger.style.display = "";
776
- }
777
- }
778
-
779
- // ── Mobile: Tab Switching ───────────────────
780
- function switchMobileTab(tab) {
781
- currentMobileTab = tab;
782
-
783
- // Update tab bar highlighting
784
- document.querySelectorAll(".mobile-tab").forEach(btn => {
785
- btn.classList.toggle("active", btn.dataset.tab === tab);
786
- });
787
-
788
- const panelFiles = document.getElementById("panel-files");
789
- const panelRight = document.getElementById("panel-right");
790
- const paneEditor = document.getElementById("pane-editor");
791
- const paneTerminal = document.getElementById("pane-terminal");
792
-
793
- // Remove all active
794
- panelFiles.classList.remove("m-active");
795
- panelRight.classList.remove("m-active");
796
- paneEditor.classList.remove("m-active");
797
- paneTerminal.classList.remove("m-active");
798
-
799
- if (tab === "files") {
800
- panelFiles.classList.add("m-active");
801
- } else if (tab === "editor") {
802
- panelRight.classList.add("m-active");
803
- paneEditor.classList.add("m-active");
804
- setTimeout(() => { if (cmEditor) cmEditor.refresh(); }, 50);
805
- } else if (tab === "terminal") {
806
- panelRight.classList.add("m-active");
807
- paneTerminal.classList.add("m-active");
808
- setTimeout(() => {
809
- if (fitAddon) fitAddon.fit();
810
- if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
811
- connectTerminal();
812
- }
813
- }, 50);
814
- }
815
- }
816
-
817
- // ── Event Binding ────────────────────────────
818
- function bindEvents() {
819
- // ── Static button bindings ────────────────
820
- document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
821
- document.getElementById("form-create-zone").addEventListener("submit", (e) => { e.preventDefault(); createZone(); });
822
- document.getElementById("btn-delete-zone").addEventListener("click", deleteZone);
823
- document.getElementById("btn-new-file").addEventListener("click", createNewFile);
824
- document.getElementById("btn-new-folder").addEventListener("click", createNewFolder);
825
- document.getElementById("btn-upload").addEventListener("click", uploadFiles);
826
- document.getElementById("file-upload-input").addEventListener("change", handleUpload);
827
- document.getElementById("btn-save-file").addEventListener("click", saveFile);
828
- document.getElementById("btn-reconnect").addEventListener("click", reconnectTerminal);
829
- document.getElementById("btn-hamburger").addEventListener("click", () => toggleSidebar(true));
830
- document.getElementById("sidebar-backdrop").addEventListener("click", () => toggleSidebar(false));
831
- document.getElementById("btn-close-sidebar").addEventListener("click", () => toggleSidebar(false));
832
- document.getElementById("btn-create-zone-welcome").addEventListener("click", () => document.getElementById("btn-add-zone").click());
833
-
834
- // Port management
835
- document.getElementById("btn-toggle-ports").addEventListener("click", togglePortPanel);
836
- document.getElementById("btn-add-port").addEventListener("click", () => showModal("modal-add-port"));
837
- document.getElementById("form-add-port").addEventListener("submit", (e) => { e.preventDefault(); addPort(); });
838
-
839
- // Modal close/cancel buttons
840
- document.getElementById("btn-close-create-modal").addEventListener("click", () => closeModal("modal-overlay"));
841
- document.getElementById("btn-cancel-create-modal").addEventListener("click", () => closeModal("modal-overlay"));
842
- document.getElementById("btn-close-prompt").addEventListener("click", () => {
843
- closeModal("modal-prompt");
844
- if (promptResolve) { promptResolve(null); promptResolve = null; }
845
- });
846
- document.getElementById("btn-close-port-modal").addEventListener("click", () => closeModal("modal-add-port"));
847
- document.getElementById("btn-cancel-port-modal").addEventListener("click", () => closeModal("modal-add-port"));
848
-
849
- // Confirm modal
850
- document.getElementById("btn-close-confirm").addEventListener("click", () => resolveConfirm(false));
851
- document.getElementById("btn-confirm-cancel").addEventListener("click", () => resolveConfirm(false));
852
- document.getElementById("btn-confirm-ok").addEventListener("click", () => resolveConfirm(true));
853
-
854
- // Prompt form
855
- document.getElementById("btn-cancel-prompt").addEventListener("click", () => {
856
- closeModal("modal-prompt");
857
- if (promptResolve) { promptResolve(null); promptResolve = null; }
858
- });
859
- document.getElementById("form-prompt").addEventListener("submit", (e) => {
860
- e.preventDefault();
861
- const val = document.getElementById("prompt-input").value;
862
- closeModal("modal-prompt");
863
- if (promptResolve) { promptResolve(val); promptResolve = null; }
864
- });
865
-
866
- // Close port panel when clicking outside
867
- document.addEventListener("click", (e) => {
868
- const panel = document.getElementById("port-panel");
869
- const toggle = document.getElementById("btn-toggle-ports");
870
- if (!panel.classList.contains("hidden") && !panel.contains(e.target) && !toggle.contains(e.target)) {
871
- panel.classList.add("hidden");
872
  }
873
- });
874
-
875
- // ── Event delegation: file list ───────────
876
- document.getElementById("file-list").addEventListener("click", (e) => {
877
- // Check action buttons first (download, rename, delete)
878
- const actionBtn = e.target.closest(".fi-actions [data-action]");
879
- if (actionBtn) {
880
- e.stopPropagation();
881
- const action = actionBtn.dataset.action;
882
- const path = actionBtn.dataset.path;
883
- if (action === "download") downloadFile(path);
884
- else if (action === "rename") renameFile(path, actionBtn.dataset.name);
885
- else if (action === "delete") deleteFile(path);
886
- return;
887
- }
888
- // File item click (open/navigate/up)
889
- const item = e.target.closest(".file-item[data-action]");
890
- if (!item) return;
891
- const action = item.dataset.action;
892
- const path = item.dataset.path;
893
- if (action === "navigate") navigateTo(path);
894
- else if (action === "edit") editFile(path);
895
- else if (action === "up") navigateUp();
896
- });
897
-
898
- // ── Event delegation: zone list (sidebar)
899
- document.getElementById("zone-list").addEventListener("click", (e) => {
900
- const li = e.target.closest("li[data-zone]");
901
- if (li) openZone(li.dataset.zone);
902
- });
903
-
904
- // ── Event delegation: welcome zone cards ──
905
- document.getElementById("welcome-zones").addEventListener("click", (e) => {
906
- const card = e.target.closest("[data-zone]");
907
- if (card) openZone(card.dataset.zone);
908
- });
909
-
910
- // ── Event delegation: breadcrumbs ─────────
911
- document.getElementById("breadcrumb").addEventListener("click", (e) => {
912
- const span = e.target.closest("[data-nav]");
913
- if (span) navigateTo(span.dataset.nav);
914
- });
915
- const mbc = document.getElementById("breadcrumb-mobile");
916
- if (mbc) {
917
- mbc.addEventListener("click", (e) => {
918
- const span = e.target.closest("[data-nav]");
919
- if (span) navigateTo(span.dataset.nav);
920
- });
921
- }
922
-
923
- // ── Event delegation: mobile tabs ─────────
924
- document.getElementById("mobile-tabs").addEventListener("click", (e) => {
925
- const tab = e.target.closest("[data-tab]");
926
- if (tab) switchMobileTab(tab.dataset.tab);
927
- });
928
-
929
- // ── Event delegation: port list ───────────
930
- document.getElementById("port-list").addEventListener("click", (e) => {
931
- const btn = e.target.closest("[data-action]");
932
- if (!btn) return;
933
- const port = parseInt(btn.dataset.port);
934
- if (btn.dataset.action === "open-port") openPort(port);
935
- else if (btn.dataset.action === "copy-port") copyPortUrl(port);
936
- else if (btn.dataset.action === "remove-port") removePort(port);
937
- });
938
-
939
- // ── Keyboard shortcuts ────────────────────
940
- document.addEventListener("keydown", (e) => {
941
- if ((e.ctrlKey || e.metaKey) && e.key === "s") {
942
- e.preventDefault();
943
- saveFile();
944
- }
945
- });
946
-
947
- window.addEventListener("resize", () => {
948
- if (fitAddon) fitAddon.fit();
949
- if (cmEditor) cmEditor.refresh();
950
- });
951
- }
 
1
+ function hugpanel() {
2
+ return {
3
+ // ── State ──
4
+ sidebarOpen: false,
5
+ zones: [],
6
+ currentZone: null,
7
+ activeTab: 'files',
8
+ tabs: [
9
+ { id: 'files', label: 'Files', icon: 'folder' },
10
+ { id: 'editor', label: 'Editor', icon: 'file-code' },
11
+ { id: 'terminal', label: 'Terminal', icon: 'terminal' },
12
+ { id: 'ports', label: 'Ports', icon: 'radio' },
13
+ ],
14
+
15
+ // Files
16
+ files: [],
17
+ currentPath: '',
18
+ filesLoading: false,
19
+ showNewFile: false,
20
+ showNewFolder: false,
21
+ newFileName: '',
22
+ newFolderName: '',
23
+
24
+ // Editor
25
+ editorFile: null,
26
+ editorContent: '',
27
+ editorOriginal: '',
28
+ editorDirty: false,
29
+
30
+ // Terminal
31
+ term: null,
32
+ termWs: null,
33
+ termFit: null,
34
+ termZone: null,
35
+
36
+ // Ports
37
+ ports: [],
38
+ newPort: null,
39
+ newPortLabel: '',
40
+
41
+ // Create Zone
42
+ showCreateZone: false,
43
+ createZoneName: '',
44
+ createZoneDesc: '',
45
+
46
+ // Rename
47
+ showRename: false,
48
+ renameOldPath: '',
49
+ renameNewName: '',
50
+
51
+ // Toast
52
+ toast: { show: false, message: '', type: 'info' },
53
+
54
+ // ── Computed ──
55
+ get currentPathParts() {
56
+ return this.currentPath ? this.currentPath.split('/').filter(Boolean) : [];
57
+ },
58
+
59
+ // ── Init ──
60
+ async init() {
61
+ await this.loadZones();
62
+ this.$nextTick(() => lucide.createIcons());
63
+
64
+ // Watch for icon updates
65
+ this.$watch('zones', () => this.$nextTick(() => lucide.createIcons()));
66
+ this.$watch('files', () => this.$nextTick(() => lucide.createIcons()));
67
+ this.$watch('activeTab', () => this.$nextTick(() => lucide.createIcons()));
68
+ this.$watch('currentZone', () => this.$nextTick(() => lucide.createIcons()));
69
+ this.$watch('ports', () => this.$nextTick(() => lucide.createIcons()));
70
+ this.$watch('showCreateZone', () => {
71
+ this.$nextTick(() => {
72
+ lucide.createIcons();
73
+ if (this.showCreateZone) this.$refs.zoneNameInput?.focus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  });
75
+ });
76
+ this.$watch('showNewFile', () => {
77
+ this.$nextTick(() => { if (this.showNewFile) this.$refs.newFileInput?.focus(); });
78
+ });
79
+ this.$watch('showNewFolder', () => {
80
+ this.$nextTick(() => { if (this.showNewFolder) this.$refs.newFolderInput?.focus(); });
81
+ });
82
+ this.$watch('showRename', () => {
83
+ this.$nextTick(() => {
84
+ lucide.createIcons();
85
+ if (this.showRename) this.$refs.renameInput?.focus();
86
  });
87
+ });
88
 
89
+ // Keyboard shortcut
90
+ document.addEventListener('keydown', (e) => {
91
+ if (e.ctrlKey && e.key === 's' && this.activeTab === 'editor') {
92
+ e.preventDefault();
93
+ this.saveFile();
94
+ }
95
+ });
96
+ },
97
+
98
+ // ── Toast ──
99
+ notify(message, type = 'info') {
100
+ this.toast = { show: true, message, type };
101
+ setTimeout(() => { this.toast.show = false; }, 3000);
102
+ },
103
+
104
+ // ── API Helper ──
105
+ async api(url, options = {}) {
106
+ try {
107
+ const resp = await fetch(url, options);
108
+ if (!resp.ok) {
109
+ const data = await resp.json().catch(() => ({ detail: resp.statusText }));
110
+ throw new Error(data.detail || resp.statusText);
111
+ }
112
+ return await resp.json();
113
+ } catch (err) {
114
+ this.notify(err.message, 'error');
115
+ throw err;
116
+ }
117
+ },
118
+
119
+ // ── Zones ──
120
+ async loadZones() {
121
+ try {
122
+ this.zones = await this.api('/api/zones');
123
+ } catch { this.zones = []; }
124
+ },
125
+
126
+ async selectZone(name) {
127
+ this.currentZone = name;
128
+ this.currentPath = '';
129
+ this.editorFile = null;
130
+ this.editorDirty = false;
131
+ this.activeTab = 'files';
132
+ this.disconnectTerminal();
133
+ await this.loadFiles();
134
+ await this.loadPorts();
135
+ },
136
+
137
+ async createZone() {
138
+ if (!this.createZoneName.trim()) return;
139
+ const form = new FormData();
140
+ form.append('name', this.createZoneName.trim());
141
+ form.append('description', this.createZoneDesc.trim());
142
+ try {
143
+ await this.api('/api/zones', { method: 'POST', body: form });
144
+ this.showCreateZone = false;
145
+ this.createZoneName = '';
146
+ this.createZoneDesc = '';
147
+ await this.loadZones();
148
+ this.notify('Zone đã được tạo');
149
+ } catch {}
150
+ },
151
+
152
+ async confirmDeleteZone() {
153
+ if (!this.currentZone) return;
154
+ if (!confirm(`Xoá zone "${this.currentZone}"? Toàn bộ dữ liệu sẽ bị mất.`)) return;
155
+ try {
156
+ await this.api(`/api/zones/${this.currentZone}`, { method: 'DELETE' });
157
+ this.disconnectTerminal();
158
+ this.currentZone = null;
159
+ await this.loadZones();
160
+ this.notify('Zone đã bị xoá');
161
+ } catch {}
162
+ },
163
+
164
+ // ── Files ──
165
+ async loadFiles() {
166
+ if (!this.currentZone) return;
167
+ this.filesLoading = true;
168
+ try {
169
+ this.files = await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(this.currentPath)}`);
170
+ } catch { this.files = []; }
171
+ this.filesLoading = false;
172
+ },
173
+
174
+ navigateTo(path) {
175
+ this.currentPath = path;
176
+ this.loadFiles();
177
+ },
178
+
179
+ navigateUp() {
180
+ const parts = this.currentPath.split('/').filter(Boolean);
181
+ parts.pop();
182
+ this.currentPath = parts.join('/');
183
+ this.loadFiles();
184
+ },
185
+
186
+ joinPath(base, name) {
187
+ return base ? `${base}/${name}` : name;
188
+ },
189
+
190
+ async openFile(path) {
191
+ if (this.editorDirty && !confirm('Bạn có thay đổi chưa lưu. Bỏ qua?')) return;
192
+ try {
193
+ const data = await this.api(`/api/zones/${this.currentZone}/files/read?path=${encodeURIComponent(path)}`);
194
+ this.editorFile = path;
195
+ this.editorContent = data.content;
196
+ this.editorOriginal = data.content;
197
+ this.editorDirty = false;
198
+ this.activeTab = 'editor';
199
+ } catch {}
200
+ },
201
+
202
+ async saveFile() {
203
+ if (!this.editorFile || !this.editorDirty) return;
204
+ const form = new FormData();
205
+ form.append('path', this.editorFile);
206
+ form.append('content', this.editorContent);
207
+ try {
208
+ await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form });
209
+ this.editorOriginal = this.editorContent;
210
+ this.editorDirty = false;
211
+ this.notify('Đã lưu');
212
+ } catch {}
213
+ },
214
+
215
+ async createFile() {
216
+ if (!this.newFileName.trim()) return;
217
+ const path = this.joinPath(this.currentPath, this.newFileName.trim());
218
+ const form = new FormData();
219
+ form.append('path', path);
220
+ form.append('content', '');
221
+ try {
222
+ await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form });
223
+ this.newFileName = '';
224
+ this.showNewFile = false;
225
+ await this.loadFiles();
226
+ } catch {}
227
+ },
228
+
229
+ async createFolder() {
230
+ if (!this.newFolderName.trim()) return;
231
+ const path = this.joinPath(this.currentPath, this.newFolderName.trim());
232
+ const form = new FormData();
233
+ form.append('path', path);
234
+ try {
235
+ await this.api(`/api/zones/${this.currentZone}/files/mkdir`, { method: 'POST', body: form });
236
+ this.newFolderName = '';
237
+ this.showNewFolder = false;
238
+ await this.loadFiles();
239
+ } catch {}
240
+ },
241
+
242
+ async uploadFile(event) {
243
+ const fileList = event.target.files;
244
+ if (!fileList || fileList.length === 0) return;
245
+ for (const file of fileList) {
246
  const form = new FormData();
247
+ form.append('path', this.currentPath);
248
+ form.append('file', file);
249
  try {
250
+ await this.api(`/api/zones/${this.currentZone}/files/upload`, { method: 'POST', body: form });
251
+ } catch {}
252
+ }
253
+ event.target.value = '';
254
+ await this.loadFiles();
255
+ this.notify(`Đã upload ${fileList.length} file`);
256
+ },
257
+
258
+ async deleteFile(path, isDir) {
259
+ const label = isDir ? 'thư mục' : 'file';
260
+ if (!confirm(`Xoá ${label} "${path}"?`)) return;
261
+ try {
262
+ await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
263
+ if (this.editorFile === path) {
264
+ this.editorFile = null;
265
+ this.editorDirty = false;
266
  }
267
+ await this.loadFiles();
268
+ } catch {}
269
+ },
270
+
271
+ downloadFile(path, name) {
272
+ const a = document.createElement('a');
273
+ a.href = `/api/zones/${this.currentZone}/files/download?path=${encodeURIComponent(path)}`;
274
+ a.download = name;
275
+ a.click();
276
+ },
277
+
278
+ startRename(file) {
279
+ this.renameOldPath = this.joinPath(this.currentPath, file.name);
280
+ this.renameNewName = file.name;
281
+ this.showRename = true;
282
+ },
283
+
284
+ async doRename() {
285
+ if (!this.renameNewName.trim()) return;
286
+ const form = new FormData();
287
+ form.append('old_path', this.renameOldPath);
288
+ form.append('new_name', this.renameNewName.trim());
289
+ try {
290
+ await this.api(`/api/zones/${this.currentZone}/files/rename`, { method: 'POST', body: form });
291
+ this.showRename = false;
292
+ await this.loadFiles();
293
+ } catch {}
294
+ },
295
+
296
+ getFileIcon(name) {
297
+ const ext = name.split('.').pop()?.toLowerCase();
298
+ const map = {
299
+ js: 'file-code', ts: 'file-code', py: 'file-code', go: 'file-code',
300
+ html: 'file-code', css: 'file-code', json: 'file-json',
301
+ md: 'file-text', txt: 'file-text', log: 'file-text',
302
+ jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', svg: 'image',
303
+ zip: 'file-archive', tar: 'file-archive', gz: 'file-archive',
304
+ };
305
+ return map[ext] || 'file';
306
+ },
307
+
308
+ formatSize(bytes) {
309
+ if (bytes === 0) return '0 B';
310
+ const k = 1024;
311
+ const sizes = ['B', 'KB', 'MB', 'GB'];
312
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
313
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
314
+ },
315
+
316
+ // ── Terminal ──
317
+ initTerminal() {
318
+ if (!this.currentZone) return;
319
+
320
+ // Already connected to same zone
321
+ if (this.termZone === this.currentZone && this.term) {
322
+ this.$nextTick(() => this.termFit?.fit());
323
+ return;
324
+ }
325
+
326
+ this.disconnectTerminal();
327
+
328
+ const container = document.getElementById('terminal-container');
329
+ if (!container) return;
330
+ container.innerHTML = '';
331
+
332
+ this.term = new Terminal({
333
+ cursorBlink: true,
334
+ fontSize: 14,
335
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
336
+ theme: {
337
+ background: '#000000',
338
+ foreground: '#e4e4e7',
339
+ cursor: '#8b5cf6',
340
+ selectionBackground: '#8b5cf644',
341
+ black: '#18181b',
342
+ red: '#ef4444',
343
+ green: '#22c55e',
344
+ yellow: '#eab308',
345
+ blue: '#3b82f6',
346
+ magenta: '#a855f7',
347
+ cyan: '#06b6d4',
348
+ white: '#e4e4e7',
349
+ },
350
+ allowProposedApi: true,
351
+ });
352
+
353
+ this.termFit = new FitAddon.FitAddon();
354
+ const webLinks = new WebLinksAddon.WebLinksAddon();
355
+ this.term.loadAddon(this.termFit);
356
+ this.term.loadAddon(webLinks);
357
+ this.term.open(container);
358
+ this.termFit.fit();
359
+ this.termZone = this.currentZone;
360
+
361
+ // WebSocket
362
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
363
+ const wsUrl = `${proto}//${location.host}/ws/terminal/${this.currentZone}`;
364
+ this.termWs = new WebSocket(wsUrl);
365
+ this.termWs.binaryType = 'arraybuffer';
366
+
367
+ this.termWs.onopen = () => {
368
+ this.term.onData((data) => {
369
+ if (this.termWs?.readyState === WebSocket.OPEN) {
370
+ this.termWs.send(JSON.stringify({ type: 'input', data }));
371
+ }
372
+ });
373
+ this.term.onResize(({ rows, cols }) => {
374
+ if (this.termWs?.readyState === WebSocket.OPEN) {
375
+ this.termWs.send(JSON.stringify({ type: 'resize', rows, cols }));
376
+ }
377
  });
378
+ // Send initial size
379
+ const dims = this.termFit.proposeDimensions();
380
+ if (dims) {
381
+ this.termWs.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  }
383
+ };
384
 
385
+ this.termWs.onmessage = (e) => {
386
+ if (e.data instanceof ArrayBuffer) {
387
+ this.term.write(new Uint8Array(e.data));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  } else {
389
+ this.term.write(e.data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  }
391
+ };
392
+
393
+ this.termWs.onclose = () => {
394
+ this.term?.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
395
+ };
396
+
397
+ // Resize handler
398
+ this._resizeHandler = () => this.termFit?.fit();
399
+ window.addEventListener('resize', this._resizeHandler);
400
+
401
+ // ResizeObserver for container
402
+ this._resizeObserver = new ResizeObserver(() => this.termFit?.fit());
403
+ this._resizeObserver.observe(container);
404
+ },
405
+
406
+ disconnectTerminal() {
407
+ if (this.termWs) {
408
+ this.termWs.close();
409
+ this.termWs = null;
410
+ }
411
+ if (this.term) {
412
+ this.term.dispose();
413
+ this.term = null;
414
+ }
415
+ if (this._resizeHandler) {
416
+ window.removeEventListener('resize', this._resizeHandler);
417
+ this._resizeHandler = null;
418
+ }
419
+ if (this._resizeObserver) {
420
+ this._resizeObserver.disconnect();
421
+ this._resizeObserver = null;
422
+ }
423
+ this.termFit = null;
424
+ this.termZone = null;
425
+ },
426
+
427
+ // ── Ports ──
428
+ async loadPorts() {
429
+ if (!this.currentZone) return;
430
+ try {
431
+ this.ports = await this.api(`/api/zones/${this.currentZone}/ports`);
432
+ } catch { this.ports = []; }
433
+ },
434
+
435
+ async addPort() {
436
+ if (!this.newPort) return;
437
+ const form = new FormData();
438
+ form.append('port', this.newPort);
439
+ form.append('label', this.newPortLabel);
440
+ try {
441
+ await this.api(`/api/zones/${this.currentZone}/ports`, { method: 'POST', body: form });
442
+ this.newPort = null;
443
+ this.newPortLabel = '';
444
+ await this.loadPorts();
445
+ this.notify('Port đã được thêm');
446
+ } catch {}
447
+ },
448
+
449
+ async removePort(port) {
450
+ if (!confirm(`Xoá port ${port}?`)) return;
451
+ try {
452
+ await this.api(`/api/zones/${this.currentZone}/ports/${port}`, { method: 'DELETE' });
453
+ await this.loadPorts();
454
+ } catch {}
455
+ },
456
+ };
457
+ }
 
 
 
 
 
 
 
 
 
 
 
 
static/index.html CHANGED
@@ -1,292 +1,387 @@
1
  <!DOCTYPE html>
2
- <html lang="vi">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
- <title>HugPanel</title>
7
- <meta name="mobile-web-app-capable" content="yes">
8
- <meta name="apple-mobile-web-app-capable" content="yes">
9
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
- <meta name="theme-color" content="#09090b">
11
- <link rel="stylesheet" href="/static/style.css">
12
- <!-- Lucide Icons -->
13
- <script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
14
- <!-- xterm.js -->
15
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
16
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
17
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
18
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
19
- <!-- CodeMirror 5 -->
20
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css">
21
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/theme/material-darker.css">
22
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
23
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/javascript/javascript.min.js"></script>
24
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/python/python.min.js"></script>
25
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/go/go.min.js"></script>
26
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/markdown/markdown.min.js"></script>
27
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/shell/shell.min.js"></script>
28
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/css/css.min.js"></script>
29
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
30
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/xml/xml.min.js"></script>
31
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/yaml/yaml.min.js"></script>
32
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/toml/toml.min.js"></script>
33
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/dockerfile/dockerfile.min.js"></script>
34
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/rust/rust.min.js"></script>
35
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closebrackets.min.js"></script>
36
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/matchbrackets.min.js"></script>
37
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/selection/active-line.min.js"></script>
 
 
 
38
  </head>
39
- <body>
40
- <div id="app">
41
- <!-- Sidebar Backdrop (mobile) -->
42
- <div id="sidebar-backdrop" class="sidebar-backdrop"></div>
43
-
44
- <!-- Sidebar -->
45
- <aside id="sidebar">
46
- <div class="sidebar-brand">
47
- <div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
48
- <span class="brand-text">HugPanel</span>
49
- <button id="btn-close-sidebar" class="icon-btn-sm sidebar-close-btn"><i data-lucide="x"></i></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  </div>
 
 
51
 
52
- <nav class="sidebar-nav">
53
- <div class="nav-group">
54
- <div class="nav-group-header">
55
- <span>ZONES</span>
56
- <button id="btn-add-zone" class="icon-btn-sm" title="Tạo zone mới">
57
- <i data-lucide="plus"></i>
58
- </button>
59
- </div>
60
- <ul id="zone-list" class="zone-list"></ul>
61
- </div>
62
- </nav>
63
 
64
- <div class="sidebar-bottom">
65
- <div class="env-badges">
66
- <span class="badge badge-green">Node 20</span>
67
- <span class="badge badge-cyan">Go 1.22</span>
68
- <span class="badge badge-yellow">Python 3.11</span>
 
 
 
 
 
 
 
 
69
  </div>
 
70
  </div>
71
- </aside>
72
-
73
- <!-- Main -->
74
- <main id="main">
75
- <!-- Global hamburger (visible on mobile for both views) -->
76
- <button id="btn-hamburger" class="icon-btn-sm hamburger-global" title="Menu">
77
- <i data-lucide="menu"></i>
78
- </button>
79
-
80
- <!-- Welcome -->
81
- <div id="welcome" class="view active">
82
- <div class="welcome-hero">
83
- <div class="welcome-glow"></div>
84
- <div class="welcome-icon"><i data-lucide="server"></i></div>
85
- <h1>HugPanel</h1>
86
- <p>Multi-zone workspace with file manager, code editor &amp; terminal.</p>
87
- <div id="welcome-zones" class="welcome-zones"></div>
88
- <button id="btn-create-zone-welcome" class="btn btn-accent btn-lg">
89
- <i data-lucide="plus"></i> Create Zone
90
- </button>
91
- </div>
92
  </div>
 
93
 
94
- <!-- Workspace -->
95
- <div id="workspace" class="view">
96
- <div class="topbar">
97
- <div class="topbar-left">
98
- <span class="zone-indicator" id="zone-badge">
99
- <i data-lucide="box"></i>
100
- <span id="zone-title"></span>
101
- </span>
102
- <span class="topbar-sep desktop-only"></span>
103
- <div class="breadcrumb desktop-only" id="breadcrumb"></div>
104
- </div>
105
- <div class="topbar-right">
106
- <div class="port-controls" id="port-controls">
107
- <button class="btn btn-sm btn-ghost" id="btn-toggle-ports" title="Virtual Ports">
108
- <i data-lucide="network"></i>
109
- <span>Ports</span>
110
- <span class="port-count" id="port-count" style="display:none">0</span>
111
- </button>
112
- </div>
113
- <button id="btn-delete-zone" class="icon-btn icon-btn-danger" title="Delete zone">
114
- <i data-lucide="trash-2"></i>
115
- </button>
116
- </div>
117
- </div>
118
 
119
- <div class="workspace-body">
120
- <!-- File Panel -->
121
- <div class="panel panel-files" id="panel-files">
122
- <div class="panel-header">
123
- <span class="panel-title"><i data-lucide="folder-open"></i> Explorer</span>
124
- <div class="panel-actions">
125
- <button class="icon-btn-sm" id="btn-new-file" title="New File"><i data-lucide="file-plus"></i></button>
126
- <button class="icon-btn-sm" id="btn-new-folder" title="New Folder"><i data-lucide="folder-plus"></i></button>
127
- <button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
128
- </div>
129
- </div>
130
- <div class="breadcrumb mobile-breadcrumb mobile-only" id="breadcrumb-mobile"></div>
131
- <div id="file-list" class="file-tree"></div>
132
- <input type="file" id="file-upload-input" style="display:none" multiple>
133
- </div>
134
 
135
- <div class="resizer-v" id="resizer-v"></div>
136
-
137
- <!-- Right: Editor + Terminal -->
138
- <div class="panel panel-right" id="panel-right">
139
- <div class="pane pane-editor" id="pane-editor">
140
- <div class="pane-header">
141
- <div class="pane-tabs" id="editor-tabs">
142
- <span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>
143
- </div>
144
- <div class="pane-actions">
145
- <button class="icon-btn-sm" id="btn-save-file" title="Save (Ctrl+S)"><i data-lucide="save"></i></button>
146
- </div>
147
- </div>
148
- <div id="editor-container" class="editor-container">
149
- <div class="editor-empty">
150
- <i data-lucide="mouse-pointer-click"></i>
151
- <p>Tap a file to open</p>
152
- </div>
153
- </div>
154
- </div>
155
-
156
- <div class="resizer-h" id="resizer-h"></div>
157
-
158
- <div class="pane pane-terminal" id="pane-terminal">
159
- <div class="pane-header">
160
- <span class="pane-title"><i data-lucide="terminal-square"></i> Terminal</span>
161
- <div class="pane-actions">
162
- <button class="icon-btn-sm" id="btn-reconnect" title="Reconnect"><i data-lucide="refresh-cw"></i></button>
163
- </div>
164
- </div>
165
- <div id="terminal-container" class="terminal-container"></div>
166
- </div>
167
- </div>
168
- </div>
169
 
170
- <!-- Mobile Bottom Tab Bar -->
171
- <nav id="mobile-tabs" class="mobile-tabs">
172
- <button class="mobile-tab active" data-tab="files">
173
- <i data-lucide="folder"></i>
174
- <span>Files</span>
 
 
 
 
 
 
 
 
 
175
  </button>
176
- <button class="mobile-tab" data-tab="editor">
177
- <i data-lucide="code-2"></i>
178
- <span>Editor</span>
179
  </button>
180
- <button class="mobile-tab" data-tab="terminal">
181
- <i data-lucide="terminal-square"></i>
182
- <span>Terminal</span>
183
  </button>
184
- </nav>
 
 
185
  </div>
186
- </main>
187
- </div>
188
-
189
- <!-- Toasts -->
190
- <div id="toast-container" class="toast-container"></div>
191
 
192
- <!-- Modal: Create Zone -->
193
- <div id="modal-overlay" class="modal-overlay hidden">
194
- <div class="modal">
195
- <div class="modal-header">
196
- <h3><i data-lucide="plus-circle"></i> Create Zone</h3>
197
- <button class="icon-btn-sm" id="btn-close-create-modal"><i data-lucide="x"></i></button>
 
 
 
 
 
 
 
 
 
 
 
198
  </div>
199
- <form id="form-create-zone">
200
- <div class="form-group">
201
- <label for="input-zone-name">Zone name</label>
202
- <input type="text" id="input-zone-name" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" required autocomplete="off">
203
- <span class="form-hint">Only a-z, 0-9, hyphens and underscores</span>
204
- </div>
205
- <div class="form-group">
206
- <label for="input-zone-desc">Description <span class="optional">(optional)</span></label>
207
- <input type="text" id="input-zone-desc" placeholder="Short description...">
208
- </div>
209
- <div class="modal-footer">
210
- <button type="button" class="btn btn-ghost" id="btn-cancel-create-modal">Cancel</button>
211
- <button type="submit" class="btn btn-accent"><i data-lucide="plus"></i> Create</button>
212
- </div>
213
- </form>
214
  </div>
215
- </div>
216
 
217
- <!-- Modal: Prompt -->
218
- <div id="modal-prompt" class="modal-overlay hidden">
219
- <div class="modal modal-sm">
220
- <div class="modal-header">
221
- <h3 id="prompt-title">Enter name</h3>
222
- <button class="icon-btn-sm" id="btn-close-prompt"><i data-lucide="x"></i></button>
 
 
 
 
 
 
 
 
 
223
  </div>
224
- <form id="form-prompt">
225
- <div class="form-group">
226
- <input type="text" id="prompt-input" required autocomplete="off">
227
- </div>
228
- <div class="modal-footer">
229
- <button type="button" class="btn btn-ghost" id="btn-cancel-prompt">Cancel</button>
230
- <button type="submit" class="btn btn-accent">OK</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  </div>
232
- </form>
 
 
 
 
 
 
233
  </div>
234
- </div>
 
 
235
 
236
- <!-- Port Panel (dropdown) -->
237
- <div id="port-panel" class="port-panel hidden">
238
- <div class="port-panel-header">
239
- <span><i data-lucide="network"></i> Virtual Ports</span>
240
- <button class="icon-btn-sm" id="btn-add-port" title="Add port"><i data-lucide="plus"></i></button>
 
 
 
 
 
 
 
 
241
  </div>
242
- <div class="port-panel-hint">
243
- Run a server in terminal, map its port here, then click Open to view it through HugPanel's single exposed port.
 
 
244
  </div>
245
- <div id="port-list" class="port-list"></div>
 
 
 
 
246
  </div>
 
247
 
248
- <!-- Modal: Confirm -->
249
- <div id="modal-confirm" class="modal-overlay hidden">
250
- <div class="modal modal-sm">
251
- <div class="modal-header">
252
- <h3 id="confirm-title">Confirm</h3>
253
- <button class="icon-btn-sm" id="btn-close-confirm"><i data-lucide="x"></i></button>
254
- </div>
255
- <div style="padding:16px">
256
- <p id="confirm-message" style="font-size:14px;color:var(--text-2)"></p>
257
- </div>
258
- <div class="modal-footer" style="padding:0 16px 16px">
259
- <button type="button" class="btn btn-ghost" id="btn-confirm-cancel">Cancel</button>
260
- <button type="button" class="btn btn-danger" id="btn-confirm-ok">Delete</button>
261
- </div>
262
- </div>
263
  </div>
 
264
 
265
- <!-- Modal: Add Port -->
266
- <div id="modal-add-port" class="modal-overlay hidden">
267
- <div class="modal modal-sm">
268
- <div class="modal-header">
269
- <h3><i data-lucide="network"></i> Map Port</h3>
270
- <button class="icon-btn-sm" id="btn-close-port-modal"><i data-lucide="x"></i></button>
271
- </div>
272
- <form id="form-add-port">
273
- <div class="form-group">
274
- <label for="input-port-number">Port number</label>
275
- <input type="number" id="input-port-number" min="1024" max="65535" placeholder="3000" required>
276
- <span class="form-hint">The internal port your server listens on (1024–65535)</span>
277
- </div>
278
- <div class="form-group">
279
- <label for="input-port-label">Label <span class="optional">(optional)</span></label>
280
- <input type="text" id="input-port-label" placeholder="e.g. React Dev Server">
281
- </div>
282
- <div class="modal-footer">
283
- <button type="button" class="btn btn-ghost" id="btn-cancel-port-modal">Cancel</button>
284
- <button type="submit" class="btn btn-accent"><i data-lucide="plus"></i> Map Port</button>
285
- </div>
286
- </form>
287
- </div>
288
- </div>
289
 
290
- <script src="/static/app.js"></script>
291
  </body>
292
- </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="vi" class="h-full">
3
  <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HugPanel</title>
7
+
8
+ <!-- Tailwind CSS -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ darkMode: 'class',
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ brand: {
17
+ 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd',
18
+ 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9',
19
+ 800: '#5b21b6', 900: '#4c1d95',
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <!-- Alpine.js -->
28
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
29
+
30
+ <!-- xterm.js -->
31
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
32
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
33
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
34
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
35
+
36
+ <!-- Lucide Icons -->
37
+ <script src="https://unpkg.com/lucide@latest"></script>
38
+
39
+ <!-- Custom CSS -->
40
+ <link rel="stylesheet" href="/static/style.css" />
41
  </head>
42
+
43
+ <body class="h-full bg-gray-950 text-gray-100 overflow-hidden" x-data="hugpanel()" x-init="init()">
44
+
45
+ <!-- ═══ Mobile Top Bar ═══ -->
46
+ <header class="lg:hidden fixed top-0 inset-x-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3 flex items-center justify-between">
47
+ <button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
48
+ <i data-lucide="menu" class="w-5 h-5"></i>
49
+ </button>
50
+ <div class="flex items-center gap-2">
51
+ <div class="w-7 h-7 rounded-lg bg-brand-600 flex items-center justify-center text-sm font-bold">H</div>
52
+ <span class="font-semibold text-sm">HugPanel</span>
53
+ </div>
54
+ <div class="w-8"></div>
55
+ </header>
56
+
57
+ <!-- ═══ Sidebar Overlay (mobile) ═══ -->
58
+ <div x-show="sidebarOpen" x-transition:enter="transition-opacity duration-200"
59
+ x-transition:leave="transition-opacity duration-200"
60
+ @click="sidebarOpen = false"
61
+ class="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"></div>
62
+
63
+ <div class="flex h-full">
64
+
65
+ <!-- ═══ Sidebar ═══ -->
66
+ <aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
67
+ class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto lg:translate-x-0 w-64 bg-gray-900 border-r border-gray-800 flex flex-col transition-transform duration-300 ease-in-out">
68
+
69
+ <!-- Logo -->
70
+ <div class="p-4 border-b border-gray-800 flex items-center gap-3">
71
+ <div class="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center text-lg font-bold shadow-lg shadow-brand-500/25">H</div>
72
+ <div>
73
+ <div class="font-bold text-sm">HugPanel</div>
74
+ <div class="text-xs text-gray-500">Workspace Manager</div>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Zone List -->
79
+ <div class="flex-1 overflow-y-auto p-3 space-y-1">
80
+ <div class="px-2 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Zones</div>
81
+
82
+ <template x-for="zone in zones" :key="zone.name">
83
+ <button @click="selectZone(zone.name); sidebarOpen = false"
84
+ :class="currentZone === zone.name ? 'bg-brand-600/20 text-brand-400 border-brand-500/30' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'"
85
+ class="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all border">
86
+ <i data-lucide="box" class="w-4 h-4 flex-shrink-0"></i>
87
+ <span x-text="zone.name" class="truncate"></span>
88
+ </button>
89
+ </template>
90
+
91
+ <div x-show="zones.length === 0" class="text-center py-8 text-gray-600 text-sm">
92
+ Chưa có zone nào
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Create Zone -->
97
+ <div class="p-3 border-t border-gray-800">
98
+ <button @click="showCreateZone = true"
99
+ class="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition shadow-lg shadow-brand-600/25">
100
+ <i data-lucide="plus" class="w-4 h-4"></i>
101
+ Tạo Zone
102
+ </button>
103
+ </div>
104
+ </aside>
105
+
106
+ <!-- ═══ Main Content ═══ -->
107
+ <main class="flex-1 flex flex-col min-w-0 pt-14 lg:pt-0">
108
+
109
+ <!-- No zone selected -->
110
+ <div x-show="!currentZone" class="flex-1 flex items-center justify-center p-6">
111
+ <div class="text-center max-w-sm">
112
+ <div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-800 flex items-center justify-center">
113
+ <i data-lucide="layout-dashboard" class="w-8 h-8 text-gray-600"></i>
114
+ </div>
115
+ <h2 class="text-lg font-semibold text-gray-400 mb-2">Chọn hoặc tạo Zone</h2>
116
+ <p class="text-sm text-gray-600">Chọn zone từ sidebar hoặc tạo zone mới để bắt đầu.</p>
117
+ </div>
118
+ </div>
119
+
120
+ <!-- Zone Content -->
121
+ <div x-show="currentZone" class="flex-1 flex flex-col min-h-0">
122
+
123
+ <!-- Tab Bar -->
124
+ <div class="bg-gray-900/80 backdrop-blur border-b border-gray-800 px-4">
125
+ <div class="flex items-center gap-1 overflow-x-auto scrollbar-hide">
126
+ <template x-for="tab in tabs" :key="tab.id">
127
+ <button @click="activeTab = tab.id"
128
+ :class="activeTab === tab.id ? 'text-brand-400 border-brand-500 bg-brand-500/10' : 'text-gray-500 border-transparent hover:text-gray-300 hover:bg-gray-800'"
129
+ class="flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap rounded-t-lg">
130
+ <i :data-lucide="tab.icon" class="w-4 h-4"></i>
131
+ <span x-text="tab.label"></span>
132
+ </button>
133
+ </template>
134
+
135
+ <!-- Zone Actions (right side) -->
136
+ <div class="ml-auto flex items-center gap-1">
137
+ <span x-text="currentZone" class="text-xs text-gray-500 font-mono mr-2 hidden sm:inline"></span>
138
+ <button @click="confirmDeleteZone()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá zone">
139
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
140
+ </button>
141
  </div>
142
+ </div>
143
+ </div>
144
 
145
+ <!-- ═══ TAB: Files ═══ -->
146
+ <div x-show="activeTab === 'files'" class="flex-1 flex flex-col min-h-0">
 
 
 
 
 
 
 
 
 
147
 
148
+ <!-- File Toolbar -->
149
+ <div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex flex-wrap items-center gap-2">
150
+ <!-- Breadcrumb -->
151
+ <div class="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto scrollbar-hide">
152
+ <button @click="navigateTo('')" class="text-brand-400 hover:text-brand-300 flex-shrink-0">
153
+ <i data-lucide="home" class="w-3.5 h-3.5"></i>
154
+ </button>
155
+ <template x-for="(part, i) in currentPathParts" :key="i">
156
+ <div class="flex items-center gap-1 flex-shrink-0">
157
+ <i data-lucide="chevron-right" class="w-3 h-3 text-gray-600"></i>
158
+ <button @click="navigateTo(currentPathParts.slice(0, i+1).join('/'))"
159
+ class="text-gray-400 hover:text-brand-400 transition truncate max-w-[120px]"
160
+ x-text="part"></button>
161
  </div>
162
+ </template>
163
  </div>
164
+
165
+ <!-- File Actions -->
166
+ <div class="flex items-center gap-1">
167
+ <button @click="showNewFile = true" class="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition" title="Tạo file">
168
+ <i data-lucide="file-plus" class="w-4 h-4"></i>
169
+ </button>
170
+ <button @click="showNewFolder = true" class="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition" title="Tạo thư mục">
171
+ <i data-lucide="folder-plus" class="w-4 h-4"></i>
172
+ </button>
173
+ <label class="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition cursor-pointer" title="Upload">
174
+ <i data-lucide="upload" class="w-4 h-4"></i>
175
+ <input type="file" class="hidden" @change="uploadFile($event)" multiple />
176
+ </label>
177
+ <button @click="loadFiles()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
178
+ <i data-lucide="refresh-cw" class="w-4 h-4"></i>
179
+ </button>
 
 
 
 
 
180
  </div>
181
+ </div>
182
 
183
+ <!-- New File/Folder Inputs -->
184
+ <div x-show="showNewFile" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
185
+ <i data-lucide="file" class="w-4 h-4 text-gray-500"></i>
186
+ <input x-ref="newFileInput" x-model="newFileName" @keydown.enter="createFile()" @keydown.escape="showNewFile = false"
187
+ placeholder="filename.txt" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
188
+ <button @click="createFile()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
189
+ <button @click="showNewFile = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
190
+ </div>
191
+ <div x-show="showNewFolder" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
192
+ <i data-lucide="folder" class="w-4 h-4 text-gray-500"></i>
193
+ <input x-ref="newFolderInput" x-model="newFolderName" @keydown.enter="createFolder()" @keydown.escape="showNewFolder = false"
194
+ placeholder="folder-name" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
195
+ <button @click="createFolder()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
196
+ <button @click="showNewFolder = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
197
+ </div>
 
 
 
 
 
 
 
 
 
198
 
199
+ <!-- File List -->
200
+ <div class="flex-1 overflow-y-auto">
201
+ <div x-show="filesLoading" class="flex items-center justify-center py-12">
202
+ <div class="w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
203
+ </div>
 
 
 
 
 
 
 
 
 
 
204
 
205
+ <div x-show="!filesLoading && files.length === 0" class="text-center py-12 text-gray-600 text-sm">
206
+ Thư mục trống
207
+ </div>
208
+
209
+ <div x-show="!filesLoading" class="divide-y divide-gray-800/50">
210
+ <!-- Back button -->
211
+ <button x-show="currentPath !== ''"
212
+ @click="navigateUp()"
213
+ class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition text-gray-400">
214
+ <i data-lucide="corner-left-up" class="w-4 h-4"></i>
215
+ <span class="text-sm">..</span>
216
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
+ <template x-for="file in files" :key="file.name">
219
+ <div class="group flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition cursor-pointer"
220
+ @click="file.is_dir ? navigateTo(joinPath(currentPath, file.name)) : openFile(joinPath(currentPath, file.name))">
221
+ <i :data-lucide="file.is_dir ? 'folder' : getFileIcon(file.name)"
222
+ :class="file.is_dir ? 'text-brand-400' : 'text-gray-500'"
223
+ class="w-4 h-4 flex-shrink-0"></i>
224
+ <span class="flex-1 text-sm truncate" x-text="file.name"></span>
225
+ <span x-show="!file.is_dir" class="text-xs text-gray-600 hidden sm:inline" x-text="formatSize(file.size)"></span>
226
+
227
+ <!-- File Actions -->
228
+ <div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
229
+ <button x-show="!file.is_dir" @click.stop="downloadFile(joinPath(currentPath, file.name), file.name)"
230
+ class="p-1 rounded text-gray-500 hover:text-brand-400 hover:bg-brand-400/10" title="Download">
231
+ <i data-lucide="download" class="w-3.5 h-3.5"></i>
232
  </button>
233
+ <button @click.stop="startRename(file)" class="p-1 rounded text-gray-500 hover:text-yellow-400 hover:bg-yellow-400/10" title="Đổi tên">
234
+ <i data-lucide="pencil" class="w-3.5 h-3.5"></i>
 
235
  </button>
236
+ <button @click.stop="deleteFile(joinPath(currentPath, file.name), file.is_dir)"
237
+ class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-red-400/10" title="Xoá">
238
+ <i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
239
  </button>
240
+ </div>
241
+ </div>
242
+ </template>
243
  </div>
244
+ </div>
245
+ </div>
 
 
 
246
 
247
+ <!-- ═══ TAB: Editor ═══ -->
248
+ <div x-show="activeTab === 'editor'" class="flex-1 flex flex-col min-h-0">
249
+ <div x-show="!editorFile" class="flex-1 flex items-center justify-center text-gray-600 text-sm">
250
+ Chọn file để chỉnh sửa
251
+ </div>
252
+ <div x-show="editorFile" class="flex-1 flex flex-col min-h-0">
253
+ <div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex items-center gap-2">
254
+ <i data-lucide="file-code" class="w-4 h-4 text-brand-400"></i>
255
+ <span class="text-sm text-gray-300 truncate" x-text="editorFile"></span>
256
+ <div class="ml-auto flex items-center gap-2">
257
+ <span x-show="editorDirty" class="text-xs text-yellow-500">Chưa lưu</span>
258
+ <button @click="saveFile()" :disabled="!editorDirty"
259
+ :class="editorDirty ? 'bg-brand-600 hover:bg-brand-500 text-white' : 'bg-gray-800 text-gray-600 cursor-not-allowed'"
260
+ class="px-3 py-1 text-xs rounded-md transition font-medium">
261
+ Lưu
262
+ </button>
263
+ </div>
264
  </div>
265
+ <div class="flex-1 min-h-0">
266
+ <textarea x-model="editorContent" @input="editorDirty = true"
267
+ @keydown.ctrl.s.prevent="saveFile()"
268
+ class="w-full h-full p-4 bg-gray-950 text-gray-200 text-sm font-mono resize-none outline-none leading-relaxed"
269
+ spellcheck="false"></textarea>
270
+ </div>
271
+ </div>
272
+ </div>
273
+
274
+ <!-- ═══ TAB: Terminal ═══ -->
275
+ <div x-show="activeTab === 'terminal'" x-effect="if(activeTab==='terminal') initTerminal()" class="flex-1 flex flex-col min-h-0">
276
+ <div id="terminal-container" class="flex-1 min-h-0 p-1 bg-black"></div>
 
 
 
277
  </div>
 
278
 
279
+ <!-- ═══ TAB: Ports ═══ -->
280
+ <div x-show="activeTab === 'ports'" class="flex-1 overflow-y-auto">
281
+ <div class="p-4 space-y-4">
282
+ <!-- Add Port -->
283
+ <div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
284
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Thêm Port</h3>
285
+ <div class="flex flex-col sm:flex-row gap-2">
286
+ <input x-model.number="newPort" type="number" min="1024" max="65535" placeholder="Port (1024-65535)"
287
+ class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm outline-none focus:border-brand-500 transition" />
288
+ <input x-model="newPortLabel" placeholder="Label (tuỳ chọn)"
289
+ class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm outline-none focus:border-brand-500 transition" />
290
+ <button @click="addPort()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">
291
+ Thêm
292
+ </button>
293
+ </div>
294
  </div>
295
+
296
+ <!-- Port List -->
297
+ <div class="space-y-2">
298
+ <template x-for="port in ports" :key="port.port">
299
+ <div class="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
300
+ <div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
301
+ <i data-lucide="radio" class="w-5 h-5 text-green-400"></i>
302
+ </div>
303
+ <div class="flex-1 min-w-0">
304
+ <div class="text-sm font-medium" x-text="port.label || 'Port ' + port.port"></div>
305
+ <div class="text-xs text-gray-500">
306
+ Port: <span x-text="port.port" class="text-gray-400 font-mono"></span>
307
+ </div>
308
+ </div>
309
+ <a :href="'/port/' + currentZone + '/' + port.port + '/'"
310
+ target="_blank"
311
+ class="p-2 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition" title="Mở">
312
+ <i data-lucide="external-link" class="w-4 h-4"></i>
313
+ </a>
314
+ <button @click="removePort(port.port)"
315
+ class="p-2 rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá">
316
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
317
+ </button>
318
  </div>
319
+ </template>
320
+
321
+ <div x-show="ports.length === 0" class="text-center py-8 text-gray-600 text-sm">
322
+ Chưa có port nào
323
+ </div>
324
+ </div>
325
+ </div>
326
  </div>
327
+ </div>
328
+ </main>
329
+ </div>
330
 
331
+ <!-- ═══ MODAL: Create Zone ═══ -->
332
+ <div x-show="showCreateZone" x-transition:enter="transition duration-200" x-transition:leave="transition duration-150"
333
+ class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
334
+ <div @click.outside="showCreateZone = false"
335
+ class="w-full max-w-md bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
336
+ <div class="p-5 border-b border-gray-800">
337
+ <h2 class="text-lg font-semibold">Tạo Zone mới</h2>
338
+ </div>
339
+ <div class="p-5 space-y-4">
340
+ <div>
341
+ <label class="block text-xs text-gray-500 mb-1.5">Tên Zone</label>
342
+ <input x-model="createZoneName" x-ref="zoneNameInput" @keydown.enter="createZone()"
343
+ placeholder="my-project" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" />
344
  </div>
345
+ <div>
346
+ <label class="block text-xs text-gray-500 mb-1.5">Mô tả (tuỳ chọn)</label>
347
+ <input x-model="createZoneDesc" @keydown.enter="createZone()"
348
+ placeholder="Mô tả ngắn..." class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" />
349
  </div>
350
+ </div>
351
+ <div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
352
+ <button @click="showCreateZone = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
353
+ <button @click="createZone()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">Tạo</button>
354
+ </div>
355
  </div>
356
+ </div>
357
 
358
+ <!-- ═══ MODAL: Rename ═══ -->
359
+ <div x-show="showRename" x-transition class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
360
+ <div @click.outside="showRename = false" class="w-full max-w-sm bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
361
+ <div class="p-5 border-b border-gray-800">
362
+ <h2 class="text-base font-semibold">Đổi tên</h2>
363
+ </div>
364
+ <div class="p-5">
365
+ <input x-model="renameNewName" x-ref="renameInput" @keydown.enter="doRename()" @keydown.escape="showRename = false"
366
+ class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" />
367
+ </div>
368
+ <div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
369
+ <button @click="showRename = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
370
+ <button @click="doRename()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">Đổi tên</button>
371
+ </div>
 
372
  </div>
373
+ </div>
374
 
375
+ <!-- ═══ Toast ═══ -->
376
+ <div x-show="toast.show" x-transition:enter="transition transform duration-300"
377
+ x-transition:enter-start="translate-y-4 opacity-0" x-transition:enter-end="translate-y-0 opacity-100"
378
+ x-transition:leave="transition transform duration-200"
379
+ x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-4 opacity-0"
380
+ :class="toast.type === 'error' ? 'bg-red-900/90 border-red-700' : 'bg-gray-800/90 border-gray-700'"
381
+ class="fixed bottom-4 right-4 z-[110] max-w-sm px-4 py-3 rounded-xl border backdrop-blur shadow-2xl text-sm">
382
+ <span x-text="toast.message"></span>
383
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
+ <script src="/static/app.js"></script>
386
  </body>
387
+ </html>
static/style.css CHANGED
@@ -1,1300 +1,92 @@
1
- /* ═══════════════════════════════════════════════
2
- HugPanel — Modern IDE-style Dashboard
3
- ═══════════════════════════════════════════════ */
4
 
5
- * { margin: 0; padding: 0; box-sizing: border-box; }
6
-
7
- :root {
8
- --bg-0: #09090b;
9
- --bg-1: #0f0f12;
10
- --bg-2: #18181b;
11
- --bg-3: #1e1e22;
12
- --bg-4: #27272a;
13
- --bg-hover: #2a2a2f;
14
- --bg-active: #323238;
15
-
16
- --border: #27272a;
17
- --border-subtle: #1e1e22;
18
- --border-focus: #3b82f6;
19
-
20
- --text: #fafafa;
21
- --text-2: #a1a1aa;
22
- --text-3: #71717a;
23
-
24
- --accent: #3b82f6;
25
- --accent-hover: #60a5fa;
26
- --accent-glow: rgba(59, 130, 246, 0.15);
27
- --danger: #ef4444;
28
- --danger-hover: #f87171;
29
- --success: #22c55e;
30
- --warning: #f59e0b;
31
-
32
- --radius-sm: 4px;
33
- --radius: 8px;
34
- --radius-lg: 12px;
35
- --radius-xl: 16px;
36
-
37
- --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
38
- --shadow: 0 4px 16px rgba(0,0,0,0.4);
39
- --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
40
-
41
- --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
42
- --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
43
-
44
- --sidebar-w: 240px;
45
- --topbar-h: 44px;
46
- --panel-header-h: 36px;
47
-
48
- --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
49
  }
50
-
51
- html, body { height: 100%; overflow: hidden; }
52
-
53
- body {
54
- background: var(--bg-0);
55
- color: var(--text);
56
- font-family: var(--font);
57
- font-size: 13px;
58
- line-height: 1.5;
59
- -webkit-font-smoothing: antialiased;
60
  }
61
-
62
- /* ── Scrollbar ──────────────── */
63
- ::-webkit-scrollbar { width: 6px; height: 6px; }
64
- ::-webkit-scrollbar-track { background: transparent; }
65
- ::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
66
- ::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
67
-
68
- /* ── Layout ─────────────────── */
69
- #app { display: flex; height: 100vh; }
70
-
71
- /* ── Sidebar ────────────────── */
72
- #sidebar {
73
- width: var(--sidebar-w);
74
- min-width: var(--sidebar-w);
75
- background: var(--bg-1);
76
- border-right: 1px solid var(--border);
77
- display: flex;
78
- flex-direction: column;
79
- z-index: 10;
80
  }
81
-
82
- .sidebar-brand {
83
- display: flex;
84
- align-items: center;
85
- gap: 10px;
86
- padding: 16px 16px 12px;
87
  }
88
 
89
- .brand-icon {
90
- width: 32px; height: 32px;
91
- background: linear-gradient(135deg, var(--accent), #8b5cf6);
92
- border-radius: var(--radius);
93
- display: flex; align-items: center; justify-content: center;
94
- color: #fff;
95
  }
96
-
97
- .brand-icon svg { width: 18px; height: 18px; }
98
- .brand-text { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
99
-
100
- .sidebar-nav { flex: 1; overflow-y: auto; padding: 4px 0; }
101
-
102
- .nav-group { padding: 0 8px; }
103
-
104
- .nav-group-header {
105
- display: flex;
106
- justify-content: space-between;
107
- align-items: center;
108
- padding: 8px 8px 6px;
109
- font-size: 11px;
110
- font-weight: 600;
111
- letter-spacing: 0.8px;
112
- color: var(--text-3);
113
  }
114
 
115
- .zone-list { list-style: none; }
116
-
117
- .zone-list li {
118
- display: flex;
119
- align-items: center;
120
- gap: 8px;
121
- padding: 7px 10px;
122
- margin: 1px 0;
123
- border-radius: var(--radius-sm);
124
- cursor: pointer;
125
- color: var(--text-2);
126
- font-size: 13px;
127
- transition: all var(--transition);
128
  }
129
-
130
- .zone-list li:hover { background: var(--bg-hover); color: var(--text); }
131
-
132
- .zone-list li.active {
133
- background: var(--accent-glow);
134
- color: var(--accent-hover);
135
  }
136
-
137
- .zone-list li .zone-icon {
138
- width: 18px; height: 18px; flex-shrink: 0;
139
- opacity: 0.6;
140
  }
141
-
142
- .zone-list li.active .zone-icon { opacity: 1; color: var(--accent); }
143
-
144
- .zone-list li .zone-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
145
- .zone-list li .zone-desc { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
146
-
147
- .sidebar-bottom {
148
- padding: 12px 16px;
149
- border-top: 1px solid var(--border);
150
  }
151
-
152
- .env-badges { display: flex; gap: 6px; flex-wrap: wrap; }
153
-
154
- .badge {
155
- padding: 2px 8px;
156
- border-radius: 20px;
157
- font-size: 10px;
158
- font-weight: 600;
159
- letter-spacing: 0.3px;
160
  }
161
 
162
- .badge-green { background: rgba(34,197,94,0.12); color: #4ade80; }
163
- .badge-cyan { background: rgba(56,189,248,0.12); color: #38bdf8; }
164
- .badge-yellow { background: rgba(245,158,11,0.12); color: #fbbf24; }
165
-
166
- /* ── Icon Buttons ──────────── */
167
- .icon-btn-sm {
168
- width: 26px; height: 26px;
169
- display: inline-flex; align-items: center; justify-content: center;
170
- background: transparent;
171
- border: none;
172
- border-radius: var(--radius-sm);
173
- color: var(--text-3);
174
- cursor: pointer;
175
- transition: all var(--transition);
176
  }
177
 
178
- .icon-btn-sm svg { width: 14px; height: 14px; }
179
- .icon-btn-sm:hover { background: var(--bg-hover); color: var(--text); }
180
-
181
- .icon-btn {
182
- width: 32px; height: 32px;
183
- display: inline-flex; align-items: center; justify-content: center;
184
- background: transparent;
185
- border: 1px solid var(--border);
186
- border-radius: var(--radius);
187
- color: var(--text-2);
188
- cursor: pointer;
189
- transition: all var(--transition);
190
  }
191
 
192
- .icon-btn svg { width: 16px; height: 16px; }
193
- .icon-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--text-3); }
194
- .icon-btn-danger:hover { background: rgba(239,68,68,0.1); color: var(--danger); border-color: var(--danger); }
195
-
196
- /* ── Buttons ───────────────── */
197
- .btn {
198
- display: inline-flex; align-items: center; gap: 6px;
199
- padding: 8px 16px;
200
- border: none;
201
- border-radius: var(--radius);
202
- font-size: 13px;
203
- font-weight: 500;
204
- cursor: pointer;
205
- transition: all var(--transition);
206
- white-space: nowrap;
207
  }
208
 
209
- .btn svg { width: 14px; height: 14px; }
210
-
211
- .btn-accent {
212
- background: var(--accent);
213
- color: #fff;
214
- box-shadow: 0 0 20px var(--accent-glow);
215
  }
216
 
217
- .btn-accent:hover { background: var(--accent-hover); box-shadow: 0 0 30px var(--accent-glow); }
218
-
219
- .btn-ghost {
220
- background: transparent;
221
- color: var(--text-2);
222
- border: 1px solid var(--border);
223
  }
224
-
225
- .btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
226
-
227
- .btn-danger {
228
- background: var(--danger);
229
- color: #fff;
230
- border: none;
231
- }
232
- .btn-danger:hover { background: #dc2626; }
233
-
234
- .btn-lg { padding: 12px 24px; font-size: 14px; border-radius: var(--radius-lg); }
235
- .btn-lg svg { width: 16px; height: 16px; }
236
-
237
- /* ── Main ──────────────────── */
238
- #main {
239
- flex: 1;
240
- display: flex;
241
- flex-direction: column;
242
- overflow: hidden;
243
- background: var(--bg-0);
244
- }
245
-
246
- .view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
247
- .view.active { display: flex; }
248
-
249
- /* ── Welcome ───────────────── */
250
- .welcome-hero {
251
- flex: 1;
252
- display: flex;
253
- flex-direction: column;
254
- align-items: center;
255
- justify-content: center;
256
- position: relative;
257
- gap: 16px;
258
- }
259
-
260
- .welcome-glow {
261
- position: absolute;
262
- width: 300px; height: 300px;
263
- background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%);
264
- border-radius: 50%;
265
- filter: blur(60px);
266
- pointer-events: none;
267
- }
268
-
269
- .welcome-icon {
270
- width: 80px; height: 80px;
271
- background: var(--bg-2);
272
- border: 1px solid var(--border);
273
- border-radius: var(--radius-xl);
274
- display: flex; align-items: center; justify-content: center;
275
- color: var(--accent);
276
- position: relative;
277
- }
278
-
279
- .welcome-icon svg { width: 36px; height: 36px; }
280
-
281
- .welcome-hero h1 {
282
- font-size: 28px;
283
- font-weight: 700;
284
- letter-spacing: -0.5px;
285
- }
286
-
287
- .welcome-hero p {
288
- color: var(--text-2);
289
- font-size: 15px;
290
- max-width: 360px;
291
- text-align: center;
292
- }
293
-
294
- /* ── Welcome Zone Grid ─────── */
295
- .welcome-zones {
296
- max-width: 480px;
297
- width: 100%;
298
- padding: 0 16px;
299
- }
300
-
301
- .welcome-hint {
302
- color: var(--text-3);
303
- font-size: 13px;
304
- text-align: center;
305
- }
306
-
307
- .zone-grid {
308
- display: flex;
309
- flex-wrap: wrap;
310
- gap: 8px;
311
- justify-content: center;
312
- }
313
-
314
- .zone-card {
315
- display: flex;
316
- align-items: center;
317
- gap: 8px;
318
- padding: 10px 18px;
319
- background: var(--bg-2);
320
- border: 1px solid var(--border);
321
- border-radius: var(--radius);
322
- color: var(--text);
323
- font-size: 14px;
324
- font-weight: 500;
325
- font-family: var(--font);
326
- cursor: pointer;
327
- transition: all var(--transition);
328
- -webkit-tap-highlight-color: transparent;
329
- min-height: 44px;
330
- }
331
-
332
- .zone-card svg { width: 16px; height: 16px; color: var(--accent); flex-shrink: 0; }
333
- .zone-card:hover { background: var(--bg-hover); border-color: var(--accent); }
334
- .zone-card:active { background: var(--bg-active); transform: scale(0.97); }
335
-
336
- /* ── Global Hamburger ──────── */
337
- .hamburger-global {
338
- display: none;
339
- position: fixed;
340
- top: 10px;
341
- left: 10px;
342
- z-index: 40;
343
- width: 40px;
344
- height: 40px;
345
- background: var(--bg-2);
346
- border: 1px solid var(--border);
347
- border-radius: var(--radius);
348
- }
349
-
350
- .hamburger-global svg { width: 20px; height: 20px; }
351
-
352
- /* ── Topbar ────────────────── */
353
- .topbar {
354
- height: var(--topbar-h);
355
- display: flex;
356
- align-items: center;
357
- justify-content: space-between;
358
- padding: 0 12px;
359
- border-bottom: 1px solid var(--border);
360
- background: var(--bg-1);
361
- flex-shrink: 0;
362
- }
363
-
364
- .topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
365
- .topbar-right { display: flex; align-items: center; gap: 6px; }
366
-
367
- .zone-indicator {
368
- display: flex; align-items: center; gap: 6px;
369
- padding: 4px 10px;
370
- background: var(--accent-glow);
371
- border-radius: var(--radius-sm);
372
- color: var(--accent-hover);
373
- font-weight: 600;
374
- font-size: 12px;
375
- flex-shrink: 0;
376
- }
377
-
378
- .zone-indicator svg { width: 13px; height: 13px; }
379
-
380
- .topbar-sep {
381
- width: 1px; height: 16px;
382
- background: var(--border);
383
- flex-shrink: 0;
384
- }
385
-
386
- .breadcrumb {
387
- display: flex; align-items: center; gap: 2px;
388
- font-size: 12px;
389
- overflow-x: auto;
390
- min-width: 0;
391
- }
392
-
393
- .breadcrumb span {
394
- color: var(--text-3);
395
- cursor: pointer;
396
- padding: 2px 5px;
397
- border-radius: var(--radius-sm);
398
- white-space: nowrap;
399
- transition: all var(--transition);
400
- }
401
-
402
- .breadcrumb span:hover { color: var(--text); background: var(--bg-hover); }
403
- .breadcrumb .sep { cursor: default; color: var(--text-3); padding: 0 1px; }
404
- .breadcrumb .sep:hover { background: none; color: var(--text-3); }
405
-
406
- /* ── Workspace Body ────────── */
407
- .workspace-body {
408
- flex: 1;
409
- display: flex;
410
- overflow: hidden;
411
- }
412
-
413
- /* ── Panels ────────────────── */
414
- .panel { display: flex; flex-direction: column; overflow: hidden; }
415
-
416
- .panel-files {
417
- width: 260px;
418
- min-width: 180px;
419
- max-width: 500px;
420
- border-right: 1px solid var(--border);
421
- background: var(--bg-1);
422
- }
423
-
424
- .panel-right { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
425
-
426
- .panel-header {
427
- height: var(--panel-header-h);
428
- display: flex;
429
- align-items: center;
430
- justify-content: space-between;
431
- padding: 0 10px;
432
- border-bottom: 1px solid var(--border);
433
- flex-shrink: 0;
434
- }
435
-
436
- .panel-title {
437
- display: flex; align-items: center; gap: 6px;
438
- font-size: 11px;
439
- font-weight: 600;
440
- text-transform: uppercase;
441
- letter-spacing: 0.5px;
442
- color: var(--text-3);
443
- }
444
-
445
- .panel-title svg { width: 13px; height: 13px; }
446
-
447
- .panel-actions { display: flex; gap: 2px; }
448
-
449
- /* ── File Tree ─────────────── */
450
- .file-tree {
451
- flex: 1;
452
- overflow-y: auto;
453
- padding: 4px 0;
454
- }
455
-
456
- .file-item {
457
- display: flex;
458
- align-items: center;
459
- padding: 4px 12px;
460
- gap: 8px;
461
- cursor: pointer;
462
- transition: background var(--transition);
463
- height: 30px;
464
- }
465
-
466
- .file-item:hover { background: var(--bg-hover); }
467
- .file-item:active { background: var(--bg-active); }
468
-
469
- .file-item .fi-icon {
470
- width: 16px; height: 16px;
471
- flex-shrink: 0;
472
- display: flex; align-items: center; justify-content: center;
473
- }
474
-
475
- .file-item .fi-icon svg { width: 14px; height: 14px; }
476
-
477
- .fi-icon-folder { color: var(--accent); }
478
- .fi-icon-file { color: var(--text-3); }
479
- .fi-icon-js { color: #f7df1e; }
480
- .fi-icon-ts { color: #3178c6; }
481
- .fi-icon-py { color: #3572a5; }
482
- .fi-icon-go { color: #00add8; }
483
- .fi-icon-html { color: #e34c26; }
484
- .fi-icon-css { color: #563d7c; }
485
- .fi-icon-json { color: #f59e0b; }
486
- .fi-icon-md { color: var(--text-2); }
487
- .fi-icon-img { color: #22c55e; }
488
- .fi-icon-config { color: var(--text-3); }
489
- .fi-icon-back { color: var(--text-3); }
490
-
491
- .file-item .fi-name {
492
- flex: 1;
493
- font-size: 13px;
494
- overflow: hidden;
495
- text-overflow: ellipsis;
496
- white-space: nowrap;
497
- color: var(--text-2);
498
- }
499
-
500
- .file-item:hover .fi-name { color: var(--text); }
501
-
502
- .file-item .fi-size {
503
- font-size: 11px;
504
- color: var(--text-3);
505
- white-space: nowrap;
506
- font-family: var(--font-mono);
507
- }
508
-
509
- .file-item .fi-actions {
510
- display: flex; gap: 1px;
511
- opacity: 0.4;
512
- transition: opacity var(--transition);
513
- }
514
-
515
- .file-item:hover .fi-actions { opacity: 1; }
516
-
517
- .fi-actions button {
518
- width: 22px; height: 22px;
519
- display: flex; align-items: center; justify-content: center;
520
- background: none; border: none;
521
- color: var(--text-3);
522
- cursor: pointer;
523
- border-radius: var(--radius-sm);
524
- transition: all var(--transition);
525
- }
526
-
527
- .fi-actions button svg { width: 12px; height: 12px; }
528
- .fi-actions button:hover { background: var(--bg-active); color: var(--text); }
529
- .fi-actions button.fi-del:hover { color: var(--danger); }
530
-
531
- /* ── Resizers ──────────────── */
532
- .resizer-v {
533
- width: 4px;
534
- cursor: col-resize;
535
- background: transparent;
536
- transition: background var(--transition);
537
- flex-shrink: 0;
538
- position: relative;
539
- }
540
-
541
- .resizer-v:hover, .resizer-v.active { background: var(--accent); }
542
-
543
- .resizer-h {
544
- height: 4px;
545
- cursor: row-resize;
546
- background: transparent;
547
- transition: background var(--transition);
548
- flex-shrink: 0;
549
- }
550
-
551
- .resizer-h:hover, .resizer-h.active { background: var(--accent); }
552
-
553
- /* ── Panes ─────────────────── */
554
- .pane { display: flex; flex-direction: column; overflow: hidden; }
555
- .pane-editor { flex: 1; min-height: 100px; }
556
- .pane-terminal { height: 260px; min-height: 100px; }
557
-
558
- .pane-header {
559
- height: var(--panel-header-h);
560
- display: flex;
561
- align-items: center;
562
- justify-content: space-between;
563
- padding: 0 10px;
564
- background: var(--bg-2);
565
- border-bottom: 1px solid var(--border);
566
- flex-shrink: 0;
567
- }
568
-
569
- .pane-tabs { display: flex; align-items: center; gap: 2px; min-width: 0; flex: 1; overflow-x: auto; }
570
-
571
- .pane-tab {
572
- display: flex; align-items: center; gap: 5px;
573
- padding: 4px 12px;
574
- border-radius: var(--radius-sm);
575
- background: var(--bg-3);
576
- color: var(--text-2);
577
- font-size: 12px;
578
- white-space: nowrap;
579
- max-width: 200px;
580
- }
581
-
582
- .pane-tab svg { width: 12px; height: 12px; flex-shrink: 0; }
583
- .pane-tab .tab-name { overflow: hidden; text-overflow: ellipsis; }
584
-
585
- .pane-tab .tab-dot {
586
- width: 6px; height: 6px;
587
- border-radius: 50%;
588
- background: var(--accent);
589
- flex-shrink: 0;
590
- }
591
-
592
- .tab-placeholder {
593
- display: flex; align-items: center; gap: 6px;
594
- color: var(--text-3);
595
- font-size: 12px;
596
- }
597
-
598
- .tab-placeholder svg { width: 13px; height: 13px; }
599
-
600
- .pane-title {
601
- display: flex; align-items: center; gap: 6px;
602
- font-size: 11px;
603
- font-weight: 600;
604
- text-transform: uppercase;
605
- letter-spacing: 0.5px;
606
- color: var(--text-3);
607
- }
608
-
609
- .pane-title svg { width: 13px; height: 13px; }
610
-
611
- .pane-actions { display: flex; gap: 2px; }
612
-
613
- /* ── Editor ────────────────── */
614
- .editor-container { flex: 1; overflow: hidden; position: relative; }
615
-
616
- .editor-empty {
617
- position: absolute;
618
- inset: 0;
619
- display: flex;
620
- flex-direction: column;
621
- align-items: center;
622
- justify-content: center;
623
- gap: 8px;
624
- color: var(--text-3);
625
- }
626
-
627
- .editor-empty svg { width: 28px; height: 28px; opacity: 0.3; }
628
- .editor-empty p { font-size: 13px; }
629
-
630
- /* CodeMirror overrides */
631
- .editor-container .CodeMirror {
632
- height: 100%;
633
- font-family: var(--font-mono);
634
- font-size: 13px;
635
- line-height: 1.6;
636
- background: var(--bg-0);
637
- }
638
-
639
- .CodeMirror-gutters {
640
- background: var(--bg-1) !important;
641
- border-right: 1px solid var(--border) !important;
642
- }
643
-
644
- .CodeMirror-linenumber { color: var(--text-3) !important; }
645
- .CodeMirror-activeline-background { background: var(--bg-hover) !important; }
646
- .CodeMirror-selected { background: rgba(59,130,246,0.2) !important; }
647
- .CodeMirror-cursor { border-left-color: var(--accent) !important; }
648
-
649
- /* ── Terminal ──────────────── */
650
- .terminal-container {
651
- flex: 1;
652
- background: var(--bg-0);
653
- overflow: hidden;
654
- }
655
-
656
- .terminal-container .xterm { padding: 4px 0 4px 4px; }
657
-
658
- /* ── Empty State ───────────── */
659
- .empty-state {
660
- display: flex;
661
- flex-direction: column;
662
- align-items: center;
663
- justify-content: center;
664
- padding: 40px 20px;
665
- color: var(--text-3);
666
- gap: 8px;
667
- }
668
-
669
- .empty-state svg { width: 32px; height: 32px; opacity: 0.3; }
670
- .empty-state p { font-size: 13px; }
671
-
672
- /* ── Modal ─────────────────── */
673
- .modal-overlay {
674
- position: fixed;
675
- inset: 0;
676
- background: rgba(0, 0, 0, 0.6);
677
- backdrop-filter: blur(4px);
678
- display: flex;
679
- align-items: center;
680
- justify-content: center;
681
- z-index: 1000;
682
- animation: fadeIn 150ms ease;
683
- }
684
-
685
- .modal-overlay.hidden { display: none; }
686
-
687
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
688
- @keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
689
-
690
- .modal {
691
- background: var(--bg-2);
692
- border: 1px solid var(--border);
693
- border-radius: var(--radius-lg);
694
- padding: 0;
695
- width: 420px;
696
- max-width: 90vw;
697
- box-shadow: var(--shadow-lg);
698
- animation: slideUp 200ms ease;
699
- }
700
-
701
- .modal-sm { width: 360px; }
702
-
703
- .modal-header {
704
- display: flex;
705
- align-items: center;
706
- justify-content: space-between;
707
- padding: 16px 20px 12px;
708
- border-bottom: 1px solid var(--border);
709
- }
710
-
711
- .modal-header h3 {
712
- display: flex; align-items: center; gap: 8px;
713
- font-size: 15px;
714
- font-weight: 600;
715
- }
716
-
717
- .modal-header h3 svg { width: 18px; height: 18px; color: var(--accent); }
718
-
719
- .modal form { padding: 16px 20px; }
720
-
721
- .form-group { margin-bottom: 14px; }
722
-
723
- .form-group label {
724
- display: block;
725
- font-size: 12px;
726
- font-weight: 500;
727
- color: var(--text-2);
728
- margin-bottom: 5px;
729
- }
730
-
731
- .form-group .optional { color: var(--text-3); font-weight: 400; }
732
-
733
- .form-group input[type="text"] {
734
- width: 100%;
735
- padding: 8px 12px;
736
- background: var(--bg-0);
737
- border: 1px solid var(--border);
738
- border-radius: var(--radius);
739
- color: var(--text);
740
- font-size: 13px;
741
- outline: none;
742
- transition: border-color var(--transition);
743
- font-family: var(--font);
744
- }
745
-
746
- .form-group input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
747
-
748
- .form-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; display: block; }
749
-
750
- .modal-footer {
751
- display: flex;
752
- justify-content: flex-end;
753
- gap: 8px;
754
- padding-top: 8px;
755
- }
756
-
757
- /* ── Toast ─────────────────── */
758
- .toast-container {
759
- position: fixed;
760
- bottom: 20px;
761
- right: 20px;
762
- display: flex;
763
- flex-direction: column;
764
- gap: 8px;
765
- z-index: 2000;
766
- pointer-events: none;
767
- }
768
-
769
- .toast {
770
- display: flex; align-items: center; gap: 8px;
771
- padding: 10px 16px;
772
- background: var(--bg-3);
773
- border: 1px solid var(--border);
774
- border-radius: var(--radius);
775
- box-shadow: var(--shadow);
776
- font-size: 13px;
777
- color: var(--text);
778
- animation: slideUp 200ms ease;
779
- pointer-events: auto;
780
- max-width: 360px;
781
- }
782
-
783
- .toast svg { width: 16px; height: 16px; flex-shrink: 0; }
784
- .toast-success svg { color: var(--success); }
785
- .toast-error svg { color: var(--danger); }
786
- .toast-info svg { color: var(--accent); }
787
-
788
- /* ── Port Panel ────────────── */
789
- .port-controls { position: relative; }
790
-
791
- .btn-sm {
792
- padding: 4px 10px;
793
- font-size: 12px;
794
- border-radius: var(--radius-sm);
795
- }
796
-
797
- .btn-sm svg { width: 14px; height: 14px; }
798
-
799
- .port-count {
800
- background: var(--accent);
801
- color: var(--bg-0);
802
- font-size: 10px;
803
- font-weight: 700;
804
- padding: 1px 6px;
805
- border-radius: 10px;
806
- min-width: 16px;
807
- text-align: center;
808
- }
809
-
810
- .port-panel {
811
- position: fixed;
812
- top: auto;
813
- right: 16px;
814
- width: 340px;
815
- background: var(--bg-2);
816
- border: 1px solid var(--border);
817
- border-radius: var(--radius);
818
- box-shadow: var(--shadow);
819
- z-index: 900;
820
- animation: slideUp 150ms ease;
821
- }
822
-
823
- .port-panel-header {
824
- display: flex;
825
- align-items: center;
826
- justify-content: space-between;
827
- padding: 10px 14px;
828
- border-bottom: 1px solid var(--border);
829
- font-size: 13px;
830
- font-weight: 600;
831
- color: var(--text);
832
- }
833
-
834
- .port-panel-header span { display: flex; align-items: center; gap: 6px; }
835
- .port-panel-header svg { width: 14px; height: 14px; color: var(--accent); }
836
-
837
- .port-panel-hint {
838
- padding: 8px 14px;
839
- font-size: 11px;
840
- color: var(--text-3);
841
- line-height: 1.4;
842
- border-bottom: 1px solid var(--border);
843
- }
844
-
845
- .port-list { max-height: 260px; overflow-y: auto; }
846
-
847
- .port-item {
848
- display: flex;
849
- align-items: center;
850
- gap: 8px;
851
- padding: 8px 14px;
852
- border-bottom: 1px solid var(--border);
853
- transition: background var(--transition);
854
- }
855
-
856
- .port-item:last-child { border-bottom: none; }
857
- .port-item:hover { background: var(--bg-hover); }
858
-
859
- .port-item .pi-port {
860
- font-family: var(--font-mono);
861
- font-size: 13px;
862
- font-weight: 600;
863
- color: var(--accent);
864
- min-width: 48px;
865
- }
866
-
867
- .port-item .pi-label {
868
- flex: 1;
869
- font-size: 12px;
870
- color: var(--text-2);
871
- overflow: hidden;
872
- text-overflow: ellipsis;
873
- white-space: nowrap;
874
- }
875
-
876
- .port-item .pi-actions {
877
- display: flex;
878
- gap: 4px;
879
- }
880
-
881
- .port-item .pi-actions button {
882
- width: 24px; height: 24px;
883
- display: inline-flex; align-items: center; justify-content: center;
884
- background: transparent;
885
- border: none;
886
- border-radius: var(--radius-sm);
887
- color: var(--text-3);
888
- cursor: pointer;
889
- transition: all var(--transition);
890
- }
891
-
892
- .port-item .pi-actions button svg { width: 13px; height: 13px; }
893
- .port-item .pi-actions button:hover { background: var(--bg-3); color: var(--text); }
894
- .port-item .pi-actions .pi-del:hover { color: var(--danger); }
895
-
896
- .port-empty {
897
- padding: 20px 14px;
898
- text-align: center;
899
- color: var(--text-3);
900
- font-size: 12px;
901
- }
902
-
903
- /* ── Mobile Infrastructure ─── */
904
- .sidebar-backdrop {
905
- display: none;
906
- position: fixed;
907
- inset: 0;
908
- background: rgba(0,0,0,0.5);
909
- z-index: 49;
910
- -webkit-tap-highlight-color: transparent;
911
- }
912
-
913
- .sidebar-close-btn { display: none; }
914
- .mobile-only { display: none !important; }
915
- .mobile-tabs { display: none; }
916
-
917
- /* ── Mobile Breakpoint (width OR touch device) ── */
918
- @media (max-width: 768px), (pointer: coarse) {
919
- :root {
920
- --sidebar-w: 280px;
921
- --topbar-h: 48px;
922
- --panel-header-h: 44px;
923
- --mobile-tab-h: 56px;
924
- }
925
-
926
- .desktop-only { display: none !important; }
927
- .mobile-only { display: flex !important; }
928
-
929
- /* Sidebar → slide-out drawer */
930
- #sidebar {
931
- position: fixed;
932
- left: 0; top: 0; bottom: 0;
933
- width: var(--sidebar-w);
934
- transform: translateX(-100%);
935
- transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
936
- z-index: 50;
937
- box-shadow: none;
938
- }
939
-
940
- #sidebar.open {
941
- transform: translateX(0);
942
- box-shadow: var(--shadow-lg);
943
- }
944
-
945
- .sidebar-backdrop.open {
946
- display: block;
947
- }
948
-
949
- .sidebar-close-btn {
950
- display: inline-flex;
951
- margin-left: auto;
952
- }
953
-
954
- .hamburger-global { display: inline-flex; }
955
-
956
- /* Sidebar brand spacing */
957
- .sidebar-brand { padding: 12px 14px 10px; }
958
- .brand-text { font-size: 14px; }
959
-
960
- /* Sidebar items bigger touch targets */
961
- .zone-list li {
962
- padding: 10px 12px;
963
- font-size: 14px;
964
- min-height: 44px;
965
- }
966
-
967
- .zone-list li .zone-icon { width: 20px; height: 20px; }
968
-
969
- /* Welcome zones on mobile */
970
- .welcome-zones { max-width: 100%; padding: 0 12px; }
971
- .zone-card { flex: 1; min-width: 120px; justify-content: center; }
972
-
973
- /* Main takes full width */
974
- #main { width: 100%; }
975
-
976
- /* Topbar mobile */
977
- .topbar {
978
- height: var(--topbar-h);
979
- padding: 0 8px;
980
- gap: 4px;
981
- }
982
-
983
- .topbar-left { gap: 6px; }
984
-
985
- .zone-indicator {
986
- font-size: 11px;
987
- padding: 3px 8px;
988
- max-width: 120px;
989
- overflow: hidden;
990
- }
991
-
992
- .zone-indicator #zone-title {
993
- overflow: hidden;
994
- text-overflow: ellipsis;
995
- white-space: nowrap;
996
- }
997
-
998
- /* Port button compact */
999
- .btn-toggle-ports span:not(.port-count) { display: none; }
1000
- #btn-toggle-ports { padding: 4px 8px; }
1001
-
1002
- /* Mobile breadcrumb (inside files panel) */
1003
- .mobile-breadcrumb {
1004
- display: flex !important;
1005
- padding: 6px 12px;
1006
- border-bottom: 1px solid var(--border);
1007
- background: var(--bg-1);
1008
- overflow-x: auto;
1009
- -webkit-overflow-scrolling: touch;
1010
- flex-shrink: 0;
1011
- }
1012
-
1013
- .mobile-breadcrumb span {
1014
- font-size: 13px;
1015
- padding: 4px 8px;
1016
- min-height: 32px;
1017
- display: flex;
1018
- align-items: center;
1019
- }
1020
-
1021
- /* Workspace body → stacked full-screen */
1022
- .workspace-body {
1023
- flex-direction: column;
1024
- position: relative;
1025
- }
1026
-
1027
- /* Hide resizers on mobile */
1028
- .resizer-v, .resizer-h { display: none; }
1029
-
1030
- /* Each panel = absolute full screen, switch via class */
1031
- .panel-files {
1032
- width: 100% !important;
1033
- max-width: 100%;
1034
- min-width: 100%;
1035
- border-right: none;
1036
- position: absolute;
1037
- inset: 0;
1038
- z-index: 1;
1039
- }
1040
-
1041
- .panel-right {
1042
- position: absolute;
1043
- inset: 0;
1044
- z-index: 1;
1045
- }
1046
-
1047
- .pane-editor {
1048
- position: absolute;
1049
- inset: 0;
1050
- z-index: 1;
1051
- }
1052
-
1053
- .pane-terminal {
1054
- position: absolute;
1055
- inset: 0;
1056
- height: 100% !important;
1057
- z-index: 1;
1058
- }
1059
-
1060
- /* Only show active mobile panel */
1061
- .panel-files,
1062
- .pane-editor,
1063
- .pane-terminal {
1064
- display: none;
1065
- }
1066
-
1067
- .panel-files.m-active { display: flex; }
1068
- .pane-editor.m-active { display: flex; }
1069
- .pane-terminal.m-active { display: flex; }
1070
-
1071
- /* When editor or terminal is active, show panel-right as container */
1072
- .panel-right.m-active { display: flex; }
1073
-
1074
- /* File items — bigger touch targets, always show actions */
1075
- .file-item {
1076
- min-height: 48px;
1077
- height: auto;
1078
- padding: 8px 12px;
1079
- gap: 10px;
1080
- }
1081
-
1082
- .file-item .fi-icon { width: 20px; height: 20px; }
1083
- .file-item .fi-icon svg { width: 18px; height: 18px; }
1084
- .file-item .fi-name { font-size: 14px; }
1085
- .file-item .fi-size { font-size: 12px; }
1086
-
1087
- /* Always show file actions on touch */
1088
- .file-item .fi-actions {
1089
- opacity: 1;
1090
- }
1091
-
1092
- .fi-actions button {
1093
- width: 34px; height: 34px;
1094
- }
1095
-
1096
- .fi-actions button svg { width: 16px; height: 16px; }
1097
-
1098
- /* Panel headers bigger */
1099
- .panel-header, .pane-header {
1100
- height: var(--panel-header-h);
1101
- padding: 0 12px;
1102
- }
1103
-
1104
- .panel-title { font-size: 12px; }
1105
- .panel-title svg { width: 15px; height: 15px; }
1106
-
1107
- .icon-btn-sm {
1108
- width: 36px; height: 36px;
1109
- }
1110
-
1111
- .icon-btn-sm svg { width: 18px; height: 18px; }
1112
-
1113
- /* Editor mobile */
1114
- .editor-container .CodeMirror {
1115
- font-size: 12px;
1116
- }
1117
-
1118
- .pane-tab {
1119
- padding: 6px 12px;
1120
- font-size: 13px;
1121
- }
1122
-
1123
- /* Terminal mobile */
1124
- .terminal-container .xterm {
1125
- padding: 2px 0 2px 2px;
1126
- }
1127
-
1128
- /* Bottom Tab Bar */
1129
- .mobile-tabs {
1130
- display: flex;
1131
- height: var(--mobile-tab-h);
1132
- background: var(--bg-1);
1133
- border-top: 1px solid var(--border);
1134
- flex-shrink: 0;
1135
- z-index: 10;
1136
- }
1137
-
1138
- .mobile-tab {
1139
- flex: 1;
1140
- display: flex;
1141
- flex-direction: column;
1142
- align-items: center;
1143
- justify-content: center;
1144
- gap: 2px;
1145
- background: none;
1146
- border: none;
1147
- color: var(--text-3);
1148
- cursor: pointer;
1149
- font-size: 10px;
1150
- font-weight: 500;
1151
- font-family: var(--font);
1152
- padding: 6px 0;
1153
- -webkit-tap-highlight-color: transparent;
1154
- transition: color var(--transition);
1155
- position: relative;
1156
- }
1157
-
1158
- .mobile-tab svg { width: 20px; height: 20px; }
1159
-
1160
- .mobile-tab.active {
1161
- color: var(--accent);
1162
- }
1163
-
1164
- .mobile-tab.active::after {
1165
- content: '';
1166
- position: absolute;
1167
- top: 0;
1168
- left: 25%; right: 25%;
1169
- height: 2px;
1170
- background: var(--accent);
1171
- border-radius: 0 0 2px 2px;
1172
- }
1173
-
1174
- .mobile-tab.has-dot::before {
1175
- content: '';
1176
- position: absolute;
1177
- top: 6px;
1178
- right: calc(50% - 16px);
1179
- width: 6px; height: 6px;
1180
- background: var(--accent);
1181
- border-radius: 50%;
1182
- }
1183
-
1184
- /* Modals full-width on mobile */
1185
- .modal {
1186
- width: 100%;
1187
- max-width: 100vw;
1188
- border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1189
- margin: 0;
1190
- position: fixed;
1191
- bottom: 0;
1192
- left: 0;
1193
- right: 0;
1194
- animation: slideUpModal 250ms ease;
1195
- }
1196
-
1197
- .modal-sm { width: 100%; }
1198
-
1199
- .modal form { padding: 16px; }
1200
-
1201
- .form-group input[type="text"],
1202
- .form-group input[type="number"] {
1203
- padding: 12px 14px;
1204
- font-size: 16px; /* prevents iOS zoom */
1205
- }
1206
-
1207
- .modal-overlay {
1208
- align-items: flex-end;
1209
- }
1210
-
1211
- @keyframes slideUpModal {
1212
- from { transform: translateY(100%); }
1213
- to { transform: translateY(0); }
1214
- }
1215
-
1216
- /* Port panel on mobile */
1217
- .port-panel {
1218
- position: fixed;
1219
- top: auto !important;
1220
- bottom: var(--mobile-tab-h);
1221
- left: 8px;
1222
- right: 8px;
1223
- width: auto;
1224
- border-radius: var(--radius-lg);
1225
- }
1226
-
1227
- /* Toast at top on mobile (avoid keyboard) */
1228
- .toast-container {
1229
- top: 12px;
1230
- bottom: auto;
1231
- left: 12px;
1232
- right: 12px;
1233
- }
1234
-
1235
- .toast { max-width: 100%; }
1236
-
1237
- /* Welcome page mobile */
1238
- .welcome-hero { padding: 20px; }
1239
- .welcome-hero h1 { font-size: 22px; }
1240
- .welcome-hero p { font-size: 13px; max-width: 280px; }
1241
- .welcome-icon { width: 64px; height: 64px; }
1242
- .welcome-icon svg { width: 28px; height: 28px; }
1243
- .welcome-glow { width: 200px; height: 200px; }
1244
-
1245
- /* Empty state mobile */
1246
- .empty-state { padding: 30px 16px; }
1247
- .editor-empty svg { width: 24px; height: 24px; }
1248
-
1249
- /* Icon buttons bigger for touch */
1250
- .icon-btn {
1251
- width: 40px; height: 40px;
1252
- }
1253
-
1254
- .icon-btn svg { width: 20px; height: 20px; }
1255
-
1256
- /* Environment badges in sidebar */
1257
- .sidebar-bottom { padding: 10px 14px; }
1258
- .badge { font-size: 9px; padding: 3px 8px; }
1259
- }
1260
-
1261
- /* ── Small phones (< 380px) ── */
1262
- @media (max-width: 380px) {
1263
- :root {
1264
- --sidebar-w: 260px;
1265
- }
1266
-
1267
- .zone-indicator { max-width: 90px; font-size: 10px; }
1268
- .mobile-tab span { font-size: 9px; }
1269
- .mobile-tab svg { width: 18px; height: 18px; }
1270
- }
1271
-
1272
- /* ── Form input number (port) ── */
1273
- .form-group input[type="number"] {
1274
- width: 100%;
1275
- padding: 8px 12px;
1276
- background: var(--bg-0);
1277
- border: 1px solid var(--border);
1278
- border-radius: var(--radius);
1279
- color: var(--text);
1280
- font-size: 13px;
1281
- outline: none;
1282
- transition: border-color var(--transition);
1283
- font-family: var(--font);
1284
- }
1285
-
1286
- .form-group input[type="number"]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
1287
-
1288
- /* Safe area for notch devices */
1289
- @supports (padding: env(safe-area-inset-bottom)) {
1290
- .mobile-tabs {
1291
- padding-bottom: env(safe-area-inset-bottom);
1292
- }
1293
-
1294
- .modal {
1295
- padding-bottom: env(safe-area-inset-bottom);
1296
- }
1297
  }
1298
 
1299
- /* ── Utility ───────────────── */
1300
- .hidden { display: none !important; }
 
 
 
 
 
 
 
 
1
+ /* ═══ HugPanel Custom Styles ═══ */
 
 
2
 
3
+ /* Scrollbar */
4
+ ::-webkit-scrollbar {
5
+ width: 6px;
6
+ height: 6px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
+ ::-webkit-scrollbar-track {
9
+ background: transparent;
 
 
 
 
 
 
 
 
10
  }
11
+ ::-webkit-scrollbar-thumb {
12
+ background: #374151;
13
+ border-radius: 3px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  }
15
+ ::-webkit-scrollbar-thumb:hover {
16
+ background: #4b5563;
 
 
 
 
17
  }
18
 
19
+ /* Hide scrollbar utility */
20
+ .scrollbar-hide {
21
+ -ms-overflow-style: none;
22
+ scrollbar-width: none;
 
 
23
  }
24
+ .scrollbar-hide::-webkit-scrollbar {
25
+ display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
+ /* Terminal container */
29
+ #terminal-container {
30
+ min-height: 200px;
 
 
 
 
 
 
 
 
 
 
31
  }
32
+ #terminal-container .xterm {
33
+ height: 100%;
34
+ padding: 4px;
 
 
 
35
  }
36
+ #terminal-container .xterm-viewport {
37
+ scrollbar-width: thin;
38
+ scrollbar-color: #374151 transparent;
 
39
  }
40
+ #terminal-container .xterm-viewport::-webkit-scrollbar {
41
+ width: 6px;
 
 
 
 
 
 
 
42
  }
43
+ #terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
44
+ background: #374151;
45
+ border-radius: 3px;
 
 
 
 
 
 
46
  }
47
 
48
+ /* Textarea editor */
49
+ textarea {
50
+ tab-size: 4;
51
+ -moz-tab-size: 4;
 
 
 
 
 
 
 
 
 
 
52
  }
53
 
54
+ /* Smooth transitions */
55
+ * {
56
+ -webkit-tap-highlight-color: transparent;
 
 
 
 
 
 
 
 
 
57
  }
58
 
59
+ /* Mobile touch improvements */
60
+ @media (max-width: 1023px) {
61
+ .group:hover .group-hover\:opacity-100 {
62
+ opacity: 1;
63
+ }
64
+ /* Always show file actions on mobile */
65
+ .group .opacity-0 {
66
+ opacity: 1 !important;
67
+ }
 
 
 
 
 
 
68
  }
69
 
70
+ /* Focus ring */
71
+ input:focus, textarea:focus, button:focus-visible {
72
+ outline: none;
73
+ box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
 
 
74
  }
75
 
76
+ /* Animation for loading spinner */
77
+ @keyframes spin {
78
+ to { transform: rotate(360deg); }
 
 
 
79
  }
80
+ .animate-spin {
81
+ animation: spin 1s linear infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  }
83
 
84
+ /* Bottom sheet modal on mobile */
85
+ @media (max-width: 639px) {
86
+ [x-show="showCreateZone"] > div,
87
+ [x-show="showRename"] > div {
88
+ border-bottom-left-radius: 0;
89
+ border-bottom-right-radius: 0;
90
+ margin-bottom: 0;
91
+ }
92
+ }