kokokoasd commited on
Commit
162a424
·
verified ·
1 Parent(s): 5c4bd29

Upload 20 files

Browse files
Files changed (3) hide show
  1. routers/backup.py +10 -2
  2. static/index.html +95 -22
  3. static/style.css +43 -1
routers/backup.py CHANGED
@@ -106,11 +106,14 @@ async def list_backups(request: Request):
106
  if resp.status_code != 200:
107
  raise HTTPException(502, f"HF API error: {resp.status_code} {resp.text}")
108
  tree = resp.json()
 
109
  return [
110
  {
111
  "zone_name": f["path"].split("/")[-1].replace(".tar.gz", ""),
112
  "file": f["path"],
113
  "size": (f.get("lfs") or {}).get("size") or f.get("size", 0),
 
 
114
  }
115
  for f in tree
116
  if f.get("type") == "file" and f["path"].endswith(".tar.gz")
@@ -278,6 +281,7 @@ async def restore_zone(zone_name: str, request: Request, background_tasks: Backg
278
  zone_path = DATA_DIR / zone_name
279
  if zone_path.exists():
280
  shutil.rmtree(zone_path)
 
281
  with tarfile.open(archive_path, "r:gz") as tar:
282
  for member in tar.getmembers():
283
  member_path = os.path.normpath(member.name)
@@ -288,7 +292,7 @@ async def restore_zone(zone_name: str, request: Request, background_tasks: Backg
288
  tar.extractall(path=str(DATA_DIR), filter="data")
289
  meta = load_meta()
290
  if zone_name not in meta:
291
- meta[zone_name] = {"description": "", "created": datetime.now().isoformat()}
292
  save_meta(meta)
293
  finally:
294
  archive_path.unlink(missing_ok=True)
@@ -361,6 +365,10 @@ async def restore_all(request: Request, background_tasks: BackgroundTasks):
361
  zone_path = DATA_DIR / zn
362
  if zone_path.exists():
363
  shutil.rmtree(zone_path)
 
 
 
 
364
  with tarfile.open(archive_path, "r:gz") as tar:
365
  for member in tar.getmembers():
366
  member_path = os.path.normpath(member.name)
@@ -371,7 +379,7 @@ async def restore_all(request: Request, background_tasks: BackgroundTasks):
371
  tar.extractall(path=str(DATA_DIR), filter="data")
372
  meta = load_meta()
373
  if zn not in meta:
374
- meta[zn] = {"description": "", "created": datetime.now().isoformat()}
375
  save_meta(meta)
376
  finally:
377
  archive_path.unlink(missing_ok=True)
 
106
  if resp.status_code != 200:
107
  raise HTTPException(502, f"HF API error: {resp.status_code} {resp.text}")
108
  tree = resp.json()
109
+ meta = load_meta()
110
  return [
111
  {
112
  "zone_name": f["path"].split("/")[-1].replace(".tar.gz", ""),
113
  "file": f["path"],
114
  "size": (f.get("lfs") or {}).get("size") or f.get("size", 0),
115
+ "last_modified": (f.get("lastCommit") or {}).get("date", ""),
116
+ "local_exists": f["path"].split("/")[-1].replace(".tar.gz", "") in meta,
117
  }
118
  for f in tree
119
  if f.get("type") == "file" and f["path"].endswith(".tar.gz")
 
281
  zone_path = DATA_DIR / zone_name
282
  if zone_path.exists():
283
  shutil.rmtree(zone_path)
284
+ zone_path.mkdir(parents=True, exist_ok=True)
285
  with tarfile.open(archive_path, "r:gz") as tar:
286
  for member in tar.getmembers():
287
  member_path = os.path.normpath(member.name)
 
292
  tar.extractall(path=str(DATA_DIR), filter="data")
293
  meta = load_meta()
294
  if zone_name not in meta:
295
+ meta[zone_name] = {"description": f"Restored from backup", "created": datetime.now().isoformat()}
296
  save_meta(meta)
297
  finally:
298
  archive_path.unlink(missing_ok=True)
 
365
  zone_path = DATA_DIR / zn
366
  if zone_path.exists():
367
  shutil.rmtree(zone_path)
368
+ zone_path = DATA_DIR / zn
369
+ if zone_path.exists():
370
+ shutil.rmtree(zone_path)
371
+ zone_path.mkdir(parents=True, exist_ok=True)
372
  with tarfile.open(archive_path, "r:gz") as tar:
373
  for member in tar.getmembers():
374
  member_path = os.path.normpath(member.name)
 
379
  tar.extractall(path=str(DATA_DIR), filter="data")
380
  meta = load_meta()
381
  if zn not in meta:
382
+ meta[zn] = {"description": "Restored from backup", "created": datetime.now().isoformat()}
383
  save_meta(meta)
384
  finally:
385
  archive_path.unlink(missing_ok=True)
static/index.html CHANGED
@@ -121,7 +121,7 @@
121
 
122
  <!-- ═══ Sidebar ═══ -->
123
  <aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
124
- class="fixed lg:static inset-y-0 left-0 z-50 lg:z-auto lg:translate-x-0 w-64 bg-gray-900 border-r border-gray-800 flex flex-col transition-transform duration-300 ease-in-out">
125
 
126
  <!-- Logo -->
127
  <div class="p-4 border-b border-gray-800 flex items-center gap-3">
@@ -178,14 +178,75 @@
178
  <main class="flex-1 flex flex-col min-w-0 pt-14 lg:pt-0">
179
 
180
  <!-- No zone selected -->
181
- <div x-show="!currentZone" class="flex-1 flex items-center justify-center p-6">
182
- <div class="text-center max-w-sm">
183
- <div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-800 flex items-center justify-center">
184
- <i data-lucide="layout-dashboard" class="w-8 h-8 text-gray-600"></i>
185
  </div>
186
- <h2 class="text-lg font-semibold text-gray-400 mb-2">Chọn hoặc tạo Zone</h2>
187
  <p class="text-sm text-gray-600">Chọn zone từ sidebar hoặc tạo zone mới để bắt đầu.</p>
188
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  </div>
190
 
191
  <!-- Zone Content -->
@@ -204,8 +265,12 @@
204
  </template>
205
 
206
  <!-- Zone Actions (right side) -->
207
- <div class="ml-auto flex items-center gap-1">
208
- <span x-text="currentZone" class="text-xs text-gray-500 font-mono mr-2 hidden sm:inline"></span>
 
 
 
 
209
  <button @click="confirmDeleteZone()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá zone">
210
  <i data-lucide="trash-2" class="w-4 h-4"></i>
211
  </button>
@@ -213,8 +278,11 @@
213
  </div>
214
  </div>
215
 
216
- <!-- ═══ TAB: Files ═══ -->
217
- <div x-show="activeTab === 'files'" class="flex-1 flex flex-col min-h-0">
 
 
 
218
 
219
  <!-- File Toolbar -->
220
  <div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex flex-wrap items-center gap-2">
@@ -287,7 +355,7 @@
287
  </button>
288
 
289
  <template x-for="file in files" :key="file.name">
290
- <div class="group flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition cursor-pointer"
291
  @click="file.is_dir ? navigateTo(joinPath(currentPath, file.name)) : openFile(joinPath(currentPath, file.name))">
292
  <i :data-lucide="file.is_dir ? 'folder' : getFileIcon(file.name)"
293
  :class="file.is_dir ? 'text-brand-400' : 'text-gray-500'"
@@ -296,7 +364,7 @@
296
  <span x-show="!file.is_dir" class="text-xs text-gray-600 hidden sm:inline" x-text="formatSize(file.size)"></span>
297
 
298
  <!-- File Actions -->
299
- <div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
300
  <button x-show="!file.is_dir" @click.stop="downloadFile(joinPath(currentPath, file.name), file.name)"
301
  class="p-1 rounded text-gray-500 hover:text-brand-400 hover:bg-brand-400/10" title="Download">
302
  <i data-lucide="download" class="w-3.5 h-3.5"></i>
@@ -316,7 +384,7 @@
316
  </div>
317
 
318
  <!-- ═══ TAB: Editor ═══ -->
319
- <div x-show="activeTab === 'editor'" class="flex-1 flex flex-col min-h-0">
320
  <div x-show="!editorFile" class="flex-1 flex items-center justify-center text-gray-600 text-sm">
321
  Chọn file để chỉnh sửa
322
  </div>
@@ -342,6 +410,8 @@
342
  </div>
343
  </div>
344
 
 
 
345
  <!-- ═══ TAB: Terminal ═══ -->
346
  <div x-show="activeTab === 'terminal'" x-effect="if(activeTab==='terminal') initTerminal()" class="flex-1 flex flex-col min-h-0">
347
  <div id="terminal-container" class="flex-1 min-h-0 p-1 bg-black"></div>
@@ -349,7 +419,7 @@
349
 
350
  <!-- ═══ TAB: Ports ═══ -->
351
  <div x-show="activeTab === 'ports'" class="flex-1 overflow-y-auto">
352
- <div class="p-4 space-y-4">
353
  <!-- Add Port -->
354
  <div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
355
  <h3 class="text-sm font-medium text-gray-300 mb-3">Thêm Port</h3>
@@ -365,7 +435,7 @@
365
  </div>
366
 
367
  <!-- Port List -->
368
- <div class="space-y-2">
369
  <template x-for="port in ports" :key="port.port">
370
  <div class="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
371
  <div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
@@ -389,7 +459,7 @@
389
  </div>
390
  </template>
391
 
392
- <div x-show="ports.length === 0" class="text-center py-8 text-gray-600 text-sm">
393
  Chưa có port nào
394
  </div>
395
  </div>
@@ -398,7 +468,7 @@
398
 
399
  <!-- ═══ TAB: Backup ═══ -->
400
  <div x-show="activeTab === 'backup'" x-effect="if(activeTab==='backup' && backupStatus.configured) loadBackupList()" class="flex-1 overflow-y-auto">
401
- <div class="p-4 space-y-4">
402
 
403
  <!-- Not configured -->
404
  <div x-show="!backupStatus.configured" class="bg-gray-900 rounded-xl border border-gray-800 p-6 text-center">
@@ -446,7 +516,7 @@
446
  </div>
447
 
448
  <!-- Action Buttons -->
449
- <div class="grid grid-cols-2 gap-2">
450
  <button @click="backupZone(currentZone)" :disabled="backupStatus.running"
451
  :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
452
  class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
@@ -459,8 +529,8 @@
459
  <i data-lucide="cloud-upload" class="w-4 h-4"></i>
460
  Backup tất cả
461
  </button>
462
- <button @click="restoreZone(currentZone)" :disabled="backupStatus.running"
463
- :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
464
  class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
465
  <i data-lucide="download-cloud" class="w-4 h-4"></i>
466
  Restore Zone này
@@ -497,10 +567,13 @@
497
  <i data-lucide="archive" class="w-4 h-4 text-brand-400"></i>
498
  </div>
499
  <div class="flex-1 min-w-0">
500
- <div class="text-sm font-medium truncate" x-text="b.zone_name"></div>
 
 
 
501
  <div class="text-xs text-gray-500">
502
  <span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
503
- <span x-show="b.last_modified"> · <span x-text="b.last_modified"></span></span>
504
  </div>
505
  </div>
506
  <button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
 
121
 
122
  <!-- ═══ Sidebar ═══ -->
123
  <aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
124
+ 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">
125
 
126
  <!-- Logo -->
127
  <div class="p-4 border-b border-gray-800 flex items-center gap-3">
 
178
  <main class="flex-1 flex flex-col min-w-0 pt-14 lg:pt-0">
179
 
180
  <!-- No zone selected -->
181
+ <div x-show="!currentZone" class="flex-1 overflow-y-auto p-4 xl:p-8" x-effect="if(!currentZone && backupStatus.configured) loadBackupList()">
182
+ <div class="text-center max-w-sm mx-auto mb-6 pt-4 xl:pt-8">
183
+ <div class="w-16 h-16 xl:w-20 xl:h-20 mx-auto mb-4 rounded-2xl bg-gray-800 flex items-center justify-center">
184
+ <i data-lucide="layout-dashboard" class="w-8 h-8 xl:w-10 xl:h-10 text-gray-600"></i>
185
  </div>
186
+ <h2 class="text-lg xl:text-xl font-semibold text-gray-400 mb-2">Chọn hoặc tạo Zone</h2>
187
  <p class="text-sm text-gray-600">Chọn zone từ sidebar hoặc tạo zone mới để bắt đầu.</p>
188
  </div>
189
+
190
+ <!-- Cloud Backups (available even without selecting a zone) -->
191
+ <div x-show="backupStatus.configured" class="max-w-lg xl:max-w-2xl mx-auto space-y-4">
192
+ <!-- Restore All button -->
193
+ <button @click="restoreAll()" :disabled="backupStatus.running"
194
+ :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
195
+ class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
196
+ <i data-lucide="cloud-download" class="w-4 h-4"></i>
197
+ Restore tất cả từ cloud
198
+ </button>
199
+
200
+ <!-- Progress -->
201
+ <div x-show="backupStatus.running" class="bg-gray-900 rounded-xl border border-gray-800 p-4">
202
+ <div class="flex items-center gap-2 mb-2">
203
+ <div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
204
+ <span class="text-xs text-brand-400">Đang chạy</span>
205
+ </div>
206
+ <div class="text-xs text-gray-400" x-text="backupStatus.progress"></div>
207
+ </div>
208
+ <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>
209
+
210
+ <!-- Cloud backup list -->
211
+ <div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
212
+ <div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
213
+ <h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
214
+ <button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
215
+ <i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
216
+ </button>
217
+ </div>
218
+ <div x-show="backupLoading" class="flex items-center justify-center py-8">
219
+ <div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
220
+ </div>
221
+ <div x-show="!backupLoading && backupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
222
+ Chưa có bản backup nào
223
+ </div>
224
+ <div x-show="!backupLoading" class="divide-y divide-gray-800/50">
225
+ <template x-for="b in backupList" :key="b.zone_name">
226
+ <div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
227
+ <div class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
228
+ :class="b.local_exists ? 'bg-brand-500/10' : 'bg-yellow-500/10'">
229
+ <i data-lucide="archive" class="w-4 h-4" :class="b.local_exists ? 'text-brand-400' : 'text-yellow-400'"></i>
230
+ </div>
231
+ <div class="flex-1 min-w-0">
232
+ <div class="flex items-center gap-2">
233
+ <span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
234
+ <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>
235
+ </div>
236
+ <div class="text-xs text-gray-500">
237
+ <span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
238
+ <span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
239
+ </div>
240
+ </div>
241
+ <button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
242
+ class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
243
+ <i data-lucide="download-cloud" class="w-4 h-4"></i>
244
+ </button>
245
+ </div>
246
+ </template>
247
+ </div>
248
+ </div>
249
+ </div>
250
  </div>
251
 
252
  <!-- Zone Content -->
 
265
  </template>
266
 
267
  <!-- Zone Actions (right side) -->
268
+ <div class="ml-auto flex items-center gap-1 xl:gap-2">
269
+ <div class="hidden xl:flex items-center gap-2 mr-3">
270
+ <div class="w-2 h-2 rounded-full bg-green-500"></div>
271
+ <span x-text="currentZone" class="text-sm text-gray-300 font-medium"></span>
272
+ </div>
273
+ <span x-text="currentZone" class="text-xs text-gray-500 font-mono mr-2 hidden sm:inline xl:hidden"></span>
274
  <button @click="confirmDeleteZone()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá zone">
275
  <i data-lucide="trash-2" class="w-4 h-4"></i>
276
  </button>
 
278
  </div>
279
  </div>
280
 
281
+ <!-- ═══ TAB: Files + Editor (split on desktop) ═══ -->
282
+ <div x-show="activeTab === 'files' || activeTab === 'editor'" class="desktop-split flex-1 flex flex-col xl:flex-row min-h-0">
283
+
284
+ <!-- Files Panel -->
285
+ <div x-show="activeTab === 'files'" class="split-panel split-files flex-1 flex flex-col min-h-0">
286
 
287
  <!-- File Toolbar -->
288
  <div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex flex-wrap items-center gap-2">
 
355
  </button>
356
 
357
  <template x-for="file in files" :key="file.name">
358
+ <div class="file-item group flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition cursor-pointer"
359
  @click="file.is_dir ? navigateTo(joinPath(currentPath, file.name)) : openFile(joinPath(currentPath, file.name))">
360
  <i :data-lucide="file.is_dir ? 'folder' : getFileIcon(file.name)"
361
  :class="file.is_dir ? 'text-brand-400' : 'text-gray-500'"
 
364
  <span x-show="!file.is_dir" class="text-xs text-gray-600 hidden sm:inline" x-text="formatSize(file.size)"></span>
365
 
366
  <!-- File Actions -->
367
+ <div class="file-actions flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
368
  <button x-show="!file.is_dir" @click.stop="downloadFile(joinPath(currentPath, file.name), file.name)"
369
  class="p-1 rounded text-gray-500 hover:text-brand-400 hover:bg-brand-400/10" title="Download">
370
  <i data-lucide="download" class="w-3.5 h-3.5"></i>
 
384
  </div>
385
 
386
  <!-- ═══ TAB: Editor ═══ -->
387
+ <div x-show="activeTab === 'editor'" class="split-panel split-editor flex-1 flex flex-col min-h-0">
388
  <div x-show="!editorFile" class="flex-1 flex items-center justify-center text-gray-600 text-sm">
389
  Chọn file để chỉnh sửa
390
  </div>
 
410
  </div>
411
  </div>
412
 
413
+ </div><!-- end desktop-split -->
414
+
415
  <!-- ═══ TAB: Terminal ═══ -->
416
  <div x-show="activeTab === 'terminal'" x-effect="if(activeTab==='terminal') initTerminal()" class="flex-1 flex flex-col min-h-0">
417
  <div id="terminal-container" class="flex-1 min-h-0 p-1 bg-black"></div>
 
419
 
420
  <!-- ═══ TAB: Ports ═══ -->
421
  <div x-show="activeTab === 'ports'" class="flex-1 overflow-y-auto">
422
+ <div class="p-4 xl:p-6 space-y-4 desktop-content mx-auto w-full">
423
  <!-- Add Port -->
424
  <div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
425
  <h3 class="text-sm font-medium text-gray-300 mb-3">Thêm Port</h3>
 
435
  </div>
436
 
437
  <!-- Port List -->
438
+ <div class="space-y-2 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0">
439
  <template x-for="port in ports" :key="port.port">
440
  <div class="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
441
  <div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
 
459
  </div>
460
  </template>
461
 
462
+ <div x-show="ports.length === 0" class="text-center py-8 text-gray-600 text-sm xl:col-span-2">
463
  Chưa có port nào
464
  </div>
465
  </div>
 
468
 
469
  <!-- ═══ TAB: Backup ═══ -->
470
  <div x-show="activeTab === 'backup'" x-effect="if(activeTab==='backup' && backupStatus.configured) loadBackupList()" class="flex-1 overflow-y-auto">
471
+ <div class="p-4 xl:p-6 space-y-4 desktop-content mx-auto w-full">
472
 
473
  <!-- Not configured -->
474
  <div x-show="!backupStatus.configured" class="bg-gray-900 rounded-xl border border-gray-800 p-6 text-center">
 
516
  </div>
517
 
518
  <!-- Action Buttons -->
519
+ <div class="grid grid-cols-2 xl:grid-cols-4 gap-2">
520
  <button @click="backupZone(currentZone)" :disabled="backupStatus.running"
521
  :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
522
  class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
 
529
  <i data-lucide="cloud-upload" class="w-4 h-4"></i>
530
  Backup tất cả
531
  </button>
532
+ <button @click="restoreZone(currentZone)" :disabled="backupStatus.running || !backupList.some(b => b.zone_name === currentZone)"
533
+ :class="(backupStatus.running || !backupList.some(b => b.zone_name === currentZone)) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
534
  class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
535
  <i data-lucide="download-cloud" class="w-4 h-4"></i>
536
  Restore Zone này
 
567
  <i data-lucide="archive" class="w-4 h-4 text-brand-400"></i>
568
  </div>
569
  <div class="flex-1 min-w-0">
570
+ <div class="flex items-center gap-2">
571
+ <span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
572
+ <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>
573
+ </div>
574
  <div class="text-xs text-gray-500">
575
  <span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
576
+ <span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
577
  </div>
578
  </div>
579
  <button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
static/style.css CHANGED
@@ -62,7 +62,8 @@ textarea {
62
  opacity: 1;
63
  }
64
  /* Always show file actions on mobile */
65
- .group .opacity-0 {
 
66
  opacity: 1 !important;
67
  }
68
  }
@@ -89,4 +90,45 @@ input:focus, textarea:focus, button:focus-visible {
89
  border-bottom-right-radius: 0;
90
  margin-bottom: 0;
91
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
 
62
  opacity: 1;
63
  }
64
  /* Always show file actions on mobile */
65
+ .group .opacity-0,
66
+ .file-item .file-actions {
67
  opacity: 1 !important;
68
  }
69
  }
 
90
  border-bottom-right-radius: 0;
91
  margin-bottom: 0;
92
  }
93
+ }
94
+
95
+ /* ═══ Desktop Enhancements ═══ */
96
+
97
+ /* Desktop split: show both files + editor side by side on xl+ */
98
+ @media (min-width: 1280px) {
99
+ .desktop-split > .split-panel {
100
+ display: flex !important;
101
+ }
102
+ .desktop-split > .split-files {
103
+ width: 380px;
104
+ flex-shrink: 0;
105
+ border-right: 1px solid #1f2937;
106
+ }
107
+ .desktop-split > .split-editor {
108
+ flex: 1;
109
+ min-width: 0;
110
+ }
111
+ }
112
+
113
+ /* Desktop: better file list items */
114
+ @media (min-width: 1280px) {
115
+ .file-item .file-actions {
116
+ opacity: 0;
117
+ transition: opacity 0.15s;
118
+ }
119
+ .file-item:hover .file-actions {
120
+ opacity: 1;
121
+ }
122
+ }
123
+
124
+ /* Desktop: wider content areas for backup/ports */
125
+ @media (min-width: 1280px) {
126
+ .desktop-content {
127
+ max-width: 960px;
128
+ }
129
+ }
130
+ @media (min-width: 1536px) {
131
+ .desktop-content {
132
+ max-width: 1100px;
133
+ }
134
  }