Upload 3 files
Browse files- static/app.js +786 -720
- static/index.html +749 -672
static/app.js
CHANGED
|
@@ -1,721 +1,787 @@
|
|
| 1 |
-
function hugpanel() {
|
| 2 |
-
return {
|
| 3 |
-
// ── Auth State ──
|
| 4 |
-
user: null,
|
| 5 |
-
token: localStorage.getItem('hugpanel_token'),
|
| 6 |
-
adminApiUrl: localStorage.getItem('hugpanel_admin_url') || '',
|
| 7 |
-
authLoading: true,
|
| 8 |
-
authMode: 'login',
|
| 9 |
-
authError: '',
|
| 10 |
-
authSubmitting: false,
|
| 11 |
-
loginForm: { username: '', password: '' },
|
| 12 |
-
registerForm: { username: '', email: '', password: '' },
|
| 13 |
-
|
| 14 |
-
// ── State ──
|
| 15 |
-
sidebarOpen: false,
|
| 16 |
-
zones: [],
|
| 17 |
-
currentZone: localStorage.getItem('hugpanel_zone') || null,
|
| 18 |
-
activeTab: localStorage.getItem('hugpanel_tab') || 'files',
|
| 19 |
-
maxZones: 0,
|
| 20 |
-
motd: '',
|
| 21 |
-
registrationDisabled: false,
|
| 22 |
-
isDesktop: window.innerWidth >= 1024,
|
| 23 |
-
tabs: [
|
| 24 |
-
{ id: 'files', label: 'Files', icon: 'folder' },
|
| 25 |
-
{ id: 'editor', label: 'Editor', icon: 'file-code' },
|
| 26 |
-
{ id: 'terminal', label: 'Terminal', icon: 'terminal' },
|
| 27 |
-
{ id: 'ports', label: 'Ports', icon: 'radio' },
|
| 28 |
-
{ id: 'backup', label: 'Backup', icon: 'cloud' },
|
| 29 |
-
],
|
| 30 |
-
|
| 31 |
-
// Files
|
| 32 |
-
files: [],
|
| 33 |
-
currentPath: '',
|
| 34 |
-
filesLoading: false,
|
| 35 |
-
showNewFile: false,
|
| 36 |
-
showNewFolder: false,
|
| 37 |
-
newFileName: '',
|
| 38 |
-
newFolderName: '',
|
| 39 |
-
|
| 40 |
-
// Editor
|
| 41 |
-
editorFile: null,
|
| 42 |
-
editorContent: '',
|
| 43 |
-
editorOriginal: '',
|
| 44 |
-
editorDirty: false,
|
| 45 |
-
|
| 46 |
-
// Terminal
|
| 47 |
-
term: null,
|
| 48 |
-
termWs: null,
|
| 49 |
-
termFit: null,
|
| 50 |
-
termZone: null,
|
| 51 |
-
|
| 52 |
-
// Ports
|
| 53 |
-
ports: [],
|
| 54 |
-
newPort: null,
|
| 55 |
-
newPortLabel: '',
|
| 56 |
-
|
| 57 |
-
// Backup
|
| 58 |
-
backupStatus: { configured: false, admin_url: null, running: false, last: null, error: null, progress: '' },
|
| 59 |
-
backupList: [],
|
| 60 |
-
backupLoading: false,
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
if (this.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
if (this.
|
| 119 |
-
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
this.
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
this.$
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
this.$watch('
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
this.$watch('
|
| 142 |
-
this.$nextTick(() => {
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
});
|
| 149 |
-
});
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
});
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
this.
|
| 307 |
-
this.
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
this.
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
this.
|
| 342 |
-
|
| 343 |
-
this.
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
this.
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
this.
|
| 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 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
},
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
this.
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
}
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
},
|
| 440 |
-
|
| 441 |
-
async
|
| 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 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
const
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
this.
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
this.
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
this.
|
| 672 |
-
this.
|
| 673 |
-
}
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
}
|
|
|
|
| 1 |
+
function hugpanel() {
|
| 2 |
+
return {
|
| 3 |
+
// ── Auth State ──
|
| 4 |
+
user: null,
|
| 5 |
+
token: localStorage.getItem('hugpanel_token'),
|
| 6 |
+
adminApiUrl: localStorage.getItem('hugpanel_admin_url') || '',
|
| 7 |
+
authLoading: true,
|
| 8 |
+
authMode: 'login',
|
| 9 |
+
authError: '',
|
| 10 |
+
authSubmitting: false,
|
| 11 |
+
loginForm: { username: '', password: '' },
|
| 12 |
+
registerForm: { username: '', email: '', password: '' },
|
| 13 |
+
|
| 14 |
+
// ── State ──
|
| 15 |
+
sidebarOpen: false,
|
| 16 |
+
zones: [],
|
| 17 |
+
currentZone: localStorage.getItem('hugpanel_zone') || null,
|
| 18 |
+
activeTab: localStorage.getItem('hugpanel_tab') || 'files',
|
| 19 |
+
maxZones: 0,
|
| 20 |
+
motd: '',
|
| 21 |
+
registrationDisabled: false,
|
| 22 |
+
isDesktop: window.innerWidth >= 1024,
|
| 23 |
+
tabs: [
|
| 24 |
+
{ id: 'files', label: 'Files', icon: 'folder' },
|
| 25 |
+
{ id: 'editor', label: 'Editor', icon: 'file-code' },
|
| 26 |
+
{ id: 'terminal', label: 'Terminal', icon: 'terminal' },
|
| 27 |
+
{ id: 'ports', label: 'Ports', icon: 'radio' },
|
| 28 |
+
{ id: 'backup', label: 'Backup', icon: 'cloud' },
|
| 29 |
+
],
|
| 30 |
+
|
| 31 |
+
// Files
|
| 32 |
+
files: [],
|
| 33 |
+
currentPath: '',
|
| 34 |
+
filesLoading: false,
|
| 35 |
+
showNewFile: false,
|
| 36 |
+
showNewFolder: false,
|
| 37 |
+
newFileName: '',
|
| 38 |
+
newFolderName: '',
|
| 39 |
+
|
| 40 |
+
// Editor
|
| 41 |
+
editorFile: null,
|
| 42 |
+
editorContent: '',
|
| 43 |
+
editorOriginal: '',
|
| 44 |
+
editorDirty: false,
|
| 45 |
+
|
| 46 |
+
// Terminal
|
| 47 |
+
term: null,
|
| 48 |
+
termWs: null,
|
| 49 |
+
termFit: null,
|
| 50 |
+
termZone: null,
|
| 51 |
+
|
| 52 |
+
// Ports
|
| 53 |
+
ports: [],
|
| 54 |
+
newPort: null,
|
| 55 |
+
newPortLabel: '',
|
| 56 |
+
|
| 57 |
+
// Backup
|
| 58 |
+
backupStatus: { configured: false, admin_url: null, running: false, last: null, error: null, progress: '' },
|
| 59 |
+
backupList: [],
|
| 60 |
+
backupLoading: false,
|
| 61 |
+
backupFilterZone: '',
|
| 62 |
+
|
| 63 |
+
// Create Zone
|
| 64 |
+
showCreateZone: false,
|
| 65 |
+
createZoneName: '',
|
| 66 |
+
createZoneDesc: '',
|
| 67 |
+
showEditZone: false,
|
| 68 |
+
editZoneName: '',
|
| 69 |
+
editZoneDesc: '',
|
| 70 |
+
|
| 71 |
+
// Rename
|
| 72 |
+
showRename: false,
|
| 73 |
+
renameOldPath: '',
|
| 74 |
+
renameNewName: '',
|
| 75 |
+
|
| 76 |
+
// Toast
|
| 77 |
+
toast: { show: false, message: '', type: 'info' },
|
| 78 |
+
|
| 79 |
+
// ── Computed ──
|
| 80 |
+
get currentPathParts() {
|
| 81 |
+
return this.currentPath ? this.currentPath.split('/').filter(Boolean) : [];
|
| 82 |
+
},
|
| 83 |
+
|
| 84 |
+
get filteredBackupList() {
|
| 85 |
+
if (!this.backupFilterZone) return this.backupList;
|
| 86 |
+
return this.backupList.filter((item) => item.zone_name === this.backupFilterZone);
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
// ── Init ──
|
| 90 |
+
async init() {
|
| 91 |
+
// Load backup status to get adminApiUrl
|
| 92 |
+
await this.loadBackupStatus();
|
| 93 |
+
if (this.backupStatus.admin_url) {
|
| 94 |
+
this.adminApiUrl = this.backupStatus.admin_url;
|
| 95 |
+
localStorage.setItem('hugpanel_admin_url', this.adminApiUrl);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Load config (MOTD, registration state, zone limit) early
|
| 99 |
+
await this._loadZoneLimit();
|
| 100 |
+
|
| 101 |
+
// Try to restore session from stored token
|
| 102 |
+
if (this.token && this.adminApiUrl) {
|
| 103 |
+
try {
|
| 104 |
+
const resp = await fetch(`${this.adminApiUrl}/auth/me`, {
|
| 105 |
+
headers: { 'Authorization': `Bearer ${this.token}` },
|
| 106 |
+
});
|
| 107 |
+
if (resp.ok) {
|
| 108 |
+
const data = await resp.json();
|
| 109 |
+
this.user = data.user;
|
| 110 |
+
} else {
|
| 111 |
+
// Token invalid/expired — clear it
|
| 112 |
+
this.token = null;
|
| 113 |
+
localStorage.removeItem('hugpanel_token');
|
| 114 |
+
}
|
| 115 |
+
} catch {
|
| 116 |
+
// Worker unreachable — keep token, let user retry
|
| 117 |
+
}
|
| 118 |
+
} else if (!this.adminApiUrl) {
|
| 119 |
+
// No admin URL available — can't verify token but don't clear it
|
| 120 |
+
} else {
|
| 121 |
+
// No token stored
|
| 122 |
+
this.token = null;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
this.authLoading = false;
|
| 126 |
+
|
| 127 |
+
if (this.user) {
|
| 128 |
+
await this._loadPanel();
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
this.$nextTick(() => lucide.createIcons());
|
| 132 |
+
|
| 133 |
+
// Watch for icon updates
|
| 134 |
+
this.$watch('zones', () => this.$nextTick(() => lucide.createIcons()));
|
| 135 |
+
this.$watch('files', () => this.$nextTick(() => lucide.createIcons()));
|
| 136 |
+
this.$watch('activeTab', () => this.$nextTick(() => lucide.createIcons()));
|
| 137 |
+
this.$watch('currentZone', () => this.$nextTick(() => lucide.createIcons()));
|
| 138 |
+
this.$watch('ports', () => this.$nextTick(() => lucide.createIcons()));
|
| 139 |
+
this.$watch('backupList', () => this.$nextTick(() => lucide.createIcons()));
|
| 140 |
+
this.$watch('backupStatus', () => this.$nextTick(() => lucide.createIcons()));
|
| 141 |
+
this.$watch('showCreateZone', () => {
|
| 142 |
+
this.$nextTick(() => {
|
| 143 |
+
lucide.createIcons();
|
| 144 |
+
if (this.showCreateZone) this.$refs.zoneNameInput?.focus();
|
| 145 |
+
});
|
| 146 |
+
});
|
| 147 |
+
this.$watch('showNewFile', () => {
|
| 148 |
+
this.$nextTick(() => { if (this.showNewFile) this.$refs.newFileInput?.focus(); });
|
| 149 |
+
});
|
| 150 |
+
this.$watch('showNewFolder', () => {
|
| 151 |
+
this.$nextTick(() => { if (this.showNewFolder) this.$refs.newFolderInput?.focus(); });
|
| 152 |
+
});
|
| 153 |
+
this.$watch('showEditZone', () => {
|
| 154 |
+
this.$nextTick(() => {
|
| 155 |
+
lucide.createIcons();
|
| 156 |
+
if (this.showEditZone) this.$refs.editZoneNameInput?.focus();
|
| 157 |
+
});
|
| 158 |
+
});
|
| 159 |
+
this.$watch('showRename', () => {
|
| 160 |
+
this.$nextTick(() => {
|
| 161 |
+
lucide.createIcons();
|
| 162 |
+
if (this.showRename) this.$refs.renameInput?.focus();
|
| 163 |
+
});
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
// Track desktop breakpoint
|
| 167 |
+
const mql = window.matchMedia('(min-width: 1024px)');
|
| 168 |
+
mql.addEventListener('change', (e) => { this.isDesktop = e.matches; });
|
| 169 |
+
|
| 170 |
+
// Persist session state
|
| 171 |
+
this.$watch('currentZone', (val) => {
|
| 172 |
+
if (val) localStorage.setItem('hugpanel_zone', val);
|
| 173 |
+
else localStorage.removeItem('hugpanel_zone');
|
| 174 |
+
});
|
| 175 |
+
this.$watch('activeTab', (val) => localStorage.setItem('hugpanel_tab', val));
|
| 176 |
+
|
| 177 |
+
// Keyboard shortcut
|
| 178 |
+
document.addEventListener('keydown', (e) => {
|
| 179 |
+
if (e.ctrlKey && e.key === 's' && this.activeTab === 'editor') {
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
this.saveFile();
|
| 182 |
+
}
|
| 183 |
+
});
|
| 184 |
+
},
|
| 185 |
+
|
| 186 |
+
// ── Toast ──
|
| 187 |
+
notify(message, type = 'info') {
|
| 188 |
+
this.toast = { show: true, message, type };
|
| 189 |
+
setTimeout(() => { this.toast.show = false; }, 3000);
|
| 190 |
+
},
|
| 191 |
+
|
| 192 |
+
// ── API Helper ──
|
| 193 |
+
async api(url, options = {}) {
|
| 194 |
+
try {
|
| 195 |
+
const headers = options.headers || {};
|
| 196 |
+
// Add JWT token to all API calls
|
| 197 |
+
if (this.token) {
|
| 198 |
+
headers['Authorization'] = `Bearer ${this.token}`;
|
| 199 |
+
}
|
| 200 |
+
const resp = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
|
| 201 |
+
if (!resp.ok) {
|
| 202 |
+
const data = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 203 |
+
throw new Error(data.detail || resp.statusText);
|
| 204 |
+
}
|
| 205 |
+
return await resp.json();
|
| 206 |
+
} catch (err) {
|
| 207 |
+
this.notify(err.message, 'error');
|
| 208 |
+
throw err;
|
| 209 |
+
}
|
| 210 |
+
},
|
| 211 |
+
|
| 212 |
+
// ── Auth ──
|
| 213 |
+
async _loadPanel() {
|
| 214 |
+
await this.loadZones();
|
| 215 |
+
await this.loadBackupStatus();
|
| 216 |
+
// Restore saved zone if it still exists
|
| 217 |
+
if (this.currentZone && this.zones.some(z => z.name === this.currentZone)) {
|
| 218 |
+
await this.selectZone(this.currentZone);
|
| 219 |
+
} else {
|
| 220 |
+
this.currentZone = null;
|
| 221 |
+
}
|
| 222 |
+
// Fetch zone limit
|
| 223 |
+
await this._loadZoneLimit();
|
| 224 |
+
},
|
| 225 |
+
|
| 226 |
+
async _loadZoneLimit() {
|
| 227 |
+
if (!this.adminApiUrl) return;
|
| 228 |
+
try {
|
| 229 |
+
const resp = await fetch(`${this.adminApiUrl}/config`);
|
| 230 |
+
if (resp.ok) {
|
| 231 |
+
const data = await resp.json();
|
| 232 |
+
this.maxZones = data.max_zones || 0;
|
| 233 |
+
this.motd = data.motd || '';
|
| 234 |
+
this.registrationDisabled = !!data.disable_registration;
|
| 235 |
+
}
|
| 236 |
+
} catch {}
|
| 237 |
+
},
|
| 238 |
+
|
| 239 |
+
async login() {
|
| 240 |
+
if (!this.adminApiUrl) {
|
| 241 |
+
this.authError = 'ADMIN_API_URL chưa cấu hình trên server';
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
this.authError = '';
|
| 245 |
+
this.authSubmitting = true;
|
| 246 |
+
try {
|
| 247 |
+
const resp = await fetch(`${this.adminApiUrl}/auth/login`, {
|
| 248 |
+
method: 'POST',
|
| 249 |
+
headers: { 'Content-Type': 'application/json' },
|
| 250 |
+
body: JSON.stringify(this.loginForm),
|
| 251 |
+
});
|
| 252 |
+
const data = await resp.json();
|
| 253 |
+
if (!resp.ok) {
|
| 254 |
+
this.authError = data.error || 'Đăng nhập thất bại';
|
| 255 |
+
this.authSubmitting = false;
|
| 256 |
+
return;
|
| 257 |
+
}
|
| 258 |
+
this.token = data.token;
|
| 259 |
+
this.user = data.user;
|
| 260 |
+
localStorage.setItem('hugpanel_token', data.token);
|
| 261 |
+
await this._loadPanel();
|
| 262 |
+
this.$nextTick(() => lucide.createIcons());
|
| 263 |
+
} catch (err) {
|
| 264 |
+
this.authError = 'Không thể kết nối Admin Server';
|
| 265 |
+
}
|
| 266 |
+
this.authSubmitting = false;
|
| 267 |
+
},
|
| 268 |
+
|
| 269 |
+
async register() {
|
| 270 |
+
if (!this.adminApiUrl) {
|
| 271 |
+
this.authError = 'ADMIN_API_URL chưa cấu hình trên server';
|
| 272 |
+
return;
|
| 273 |
+
}
|
| 274 |
+
this.authError = '';
|
| 275 |
+
this.authSubmitting = true;
|
| 276 |
+
try {
|
| 277 |
+
const resp = await fetch(`${this.adminApiUrl}/auth/register`, {
|
| 278 |
+
method: 'POST',
|
| 279 |
+
headers: { 'Content-Type': 'application/json' },
|
| 280 |
+
body: JSON.stringify(this.registerForm),
|
| 281 |
+
});
|
| 282 |
+
const data = await resp.json();
|
| 283 |
+
if (!resp.ok) {
|
| 284 |
+
this.authError = data.error || 'Đăng ký thất bại';
|
| 285 |
+
this.authSubmitting = false;
|
| 286 |
+
return;
|
| 287 |
+
}
|
| 288 |
+
this.token = data.token;
|
| 289 |
+
this.user = data.user;
|
| 290 |
+
localStorage.setItem('hugpanel_token', data.token);
|
| 291 |
+
await this._loadPanel();
|
| 292 |
+
this.$nextTick(() => lucide.createIcons());
|
| 293 |
+
} catch (err) {
|
| 294 |
+
this.authError = 'Không thể kết nối Admin Server';
|
| 295 |
+
}
|
| 296 |
+
this.authSubmitting = false;
|
| 297 |
+
},
|
| 298 |
+
|
| 299 |
+
logout() {
|
| 300 |
+
this.token = null;
|
| 301 |
+
this.user = null;
|
| 302 |
+
localStorage.removeItem('hugpanel_token');
|
| 303 |
+
localStorage.removeItem('hugpanel_admin_url');
|
| 304 |
+
localStorage.removeItem('hugpanel_zone');
|
| 305 |
+
localStorage.removeItem('hugpanel_tab');
|
| 306 |
+
this.currentZone = null;
|
| 307 |
+
this.disconnectTerminal();
|
| 308 |
+
},
|
| 309 |
+
|
| 310 |
+
// ── Zones ──
|
| 311 |
+
async loadZones() {
|
| 312 |
+
try {
|
| 313 |
+
this.zones = await this.api('/api/zones');
|
| 314 |
+
} catch { this.zones = []; }
|
| 315 |
+
},
|
| 316 |
+
|
| 317 |
+
async selectZone(name) {
|
| 318 |
+
this.currentZone = name;
|
| 319 |
+
this.currentPath = '';
|
| 320 |
+
this.editorFile = null;
|
| 321 |
+
this.editorDirty = false;
|
| 322 |
+
this.activeTab = 'files';
|
| 323 |
+
this.disconnectTerminal();
|
| 324 |
+
await this.loadFiles();
|
| 325 |
+
await this.loadPorts();
|
| 326 |
+
if (this.backupStatus.configured) {
|
| 327 |
+
await this.loadBackupList();
|
| 328 |
+
}
|
| 329 |
+
},
|
| 330 |
+
|
| 331 |
+
async createZone() {
|
| 332 |
+
if (!this.createZoneName.trim()) return;
|
| 333 |
+
if (this.maxZones > 0 && this.zones.length >= this.maxZones) {
|
| 334 |
+
this.notify(`Đã đạt giới hạn ${this.maxZones} zones`, 'error');
|
| 335 |
+
return;
|
| 336 |
+
}
|
| 337 |
+
const form = new FormData();
|
| 338 |
+
form.append('name', this.createZoneName.trim());
|
| 339 |
+
form.append('description', this.createZoneDesc.trim());
|
| 340 |
+
try {
|
| 341 |
+
await this.api('/api/zones', { method: 'POST', body: form });
|
| 342 |
+
this.showCreateZone = false;
|
| 343 |
+
this.createZoneName = '';
|
| 344 |
+
this.createZoneDesc = '';
|
| 345 |
+
await this.loadZones();
|
| 346 |
+
this.notify('Zone đã được tạo');
|
| 347 |
+
} catch {}
|
| 348 |
+
},
|
| 349 |
+
|
| 350 |
+
startEditZone(zone = null) {
|
| 351 |
+
const current = zone || this.zones.find((item) => item.name === this.currentZone);
|
| 352 |
+
if (!current) return;
|
| 353 |
+
this.editZoneName = current.name || '';
|
| 354 |
+
this.editZoneDesc = current.description || '';
|
| 355 |
+
this.showEditZone = true;
|
| 356 |
+
},
|
| 357 |
+
|
| 358 |
+
async saveZoneSettings() {
|
| 359 |
+
if (!this.currentZone) return;
|
| 360 |
+
const form = new FormData();
|
| 361 |
+
form.append('new_name', this.editZoneName.trim());
|
| 362 |
+
form.append('description', this.editZoneDesc.trim());
|
| 363 |
+
try {
|
| 364 |
+
const data = await this.api(`/api/zones/${this.currentZone}`, { method: 'PATCH', body: form });
|
| 365 |
+
const previous = this.currentZone;
|
| 366 |
+
this.currentZone = data.name;
|
| 367 |
+
this.showEditZone = false;
|
| 368 |
+
await this.loadZones();
|
| 369 |
+
if (previous !== data.name) {
|
| 370 |
+
await this.selectZone(data.name);
|
| 371 |
+
} else {
|
| 372 |
+
await this.loadFiles();
|
| 373 |
+
}
|
| 374 |
+
this.notify('Đã cập nhật zone');
|
| 375 |
+
} catch {}
|
| 376 |
+
},
|
| 377 |
+
|
| 378 |
+
async confirmDeleteZone() {
|
| 379 |
+
if (!this.currentZone) return;
|
| 380 |
+
if (!confirm(`Xoá zone "${this.currentZone}"? Toàn bộ dữ liệu sẽ bị mất.`)) return;
|
| 381 |
+
try {
|
| 382 |
+
await this.api(`/api/zones/${this.currentZone}`, { method: 'DELETE' });
|
| 383 |
+
this.disconnectTerminal();
|
| 384 |
+
this.currentZone = null;
|
| 385 |
+
await this.loadZones();
|
| 386 |
+
this.notify('Zone đã bị xoá');
|
| 387 |
+
} catch {}
|
| 388 |
+
},
|
| 389 |
+
|
| 390 |
+
// ── Files ──
|
| 391 |
+
async loadFiles() {
|
| 392 |
+
if (!this.currentZone) return;
|
| 393 |
+
this.filesLoading = true;
|
| 394 |
+
try {
|
| 395 |
+
this.files = await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(this.currentPath)}`);
|
| 396 |
+
} catch { this.files = []; }
|
| 397 |
+
this.filesLoading = false;
|
| 398 |
+
},
|
| 399 |
+
|
| 400 |
+
navigateTo(path) {
|
| 401 |
+
this.currentPath = path;
|
| 402 |
+
this.loadFiles();
|
| 403 |
+
},
|
| 404 |
+
|
| 405 |
+
navigateUp() {
|
| 406 |
+
const parts = this.currentPath.split('/').filter(Boolean);
|
| 407 |
+
parts.pop();
|
| 408 |
+
this.currentPath = parts.join('/');
|
| 409 |
+
this.loadFiles();
|
| 410 |
+
},
|
| 411 |
+
|
| 412 |
+
joinPath(base, name) {
|
| 413 |
+
return base ? `${base}/${name}` : name;
|
| 414 |
+
},
|
| 415 |
+
|
| 416 |
+
async openFile(path) {
|
| 417 |
+
if (this.editorDirty && !confirm('Bạn có thay đổi chưa lưu. Bỏ qua?')) return;
|
| 418 |
+
try {
|
| 419 |
+
const data = await this.api(`/api/zones/${this.currentZone}/files/read?path=${encodeURIComponent(path)}`);
|
| 420 |
+
this.editorFile = path;
|
| 421 |
+
this.editorContent = data.content;
|
| 422 |
+
this.editorOriginal = data.content;
|
| 423 |
+
this.editorDirty = false;
|
| 424 |
+
this.activeTab = 'editor';
|
| 425 |
+
} catch {}
|
| 426 |
+
},
|
| 427 |
+
|
| 428 |
+
async saveFile() {
|
| 429 |
+
if (!this.editorFile || !this.editorDirty) return;
|
| 430 |
+
const form = new FormData();
|
| 431 |
+
form.append('path', this.editorFile);
|
| 432 |
+
form.append('content', this.editorContent);
|
| 433 |
+
try {
|
| 434 |
+
await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form });
|
| 435 |
+
this.editorOriginal = this.editorContent;
|
| 436 |
+
this.editorDirty = false;
|
| 437 |
+
this.notify('Đã lưu');
|
| 438 |
+
} catch {}
|
| 439 |
+
},
|
| 440 |
+
|
| 441 |
+
async createFile() {
|
| 442 |
+
if (!this.newFileName.trim()) return;
|
| 443 |
+
const path = this.joinPath(this.currentPath, this.newFileName.trim());
|
| 444 |
+
const form = new FormData();
|
| 445 |
+
form.append('path', path);
|
| 446 |
+
form.append('content', '');
|
| 447 |
+
try {
|
| 448 |
+
await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form });
|
| 449 |
+
this.newFileName = '';
|
| 450 |
+
this.showNewFile = false;
|
| 451 |
+
await this.loadFiles();
|
| 452 |
+
} catch {}
|
| 453 |
+
},
|
| 454 |
+
|
| 455 |
+
async createFolder() {
|
| 456 |
+
if (!this.newFolderName.trim()) return;
|
| 457 |
+
const path = this.joinPath(this.currentPath, this.newFolderName.trim());
|
| 458 |
+
const form = new FormData();
|
| 459 |
+
form.append('path', path);
|
| 460 |
+
try {
|
| 461 |
+
await this.api(`/api/zones/${this.currentZone}/files/mkdir`, { method: 'POST', body: form });
|
| 462 |
+
this.newFolderName = '';
|
| 463 |
+
this.showNewFolder = false;
|
| 464 |
+
await this.loadFiles();
|
| 465 |
+
} catch {}
|
| 466 |
+
},
|
| 467 |
+
|
| 468 |
+
async uploadFile(event) {
|
| 469 |
+
const fileList = event.target.files;
|
| 470 |
+
if (!fileList || fileList.length === 0) return;
|
| 471 |
+
for (const file of fileList) {
|
| 472 |
+
const form = new FormData();
|
| 473 |
+
form.append('path', this.currentPath);
|
| 474 |
+
form.append('file', file);
|
| 475 |
+
try {
|
| 476 |
+
await this.api(`/api/zones/${this.currentZone}/files/upload`, { method: 'POST', body: form });
|
| 477 |
+
} catch {}
|
| 478 |
+
}
|
| 479 |
+
event.target.value = '';
|
| 480 |
+
await this.loadFiles();
|
| 481 |
+
this.notify(`Đã upload ${fileList.length} file`);
|
| 482 |
+
},
|
| 483 |
+
|
| 484 |
+
async deleteFile(path, isDir) {
|
| 485 |
+
const label = isDir ? 'thư mục' : 'file';
|
| 486 |
+
if (!confirm(`Xoá ${label} "${path}"?`)) return;
|
| 487 |
+
try {
|
| 488 |
+
await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
|
| 489 |
+
if (this.editorFile === path) {
|
| 490 |
+
this.editorFile = null;
|
| 491 |
+
this.editorDirty = false;
|
| 492 |
+
}
|
| 493 |
+
await this.loadFiles();
|
| 494 |
+
} catch {}
|
| 495 |
+
},
|
| 496 |
+
|
| 497 |
+
async downloadFile(path, name) {
|
| 498 |
+
try {
|
| 499 |
+
const resp = await fetch(
|
| 500 |
+
`/api/zones/${this.currentZone}/files/download?path=${encodeURIComponent(path)}`,
|
| 501 |
+
{ headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {} }
|
| 502 |
+
);
|
| 503 |
+
if (!resp.ok) throw new Error('Download failed');
|
| 504 |
+
const blob = await resp.blob();
|
| 505 |
+
const url = URL.createObjectURL(blob);
|
| 506 |
+
const a = document.createElement('a');
|
| 507 |
+
a.href = url;
|
| 508 |
+
a.download = name;
|
| 509 |
+
a.click();
|
| 510 |
+
URL.revokeObjectURL(url);
|
| 511 |
+
} catch (err) {
|
| 512 |
+
this.notify(err.message, 'error');
|
| 513 |
+
}
|
| 514 |
+
},
|
| 515 |
+
|
| 516 |
+
startRename(file) {
|
| 517 |
+
this.renameOldPath = this.joinPath(this.currentPath, file.name);
|
| 518 |
+
this.renameNewName = file.name;
|
| 519 |
+
this.showRename = true;
|
| 520 |
+
},
|
| 521 |
+
|
| 522 |
+
async doRename() {
|
| 523 |
+
if (!this.renameNewName.trim()) return;
|
| 524 |
+
const form = new FormData();
|
| 525 |
+
form.append('old_path', this.renameOldPath);
|
| 526 |
+
form.append('new_name', this.renameNewName.trim());
|
| 527 |
+
try {
|
| 528 |
+
await this.api(`/api/zones/${this.currentZone}/files/rename`, { method: 'POST', body: form });
|
| 529 |
+
this.showRename = false;
|
| 530 |
+
await this.loadFiles();
|
| 531 |
+
} catch {}
|
| 532 |
+
},
|
| 533 |
+
|
| 534 |
+
getFileIcon(name) {
|
| 535 |
+
const ext = name.split('.').pop()?.toLowerCase();
|
| 536 |
+
const map = {
|
| 537 |
+
js: 'file-code', ts: 'file-code', py: 'file-code', go: 'file-code',
|
| 538 |
+
html: 'file-code', css: 'file-code', json: 'file-json',
|
| 539 |
+
md: 'file-text', txt: 'file-text', log: 'file-text',
|
| 540 |
+
jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', svg: 'image',
|
| 541 |
+
zip: 'file-archive', tar: 'file-archive', gz: 'file-archive',
|
| 542 |
+
};
|
| 543 |
+
return map[ext] || 'file';
|
| 544 |
+
},
|
| 545 |
+
|
| 546 |
+
formatSize(bytes) {
|
| 547 |
+
if (bytes === 0) return '0 B';
|
| 548 |
+
const k = 1024;
|
| 549 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
| 550 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 551 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
| 552 |
+
},
|
| 553 |
+
|
| 554 |
+
latestBackupForZone(zoneName) {
|
| 555 |
+
return this.backupList.find((item) => item.zone_name === zoneName) || null;
|
| 556 |
+
},
|
| 557 |
+
|
| 558 |
+
async copyText(value, message = 'Đã copy') {
|
| 559 |
+
try {
|
| 560 |
+
await navigator.clipboard.writeText(value);
|
| 561 |
+
this.notify(message);
|
| 562 |
+
} catch {
|
| 563 |
+
this.notify('Không thể copy', 'error');
|
| 564 |
+
}
|
| 565 |
+
},
|
| 566 |
+
|
| 567 |
+
// ── Terminal ──
|
| 568 |
+
initTerminal() {
|
| 569 |
+
if (!this.currentZone) return;
|
| 570 |
+
|
| 571 |
+
// Already connected to same zone
|
| 572 |
+
if (this.termZone === this.currentZone && this.term) {
|
| 573 |
+
this.$nextTick(() => this.termFit?.fit());
|
| 574 |
+
return;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
this.disconnectTerminal();
|
| 578 |
+
|
| 579 |
+
const container = document.getElementById('terminal-container');
|
| 580 |
+
if (!container) return;
|
| 581 |
+
container.innerHTML = '';
|
| 582 |
+
|
| 583 |
+
this.term = new Terminal({
|
| 584 |
+
cursorBlink: true,
|
| 585 |
+
fontSize: 14,
|
| 586 |
+
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 587 |
+
theme: {
|
| 588 |
+
background: '#000000',
|
| 589 |
+
foreground: '#e4e4e7',
|
| 590 |
+
cursor: '#8b5cf6',
|
| 591 |
+
selectionBackground: '#8b5cf644',
|
| 592 |
+
black: '#18181b',
|
| 593 |
+
red: '#ef4444',
|
| 594 |
+
green: '#22c55e',
|
| 595 |
+
yellow: '#eab308',
|
| 596 |
+
blue: '#3b82f6',
|
| 597 |
+
magenta: '#a855f7',
|
| 598 |
+
cyan: '#06b6d4',
|
| 599 |
+
white: '#e4e4e7',
|
| 600 |
+
},
|
| 601 |
+
allowProposedApi: true,
|
| 602 |
+
});
|
| 603 |
+
|
| 604 |
+
this.termFit = new FitAddon.FitAddon();
|
| 605 |
+
const webLinks = new WebLinksAddon.WebLinksAddon();
|
| 606 |
+
this.term.loadAddon(this.termFit);
|
| 607 |
+
this.term.loadAddon(webLinks);
|
| 608 |
+
this.term.open(container);
|
| 609 |
+
this.termFit.fit();
|
| 610 |
+
this.termZone = this.currentZone;
|
| 611 |
+
|
| 612 |
+
// WebSocket
|
| 613 |
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 614 |
+
const wsUrl = `${proto}//${location.host}/ws/terminal/${this.currentZone}?token=${encodeURIComponent(this.token || '')}`;
|
| 615 |
+
this.termWs = new WebSocket(wsUrl);
|
| 616 |
+
this.termWs.binaryType = 'arraybuffer';
|
| 617 |
+
|
| 618 |
+
this.termWs.onopen = () => {
|
| 619 |
+
this.term.onData((data) => {
|
| 620 |
+
if (this.termWs?.readyState === WebSocket.OPEN) {
|
| 621 |
+
this.termWs.send(JSON.stringify({ type: 'input', data }));
|
| 622 |
+
}
|
| 623 |
+
});
|
| 624 |
+
this.term.onResize(({ rows, cols }) => {
|
| 625 |
+
if (this.termWs?.readyState === WebSocket.OPEN) {
|
| 626 |
+
this.termWs.send(JSON.stringify({ type: 'resize', rows, cols }));
|
| 627 |
+
}
|
| 628 |
+
});
|
| 629 |
+
// Send initial size
|
| 630 |
+
const dims = this.termFit.proposeDimensions();
|
| 631 |
+
if (dims) {
|
| 632 |
+
this.termWs.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }));
|
| 633 |
+
}
|
| 634 |
+
};
|
| 635 |
+
|
| 636 |
+
this.termWs.onmessage = (e) => {
|
| 637 |
+
if (e.data instanceof ArrayBuffer) {
|
| 638 |
+
this.term.write(new Uint8Array(e.data));
|
| 639 |
+
} else {
|
| 640 |
+
this.term.write(e.data);
|
| 641 |
+
}
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
this.termWs.onclose = () => {
|
| 645 |
+
this.term?.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
|
| 646 |
+
};
|
| 647 |
+
|
| 648 |
+
// Resize handler
|
| 649 |
+
this._resizeHandler = () => this.termFit?.fit();
|
| 650 |
+
window.addEventListener('resize', this._resizeHandler);
|
| 651 |
+
|
| 652 |
+
// ResizeObserver for container
|
| 653 |
+
this._resizeObserver = new ResizeObserver(() => this.termFit?.fit());
|
| 654 |
+
this._resizeObserver.observe(container);
|
| 655 |
+
},
|
| 656 |
+
|
| 657 |
+
disconnectTerminal() {
|
| 658 |
+
if (this.termWs) {
|
| 659 |
+
this.termWs.close();
|
| 660 |
+
this.termWs = null;
|
| 661 |
+
}
|
| 662 |
+
if (this.term) {
|
| 663 |
+
this.term.dispose();
|
| 664 |
+
this.term = null;
|
| 665 |
+
}
|
| 666 |
+
if (this._resizeHandler) {
|
| 667 |
+
window.removeEventListener('resize', this._resizeHandler);
|
| 668 |
+
this._resizeHandler = null;
|
| 669 |
+
}
|
| 670 |
+
if (this._resizeObserver) {
|
| 671 |
+
this._resizeObserver.disconnect();
|
| 672 |
+
this._resizeObserver = null;
|
| 673 |
+
}
|
| 674 |
+
this.termFit = null;
|
| 675 |
+
this.termZone = null;
|
| 676 |
+
},
|
| 677 |
+
|
| 678 |
+
// ── Ports ──
|
| 679 |
+
async loadPorts() {
|
| 680 |
+
if (!this.currentZone) return;
|
| 681 |
+
try {
|
| 682 |
+
this.ports = await this.api(`/api/zones/${this.currentZone}/ports`);
|
| 683 |
+
} catch { this.ports = []; }
|
| 684 |
+
},
|
| 685 |
+
|
| 686 |
+
async addPort() {
|
| 687 |
+
if (!this.newPort) return;
|
| 688 |
+
const form = new FormData();
|
| 689 |
+
form.append('port', this.newPort);
|
| 690 |
+
form.append('label', this.newPortLabel);
|
| 691 |
+
try {
|
| 692 |
+
await this.api(`/api/zones/${this.currentZone}/ports`, { method: 'POST', body: form });
|
| 693 |
+
this.newPort = null;
|
| 694 |
+
this.newPortLabel = '';
|
| 695 |
+
await this.loadPorts();
|
| 696 |
+
this.notify('Port đã được thêm');
|
| 697 |
+
} catch {}
|
| 698 |
+
},
|
| 699 |
+
|
| 700 |
+
async removePort(port) {
|
| 701 |
+
if (!confirm(`Xoá port ${port}?`)) return;
|
| 702 |
+
try {
|
| 703 |
+
await this.api(`/api/zones/${this.currentZone}/ports/${port}`, { method: 'DELETE' });
|
| 704 |
+
await this.loadPorts();
|
| 705 |
+
} catch {}
|
| 706 |
+
},
|
| 707 |
+
|
| 708 |
+
// ── Backup ──
|
| 709 |
+
async loadBackupStatus() {
|
| 710 |
+
try {
|
| 711 |
+
this.backupStatus = await this.api('/api/backup/status');
|
| 712 |
+
} catch {}
|
| 713 |
+
},
|
| 714 |
+
|
| 715 |
+
async loadBackupList() {
|
| 716 |
+
this.backupLoading = true;
|
| 717 |
+
try {
|
| 718 |
+
this.backupList = await this.api('/api/backup/list');
|
| 719 |
+
} catch { this.backupList = []; }
|
| 720 |
+
this.backupLoading = false;
|
| 721 |
+
},
|
| 722 |
+
|
| 723 |
+
async backupZone(zoneName) {
|
| 724 |
+
if (!confirm(`Backup zone "${zoneName}" lên HuggingFace?`)) return;
|
| 725 |
+
try {
|
| 726 |
+
const res = await this.api(`/api/backup/zone/${zoneName}`, { method: 'POST' });
|
| 727 |
+
this.notify(res.message);
|
| 728 |
+
this._pollBackupStatus();
|
| 729 |
+
} catch {}
|
| 730 |
+
},
|
| 731 |
+
|
| 732 |
+
async backupAll() {
|
| 733 |
+
if (!confirm('Backup tất cả zones lên HuggingFace?')) return;
|
| 734 |
+
try {
|
| 735 |
+
const res = await this.api('/api/backup/all', { method: 'POST' });
|
| 736 |
+
this.notify(res.message);
|
| 737 |
+
this._pollBackupStatus();
|
| 738 |
+
} catch {}
|
| 739 |
+
},
|
| 740 |
+
|
| 741 |
+
async restoreZone(zoneName, backupName = null) {
|
| 742 |
+
if (!confirm(`Restore zone "${zoneName}" từ backup? Dữ liệu hiện tại sẽ bị ghi đè.`)) return;
|
| 743 |
+
try {
|
| 744 |
+
const query = backupName ? `?backup_name=${encodeURIComponent(backupName)}` : '';
|
| 745 |
+
const res = await this.api(`/api/backup/restore/${zoneName}${query}`, { method: 'POST' });
|
| 746 |
+
this.notify(res.message);
|
| 747 |
+
this._pollBackupStatus();
|
| 748 |
+
} catch {}
|
| 749 |
+
},
|
| 750 |
+
|
| 751 |
+
async restoreAll() {
|
| 752 |
+
if (!confirm('Restore tất cả zones từ backup? Dữ liệu hiện tại sẽ bị ghi đè.')) return;
|
| 753 |
+
try {
|
| 754 |
+
const res = await this.api('/api/backup/restore-all', { method: 'POST' });
|
| 755 |
+
this.notify(res.message);
|
| 756 |
+
this._pollBackupStatus();
|
| 757 |
+
} catch {}
|
| 758 |
+
},
|
| 759 |
+
|
| 760 |
+
async deleteBackup(backupName) {
|
| 761 |
+
if (!confirm(`Xoá backup "${backupName}" trên cloud?`)) return;
|
| 762 |
+
try {
|
| 763 |
+
await this.api(`/api/backup/file?backup_name=${encodeURIComponent(backupName)}`, { method: 'DELETE' });
|
| 764 |
+
await this.loadBackupList();
|
| 765 |
+
this.notify('Đã xoá backup');
|
| 766 |
+
} catch {}
|
| 767 |
+
},
|
| 768 |
+
|
| 769 |
+
_pollBackupStatus() {
|
| 770 |
+
if (this._pollTimer) return;
|
| 771 |
+
this._pollTimer = setInterval(async () => {
|
| 772 |
+
await this.loadBackupStatus();
|
| 773 |
+
if (!this.backupStatus.running) {
|
| 774 |
+
clearInterval(this._pollTimer);
|
| 775 |
+
this._pollTimer = null;
|
| 776 |
+
await this.loadBackupList();
|
| 777 |
+
await this.loadZones();
|
| 778 |
+
if (this.backupStatus.error) {
|
| 779 |
+
this.notify(this.backupStatus.error, 'error');
|
| 780 |
+
} else {
|
| 781 |
+
this.notify(this.backupStatus.progress);
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
}, 2000);
|
| 785 |
+
},
|
| 786 |
+
};
|
| 787 |
}
|
static/index.html
CHANGED
|
@@ -1,673 +1,750 @@
|
|
| 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 |
-
<!-- ═══ AUTH: Login / Register Screen ═══ -->
|
| 46 |
-
<div x-show="!user && !authLoading" x-transition class="min-h-full flex items-center justify-center p-4">
|
| 47 |
-
<div class="w-full max-w-sm">
|
| 48 |
-
<!-- Logo -->
|
| 49 |
-
<div class="text-center mb-6">
|
| 50 |
-
<div class="w-14 h-14 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center text-2xl font-bold shadow-lg shadow-brand-500/25">H</div>
|
| 51 |
-
<h1 class="text-xl font-bold">HugPanel</h1>
|
| 52 |
-
<p class="text-xs text-gray-500 mt-1">Workspace Manager</p>
|
| 53 |
-
</div>
|
| 54 |
-
|
| 55 |
-
<div class="bg-gray-900 rounded-2xl border border-gray-800 p-6 space-y-4">
|
| 56 |
-
<!-- Tab switch -->
|
| 57 |
-
<div class="flex bg-gray-800 rounded-lg p-0.5">
|
| 58 |
-
<button @click="authMode = 'login'" :class="authMode === 'login' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng nhập</button>
|
| 59 |
-
<button x-show="!registrationDisabled" @click="authMode = 'register'" :class="authMode === 'register' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng ký</button>
|
| 60 |
-
</div>
|
| 61 |
-
|
| 62 |
-
<!-- Registration disabled notice -->
|
| 63 |
-
<div x-show="registrationDisabled && authMode === 'register'" x-init="if(registrationDisabled) authMode='login'" class="text-xs text-yellow-400 bg-yellow-400/10 rounded-lg px-3 py-2">Đăng ký đã bị tắt bởi admin</div>
|
| 64 |
-
|
| 65 |
-
<!-- Error -->
|
| 66 |
-
<div x-show="authError" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2" x-text="authError"></div>
|
| 67 |
-
|
| 68 |
-
<!-- Login Form -->
|
| 69 |
-
<div x-show="authMode === 'login'" class="space-y-3">
|
| 70 |
-
<input x-model="loginForm.username" placeholder="Username hoặc Email" 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" @keydown.enter="login()" />
|
| 71 |
-
<input x-model="loginForm.password" type="password" placeholder="Mật khẩu" 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" @keydown.enter="login()" />
|
| 72 |
-
<button @click="login()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
|
| 73 |
-
<span x-show="!authSubmitting">Đăng nhập</span>
|
| 74 |
-
<span x-show="authSubmitting">Đang xử lý...</span>
|
| 75 |
-
</button>
|
| 76 |
-
</div>
|
| 77 |
-
|
| 78 |
-
<!-- Register Form -->
|
| 79 |
-
<div x-show="authMode === 'register'" class="space-y-3">
|
| 80 |
-
<input x-model="registerForm.username" placeholder="Username" 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" @keydown.enter="register()" />
|
| 81 |
-
<input x-model="registerForm.email" type="email" placeholder="Email" 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" @keydown.enter="register()" />
|
| 82 |
-
<input x-model="registerForm.password" type="password" placeholder="Mật khẩu (ít nhất 6 ký tự)" 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" @keydown.enter="register()" />
|
| 83 |
-
<button @click="register()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
|
| 84 |
-
<span x-show="!authSubmitting">Đăng ký</span>
|
| 85 |
-
<span x-show="authSubmitting">Đang xử lý...</span>
|
| 86 |
-
</button>
|
| 87 |
-
</div>
|
| 88 |
-
</div>
|
| 89 |
-
|
| 90 |
-
<!-- Admin API URL indicator -->
|
| 91 |
-
<div class="mt-4 text-center">
|
| 92 |
-
<div x-show="!adminApiUrl" class="text-xs text-yellow-500">ADMIN_API_URL chưa cấu hình</div>
|
| 93 |
-
</div>
|
| 94 |
-
</div>
|
| 95 |
-
</div>
|
| 96 |
-
|
| 97 |
-
<!-- Auth loading spinner -->
|
| 98 |
-
<div x-show="authLoading" class="min-h-full flex items-center justify-center">
|
| 99 |
-
<div class="w-8 h-8 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 100 |
-
</div>
|
| 101 |
-
|
| 102 |
-
<!-- ═══ MAIN PANEL (shown when logged in) ═══ -->
|
| 103 |
-
<div x-show="user" x-cloak>
|
| 104 |
-
|
| 105 |
-
<!-- MOTD Banner -->
|
| 106 |
-
<div x-show="motd" class="fixed top-0 inset-x-0 z-[60] bg-brand-600/95 backdrop-blur text-white text-sm px-4 py-2.5 flex items-center justify-between lg:relative lg:z-auto">
|
| 107 |
-
<span x-text="motd" class="flex-1 text-center"></span>
|
| 108 |
-
<button @click="motd=''" class="ml-3 p-1 hover:bg-white/20 rounded transition text-xs">✕</button>
|
| 109 |
-
</div>
|
| 110 |
-
|
| 111 |
-
<!-- ═══ Mobile Top Bar ═══ -->
|
| 112 |
-
<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">
|
| 113 |
-
<button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
|
| 114 |
-
<i data-lucide="menu" class="w-5 h-5"></i>
|
| 115 |
-
</button>
|
| 116 |
-
<div class="flex items-center gap-2">
|
| 117 |
-
<div class="w-7 h-7 rounded-lg bg-brand-600 flex items-center justify-center text-sm font-bold">H</div>
|
| 118 |
-
<span class="font-semibold text-sm">HugPanel</span>
|
| 119 |
-
</div>
|
| 120 |
-
<div class="w-8"></div>
|
| 121 |
-
</header>
|
| 122 |
-
|
| 123 |
-
<!-- ═══ Sidebar Overlay (mobile) ═══ -->
|
| 124 |
-
<div x-show="sidebarOpen" x-transition:enter="transition-opacity duration-200"
|
| 125 |
-
x-transition:leave="transition-opacity duration-200"
|
| 126 |
-
@click="sidebarOpen = false"
|
| 127 |
-
class="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"></div>
|
| 128 |
-
|
| 129 |
-
<div class="flex h-screen overflow-hidden">
|
| 130 |
-
|
| 131 |
-
<!-- ═══ Sidebar ═══ -->
|
| 132 |
-
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
| 133 |
-
class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto lg:translate-x-0 w-64 xl:w-72 bg-gray-900 border-r border-gray-800 flex flex-col transition-transform duration-300 ease-in-out">
|
| 134 |
-
|
| 135 |
-
<!-- Logo -->
|
| 136 |
-
<div class="p-4 border-b border-gray-800 flex items-center gap-3">
|
| 137 |
-
<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>
|
| 138 |
-
<div>
|
| 139 |
-
<div class="font-bold text-sm">HugPanel</div>
|
| 140 |
-
<div class="text-xs text-gray-500">Workspace Manager</div>
|
| 141 |
-
</div>
|
| 142 |
-
</div>
|
| 143 |
-
|
| 144 |
-
<!-- Zone List -->
|
| 145 |
-
<div class="flex-1 overflow-y-auto p-3 space-y-1">
|
| 146 |
-
<div class="px-2 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Zones</div>
|
| 147 |
-
|
| 148 |
-
<template x-for="zone in zones" :key="zone.name">
|
| 149 |
-
<div class="group relative">
|
| 150 |
-
<button @click="selectZone(zone.name); sidebarOpen = false"
|
| 151 |
-
: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'"
|
| 152 |
-
class="w-full flex items-
|
| 153 |
-
<i data-lucide="box" class="w-4 h-4 flex-shrink-0"></i>
|
| 154 |
-
<
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
<
|
| 159 |
-
</button>
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
</
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
<
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
<
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
</
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
<
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
<
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
</
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
<
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
<
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
</div>
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
<
|
| 244 |
-
|
| 245 |
-
<
|
| 246 |
-
<
|
| 247 |
-
<
|
| 248 |
-
|
| 249 |
-
<
|
| 250 |
-
</
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
<
|
| 292 |
-
</
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
<
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
<
|
| 316 |
-
<div class="
|
| 317 |
-
|
| 318 |
-
|
| 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 |
-
|
| 350 |
-
|
| 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 |
-
|
| 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 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
<
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
<
|
| 447 |
-
|
| 448 |
-
<
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
<button
|
| 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 |
-
|
| 480 |
-
<
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
</div>
|
| 486 |
-
</div>
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
<
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
<
|
| 538 |
-
|
| 539 |
-
<
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
<
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
<
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
<
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
<
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
<
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
<
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
<
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
</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 |
+
<!-- ═══ AUTH: Login / Register Screen ═══ -->
|
| 46 |
+
<div x-show="!user && !authLoading" x-transition class="min-h-full flex items-center justify-center p-4">
|
| 47 |
+
<div class="w-full max-w-sm">
|
| 48 |
+
<!-- Logo -->
|
| 49 |
+
<div class="text-center mb-6">
|
| 50 |
+
<div class="w-14 h-14 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center text-2xl font-bold shadow-lg shadow-brand-500/25">H</div>
|
| 51 |
+
<h1 class="text-xl font-bold">HugPanel</h1>
|
| 52 |
+
<p class="text-xs text-gray-500 mt-1">Workspace Manager</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="bg-gray-900 rounded-2xl border border-gray-800 p-6 space-y-4">
|
| 56 |
+
<!-- Tab switch -->
|
| 57 |
+
<div class="flex bg-gray-800 rounded-lg p-0.5">
|
| 58 |
+
<button @click="authMode = 'login'" :class="authMode === 'login' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng nhập</button>
|
| 59 |
+
<button x-show="!registrationDisabled" @click="authMode = 'register'" :class="authMode === 'register' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng ký</button>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<!-- Registration disabled notice -->
|
| 63 |
+
<div x-show="registrationDisabled && authMode === 'register'" x-init="if(registrationDisabled) authMode='login'" class="text-xs text-yellow-400 bg-yellow-400/10 rounded-lg px-3 py-2">Đăng ký đã bị tắt bởi admin</div>
|
| 64 |
+
|
| 65 |
+
<!-- Error -->
|
| 66 |
+
<div x-show="authError" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2" x-text="authError"></div>
|
| 67 |
+
|
| 68 |
+
<!-- Login Form -->
|
| 69 |
+
<div x-show="authMode === 'login'" class="space-y-3">
|
| 70 |
+
<input x-model="loginForm.username" placeholder="Username hoặc Email" 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" @keydown.enter="login()" />
|
| 71 |
+
<input x-model="loginForm.password" type="password" placeholder="Mật khẩu" 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" @keydown.enter="login()" />
|
| 72 |
+
<button @click="login()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
|
| 73 |
+
<span x-show="!authSubmitting">Đăng nhập</span>
|
| 74 |
+
<span x-show="authSubmitting">Đang xử lý...</span>
|
| 75 |
+
</button>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Register Form -->
|
| 79 |
+
<div x-show="authMode === 'register'" class="space-y-3">
|
| 80 |
+
<input x-model="registerForm.username" placeholder="Username" 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" @keydown.enter="register()" />
|
| 81 |
+
<input x-model="registerForm.email" type="email" placeholder="Email" 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" @keydown.enter="register()" />
|
| 82 |
+
<input x-model="registerForm.password" type="password" placeholder="Mật khẩu (ít nhất 6 ký tự)" 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" @keydown.enter="register()" />
|
| 83 |
+
<button @click="register()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
|
| 84 |
+
<span x-show="!authSubmitting">Đăng ký</span>
|
| 85 |
+
<span x-show="authSubmitting">Đang xử lý...</span>
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<!-- Admin API URL indicator -->
|
| 91 |
+
<div class="mt-4 text-center">
|
| 92 |
+
<div x-show="!adminApiUrl" class="text-xs text-yellow-500">ADMIN_API_URL chưa cấu hình</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- Auth loading spinner -->
|
| 98 |
+
<div x-show="authLoading" class="min-h-full flex items-center justify-center">
|
| 99 |
+
<div class="w-8 h-8 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<!-- ═══ MAIN PANEL (shown when logged in) ═══ -->
|
| 103 |
+
<div x-show="user" x-cloak>
|
| 104 |
+
|
| 105 |
+
<!-- MOTD Banner -->
|
| 106 |
+
<div x-show="motd" class="fixed top-0 inset-x-0 z-[60] bg-brand-600/95 backdrop-blur text-white text-sm px-4 py-2.5 flex items-center justify-between lg:relative lg:z-auto">
|
| 107 |
+
<span x-text="motd" class="flex-1 text-center"></span>
|
| 108 |
+
<button @click="motd=''" class="ml-3 p-1 hover:bg-white/20 rounded transition text-xs">✕</button>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- ═══ Mobile Top Bar ═══ -->
|
| 112 |
+
<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">
|
| 113 |
+
<button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
|
| 114 |
+
<i data-lucide="menu" class="w-5 h-5"></i>
|
| 115 |
+
</button>
|
| 116 |
+
<div class="flex items-center gap-2">
|
| 117 |
+
<div class="w-7 h-7 rounded-lg bg-brand-600 flex items-center justify-center text-sm font-bold">H</div>
|
| 118 |
+
<span class="font-semibold text-sm">HugPanel</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div class="w-8"></div>
|
| 121 |
+
</header>
|
| 122 |
+
|
| 123 |
+
<!-- ═══ Sidebar Overlay (mobile) ═══ -->
|
| 124 |
+
<div x-show="sidebarOpen" x-transition:enter="transition-opacity duration-200"
|
| 125 |
+
x-transition:leave="transition-opacity duration-200"
|
| 126 |
+
@click="sidebarOpen = false"
|
| 127 |
+
class="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"></div>
|
| 128 |
+
|
| 129 |
+
<div class="flex h-screen overflow-hidden">
|
| 130 |
+
|
| 131 |
+
<!-- ═══ Sidebar ═══ -->
|
| 132 |
+
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
| 133 |
+
class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto lg:translate-x-0 w-64 xl:w-72 bg-gray-900 border-r border-gray-800 flex flex-col transition-transform duration-300 ease-in-out">
|
| 134 |
+
|
| 135 |
+
<!-- Logo -->
|
| 136 |
+
<div class="p-4 border-b border-gray-800 flex items-center gap-3">
|
| 137 |
+
<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>
|
| 138 |
+
<div>
|
| 139 |
+
<div class="font-bold text-sm">HugPanel</div>
|
| 140 |
+
<div class="text-xs text-gray-500">Workspace Manager</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<!-- Zone List -->
|
| 145 |
+
<div class="flex-1 overflow-y-auto p-3 space-y-1">
|
| 146 |
+
<div class="px-2 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Zones</div>
|
| 147 |
+
|
| 148 |
+
<template x-for="zone in zones" :key="zone.name">
|
| 149 |
+
<div class="group relative">
|
| 150 |
+
<button @click="selectZone(zone.name); sidebarOpen = false"
|
| 151 |
+
: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'"
|
| 152 |
+
class="w-full text-left flex items-start gap-2.5 px-3 py-2 rounded-lg text-sm transition-all border">
|
| 153 |
+
<i data-lucide="box" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
| 154 |
+
<div class="min-w-0 flex-1 pr-12">
|
| 155 |
+
<div class="truncate font-medium" x-text="zone.name"></div>
|
| 156 |
+
<div class="text-[11px] text-gray-500 truncate" x-text="zone.description || 'Không có mô tả'"></div>
|
| 157 |
+
<div class="text-[10px] text-gray-600 mt-1" x-text="zoneBackupCount(zone.name) + ' backups'"></div>
|
| 158 |
+
</div>
|
| 159 |
+
</button>
|
| 160 |
+
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition">
|
| 161 |
+
<button @click.stop="currentZone = zone.name; startEditZone(zone)"
|
| 162 |
+
class="p-1 rounded text-gray-600 hover:text-yellow-400 hover:bg-yellow-400/10" title="Sửa zone">
|
| 163 |
+
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
| 164 |
+
</button>
|
| 165 |
+
<button @click.stop="currentZone = zone.name; confirmDeleteZone()"
|
| 166 |
+
class="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-400/10" title="Xoá zone">
|
| 167 |
+
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
| 168 |
+
</button>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</template>
|
| 173 |
+
|
| 174 |
+
<div x-show="zones.length === 0" class="text-center py-8 text-gray-600 text-sm">
|
| 175 |
+
Chưa có zone nào
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<!-- Create Zone -->
|
| 180 |
+
<div class="p-3 border-t border-gray-800 space-y-2">
|
| 181 |
+
<button @click="showCreateZone = true"
|
| 182 |
+
:disabled="maxZones > 0 && zones.length >= maxZones"
|
| 183 |
+
:class="(maxZones > 0 && zones.length >= maxZones) ? 'opacity-50 cursor-not-allowed bg-gray-700' : 'bg-brand-600 hover:bg-brand-500 shadow-lg shadow-brand-600/25'"
|
| 184 |
+
class="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-white text-sm font-medium transition">
|
| 185 |
+
<i data-lucide="plus" class="w-4 h-4"></i>
|
| 186 |
+
Tạo Zone
|
| 187 |
+
</button>
|
| 188 |
+
<div x-show="maxZones > 0" class="text-center text-xs text-gray-500">
|
| 189 |
+
<span x-text="zones.length"></span> / <span x-text="maxZones"></span> zones
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<!-- User Info + Logout -->
|
| 194 |
+
<div class="p-3 border-t border-gray-800">
|
| 195 |
+
<div class="flex items-center gap-2.5 px-2 py-1.5">
|
| 196 |
+
<div class="w-8 h-8 rounded-lg bg-gray-800 flex items-center justify-center text-xs font-bold text-brand-400" x-text="user?.username?.charAt(0).toUpperCase()"></div>
|
| 197 |
+
<div class="flex-1 min-w-0">
|
| 198 |
+
<div class="text-sm font-medium truncate" x-text="user?.username"></div>
|
| 199 |
+
<div class="text-xs text-gray-500" x-text="user?.role === 'admin' ? 'Admin' : 'User'"></div>
|
| 200 |
+
</div>
|
| 201 |
+
<button @click="logout()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition" title="Đăng xuất">
|
| 202 |
+
<i data-lucide="log-out" class="w-4 h-4"></i>
|
| 203 |
+
</button>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</aside>
|
| 207 |
+
|
| 208 |
+
<!-- ═══ Main Content ═══ -->
|
| 209 |
+
<main class="flex-1 flex flex-col min-w-0 min-h-0 pt-14 lg:pt-0 h-full">
|
| 210 |
+
|
| 211 |
+
<!-- No zone selected -->
|
| 212 |
+
<div x-show="!currentZone" class="flex-1 overflow-y-auto p-4 lg:p-8" x-effect="if(!currentZone && backupStatus.configured) loadBackupList()">
|
| 213 |
+
<div class="text-center max-w-sm mx-auto mb-6 pt-4 lg:pt-8">
|
| 214 |
+
<div class="w-16 h-16 lg:w-20 lg:h-20 mx-auto mb-4 rounded-2xl bg-gray-800 flex items-center justify-center">
|
| 215 |
+
<i data-lucide="layout-dashboard" class="w-8 h-8 lg:w-10 lg:h-10 text-gray-600"></i>
|
| 216 |
+
</div>
|
| 217 |
+
<h2 class="text-lg lg:text-xl font-semibold text-gray-400 mb-2">Chọn hoặc tạo Zone</h2>
|
| 218 |
+
<p class="text-sm text-gray-600">Chọn zone từ sidebar hoặc tạo zone mới để bắt đầu.</p>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<!-- Cloud Backups (available even without selecting a zone) -->
|
| 222 |
+
<div x-show="backupStatus.configured" class="max-w-3xl mx-auto space-y-4">
|
| 223 |
+
<!-- Restore All button -->
|
| 224 |
+
<button @click="restoreAll()" :disabled="backupStatus.running"
|
| 225 |
+
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
|
| 226 |
+
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
|
| 227 |
+
<i data-lucide="cloud-download" class="w-4 h-4"></i>
|
| 228 |
+
Restore tất cả từ cloud
|
| 229 |
+
</button>
|
| 230 |
+
|
| 231 |
+
<!-- Progress -->
|
| 232 |
+
<div x-show="backupStatus.running" class="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
| 233 |
+
<div class="flex items-center gap-2 mb-2">
|
| 234 |
+
<div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 235 |
+
<span class="text-xs text-brand-400">Đang chạy</span>
|
| 236 |
+
</div>
|
| 237 |
+
<div class="text-xs text-gray-400" x-text="backupStatus.progress"></div>
|
| 238 |
+
</div>
|
| 239 |
+
<div x-show="backupStatus.error" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2" x-text="backupStatus.error"></div>
|
| 240 |
+
|
| 241 |
+
<!-- Cloud backup list -->
|
| 242 |
+
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
| 243 |
+
<div class="px-4 py-3 border-b border-gray-800 flex flex-col sm:flex-row sm:items-center gap-3 sm:justify-between">
|
| 244 |
+
<h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
|
| 245 |
+
<div class="flex items-center gap-2">
|
| 246 |
+
<select x-model="backupFilterZone" class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
|
| 247 |
+
<option value="">Tất cả zone</option>
|
| 248 |
+
<template x-for="zone in zones" :key="zone.name">
|
| 249 |
+
<option :value="zone.name" x-text="zone.name"></option>
|
| 250 |
+
</template>
|
| 251 |
+
</select>
|
| 252 |
+
<button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
|
| 253 |
+
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
|
| 254 |
+
</button>
|
| 255 |
+
</div>
|
| 256 |
+
<div x-show="backupLoading" class="flex items-center justify-center py-8">
|
| 257 |
+
<div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 258 |
+
</div>
|
| 259 |
+
<div x-show="!backupLoading && filteredBackupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
|
| 260 |
+
Chưa có bản backup nào
|
| 261 |
+
</div>
|
| 262 |
+
<div x-show="!backupLoading" class="divide-y divide-gray-800/50">
|
| 263 |
+
<template x-for="b in filteredBackupList" :key="b.backup_name">
|
| 264 |
+
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
|
| 265 |
+
<div class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
| 266 |
+
:class="b.local_exists ? 'bg-brand-500/10' : 'bg-yellow-500/10'">
|
| 267 |
+
<i data-lucide="archive" class="w-4 h-4" :class="b.local_exists ? 'text-brand-400' : 'text-yellow-400'"></i>
|
| 268 |
+
</div>
|
| 269 |
+
<div class="flex-1 min-w-0">
|
| 270 |
+
<div class="flex items-center gap-2">
|
| 271 |
+
<span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
|
| 272 |
+
<span x-show="!b.local_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Chỉ trên cloud</span>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="text-xs text-gray-500">
|
| 275 |
+
<span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
|
| 276 |
+
<span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
<button @click="copyText(b.backup_name, 'Đã copy tên backup')"
|
| 280 |
+
class="p-2 rounded-lg text-gray-400 hover:text-cyan-400 hover:bg-cyan-400/10 transition" title="Copy tên">
|
| 281 |
+
<i data-lucide="copy" class="w-4 h-4"></i>
|
| 282 |
+
</button>
|
| 283 |
+
<button @click="restoreZone(b.zone_name, b.backup_name)" :disabled="backupStatus.running"
|
| 284 |
+
class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
|
| 285 |
+
<i data-lucide="download-cloud" class="w-4 h-4"></i>
|
| 286 |
+
</button>
|
| 287 |
+
<button @click="deleteBackup(b.backup_name)" :disabled="backupStatus.running"
|
| 288 |
+
class="p-2 rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá backup">
|
| 289 |
+
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
| 290 |
+
</button>
|
| 291 |
+
</div>
|
| 292 |
+
</template>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
<!-- Zone Content -->
|
| 299 |
+
<div x-show="currentZone" class="flex-1 flex flex-col min-h-0">
|
| 300 |
+
|
| 301 |
+
<!-- Tab Bar -->
|
| 302 |
+
<div class="bg-gray-900/80 backdrop-blur border-b border-gray-800 px-4">
|
| 303 |
+
<div class="flex items-center gap-1 overflow-x-auto scrollbar-hide">
|
| 304 |
+
<template x-for="tab in tabs" :key="tab.id">
|
| 305 |
+
<button @click="activeTab = tab.id"
|
| 306 |
+
: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'"
|
| 307 |
+
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">
|
| 308 |
+
<i :data-lucide="tab.icon" class="w-4 h-4"></i>
|
| 309 |
+
<span x-text="tab.label"></span>
|
| 310 |
+
</button>
|
| 311 |
+
</template>
|
| 312 |
+
|
| 313 |
+
<!-- Zone Actions (right side) -->
|
| 314 |
+
<div class="ml-auto flex items-center gap-1 lg:gap-2">
|
| 315 |
+
<div class="hidden lg:flex items-center gap-2 mr-3">
|
| 316 |
+
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
| 317 |
+
<span x-text="currentZone" class="text-sm text-gray-300 font-medium"></span>
|
| 318 |
+
</div>
|
| 319 |
+
<span x-text="currentZone" class="text-xs text-gray-500 font-mono mr-2 hidden sm:inline lg:hidden"></span>
|
| 320 |
+
<button @click="startEditZone()" class="p-1.5 rounded-lg text-gray-500 hover:text-yellow-400 hover:bg-yellow-400/10 transition flex items-center gap-1.5" title="Sửa zone">
|
| 321 |
+
<i data-lucide="pencil" class="w-4 h-4"></i>
|
| 322 |
+
<span class="hidden lg:inline text-xs">Sửa zone</span>
|
| 323 |
+
</button>
|
| 324 |
+
<button @click="confirmDeleteZone()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition flex items-center gap-1.5" title="Xoá zone">
|
| 325 |
+
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
| 326 |
+
<span class="hidden lg:inline text-xs">Xoá zone</span>
|
| 327 |
+
</button>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<!-- ═══ TAB: Files + Editor (split on desktop) ═══ -->
|
| 333 |
+
<div x-show="activeTab === 'files' || activeTab === 'editor'" class="desktop-split flex-1 flex flex-col lg:flex-row min-h-0">
|
| 334 |
+
|
| 335 |
+
<!-- Files Panel (always visible on desktop when in files/editor tab) -->
|
| 336 |
+
<div x-show="activeTab === 'files' || (isDesktop && activeTab === 'editor')" class="split-panel split-files flex flex-col min-h-0" :class="isDesktop ? '' : 'flex-1'">
|
| 337 |
+
|
| 338 |
+
<!-- File Toolbar -->
|
| 339 |
+
<div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex flex-wrap items-center gap-2">
|
| 340 |
+
<!-- Breadcrumb -->
|
| 341 |
+
<div class="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
| 342 |
+
<button @click="navigateTo('')" class="text-brand-400 hover:text-brand-300 flex-shrink-0">
|
| 343 |
+
<i data-lucide="home" class="w-3.5 h-3.5"></i>
|
| 344 |
+
</button>
|
| 345 |
+
<template x-for="(part, i) in currentPathParts" :key="i">
|
| 346 |
+
<div class="flex items-center gap-1 flex-shrink-0">
|
| 347 |
+
<i data-lucide="chevron-right" class="w-3 h-3 text-gray-600"></i>
|
| 348 |
+
<button @click="navigateTo(currentPathParts.slice(0, i+1).join('/'))"
|
| 349 |
+
class="text-gray-400 hover:text-brand-400 transition truncate max-w-[120px]"
|
| 350 |
+
x-text="part"></button>
|
| 351 |
+
</div>
|
| 352 |
+
</template>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<!-- File Actions -->
|
| 356 |
+
<div class="flex items-center gap-1">
|
| 357 |
+
<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">
|
| 358 |
+
<i data-lucide="file-plus" class="w-4 h-4"></i>
|
| 359 |
+
</button>
|
| 360 |
+
<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">
|
| 361 |
+
<i data-lucide="folder-plus" class="w-4 h-4"></i>
|
| 362 |
+
</button>
|
| 363 |
+
<label class="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition cursor-pointer" title="Upload">
|
| 364 |
+
<i data-lucide="upload" class="w-4 h-4"></i>
|
| 365 |
+
<input type="file" class="hidden" @change="uploadFile($event)" multiple />
|
| 366 |
+
</label>
|
| 367 |
+
<button @click="loadFiles()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
|
| 368 |
+
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
| 369 |
+
</button>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
<!-- New File/Folder Inputs -->
|
| 374 |
+
<div x-show="showNewFile" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
|
| 375 |
+
<i data-lucide="file" class="w-4 h-4 text-gray-500"></i>
|
| 376 |
+
<input x-ref="newFileInput" x-model="newFileName" @keydown.enter="createFile()" @keydown.escape="showNewFile = false"
|
| 377 |
+
placeholder="filename.txt" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
|
| 378 |
+
<button @click="createFile()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
|
| 379 |
+
<button @click="showNewFile = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
|
| 380 |
+
</div>
|
| 381 |
+
<div x-show="showNewFolder" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
|
| 382 |
+
<i data-lucide="folder" class="w-4 h-4 text-gray-500"></i>
|
| 383 |
+
<input x-ref="newFolderInput" x-model="newFolderName" @keydown.enter="createFolder()" @keydown.escape="showNewFolder = false"
|
| 384 |
+
placeholder="folder-name" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
|
| 385 |
+
<button @click="createFolder()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
|
| 386 |
+
<button @click="showNewFolder = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<!-- File List -->
|
| 390 |
+
<div class="flex-1 overflow-y-auto">
|
| 391 |
+
<div x-show="filesLoading" class="flex items-center justify-center py-12">
|
| 392 |
+
<div class="w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
<div x-show="!filesLoading && files.length === 0" class="text-center py-12 text-gray-600 text-sm">
|
| 396 |
+
Thư mục trống
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<div x-show="!filesLoading" class="divide-y divide-gray-800/50">
|
| 400 |
+
<!-- Back button -->
|
| 401 |
+
<button x-show="currentPath !== ''"
|
| 402 |
+
@click="navigateUp()"
|
| 403 |
+
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition text-gray-400">
|
| 404 |
+
<i data-lucide="corner-left-up" class="w-4 h-4"></i>
|
| 405 |
+
<span class="text-sm">..</span>
|
| 406 |
+
</button>
|
| 407 |
+
|
| 408 |
+
<template x-for="file in files" :key="file.name">
|
| 409 |
+
<div class="file-item group flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition cursor-pointer"
|
| 410 |
+
@click="file.is_dir ? navigateTo(joinPath(currentPath, file.name)) : openFile(joinPath(currentPath, file.name))">
|
| 411 |
+
<i :data-lucide="file.is_dir ? 'folder' : getFileIcon(file.name)"
|
| 412 |
+
:class="file.is_dir ? 'text-brand-400' : 'text-gray-500'"
|
| 413 |
+
class="w-4 h-4 flex-shrink-0"></i>
|
| 414 |
+
<span class="flex-1 text-sm truncate" x-text="file.name"></span>
|
| 415 |
+
<span x-show="!file.is_dir" class="text-xs text-gray-600 hidden sm:inline" x-text="formatSize(file.size)"></span>
|
| 416 |
+
|
| 417 |
+
<!-- File Actions -->
|
| 418 |
+
<div class="file-actions flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
|
| 419 |
+
<button @click.stop="downloadFile(joinPath(currentPath, file.name), file.name)"
|
| 420 |
+
class="p-1 rounded text-gray-500 hover:text-brand-400 hover:bg-brand-400/10" title="Download">
|
| 421 |
+
<i data-lucide="download" class="w-3.5 h-3.5"></i>
|
| 422 |
+
</button>
|
| 423 |
+
<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">
|
| 424 |
+
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
| 425 |
+
</button>
|
| 426 |
+
<button @click.stop="deleteFile(joinPath(currentPath, file.name), file.is_dir)"
|
| 427 |
+
class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-red-400/10" title="Xoá">
|
| 428 |
+
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
| 429 |
+
</button>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
</template>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
|
| 437 |
+
<!-- ═══ TAB: Editor (always visible on desktop when in files/editor tab) ═══ -->
|
| 438 |
+
<div x-show="activeTab === 'editor' || (isDesktop && activeTab === 'files')" class="split-panel split-editor flex-1 flex flex-col min-h-0">
|
| 439 |
+
<div x-show="!editorFile" class="flex-1 flex items-center justify-center text-gray-600 text-sm">
|
| 440 |
+
Chọn file để chỉnh sửa
|
| 441 |
+
</div>
|
| 442 |
+
<div x-show="editorFile" class="flex-1 flex flex-col min-h-0">
|
| 443 |
+
<div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex items-center gap-2">
|
| 444 |
+
<i data-lucide="file-code" class="w-4 h-4 text-brand-400"></i>
|
| 445 |
+
<span class="text-sm text-gray-300 truncate" x-text="editorFile"></span>
|
| 446 |
+
<div class="ml-auto flex items-center gap-2">
|
| 447 |
+
<span x-show="editorDirty" class="text-xs text-yellow-500">Chưa lưu</span>
|
| 448 |
+
<button @click="saveFile()" :disabled="!editorDirty"
|
| 449 |
+
:class="editorDirty ? 'bg-brand-600 hover:bg-brand-500 text-white' : 'bg-gray-800 text-gray-600 cursor-not-allowed'"
|
| 450 |
+
class="px-3 py-1 text-xs rounded-md transition font-medium">
|
| 451 |
+
Lưu
|
| 452 |
+
</button>
|
| 453 |
+
</div>
|
| 454 |
+
</div>
|
| 455 |
+
<div class="flex-1 min-h-0 overflow-hidden">
|
| 456 |
+
<textarea x-model="editorContent" @input="editorDirty = true"
|
| 457 |
+
@keydown.ctrl.s.prevent="saveFile()"
|
| 458 |
+
class="w-full h-full p-4 bg-gray-950 text-gray-200 text-sm font-mono resize-none outline-none leading-relaxed block"
|
| 459 |
+
spellcheck="false"></textarea>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
</div><!-- end desktop-split -->
|
| 465 |
+
|
| 466 |
+
<!-- ═══ TAB: Terminal ═══ -->
|
| 467 |
+
<div x-show="activeTab === 'terminal'" x-effect="if(activeTab==='terminal') initTerminal()" class="flex-1 flex flex-col min-h-0">
|
| 468 |
+
<div id="terminal-container" class="flex-1 min-h-0 p-1 bg-black"></div>
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
<!-- ═══ TAB: Ports ═══ -->
|
| 472 |
+
<div x-show="activeTab === 'ports'" class="flex-1 overflow-y-auto">
|
| 473 |
+
<div class="p-4 lg:p-6 space-y-4 w-full">
|
| 474 |
+
<!-- Add Port -->
|
| 475 |
+
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
| 476 |
+
<h3 class="text-sm font-medium text-gray-300 mb-3">Thêm Port</h3>
|
| 477 |
+
<div class="flex flex-col sm:flex-row gap-2">
|
| 478 |
+
<input x-model.number="newPort" type="number" min="1024" max="65535" placeholder="Port (1024-65535)"
|
| 479 |
+
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" />
|
| 480 |
+
<input x-model="newPortLabel" placeholder="Label (tuỳ chọn)"
|
| 481 |
+
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" />
|
| 482 |
+
<button @click="addPort()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">
|
| 483 |
+
Thêm
|
| 484 |
+
</button>
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
|
| 488 |
+
<!-- Port List -->
|
| 489 |
+
<div class="space-y-2 lg:grid lg:grid-cols-2 2xl:grid-cols-3 lg:gap-3 lg:space-y-0">
|
| 490 |
+
<template x-for="port in ports" :key="port.port">
|
| 491 |
+
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
|
| 492 |
+
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
| 493 |
+
<i data-lucide="radio" class="w-5 h-5 text-green-400"></i>
|
| 494 |
+
</div>
|
| 495 |
+
<div class="flex-1 min-w-0">
|
| 496 |
+
<div class="text-sm font-medium" x-text="port.label || 'Port ' + port.port"></div>
|
| 497 |
+
<div class="text-xs text-gray-500">
|
| 498 |
+
Port: <span x-text="port.port" class="text-gray-400 font-mono"></span>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
<button @click="copyText((location.origin || '') + (port.url || ('/port/' + currentZone + '/' + port.port + '/')), 'Đã copy link port')"
|
| 502 |
+
class="p-2 rounded-lg text-gray-400 hover:text-cyan-400 hover:bg-cyan-400/10 transition" title="Copy link">
|
| 503 |
+
<i data-lucide="copy" class="w-4 h-4"></i>
|
| 504 |
+
</button>
|
| 505 |
+
<a :href="port.url || ('/port/' + currentZone + '/' + port.port + '/')"
|
| 506 |
+
target="_blank"
|
| 507 |
+
class="p-2 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition" title="Mở">
|
| 508 |
+
<i data-lucide="external-link" class="w-4 h-4"></i>
|
| 509 |
+
</a>
|
| 510 |
+
<button @click="removePort(port.port)"
|
| 511 |
+
class="p-2 rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá">
|
| 512 |
+
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
| 513 |
+
</button>
|
| 514 |
+
</div>
|
| 515 |
+
</template>
|
| 516 |
+
|
| 517 |
+
<div x-show="ports.length === 0" class="text-center py-8 text-gray-600 text-sm lg:col-span-2 2xl:col-span-3">
|
| 518 |
+
Chưa có port nào
|
| 519 |
+
</div>
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
</div>
|
| 523 |
+
|
| 524 |
+
<!-- ═══ TAB: Backup ═══ -->
|
| 525 |
+
<div x-show="activeTab === 'backup'" x-effect="if(activeTab==='backup' && backupStatus.configured) loadBackupList()" class="flex-1 overflow-y-auto">
|
| 526 |
+
<div class="p-4 lg:p-6 space-y-4 w-full">
|
| 527 |
+
|
| 528 |
+
<!-- Not configured -->
|
| 529 |
+
<div x-show="!backupStatus.configured" class="bg-gray-900 rounded-xl border border-gray-800 p-6 text-center">
|
| 530 |
+
<div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-yellow-500/10 flex items-center justify-center">
|
| 531 |
+
<i data-lucide="cloud-off" class="w-7 h-7 text-yellow-500"></i>
|
| 532 |
+
</div>
|
| 533 |
+
<h3 class="text-sm font-semibold text-gray-300 mb-2">Chưa cấu hình Backup</h3>
|
| 534 |
+
<p class="text-xs text-gray-500 mb-4 max-w-xs mx-auto">
|
| 535 |
+
Đặt biến môi trường <code class="text-brand-400">ADMIN_API_URL</code> để kết nối với Admin Worker và sử dụng tính năng backup.
|
| 536 |
+
</p>
|
| 537 |
+
<div class="bg-gray-800 rounded-lg p-3 text-left text-xs font-mono text-gray-400 max-w-sm mx-auto space-y-1">
|
| 538 |
+
<div>ADMIN_API_URL=https://your-worker.workers.dev</div>
|
| 539 |
+
</div>
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
<!-- Configured: Status & Actions -->
|
| 543 |
+
<div x-show="backupStatus.configured" class="space-y-4">
|
| 544 |
+
|
| 545 |
+
<!-- Status Card -->
|
| 546 |
+
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
| 547 |
+
<div class="flex items-center gap-3 mb-3">
|
| 548 |
+
<div class="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
| 549 |
+
<i data-lucide="cloud" class="w-5 h-5 text-brand-400"></i>
|
| 550 |
+
</div>
|
| 551 |
+
<div class="flex-1 min-w-0">
|
| 552 |
+
<div class="text-sm font-medium">Cloud Backup</div>
|
| 553 |
+
<div class="text-xs text-gray-500 font-mono truncate" x-text="backupStatus.admin_url"></div>
|
| 554 |
+
</div>
|
| 555 |
+
<div x-show="backupStatus.running" class="flex items-center gap-2">
|
| 556 |
+
<div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 557 |
+
<span class="text-xs text-brand-400">Đang chạy</span>
|
| 558 |
+
</div>
|
| 559 |
+
</div>
|
| 560 |
+
|
| 561 |
+
<!-- Progress -->
|
| 562 |
+
<div x-show="backupStatus.progress" class="text-xs text-gray-400 mb-3 px-1" x-text="backupStatus.progress"></div>
|
| 563 |
+
|
| 564 |
+
<!-- Error -->
|
| 565 |
+
<div x-show="backupStatus.error" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2 mb-3" x-text="backupStatus.error"></div>
|
| 566 |
+
|
| 567 |
+
<!-- Last backup -->
|
| 568 |
+
<div x-show="backupStatus.last" class="text-xs text-gray-500 px-1">
|
| 569 |
+
Lần cuối: <span x-text="backupStatus.last ? new Date(backupStatus.last).toLocaleString('vi-VN') : 'Chưa có'" class="text-gray-400"></span>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
<!-- Action Buttons -->
|
| 574 |
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
| 575 |
+
<button @click="backupZone(currentZone)" :disabled="backupStatus.running"
|
| 576 |
+
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
|
| 577 |
+
class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
|
| 578 |
+
<i data-lucide="upload-cloud" class="w-4 h-4"></i>
|
| 579 |
+
Backup Zone này
|
| 580 |
+
</button>
|
| 581 |
+
<button @click="backupAll()" :disabled="backupStatus.running"
|
| 582 |
+
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
|
| 583 |
+
class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
|
| 584 |
+
<i data-lucide="cloud-upload" class="w-4 h-4"></i>
|
| 585 |
+
Backup tất cả
|
| 586 |
+
</button>
|
| 587 |
+
<button @click="restoreZone(currentZone, latestBackupForZone(currentZone)?.backup_name || null)" :disabled="backupStatus.running || !latestBackupForZone(currentZone)"
|
| 588 |
+
:class="(backupStatus.running || !latestBackupForZone(currentZone)) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
|
| 589 |
+
class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
|
| 590 |
+
<i data-lucide="download-cloud" class="w-4 h-4"></i>
|
| 591 |
+
Restore Zone này
|
| 592 |
+
</button>
|
| 593 |
+
<button @click="restoreAll()" :disabled="backupStatus.running"
|
| 594 |
+
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
|
| 595 |
+
class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
|
| 596 |
+
<i data-lucide="cloud-download" class="w-4 h-4"></i>
|
| 597 |
+
Restore tất cả
|
| 598 |
+
</button>
|
| 599 |
+
</div>
|
| 600 |
+
|
| 601 |
+
<!-- Backup List -->
|
| 602 |
+
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
| 603 |
+
<div class="px-4 py-3 border-b border-gray-800 flex flex-col sm:flex-row sm:items-center gap-3 sm:justify-between">
|
| 604 |
+
<h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
|
| 605 |
+
<div class="flex items-center gap-2">
|
| 606 |
+
<select x-model="backupFilterZone" class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
|
| 607 |
+
<option value="">Tất cả zone</option>
|
| 608 |
+
<template x-for="zone in zones" :key="zone.name">
|
| 609 |
+
<option :value="zone.name" x-text="zone.name"></option>
|
| 610 |
+
</template>
|
| 611 |
+
</select>
|
| 612 |
+
<button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
|
| 613 |
+
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
|
| 614 |
+
</button>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
|
| 618 |
+
<div x-show="backupLoading" class="flex items-center justify-center py-8">
|
| 619 |
+
<div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
| 620 |
+
</div>
|
| 621 |
+
|
| 622 |
+
<div x-show="!backupLoading && filteredBackupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
|
| 623 |
+
Chưa có bản backup nào
|
| 624 |
+
</div>
|
| 625 |
+
|
| 626 |
+
<div x-show="!backupLoading" class="divide-y divide-gray-800/50">
|
| 627 |
+
<template x-for="b in filteredBackupList" :key="b.backup_name">
|
| 628 |
+
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
|
| 629 |
+
<div class="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
| 630 |
+
<i data-lucide="archive" class="w-4 h-4 text-brand-400"></i>
|
| 631 |
+
</div>
|
| 632 |
+
<div class="flex-1 min-w-0">
|
| 633 |
+
<div class="flex items-center gap-2">
|
| 634 |
+
<span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
|
| 635 |
+
<span x-show="!b.local_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Chỉ trên cloud</span>
|
| 636 |
+
</div>
|
| 637 |
+
<div class="text-xs text-gray-500">
|
| 638 |
+
<span x-show="b.size" x-text="formatSize(b.size)"></span>
|
| 639 |
+
<span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
|
| 640 |
+
</div>
|
| 641 |
+
<div class="text-[11px] text-gray-600 font-mono truncate" x-text="b.backup_name"></div>
|
| 642 |
+
</div>
|
| 643 |
+
<button @click="copyText(b.backup_name, 'Đã copy tên backup')"
|
| 644 |
+
class="p-2 rounded-lg text-gray-400 hover:text-cyan-400 hover:bg-cyan-400/10 transition" title="Copy tên">
|
| 645 |
+
<i data-lucide="copy" class="w-4 h-4"></i>
|
| 646 |
+
</button>
|
| 647 |
+
<button @click="restoreZone(b.zone_name, b.backup_name)" :disabled="backupStatus.running"
|
| 648 |
+
class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
|
| 649 |
+
<i data-lucide="download-cloud" class="w-4 h-4"></i>
|
| 650 |
+
</button>
|
| 651 |
+
<button @click="deleteBackup(b.backup_name)" :disabled="backupStatus.running"
|
| 652 |
+
class="p-2 rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá backup">
|
| 653 |
+
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
| 654 |
+
</button>
|
| 655 |
+
</div>
|
| 656 |
+
</template>
|
| 657 |
+
</div>
|
| 658 |
+
</div>
|
| 659 |
+
</div>
|
| 660 |
+
</div>
|
| 661 |
+
</div>
|
| 662 |
+
</div>
|
| 663 |
+
</main>
|
| 664 |
+
</div>
|
| 665 |
+
|
| 666 |
+
</div><!-- end x-show="user" -->
|
| 667 |
+
|
| 668 |
+
<!-- ═══ MODAL: Create Zone ═══ -->
|
| 669 |
+
<div x-show="showCreateZone" x-transition:enter="transition duration-200" x-transition:leave="transition duration-150"
|
| 670 |
+
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
| 671 |
+
<div @click.outside="showCreateZone = false"
|
| 672 |
+
class="w-full max-w-md bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
|
| 673 |
+
<div class="p-5 border-b border-gray-800">
|
| 674 |
+
<h2 class="text-lg font-semibold">Tạo Zone mới</h2>
|
| 675 |
+
</div>
|
| 676 |
+
<div class="p-5 space-y-4">
|
| 677 |
+
<div>
|
| 678 |
+
<label class="block text-xs text-gray-500 mb-1.5">Tên Zone</label>
|
| 679 |
+
<input x-model="createZoneName" x-ref="zoneNameInput" @keydown.enter="createZone()"
|
| 680 |
+
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" />
|
| 681 |
+
</div>
|
| 682 |
+
<div>
|
| 683 |
+
<label class="block text-xs text-gray-500 mb-1.5">Mô tả (tuỳ chọn)</label>
|
| 684 |
+
<input x-model="createZoneDesc" @keydown.enter="createZone()"
|
| 685 |
+
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" />
|
| 686 |
+
</div>
|
| 687 |
+
</div>
|
| 688 |
+
<div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
|
| 689 |
+
<button @click="showCreateZone = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
|
| 690 |
+
<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>
|
| 691 |
+
</div>
|
| 692 |
+
</div>
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
<!-- ═══ MODAL: Edit Zone ═══ -->
|
| 697 |
+
<div x-show="showEditZone" x-transition class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
| 698 |
+
<div @click.outside="showEditZone = false" class="w-full max-w-md bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
|
| 699 |
+
<div class="p-5 border-b border-gray-800">
|
| 700 |
+
<h2 class="text-lg font-semibold">Sửa Zone</h2>
|
| 701 |
+
</div>
|
| 702 |
+
<div class="p-5 space-y-4">
|
| 703 |
+
<div>
|
| 704 |
+
<label class="block text-xs text-gray-500 mb-1.5">Tên Zone</label>
|
| 705 |
+
<input x-model="editZoneName" x-ref="editZoneNameInput" @keydown.enter="saveZoneSettings()"
|
| 706 |
+
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" />
|
| 707 |
+
</div>
|
| 708 |
+
<div>
|
| 709 |
+
<label class="block text-xs text-gray-500 mb-1.5">Mô tả</label>
|
| 710 |
+
<input x-model="editZoneDesc" @keydown.enter="saveZoneSettings()"
|
| 711 |
+
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" />
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
<div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
|
| 715 |
+
<button @click="showEditZone = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
|
| 716 |
+
<button @click="saveZoneSettings()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">Lưu</button>
|
| 717 |
+
</div>
|
| 718 |
+
</div>
|
| 719 |
+
</div>
|
| 720 |
+
|
| 721 |
+
<!-- ═══ MODAL: Rename ═══ -->
|
| 722 |
+
<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">
|
| 723 |
+
<div @click.outside="showRename = false" class="w-full max-w-sm bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
|
| 724 |
+
<div class="p-5 border-b border-gray-800">
|
| 725 |
+
<h2 class="text-base font-semibold">Đổi tên</h2>
|
| 726 |
+
</div>
|
| 727 |
+
<div class="p-5">
|
| 728 |
+
<input x-model="renameNewName" x-ref="renameInput" @keydown.enter="doRename()" @keydown.escape="showRename = false"
|
| 729 |
+
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" />
|
| 730 |
+
</div>
|
| 731 |
+
<div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
|
| 732 |
+
<button @click="showRename = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
|
| 733 |
+
<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>
|
| 734 |
+
</div>
|
| 735 |
+
</div>
|
| 736 |
+
</div>
|
| 737 |
+
|
| 738 |
+
<!-- ═══ Toast ═══ -->
|
| 739 |
+
<div x-show="toast.show" x-transition:enter="transition transform duration-300"
|
| 740 |
+
x-transition:enter-start="translate-y-4 opacity-0" x-transition:enter-end="translate-y-0 opacity-100"
|
| 741 |
+
x-transition:leave="transition transform duration-200"
|
| 742 |
+
x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-4 opacity-0"
|
| 743 |
+
:class="toast.type === 'error' ? 'bg-red-900/90 border-red-700' : 'bg-gray-800/90 border-gray-700'"
|
| 744 |
+
class="fixed bottom-4 right-4 z-[110] max-w-sm px-4 py-3 rounded-xl border backdrop-blur shadow-2xl text-sm">
|
| 745 |
+
<span x-text="toast.message"></span>
|
| 746 |
+
</div>
|
| 747 |
+
|
| 748 |
+
<script src="/static/app.js"></script>
|
| 749 |
+
</body>
|
| 750 |
</html>
|