Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../layouts/AdminLayout.astro'; | |
| import { txasupabase } from '../../lib/txasupabase.js'; | |
| const logs = await txasupabase.getEmailLogs(); | |
| --- | |
| <AdminLayout title="Nhật ký Email gửi đi"> | |
| <div class="mb-8"> | |
| <h1 class="text-4xl font-black tracking-tighter text-white">Nhật ký Email</h1> | |
| <p class="mt-2 text-gray-500">Giám sát và kiểm tra toàn bộ hòm thư gửi đi, trạng thái SMTP và xem trước nội dung email của hệ thống.</p> | |
| </div> | |
| <!-- 2 Column Layout - Desktop (Desktop: side-by-side, Mobile: dynamic overlay) --> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start"> | |
| <!-- Left Column: Email List (8 cols or 5 cols? Let's do 5 cols on desktop for a compact list, and 7 cols for preview) --> | |
| <div class="lg:col-span-5 glass rounded-[35px] border border-white/5 p-6 md:p-8 space-y-6"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-4"> | |
| <span class="text-xs font-black uppercase tracking-widest text-primary"><i class="fas fa-history"></i> Lịch sử thư gửi</span> | |
| <span class="text-[10px] font-black uppercase bg-white/5 border border-white/10 px-3 py-1 rounded-full text-gray-400">Tổng: {logs.length}</span> | |
| </div> | |
| <!-- Filter & Search --> | |
| <div class="relative"> | |
| <i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></i> | |
| <input type="text" id="email-search" placeholder="Tìm người nhận, tiêu đề..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl pl-11 pr-4 py-3 text-xs text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <!-- Scrollable List --> | |
| <div class="space-y-3 overflow-y-auto max-h-[calc(100vh-320px)] pr-2 scrollbar-thin" id="email-list"> | |
| <!-- Rendered via JS --> | |
| </div> | |
| </div> | |
| <!-- Right Column: Content Preview Pane (7 cols - Hidden on mobile but slides in) --> | |
| <div class="lg:col-span-7 hidden lg:block glass rounded-[35px] border border-white/5 p-8 h-[calc(100vh-200px)] sticky top-24" id="desktop-preview-pane"> | |
| <!-- Empty State --> | |
| <div id="preview-empty-state" class="h-full flex flex-col items-center justify-center text-center text-gray-500"> | |
| <div class="h-16 w-16 rounded-full bg-white/5 flex items-center justify-center mb-4 border border-white/5 shadow-[0_0_30px_rgba(255,255,255,0.02)]"> | |
| <i class="fas fa-envelope-open text-2xl opacity-20"></i> | |
| </div> | |
| <p class="text-xs font-bold uppercase tracking-widest">Chọn một email</p> | |
| <p class="text-[11px] text-gray-500 mt-2">Nhấp vào một email bất kỳ bên danh sách để hiển thị nội dung xem trước đầy đủ.</p> | |
| </div> | |
| <!-- Content Preview (Hidden by default) --> | |
| <div id="preview-content-state" class="hidden h-full flex flex-col"> | |
| <!-- Preview Header --> | |
| <div class="border-b border-white/5 pb-4 mb-4 flex items-start justify-between"> | |
| <div> | |
| <h2 id="prev-subject" class="text-base font-black text-white leading-tight"></h2> | |
| <p class="text-xs text-gray-400 mt-1">Đến: <span id="prev-to" class="font-bold text-primary"></span></p> | |
| </div> | |
| <div id="prev-status-badge"></div> | |
| </div> | |
| <!-- Meta Info Row --> | |
| <div class="grid grid-cols-2 gap-4 bg-white/[0.02] border border-white/5 rounded-2xl p-4 mb-4 text-[11px]"> | |
| <div> | |
| <span class="text-gray-500 block uppercase font-black tracking-wider text-[9px]">Mẫu Template</span> | |
| <span id="prev-template" class="font-mono text-gray-300 font-bold"></span> | |
| </div> | |
| <div> | |
| <span class="text-gray-500 block uppercase font-black tracking-wider text-[9px]">Thời gian gửi</span> | |
| <span id="prev-time" class="text-gray-300 font-bold"></span> | |
| </div> | |
| </div> | |
| <!-- Error message block if failed --> | |
| <div id="prev-error-box" class="hidden bg-red-500/10 border border-red-500/20 rounded-2xl p-4 mb-4 text-xs text-red-400"> | |
| <p class="font-bold"><i class="fas fa-exclamation-triangle"></i> Lỗi gửi SMTP:</p> | |
| <p id="prev-error-msg" class="mt-1 font-mono"></p> | |
| </div> | |
| <!-- Preview Selector Tab (HTML Preview / HTML Source) --> | |
| <div class="flex gap-2 border-b border-white/5 pb-3 mb-4"> | |
| <button id="btn-view-preview" class="px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary border border-primary/20">Xem trước</button> | |
| <button id="btn-view-source" class="px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-500 hover:text-white border border-transparent">Mã nguồn HTML</button> | |
| </div> | |
| <!-- Body Preview Container --> | |
| <div class="flex-1 rounded-2xl overflow-hidden bg-white relative border border-white/10" id="preview-iframe-wrapper"> | |
| <iframe id="preview-iframe" class="w-full h-full border-none"></iframe> | |
| </div> | |
| <div class="flex-1 rounded-2xl overflow-hidden bg-[#12141d] border border-white/5 p-4 font-mono text-xs text-gray-400 overflow-y-auto hidden" id="preview-source-wrapper"> | |
| <pre id="preview-source-code" class="whitespace-pre-wrap word-break-all"></pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Mobile Preview Slide-out Bottom Sheet / Drawer --> | |
| <div id="mobile-preview-drawer" class="fixed inset-0 z-[200] hidden bg-black/80 opacity-0 transition-opacity duration-300 pointer-events-none lg:hidden flex flex-col justify-end"> | |
| <div class="w-full h-[85vh] bg-[#0f0f12] border-t border-white/10 rounded-t-[40px] p-6 flex flex-col transform translate-y-full transition-transform duration-300 pointer-events-auto"> | |
| <!-- Close & Drag Handle --> | |
| <div class="flex items-center justify-between pb-4 border-b border-white/5 mb-4"> | |
| <div> | |
| <span class="text-[10px] font-black uppercase tracking-widest text-primary"><i class="fas fa-eye"></i> Nội dung Email</span> | |
| </div> | |
| <button id="btn-close-mobile-preview" class="h-9 w-9 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-times text-xs"></i> | |
| </button> | |
| </div> | |
| <!-- Preview content panel (Exactly like desktop inside bottom sheet) --> | |
| <div class="flex-1 flex flex-col overflow-y-auto" id="mobile-preview-container"> | |
| <!-- Content will be mirrored from desktop rendering logic dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| </AdminLayout> | |
| <script is:inline define:vars={{ initialLogs: logs }}> | |
| let emailLogs = initialLogs || []; | |
| let searchQuery = ''; | |
| let selectedLogId = null; | |
| let viewMode = 'preview'; // 'preview' or 'source' | |
| function formatTime(isoString) { | |
| if (!isoString) return ''; | |
| const date = new Date(isoString); | |
| return date.toLocaleString('vi-VN', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| day: '2-digit', | |
| month: '2-digit', | |
| year: 'numeric' | |
| }); | |
| } | |
| function renderList() { | |
| const listContainer = document.getElementById('email-list'); | |
| if (!listContainer) return; | |
| const filtered = emailLogs.filter(log => | |
| log.to_email.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| log.subject.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| log.template_name.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ); | |
| if (filtered.length === 0) { | |
| listContainer.innerHTML = ` | |
| <div class="py-12 text-center text-gray-500 text-xs"> | |
| <i class="fas fa-envelope text-2xl mb-2 opacity-10"></i> | |
| Không tìm thấy email nào | |
| </div> | |
| `; | |
| return; | |
| } | |
| listContainer.innerHTML = filtered.map(log => { | |
| const isSelected = log.id === selectedLogId; | |
| const isSuccess = log.status === 'sent'; | |
| return ` | |
| <div | |
| class="email-row p-4 rounded-2xl border transition-all cursor-pointer flex flex-col gap-2 relative overflow-hidden ${ | |
| isSelected | |
| ? 'bg-primary/10 border-primary shadow-[0_0_15px_rgba(124,58,237,0.1)]' | |
| : 'bg-[#12141d]/40 border-white/5 hover:bg-white/[0.02] hover:border-white/10' | |
| }" | |
| data-id="${log.id}" | |
| > | |
| <!-- Top Row --> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-[10px] font-black uppercase text-gray-400 font-mono">${log.template_name}</span> | |
| <span class="text-[10px] text-gray-500">${formatTime(log.created_at)}</span> | |
| </div> | |
| <!-- Mid Row --> | |
| <div class="flex flex-col"> | |
| <span class="text-xs font-bold text-white leading-snug truncate">${log.subject}</span> | |
| <span class="text-[11px] text-primary/70 font-semibold truncate mt-0.5">${log.to_email}</span> | |
| </div> | |
| <!-- Status Indicator Dot --> | |
| <div class="absolute right-4 bottom-4 flex items-center gap-1.5"> | |
| <span class="h-2 w-2 rounded-full relative flex"> | |
| <span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${ | |
| isSuccess ? 'bg-emerald-400' : 'bg-red-400' | |
| }"></span> | |
| <span class="relative inline-flex rounded-full h-2 w-2 ${ | |
| isSuccess ? 'bg-emerald-500' : 'bg-red-500' | |
| }"></span> | |
| </span> | |
| <span class="text-[8px] font-black uppercase tracking-widest ${ | |
| isSuccess ? 'text-emerald-400' : 'text-red-400' | |
| }">${isSuccess ? 'Thành công' : 'Thất bại'}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| bindListEvents(); | |
| } | |
| function selectLog(id) { | |
| selectedLogId = id; | |
| renderList(); | |
| const log = emailLogs.find(l => l.id === id); | |
| if (!log) return; | |
| // Detect if we are on mobile | |
| const isMobile = window.innerWidth < 1024; | |
| if (isMobile) { | |
| showMobilePreview(log); | |
| } else { | |
| showDesktopPreview(log); | |
| } | |
| } | |
| function showDesktopPreview(log) { | |
| const emptyState = document.getElementById('preview-empty-state'); | |
| const contentState = document.getElementById('preview-content-state'); | |
| if (!emptyState || !contentState) return; | |
| emptyState.classList.add('hidden'); | |
| contentState.classList.remove('hidden'); | |
| // Fill elements | |
| document.getElementById('prev-subject').textContent = log.subject; | |
| document.getElementById('prev-to').textContent = log.to_email; | |
| document.getElementById('prev-template').textContent = log.template_name; | |
| document.getElementById('prev-time').textContent = formatTime(log.created_at); | |
| // Badges | |
| const badge = document.getElementById('prev-status-badge'); | |
| const isSuccess = log.status === 'sent'; | |
| if (badge) { | |
| badge.innerHTML = ` | |
| <span class="px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${ | |
| isSuccess | |
| ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' | |
| : 'bg-red-500/10 border-red-500/20 text-red-400' | |
| }"> | |
| ${isSuccess ? '<i class="fas fa-check-circle"></i> Đã gửi' : '<i class="fas fa-times-circle"></i> Lỗi'} | |
| </span> | |
| `; | |
| } | |
| // Error Box | |
| const errorBox = document.getElementById('prev-error-box'); | |
| const errorMsg = document.getElementById('prev-error-msg'); | |
| if (errorBox && errorMsg) { | |
| if (!isSuccess && log.error_message) { | |
| errorBox.classList.remove('hidden'); | |
| errorMsg.textContent = log.error_message; | |
| } else { | |
| errorBox.classList.add('hidden'); | |
| } | |
| } | |
| // Body Preview Frame | |
| const iframe = document.getElementById('preview-iframe'); | |
| if (iframe) { | |
| iframe.srcdoc = log.body; | |
| } | |
| // HTML source view | |
| const sourceCode = document.getElementById('preview-source-code'); | |
| if (sourceCode) { | |
| sourceCode.textContent = log.body; | |
| } | |
| updateViewMode(); | |
| } | |
| function showMobilePreview(log) { | |
| const drawer = document.getElementById('mobile-preview-drawer'); | |
| const panel = drawer?.querySelector('.pointer-events-auto'); | |
| const container = document.getElementById('mobile-preview-container'); | |
| if (!drawer || !panel || !container) return; | |
| const isSuccess = log.status === 'sent'; | |
| const statusBadge = ` | |
| <span class="px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${ | |
| isSuccess | |
| ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' | |
| : 'bg-red-500/10 border-red-500/20 text-red-400' | |
| }"> | |
| ${isSuccess ? 'Thành công' : 'Lỗi SMTP'} | |
| </span> | |
| `; | |
| const errorBlock = (!isSuccess && log.error_message) ? ` | |
| <div class="bg-red-500/10 border border-red-500/20 rounded-2xl p-4 mb-4 text-xs text-red-400"> | |
| <p class="font-bold"><i class="fas fa-exclamation-triangle"></i> Lỗi chi tiết:</p> | |
| <p class="mt-1 font-mono">${log.error_message}</p> | |
| </div> | |
| ` : ''; | |
| container.innerHTML = ` | |
| <div class="space-y-4"> | |
| <div class="flex items-start justify-between"> | |
| <div> | |
| <h3 class="text-sm font-bold text-white leading-tight">${log.subject}</h3> | |
| <p class="text-xs text-gray-400 mt-1">Đến: <span class="font-bold text-primary">${log.to_email}</span></p> | |
| </div> | |
| <div>${statusBadge}</div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4 bg-white/[0.02] border border-white/5 rounded-2xl p-3 text-[10px]"> | |
| <div> | |
| <span class="text-gray-500 block uppercase font-black text-[8px]">Template</span> | |
| <span class="font-mono text-gray-300 font-bold">${log.template_name}</span> | |
| </div> | |
| <div> | |
| <span class="text-gray-500 block uppercase font-black text-[8px]">Thời gian</span> | |
| <span class="text-gray-300 font-bold">${formatTime(log.created_at)}</span> | |
| </div> | |
| </div> | |
| ${errorBlock} | |
| <!-- Body Preview --> | |
| <div class="rounded-2xl overflow-hidden bg-white border border-white/10 h-[350px] relative"> | |
| <iframe class="w-full h-full border-none" srcdoc="${log.body.replace(/"/g, '"')}"></iframe> | |
| </div> | |
| </div> | |
| `; | |
| // Open sheet | |
| drawer.classList.remove('hidden'); | |
| void drawer.offsetWidth; | |
| drawer.classList.add('opacity-100'); | |
| drawer.classList.remove('pointer-events-none'); | |
| panel.classList.remove('translate-x-full', 'translate-y-full'); | |
| } | |
| function closeMobilePreview() { | |
| const drawer = document.getElementById('mobile-preview-drawer'); | |
| const panel = drawer?.querySelector('.pointer-events-auto'); | |
| if (drawer && panel) { | |
| drawer.classList.remove('opacity-100'); | |
| drawer.classList.add('pointer-events-none'); | |
| panel.classList.add('translate-y-full'); | |
| setTimeout(() => { | |
| drawer.classList.add('hidden'); | |
| }, 300); | |
| } | |
| } | |
| function updateViewMode() { | |
| const iframeWrapper = document.getElementById('preview-iframe-wrapper'); | |
| const sourceWrapper = document.getElementById('preview-source-wrapper'); | |
| const btnPrev = document.getElementById('btn-view-preview'); | |
| const btnSrc = document.getElementById('btn-view-source'); | |
| if (!iframeWrapper || !sourceWrapper || !btnPrev || !btnSrc) return; | |
| if (viewMode === 'preview') { | |
| iframeWrapper.classList.remove('hidden'); | |
| sourceWrapper.classList.add('hidden'); | |
| btnPrev.className = 'px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary border border-primary/20'; | |
| btnSrc.className = 'px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-500 hover:text-white border border-transparent'; | |
| } else { | |
| iframeWrapper.classList.add('hidden'); | |
| sourceWrapper.classList.remove('hidden'); | |
| btnPrev.className = 'px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-500 hover:text-white border border-transparent'; | |
| btnSrc.className = 'px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary border border-primary/20'; | |
| } | |
| } | |
| function bindListEvents() { | |
| document.querySelectorAll('.email-row').forEach(row => { | |
| row.onclick = () => { | |
| const id = row.getAttribute('data-id'); | |
| selectLog(id); | |
| }; | |
| }); | |
| } | |
| function init() { | |
| renderList(); | |
| const searchInput = document.getElementById('email-search'); | |
| if (searchInput) { | |
| searchInput.oninput = (e) => { | |
| searchQuery = e.target.value.trim(); | |
| renderList(); | |
| }; | |
| } | |
| // View tabs trigger | |
| const btnPrev = document.getElementById('btn-view-preview'); | |
| const btnSrc = document.getElementById('btn-view-source'); | |
| btnPrev?.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| viewMode = 'preview'; | |
| updateViewMode(); | |
| }); | |
| btnSrc?.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| viewMode = 'source'; | |
| updateViewMode(); | |
| }); | |
| // Mobile close | |
| const btnCloseMobile = document.getElementById('btn-close-mobile-preview'); | |
| btnCloseMobile?.addEventListener('click', closeMobilePreview); | |
| const mobileDrawer = document.getElementById('mobile-preview-drawer'); | |
| mobileDrawer?.addEventListener('click', (e) => { | |
| if (e.target === mobileDrawer) closeMobilePreview(); | |
| }); | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| <style> | |
| .glass { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |
| .glass-sub { | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| /* Custom scrollbar for clean dashboard */ | |
| .scrollbar-thin::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-thumb { | |
| background: rgba(124, 58, 237, 0.2); | |
| border-radius: 99px; | |
| } | |
| .scrollbar-thin::-webkit-scrollbar-thumb:hover { | |
| background: rgba(124, 58, 237, 0.4); | |
| } | |
| </style> | |