marriedtermiteblyi commited on
Commit
8fed43a
·
verified ·
1 Parent(s): 51d57fc

Upload 3 files

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