cc3m / static /index.html
kokokoasd's picture
Upload 20 files
afc671b verified
<!DOCTYPE html>
<html lang="vi" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HugPanel</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd',
400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9',
800: '#5b21b6', 900: '#4c1d95',
}
}
}
}
}
</script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="h-full bg-gray-950 text-gray-100 overflow-hidden" x-data="hugpanel()" x-init="init()">
<!-- ═══ AUTH: Login / Register Screen ═══ -->
<div x-show="!user && !authLoading" x-transition class="min-h-full flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="text-center mb-6">
<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>
<h1 class="text-xl font-bold">HugPanel</h1>
<p class="text-xs text-gray-500 mt-1">Workspace Manager</p>
</div>
<div class="bg-gray-900 rounded-2xl border border-gray-800 p-6 space-y-4">
<!-- Tab switch -->
<div class="flex bg-gray-800 rounded-lg p-0.5">
<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>
<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>
</div>
<!-- Registration disabled notice -->
<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>
<!-- Error -->
<div x-show="authError" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2" x-text="authError"></div>
<!-- Login Form -->
<div x-show="authMode === 'login'" class="space-y-3">
<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()" />
<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()" />
<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">
<span x-show="!authSubmitting">Đăng nhập</span>
<span x-show="authSubmitting">Đang xử lý...</span>
</button>
</div>
<!-- Register Form -->
<div x-show="authMode === 'register'" class="space-y-3">
<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()" />
<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()" />
<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()" />
<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">
<span x-show="!authSubmitting">Đăng ký</span>
<span x-show="authSubmitting">Đang xử lý...</span>
</button>
</div>
</div>
<!-- Admin API URL indicator -->
<div class="mt-4 text-center">
<div x-show="!adminApiUrl" class="text-xs text-yellow-500">ADMIN_API_URL chưa cấu hình</div>
</div>
</div>
</div>
<!-- Auth loading spinner -->
<div x-show="authLoading" class="min-h-full flex items-center justify-center">
<div class="w-8 h-8 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- ═══ MAIN PANEL (shown when logged in) ═══ -->
<div x-show="user" x-cloak>
<!-- MOTD Banner -->
<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">
<span x-text="motd" class="flex-1 text-center"></span>
<button @click="motd=''" class="ml-3 p-1 hover:bg-white/20 rounded transition text-xs"></button>
</div>
<!-- ═══ Mobile Top Bar ═══ -->
<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">
<button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
<i data-lucide="menu" class="w-5 h-5"></i>
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-brand-600 flex items-center justify-center text-sm font-bold">H</div>
<span class="font-semibold text-sm">HugPanel</span>
</div>
<div class="w-8"></div>
</header>
<!-- ═══ Sidebar Overlay (mobile) ═══ -->
<div x-show="sidebarOpen" x-transition:enter="transition-opacity duration-200"
x-transition:leave="transition-opacity duration-200"
@click="sidebarOpen = false"
class="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"></div>
<div class="flex h-screen overflow-hidden">
<!-- ═══ Sidebar ═══ -->
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
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">
<!-- Logo -->
<div class="p-4 border-b border-gray-800 flex items-center gap-3">
<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>
<div>
<div class="font-bold text-sm">HugPanel</div>
<div class="text-xs text-gray-500">Workspace Manager</div>
</div>
</div>
<!-- Zone List -->
<div class="flex-1 overflow-y-auto p-3 space-y-1">
<div class="px-2 py-1.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Zones</div>
<template x-for="zone in zones" :key="zone.name">
<div class="group relative">
<button @click="selectZone(zone.name); sidebarOpen = false"
: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'"
class="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all border">
<i data-lucide="box" class="w-4 h-4 flex-shrink-0"></i>
<span x-text="zone.name" class="truncate"></span>
</button>
<button @click.stop="currentZone = zone.name; confirmDeleteZone()"
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">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</template>
<div x-show="zones.length === 0" class="text-center py-8 text-gray-600 text-sm">
Chưa có zone nào
</div>
</div>
<!-- Create Zone -->
<div class="p-3 border-t border-gray-800 space-y-2">
<button @click="showCreateZone = true"
:disabled="maxZones > 0 && zones.length >= maxZones"
: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'"
class="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-white text-sm font-medium transition">
<i data-lucide="plus" class="w-4 h-4"></i>
Tạo Zone
</button>
<div x-show="maxZones > 0" class="text-center text-xs text-gray-500">
<span x-text="zones.length"></span> / <span x-text="maxZones"></span> zones
</div>
</div>
<!-- User Info + Logout -->
<div class="p-3 border-t border-gray-800">
<div class="flex items-center gap-2.5 px-2 py-1.5">
<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>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate" x-text="user?.username"></div>
<div class="text-xs text-gray-500" x-text="user?.role === 'admin' ? 'Admin' : 'User'"></div>
</div>
<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">
<i data-lucide="log-out" class="w-4 h-4"></i>
</button>
</div>
</div>
</aside>
<!-- ═══ Main Content ═══ -->
<main class="flex-1 flex flex-col min-w-0 min-h-0 pt-14 lg:pt-0 h-full">
<!-- No zone selected -->
<div x-show="!currentZone" class="flex-1 overflow-y-auto p-4 lg:p-8" x-effect="if(!currentZone && backupStatus.configured) loadBackupList()">
<div class="text-center max-w-sm mx-auto mb-6 pt-4 lg:pt-8">
<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">
<i data-lucide="layout-dashboard" class="w-8 h-8 lg:w-10 lg:h-10 text-gray-600"></i>
</div>
<h2 class="text-lg lg:text-xl font-semibold text-gray-400 mb-2">Chọn hoặc tạo Zone</h2>
<p class="text-sm text-gray-600">Chọn zone từ sidebar hoặc tạo zone mới để bắt đầu.</p>
</div>
<!-- Cloud Backups (available even without selecting a zone) -->
<div x-show="backupStatus.configured" class="max-w-3xl mx-auto space-y-4">
<!-- Restore All button -->
<button @click="restoreAll()" :disabled="backupStatus.running"
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
<i data-lucide="cloud-download" class="w-4 h-4"></i>
Restore tất cả từ cloud
</button>
<!-- Progress -->
<div x-show="backupStatus.running" class="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
<span class="text-xs text-brand-400">Đang chạy</span>
</div>
<div class="text-xs text-gray-400" x-text="backupStatus.progress"></div>
</div>
<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>
<!-- Cloud backup list -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
<button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
</button>
</div>
<div x-show="backupLoading" class="flex items-center justify-center py-8">
<div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div x-show="!backupLoading && backupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
Chưa có bản backup nào
</div>
<div x-show="!backupLoading" class="divide-y divide-gray-800/50">
<template x-for="b in backupList" :key="b.zone_name">
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
<div class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
:class="b.local_exists ? 'bg-brand-500/10' : 'bg-yellow-500/10'">
<i data-lucide="archive" class="w-4 h-4" :class="b.local_exists ? 'text-brand-400' : 'text-yellow-400'"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
<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>
</div>
<div class="text-xs text-gray-500">
<span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
<span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
</div>
</div>
<button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
<i data-lucide="download-cloud" class="w-4 h-4"></i>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Zone Content -->
<div x-show="currentZone" class="flex-1 flex flex-col min-h-0">
<!-- Tab Bar -->
<div class="bg-gray-900/80 backdrop-blur border-b border-gray-800 px-4">
<div class="flex items-center gap-1 overflow-x-auto scrollbar-hide">
<template x-for="tab in tabs" :key="tab.id">
<button @click="activeTab = tab.id"
: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'"
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">
<i :data-lucide="tab.icon" class="w-4 h-4"></i>
<span x-text="tab.label"></span>
</button>
</template>
<!-- Zone Actions (right side) -->
<div class="ml-auto flex items-center gap-1 lg:gap-2">
<div class="hidden lg:flex items-center gap-2 mr-3">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<span x-text="currentZone" class="text-sm text-gray-300 font-medium"></span>
</div>
<span x-text="currentZone" class="text-xs text-gray-500 font-mono mr-2 hidden sm:inline lg:hidden"></span>
<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">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span class="hidden lg:inline text-xs">Xoá zone</span>
</button>
</div>
</div>
</div>
<!-- ═══ TAB: Files + Editor (split on desktop) ═══ -->
<div x-show="activeTab === 'files' || activeTab === 'editor'" class="desktop-split flex-1 flex flex-col lg:flex-row min-h-0">
<!-- Files Panel (always visible on desktop when in files/editor tab) -->
<div x-show="activeTab === 'files' || (isDesktop && activeTab === 'editor')" class="split-panel split-files flex flex-col min-h-0" :class="isDesktop ? '' : 'flex-1'">
<!-- File Toolbar -->
<div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex flex-wrap items-center gap-2">
<!-- Breadcrumb -->
<div class="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto scrollbar-hide">
<button @click="navigateTo('')" class="text-brand-400 hover:text-brand-300 flex-shrink-0">
<i data-lucide="home" class="w-3.5 h-3.5"></i>
</button>
<template x-for="(part, i) in currentPathParts" :key="i">
<div class="flex items-center gap-1 flex-shrink-0">
<i data-lucide="chevron-right" class="w-3 h-3 text-gray-600"></i>
<button @click="navigateTo(currentPathParts.slice(0, i+1).join('/'))"
class="text-gray-400 hover:text-brand-400 transition truncate max-w-[120px]"
x-text="part"></button>
</div>
</template>
</div>
<!-- File Actions -->
<div class="flex items-center gap-1">
<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">
<i data-lucide="file-plus" class="w-4 h-4"></i>
</button>
<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">
<i data-lucide="folder-plus" class="w-4 h-4"></i>
</button>
<label class="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition cursor-pointer" title="Upload">
<i data-lucide="upload" class="w-4 h-4"></i>
<input type="file" class="hidden" @change="uploadFile($event)" multiple />
</label>
<button @click="loadFiles()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- New File/Folder Inputs -->
<div x-show="showNewFile" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
<i data-lucide="file" class="w-4 h-4 text-gray-500"></i>
<input x-ref="newFileInput" x-model="newFileName" @keydown.enter="createFile()" @keydown.escape="showNewFile = false"
placeholder="filename.txt" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
<button @click="createFile()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
<button @click="showNewFile = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
</div>
<div x-show="showNewFolder" class="px-4 py-2 bg-gray-800/50 border-b border-gray-800 flex items-center gap-2">
<i data-lucide="folder" class="w-4 h-4 text-gray-500"></i>
<input x-ref="newFolderInput" x-model="newFolderName" @keydown.enter="createFolder()" @keydown.escape="showNewFolder = false"
placeholder="folder-name" class="flex-1 bg-transparent text-sm outline-none placeholder-gray-600" />
<button @click="createFolder()" class="px-3 py-1 text-xs bg-brand-600 hover:bg-brand-500 rounded-md transition">Tạo</button>
<button @click="showNewFolder = false" class="px-2 py-1 text-xs text-gray-500 hover:text-gray-300">Huỷ</button>
</div>
<!-- File List -->
<div class="flex-1 overflow-y-auto">
<div x-show="filesLoading" class="flex items-center justify-center py-12">
<div class="w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div x-show="!filesLoading && files.length === 0" class="text-center py-12 text-gray-600 text-sm">
Thư mục trống
</div>
<div x-show="!filesLoading" class="divide-y divide-gray-800/50">
<!-- Back button -->
<button x-show="currentPath !== ''"
@click="navigateUp()"
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition text-gray-400">
<i data-lucide="corner-left-up" class="w-4 h-4"></i>
<span class="text-sm">..</span>
</button>
<template x-for="file in files" :key="file.name">
<div class="file-item group flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800/50 transition cursor-pointer"
@click="file.is_dir ? navigateTo(joinPath(currentPath, file.name)) : openFile(joinPath(currentPath, file.name))">
<i :data-lucide="file.is_dir ? 'folder' : getFileIcon(file.name)"
:class="file.is_dir ? 'text-brand-400' : 'text-gray-500'"
class="w-4 h-4 flex-shrink-0"></i>
<span class="flex-1 text-sm truncate" x-text="file.name"></span>
<span x-show="!file.is_dir" class="text-xs text-gray-600 hidden sm:inline" x-text="formatSize(file.size)"></span>
<!-- File Actions -->
<div class="file-actions flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
<button x-show="!file.is_dir" @click.stop="downloadFile(joinPath(currentPath, file.name), file.name)"
class="p-1 rounded text-gray-500 hover:text-brand-400 hover:bg-brand-400/10" title="Download">
<i data-lucide="download" class="w-3.5 h-3.5"></i>
</button>
<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">
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</button>
<button @click.stop="deleteFile(joinPath(currentPath, file.name), file.is_dir)"
class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-red-400/10" title="Xoá">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- ═══ TAB: Editor (always visible on desktop when in files/editor tab) ═══ -->
<div x-show="activeTab === 'editor' || (isDesktop && activeTab === 'files')" class="split-panel split-editor flex-1 flex flex-col min-h-0">
<div x-show="!editorFile" class="flex-1 flex items-center justify-center text-gray-600 text-sm">
Chọn file để chỉnh sửa
</div>
<div x-show="editorFile" class="flex-1 flex flex-col min-h-0">
<div class="px-4 py-2 bg-gray-900/50 border-b border-gray-800 flex items-center gap-2">
<i data-lucide="file-code" class="w-4 h-4 text-brand-400"></i>
<span class="text-sm text-gray-300 truncate" x-text="editorFile"></span>
<div class="ml-auto flex items-center gap-2">
<span x-show="editorDirty" class="text-xs text-yellow-500">Chưa lưu</span>
<button @click="saveFile()" :disabled="!editorDirty"
:class="editorDirty ? 'bg-brand-600 hover:bg-brand-500 text-white' : 'bg-gray-800 text-gray-600 cursor-not-allowed'"
class="px-3 py-1 text-xs rounded-md transition font-medium">
Lưu
</button>
</div>
</div>
<div class="flex-1 min-h-0 overflow-hidden">
<textarea x-model="editorContent" @input="editorDirty = true"
@keydown.ctrl.s.prevent="saveFile()"
class="w-full h-full p-4 bg-gray-950 text-gray-200 text-sm font-mono resize-none outline-none leading-relaxed block"
spellcheck="false"></textarea>
</div>
</div>
</div>
</div><!-- end desktop-split -->
<!-- ═══ TAB: Terminal ═══ -->
<div x-show="activeTab === 'terminal'" x-effect="if(activeTab==='terminal') initTerminal()" class="flex-1 flex flex-col min-h-0">
<div id="terminal-container" class="flex-1 min-h-0 p-1 bg-black"></div>
</div>
<!-- ═══ TAB: Ports ═══ -->
<div x-show="activeTab === 'ports'" class="flex-1 overflow-y-auto">
<div class="p-4 lg:p-6 space-y-4 w-full">
<!-- Add Port -->
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 class="text-sm font-medium text-gray-300 mb-3">Thêm Port</h3>
<div class="flex flex-col sm:flex-row gap-2">
<input x-model.number="newPort" type="number" min="1024" max="65535" placeholder="Port (1024-65535)"
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" />
<input x-model="newPortLabel" placeholder="Label (tuỳ chọn)"
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" />
<button @click="addPort()" class="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition">
Thêm
</button>
</div>
</div>
<!-- Port List -->
<div class="space-y-2 lg:grid lg:grid-cols-2 2xl:grid-cols-3 lg:gap-3 lg:space-y-0">
<template x-for="port in ports" :key="port.port">
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="radio" class="w-5 h-5 text-green-400"></i>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium" x-text="port.label || 'Port ' + port.port"></div>
<div class="text-xs text-gray-500">
Port: <span x-text="port.port" class="text-gray-400 font-mono"></span>
</div>
</div>
<a :href="'/port/' + currentZone + '/' + port.port + '/'"
target="_blank"
class="p-2 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-brand-400/10 transition" title="Mở">
<i data-lucide="external-link" class="w-4 h-4"></i>
</a>
<button @click="removePort(port.port)"
class="p-2 rounded-lg text-gray-400 hover:text-red-400 hover:bg-red-400/10 transition" title="Xoá">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</template>
<div x-show="ports.length === 0" class="text-center py-8 text-gray-600 text-sm lg:col-span-2 2xl:col-span-3">
Chưa có port nào
</div>
</div>
</div>
</div>
<!-- ═══ TAB: Backup ═══ -->
<div x-show="activeTab === 'backup'" x-effect="if(activeTab==='backup' && backupStatus.configured) loadBackupList()" class="flex-1 overflow-y-auto">
<div class="p-4 lg:p-6 space-y-4 w-full">
<!-- Not configured -->
<div x-show="!backupStatus.configured" class="bg-gray-900 rounded-xl border border-gray-800 p-6 text-center">
<div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-yellow-500/10 flex items-center justify-center">
<i data-lucide="cloud-off" class="w-7 h-7 text-yellow-500"></i>
</div>
<h3 class="text-sm font-semibold text-gray-300 mb-2">Chưa cấu hình Backup</h3>
<p class="text-xs text-gray-500 mb-4 max-w-xs mx-auto">
Đặ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.
</p>
<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">
<div>ADMIN_API_URL=https://your-worker.workers.dev</div>
</div>
</div>
<!-- Configured: Status & Actions -->
<div x-show="backupStatus.configured" class="space-y-4">
<!-- Status Card -->
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="cloud" class="w-5 h-5 text-brand-400"></i>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium">Cloud Backup</div>
<div class="text-xs text-gray-500 font-mono truncate" x-text="backupStatus.admin_url"></div>
</div>
<div x-show="backupStatus.running" class="flex items-center gap-2">
<div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
<span class="text-xs text-brand-400">Đang chạy</span>
</div>
</div>
<!-- Progress -->
<div x-show="backupStatus.progress" class="text-xs text-gray-400 mb-3 px-1" x-text="backupStatus.progress"></div>
<!-- Error -->
<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>
<!-- Last backup -->
<div x-show="backupStatus.last" class="text-xs text-gray-500 px-1">
Lần cuối: <span x-text="backupStatus.last ? new Date(backupStatus.last).toLocaleString('vi-VN') : 'Chưa có'" class="text-gray-400"></span>
</div>
</div>
<!-- Action Buttons -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
<button @click="backupZone(currentZone)" :disabled="backupStatus.running"
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
<i data-lucide="upload-cloud" class="w-4 h-4"></i>
Backup Zone này
</button>
<button @click="backupAll()" :disabled="backupStatus.running"
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
<i data-lucide="cloud-upload" class="w-4 h-4"></i>
Backup tất cả
</button>
<button @click="restoreZone(currentZone)" :disabled="backupStatus.running || !backupList.some(b => b.zone_name === currentZone)"
:class="(backupStatus.running || !backupList.some(b => b.zone_name === currentZone)) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
<i data-lucide="download-cloud" class="w-4 h-4"></i>
Restore Zone này
</button>
<button @click="restoreAll()" :disabled="backupStatus.running"
:class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
<i data-lucide="cloud-download" class="w-4 h-4"></i>
Restore tất cả
</button>
</div>
<!-- Backup List -->
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
<button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
</button>
</div>
<div x-show="backupLoading" class="flex items-center justify-center py-8">
<div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div x-show="!backupLoading && backupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
Chưa có bản backup nào
</div>
<div x-show="!backupLoading" class="divide-y divide-gray-800/50">
<template x-for="b in backupList" :key="b.zone_name">
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
<div class="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
<i data-lucide="archive" class="w-4 h-4 text-brand-400"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate" x-text="b.zone_name"></span>
<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>
</div>
<div class="text-xs text-gray-500">
<span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
<span x-show="b.last_modified"> · <span x-text="new Date(b.last_modified).toLocaleString('vi-VN')"></span></span>
</div>
</div>
<button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
<i data-lucide="download-cloud" class="w-4 h-4"></i>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div><!-- end x-show="user" -->
<!-- ═══ MODAL: Create Zone ═══ -->
<div x-show="showCreateZone" x-transition:enter="transition duration-200" x-transition:leave="transition duration-150"
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div @click.outside="showCreateZone = false"
class="w-full max-w-md bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
<div class="p-5 border-b border-gray-800">
<h2 class="text-lg font-semibold">Tạo Zone mới</h2>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-xs text-gray-500 mb-1.5">Tên Zone</label>
<input x-model="createZoneName" x-ref="zoneNameInput" @keydown.enter="createZone()"
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" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1.5">Mô tả (tuỳ chọn)</label>
<input x-model="createZoneDesc" @keydown.enter="createZone()"
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" />
</div>
</div>
<div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
<button @click="showCreateZone = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
<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>
</div>
</div>
</div>
<!-- ═══ MODAL: Rename ═══ -->
<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">
<div @click.outside="showRename = false" class="w-full max-w-sm bg-gray-900 rounded-2xl border border-gray-800 shadow-2xl overflow-hidden">
<div class="p-5 border-b border-gray-800">
<h2 class="text-base font-semibold">Đổi tên</h2>
</div>
<div class="p-5">
<input x-model="renameNewName" x-ref="renameInput" @keydown.enter="doRename()" @keydown.escape="showRename = false"
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" />
</div>
<div class="p-5 border-t border-gray-800 flex gap-2 justify-end">
<button @click="showRename = false" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition">Huỷ</button>
<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>
</div>
</div>
</div>
<!-- ═══ Toast ═══ -->
<div x-show="toast.show" x-transition:enter="transition transform duration-300"
x-transition:enter-start="translate-y-4 opacity-0" x-transition:enter-end="translate-y-0 opacity-100"
x-transition:leave="transition transform duration-200"
x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-end="translate-y-4 opacity-0"
:class="toast.type === 'error' ? 'bg-red-900/90 border-red-700' : 'bg-gray-800/90 border-gray-700'"
class="fixed bottom-4 right-4 z-[110] max-w-sm px-4 py-3 rounded-xl border backdrop-blur shadow-2xl text-sm">
<span x-text="toast.message"></span>
</div>
<script src="/static/app.js"></script>
</body>
</html>