Upload 19 files
Browse files- static/app.js +446 -940
- static/index.html +354 -259
- static/style.css +66 -1274
static/app.js
CHANGED
|
@@ -1,951 +1,457 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
//
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
//
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 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 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
});
|
|
|
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
}
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
const
|
| 350 |
-
if (
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
await api(`/api/zones/${currentZone}
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
}
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
const form = new FormData();
|
| 430 |
-
form.append(
|
| 431 |
-
form.append(
|
| 432 |
try {
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
}
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
});
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 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 |
-
|
| 523 |
-
if (
|
| 524 |
-
|
| 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 |
-
|
| 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, """);
|
| 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 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
}
|
| 938 |
-
|
| 939 |
-
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
</head>
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
|
|
|
| 70 |
</div>
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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 |
-
|
| 171 |
-
<
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
</button>
|
| 176 |
-
<button class="
|
| 177 |
-
|
| 178 |
-
<span>Editor</span>
|
| 179 |
</button>
|
| 180 |
-
<button
|
| 181 |
-
|
| 182 |
-
|
| 183 |
</button>
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
</div>
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
<!-- Toasts -->
|
| 190 |
-
<div id="toast-container" class="toast-container"></div>
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
</div>
|
| 199 |
-
<
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 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 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
<
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
<div class="
|
| 229 |
-
|
| 230 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
</div>
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
</div>
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
</div>
|
| 242 |
-
<div
|
| 243 |
-
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
</div>
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
</div>
|
| 263 |
</div>
|
|
|
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 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 |
-
|
| 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 |
-
*
|
| 6 |
-
|
| 7 |
-
:
|
| 8 |
-
|
| 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 |
-
|
| 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 |
-
|
| 63 |
-
|
| 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 |
-
|
| 83 |
-
display: flex;
|
| 84 |
-
align-items: center;
|
| 85 |
-
gap: 10px;
|
| 86 |
-
padding: 16px 16px 12px;
|
| 87 |
}
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
display: flex; align-items: center; justify-content: center;
|
| 94 |
-
color: #fff;
|
| 95 |
}
|
| 96 |
-
|
| 97 |
-
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
.zone-list li.active {
|
| 133 |
-
background: var(--accent-glow);
|
| 134 |
-
color: var(--accent-hover);
|
| 135 |
}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
opacity: 0.6;
|
| 140 |
}
|
| 141 |
-
|
| 142 |
-
|
| 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 |
-
|
| 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 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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 |
-
|
| 179 |
-
|
| 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 |
-
|
| 193 |
-
|
| 194 |
-
.
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 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 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
color: #fff;
|
| 214 |
-
box-shadow: 0 0 20px var(--accent-glow);
|
| 215 |
}
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
background: transparent;
|
| 221 |
-
color: var(--text-2);
|
| 222 |
-
border: 1px solid var(--border);
|
| 223 |
}
|
| 224 |
-
|
| 225 |
-
|
| 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 |
-
/*
|
| 1300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|