Spaces:
Sleeping
Sleeping
| --- | |
| import '../styles/global.css'; | |
| import { ClientRouter } from 'astro:transitions'; | |
| interface Props { | |
| title: string; | |
| } | |
| const { title } = Astro.props; | |
| // Mock admin check - in real app, check role from cookie/session | |
| const token = Astro.cookies.get('auth_token')?.value; | |
| let user = null; | |
| if (token) { | |
| try { | |
| user = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); | |
| } catch(e) {} | |
| } | |
| let forceRedirect = false; | |
| if (!user || user.role !== 'admin') { | |
| forceRedirect = true; | |
| } | |
| const sidebarLinks = [ | |
| { href: '/admin', label: 'Tổng quan', icon: 'fas fa-chart-line' }, | |
| { href: '/admin/duyet-zalo', label: 'Duyệt Zalo', icon: 'fas fa-user-check', badgeId: 'nav-badge-zalo' }, | |
| { href: '/admin/bao-cao-loi', label: 'Báo cáo lỗi', icon: 'fas fa-exclamation-triangle', badgeId: 'nav-badge-loi' }, | |
| { href: '/admin/phim', label: 'Quản lý phim', icon: 'fas fa-film' }, | |
| { href: '/admin/crawl', label: 'Thu thập phim', icon: 'fas fa-spider' }, | |
| { href: '/admin/crawl-nang-cao', label: 'Cào nâng cao', icon: 'fas fa-search-plus' }, | |
| { href: '/admin/server', label: 'Quản lý Server', icon: 'fas fa-server' }, | |
| { href: '/admin/the-loai', label: 'Thể loại', icon: 'fas fa-tags' }, | |
| { href: '/admin/quoc-gia', label: 'Quốc gia', icon: 'fas fa-globe' }, | |
| { href: '/admin/dien-vien', label: 'Diễn viên', icon: 'fas fa-user-friends' }, | |
| { href: '/admin/nguoi-dung', label: 'Người dùng', icon: 'fas fa-users' }, | |
| { href: '/admin/binh-luan', label: 'Bình luận', icon: 'fas fa-comments', badgeId: 'nav-badge-comment' }, | |
| { href: '/admin/email', label: 'Lịch sử Email', icon: 'fas fa-envelope-open-text' }, | |
| { href: '/admin/cai-dat', label: 'Cài đặt', icon: 'fas fa-cog' }, | |
| ]; | |
| --- | |
| <!doctype html> | |
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| {forceRedirect && <meta http-equiv="refresh" content="0;url=/dang-nhap" />} | |
| <title>{title} - TPHIMX Admin</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" /> | |
| <!-- TXA Core Libraries --> | |
| <script is:inline src="/lib/txa/txaformat.js" defer></script> | |
| <script is:inline src="/lib/txa/txatooltip.js" defer></script> | |
| <script is:inline src="/lib/txa/txatoast.js" defer></script> | |
| <script is:inline src="/lib/txa/txamodal.js" type="module" defer></script> | |
| <ClientRouter /> | |
| </head> | |
| <body class="bg-[#0b0c10] text-white selection:bg-primary selection:text-dark overflow-x-hidden w-full max-w-full"> | |
| {forceRedirect ? ( | |
| <div class="fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-[#0b0c10]"> | |
| <div class="w-10 h-10 rounded-full border-4 border-white/5 border-t-primary animate-spin shadow-[0_0_30px_rgba(124,58,237,0.4)] mb-4"></div> | |
| <p class="text-xs font-black uppercase tracking-widest text-primary animate-pulse">Vui lòng đăng nhập...</p> | |
| </div> | |
| ) : ( | |
| <> | |
| <!-- Premium Cinematic Splash Loading Screen --> | |
| <div id="txa-splash-loader" class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#0b0c10] transition-all duration-700"> | |
| <!-- Glowing aura behind logo --> | |
| <div class="absolute w-[400px] h-[400px] bg-primary/10 rounded-full blur-[120px] animate-pulse"></div> | |
| <div class="relative flex flex-col items-center gap-6 scale-95 animate-fade-in"> | |
| <!-- Spinning Outer Ring --> | |
| <div class="relative flex items-center justify-center"> | |
| <div class="w-24 h-24 rounded-full border-4 border-white/5 border-t-primary animate-spin shadow-[0_0_30px_rgba(124,58,237,0.4)]"></div> | |
| <!-- Logo Icon inside --> | |
| <div class="absolute w-14 h-14 overflow-hidden rounded-2xl shadow-lg"> | |
| <img src="/logo-icon.png" class="h-full w-full object-cover" /> | |
| </div> | |
| </div> | |
| <!-- Branding & Subtext --> | |
| <div class="flex flex-col items-center"> | |
| <div class="text-3xl font-black tracking-tighter text-white animate-pulse"> | |
| T<span class="text-primary">PHIM</span>X | |
| </div> | |
| <div class="mt-2 text-[10px] font-black uppercase tracking-[0.2em] text-gray-500"> | |
| Hệ thống Quản trị Cao cấp | |
| </div> | |
| </div> | |
| <!-- Premium Horizontal Progress Bar --> | |
| <div class="w-48 h-1 bg-white/5 rounded-full overflow-hidden border border-white/5"> | |
| <div class="h-full bg-gradient-to-r from-primary to-violet-400 w-1/3 rounded-full animate-progress-loading"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex min-h-screen w-full max-w-full overflow-x-hidden"> | |
| <!-- Desktop Sidebar --> | |
| <aside class="fixed left-0 top-0 hidden h-screen w-72 border-r border-white/5 bg-dark-light lg:block z-[100]"> | |
| <div class="flex h-full flex-col p-8"> | |
| <div class="mb-12 flex items-center gap-4"> | |
| <div class="h-10 w-10 overflow-hidden rounded-xl shadow-[0_0_20px_var(--primary)]"> | |
| <img src="/logo-icon.png" class="h-full w-full object-cover" /> | |
| </div> | |
| <div class="text-2xl font-black tracking-tighter"> | |
| T<span class="text-primary">PHIM</span>X | |
| </div> | |
| </div> | |
| <nav class="flex-1 space-y-1.5 overflow-y-auto pr-2 scrollbar-thin"> | |
| {sidebarLinks.map(link => { | |
| const isActive = Astro.url.pathname === link.href || (link.href !== '/admin' && Astro.url.pathname.startsWith(link.href + '/')); | |
| return ( | |
| <a | |
| href={link.href} | |
| class={`flex items-center gap-4 rounded-2xl px-6 py-3.5 text-xs font-black uppercase tracking-widest transition-all group ${ | |
| isActive | |
| ? 'bg-primary/15 text-primary border-l-4 border-primary shadow-[0_0_15px_rgba(124,58,237,0.1)]' | |
| : 'text-gray-500 hover:bg-white/5 hover:text-white active:bg-primary/10' | |
| }`} | |
| > | |
| <i class={`${link.icon} text-base ${isActive ? 'text-primary animate-pulse' : 'group-hover:text-primary transition-colors'}`}></i> | |
| <span>{link.label}</span> | |
| {link.badgeId && ( | |
| <span | |
| id={link.badgeId} | |
| class={`hidden ml-auto px-2 py-0.5 rounded-full text-[9px] font-black text-white min-w-[18px] text-center animate-pulse ${ | |
| link.badgeId === 'nav-badge-loi' ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.4)]' : | |
| link.badgeId === 'nav-badge-comment' ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]' : | |
| 'bg-primary shadow-[0_0_10px_rgba(124,58,237,0.4)]' | |
| }`} | |
| > | |
| 0 | |
| </span> | |
| )} | |
| </a> | |
| ); | |
| })} | |
| </nav> | |
| <div class="mt-auto pt-6 border-t border-white/5"> | |
| <div class="flex items-center gap-4"> | |
| {user?.avatar_url ? ( | |
| <div class="h-10 w-10 overflow-hidden rounded-full shrink-0 shadow-lg"> | |
| <img src={user.avatar_url} class="h-full w-full object-cover" /> | |
| </div> | |
| ) : ( | |
| <div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-dark font-black shrink-0"> | |
| {user?.username?.[0]?.toUpperCase() || 'A'} | |
| </div> | |
| )} | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-bold text-white">{user?.username || 'Admin'}</span> | |
| <span class="text-[10px] font-black uppercase tracking-widest text-gray-500">Quản trị viên</span> | |
| </div> | |
| <a href="/" class="ml-auto text-gray-500 hover:text-red-500 transition-colors"> | |
| <i class="fas fa-sign-out-alt"></i> | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Mobile Top Header --> | |
| <header class="fixed top-0 left-0 right-0 z-[90] flex h-16 items-center justify-between border-b border-white/5 bg-[#12141d]/80 px-6 backdrop-blur-xl lg:hidden"> | |
| <div class="flex items-center gap-3"> | |
| <div class="h-8 w-8 overflow-hidden rounded-lg shadow-[0_0_15px_rgba(124,58,237,0.5)]"> | |
| <img src="/logo-icon.png" class="h-full w-full object-cover" /> | |
| </div> | |
| <span class="text-lg font-black tracking-tighter text-white">T<span class="text-primary">PHIM</span>X</span> | |
| </div> | |
| <button id="mobile-menu-trigger" class="h-10 w-10 rounded-xl bg-white/5 flex items-center justify-center text-gray-400 hover:text-white border border-white/5 active:scale-95 transition-all"> | |
| <i class="fas fa-bars"></i> | |
| </button> | |
| </header> | |
| <!-- Mobile Slide-out Drawer --> | |
| <div id="mobile-drawer" class="fixed inset-0 z-[200] hidden bg-black/60 opacity-0 backdrop-blur-md transition-all duration-300 pointer-events-none"> | |
| <div class="absolute inset-y-0 right-0 w-[280px] bg-[#12141d] border-l border-white/5 p-8 flex flex-col justify-between transform translate-x-full transition-transform duration-300 pointer-events-auto overflow-y-auto scrollbar-thin"> | |
| <div> | |
| <div class="flex items-center justify-between mb-12"> | |
| <div class="flex items-center gap-3"> | |
| <div class="h-8 w-8 overflow-hidden rounded-lg shadow-[0_0_15px_rgba(124,58,237,0.5)]"> | |
| <img src="/logo-icon.png" class="h-full w-full object-cover" /> | |
| </div> | |
| <span class="text-lg font-black tracking-tighter text-white">T<span class="text-primary">PHIM</span>X</span> | |
| </div> | |
| <button id="mobile-menu-close" class="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center text-gray-400 hover:text-white border border-white/5"> | |
| <i class="fas fa-times text-sm"></i> | |
| </button> | |
| </div> | |
| <nav class="space-y-1.5 pr-1"> | |
| {sidebarLinks.map(link => { | |
| const isActive = Astro.url.pathname === link.href || (link.href !== '/admin' && Astro.url.pathname.startsWith(link.href + '/')); | |
| return ( | |
| <a | |
| href={link.href} | |
| class={`flex items-center gap-4 rounded-xl px-5 py-3.5 text-xs font-black uppercase tracking-widest transition-all group ${ | |
| isActive | |
| ? 'bg-primary/15 text-primary border-l-4 border-primary shadow-[0_0_15px_rgba(124,58,237,0.1)]' | |
| : 'text-gray-500 hover:bg-white/5 hover:text-white active:bg-primary/10' | |
| }`} | |
| > | |
| <i class={`${link.icon} text-base ${isActive ? 'text-primary' : 'group-hover:text-primary transition-colors'}`}></i> | |
| <span>{link.label}</span> | |
| {link.badgeId && ( | |
| <span | |
| id={`${link.badgeId}-mobile`} | |
| class={`hidden ml-auto px-2 py-0.5 rounded-full text-[9px] font-black text-white min-w-[18px] text-center animate-pulse ${ | |
| link.badgeId === 'nav-badge-loi' ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.4)]' : | |
| link.badgeId === 'nav-badge-comment' ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)]' : | |
| 'bg-primary shadow-[0_0_10px_rgba(124,58,237,0.4)]' | |
| }`} | |
| > | |
| 0 | |
| </span> | |
| )} | |
| </a> | |
| ); | |
| })} | |
| </nav> | |
| </div> | |
| <div class="pt-8 border-t border-white/5"> | |
| <div class="flex items-center gap-4"> | |
| {user?.avatar_url ? ( | |
| <div class="h-10 w-10 overflow-hidden rounded-full shrink-0 shadow-lg"> | |
| <img src={user.avatar_url} class="h-full w-full object-cover" /> | |
| </div> | |
| ) : ( | |
| <div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-dark font-black shrink-0"> | |
| {user?.username?.[0]?.toUpperCase() || 'A'} | |
| </div> | |
| )} | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-bold text-white">{user?.username || 'Admin'}</span> | |
| <span class="text-[10px] font-black uppercase tracking-widest text-gray-500">Quản trị viên</span> | |
| </div> | |
| <a href="/" class="ml-auto text-gray-500 hover:text-red-500 transition-colors"> | |
| <i class="fas fa-sign-out-alt"></i> | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <main class="flex-1 lg:ml-72 p-4 sm:p-6 pt-24 pb-24 lg:p-12 lg:pt-12 overflow-x-hidden max-w-full"> | |
| <slot /> | |
| </main> | |
| </div> | |
| </> | |
| )} | |
| </body> | |
| </html> | |
| <script> | |
| // Hide splash loader on initial load with a smooth buffer delay | |
| const hideInitialSplash = () => { | |
| const splash = document.getElementById('txa-splash-loader'); | |
| if (splash) { | |
| setTimeout(() => { | |
| splash.style.opacity = '0'; | |
| splash.style.pointerEvents = 'none'; | |
| setTimeout(() => { | |
| splash.style.display = 'none'; | |
| }, 700); | |
| }, 450); // 450ms buffer delay to let admin panels mount seamlessly | |
| } | |
| }; | |
| if (document.readyState === 'complete') { | |
| hideInitialSplash(); | |
| } else { | |
| window.addEventListener('load', () => { | |
| setTimeout(hideInitialSplash, 150); | |
| }); | |
| } | |
| // Handle transition page load | |
| document.addEventListener('astro:before-preparation', () => { | |
| const splash = document.getElementById('txa-splash-loader'); | |
| if (splash) { | |
| splash.style.display = 'flex'; | |
| void splash.offsetWidth; | |
| splash.style.opacity = '1'; | |
| splash.style.pointerEvents = 'auto'; | |
| } | |
| }); | |
| document.addEventListener('astro:page-load', () => { | |
| // Hide splash loader on navigation complete | |
| hideInitialSplash(); | |
| if ((window as any).initTxaTooltips) (window as any).initTxaTooltips(); | |
| // Mobile Drawer Logic | |
| const trigger = document.getElementById('mobile-menu-trigger'); | |
| const closeBtn = document.getElementById('mobile-menu-close'); | |
| const drawer = document.getElementById('mobile-drawer'); | |
| const panel = drawer?.querySelector('.pointer-events-auto'); | |
| const openDrawer = () => { | |
| if (drawer && panel) { | |
| drawer.classList.remove('hidden'); | |
| // Force reflow | |
| void drawer.offsetWidth; | |
| drawer.classList.add('opacity-100'); | |
| drawer.classList.remove('pointer-events-none'); | |
| panel.classList.remove('translate-x-full'); | |
| } | |
| }; | |
| const closeDrawer = () => { | |
| if (drawer && panel) { | |
| drawer.classList.remove('opacity-100'); | |
| drawer.classList.add('pointer-events-none'); | |
| panel.classList.add('translate-x-full'); | |
| setTimeout(() => { | |
| drawer.classList.add('hidden'); | |
| }, 300); | |
| } | |
| }; | |
| trigger?.addEventListener('click', openDrawer); | |
| closeBtn?.addEventListener('click', closeDrawer); | |
| drawer?.addEventListener('click', (e) => { | |
| if (e.target === drawer) closeDrawer(); | |
| }); | |
| // Real-time notifications logic (8 seconds polling) | |
| const pollNotifications = async () => { | |
| try { | |
| const res = await fetch('/api/admin/notifications'); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| if (!data.success) return; | |
| // 1. Zalo badge | |
| const zaloCount = data.pendingZalo; | |
| ['nav-badge-zalo', 'nav-badge-zalo-mobile'].forEach(id => { | |
| const badge = document.getElementById(id); | |
| if (badge) { | |
| if (zaloCount > 0) { | |
| badge.textContent = zaloCount; | |
| badge.classList.remove('hidden'); | |
| } else { | |
| badge.classList.add('hidden'); | |
| } | |
| } | |
| }); | |
| // 2. Report badge | |
| const reportCount = data.pendingReports; | |
| ['nav-badge-loi', 'nav-badge-loi-mobile'].forEach(id => { | |
| const badge = document.getElementById(id); | |
| if (badge) { | |
| if (reportCount > 0) { | |
| badge.textContent = reportCount; | |
| badge.classList.remove('hidden'); | |
| } else { | |
| badge.classList.add('hidden'); | |
| } | |
| } | |
| }); | |
| // 3. Comments badge | |
| if (window.location.pathname.startsWith('/admin/binh-luan')) { | |
| localStorage.setItem('admin_last_viewed_comments', new Date().toISOString()); | |
| } | |
| const lastViewedStr = localStorage.getItem('admin_last_viewed_comments'); | |
| let newCommentsCount = 0; | |
| if (lastViewedStr && data.comments) { | |
| const lastViewedTime = new Date(lastViewedStr).getTime(); | |
| newCommentsCount = data.comments.filter(c => new Date(c.created_at).getTime() > lastViewedTime).length; | |
| } else if (data.comments) { | |
| const last24h = Date.now() - 24 * 60 * 60 * 1000; | |
| newCommentsCount = data.comments.filter(c => new Date(c.created_at).getTime() > last24h).length; | |
| } | |
| ['nav-badge-comment', 'nav-badge-comment-mobile'].forEach(id => { | |
| const badge = document.getElementById(id); | |
| if (badge) { | |
| if (newCommentsCount > 0) { | |
| badge.textContent = newCommentsCount; | |
| badge.classList.remove('hidden'); | |
| } else { | |
| badge.classList.add('hidden'); | |
| } | |
| } | |
| }); | |
| // 4. Comparison and Toast alerts | |
| const prevZalo = parseInt(sessionStorage.getItem('prev_pending_zalo') || '0', 10); | |
| const prevReports = parseInt(sessionStorage.getItem('prev_pending_reports') || '0', 10); | |
| const prevComments = parseInt(sessionStorage.getItem('prev_new_comments') || '0', 10); | |
| // Make sure toast objects are present | |
| if ((window as any).txatoast) { | |
| if (zaloCount > prevZalo) { | |
| (window as any).txatoast.info(`🔔 Có yêu cầu duyệt Zalo mới đang chờ phê duyệt!`); | |
| } | |
| if (reportCount > prevReports) { | |
| (window as any).txatoast.warning(`⚠️ Có báo cáo lỗi phim mới chưa được xử lý!`); | |
| } | |
| if (newCommentsCount > prevComments) { | |
| (window as any).txatoast.success(`💬 Có bình luận mới của người xem trên trang phim!`); | |
| } | |
| } | |
| // Cache counts | |
| sessionStorage.setItem('prev_pending_zalo', zaloCount.toString()); | |
| sessionStorage.setItem('prev_pending_reports', reportCount.toString()); | |
| sessionStorage.setItem('prev_new_comments', newCommentsCount.toString()); | |
| } catch (err) { | |
| console.error('Failed to fetch notifications:', err); | |
| } | |
| }; | |
| // Expose notification fetcher globally so admin sub-pages can refresh counts immediately after any actions | |
| (window as any).txaPollNotifications = pollNotifications; | |
| // Initial fetch | |
| pollNotifications(); | |
| // Clear previous interval if any | |
| if ((window as any).txaNotificationInterval) { | |
| clearInterval((window as any).txaNotificationInterval); | |
| } | |
| // Set polling interval | |
| (window as any).txaNotificationInterval = setInterval(pollNotifications, 8000); | |
| }); | |
| </script> | |
| <style> | |
| @theme { | |
| --color-primary: #7c3aed; | |
| --color-dark: #0b0c10; | |
| --color-dark-light: #12141d; | |
| } | |
| @keyframes progress-loading { | |
| 0% { transform: translateX(-100%); } | |
| 50% { transform: translateX(100%); } | |
| 100% { transform: translateX(-100%); } | |
| } | |
| .animate-progress-loading { | |
| animation: progress-loading 2s infinite linear; | |
| } | |
| @keyframes fade-in { | |
| from { opacity: 0; transform: scale(0.9); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| .animate-fade-in { | |
| animation: fade-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; | |
| } | |
| </style> | |