Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../../layouts/AdminLayout.astro'; | |
| import { getServers, getLocalMovies } from '../../../lib/localDb.js'; | |
| const localMovies = await getLocalMovies(); | |
| const servers = await getServers(); | |
| const movieData = { | |
| movie: { | |
| name: '', | |
| origin_name: '', | |
| slug: '', | |
| type: 'series', | |
| status: 'ongoing', | |
| year: new Date().getFullYear(), | |
| quality: 'FHD', | |
| lang: 'Vietsub', | |
| poster_url: '', | |
| thumb_url: '', | |
| category: '', | |
| country: '', | |
| director: '', | |
| actor: '', | |
| content: '', | |
| isLocal: true | |
| }, | |
| episodes: [] | |
| }; | |
| const isEditMode = false; | |
| const categoryValue = ''; | |
| const countryValue = ''; | |
| const directorValue = ''; | |
| const actorValue = ''; | |
| --- | |
| <AdminLayout title="Thêm phim mới"> | |
| <div class="mb-10 flex flex-col gap-4 md:flex-row md:items-center md:justify-between border-b border-white/5 pb-8"> | |
| <div> | |
| <div class="flex items-center gap-2 text-xs text-gray-500 mb-2"> | |
| <a href="/admin/phim" class="hover:text-primary transition-colors">Quản lý phim</a> | |
| <i class="fas fa-chevron-right text-[8px]"></i> | |
| <span class="text-white">Thêm phim mới</span> | |
| </div> | |
| <h1 class="text-3xl font-black tracking-tighter text-white">Thêm phim mới</h1> | |
| </div> | |
| <div class="flex gap-4"> | |
| <button id="save-movie-btn" class="flex items-center gap-2 rounded-2xl bg-primary px-6 py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-105 transition-all shadow-[0_0_20px_var(--primary)] active:scale-95"> | |
| <i class="fas fa-save"></i> | |
| Lưu phim & Tập | |
| </button> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-10 lg:grid-cols-3"> | |
| <!-- Cột Trái: Nhập nhanh + Thông tin chung + Tập phim (70%) --> | |
| <div class="lg:col-span-2 space-y-10"> | |
| <!-- NHẬP NHANH DỮ LIỆU TỰ ĐỘNG --> | |
| <div class="glass rounded-[40px] p-8 md:p-10 border border-white/5 space-y-6 relative overflow-hidden group"> | |
| <div class="absolute -inset-px bg-gradient-to-r from-primary/10 to-blue-500/10 rounded-[40px] opacity-30 blur-lg group-hover:opacity-50 transition-opacity"></div> | |
| <div class="relative z-10 space-y-4"> | |
| <h2 class="text-lg font-black uppercase tracking-wider text-white flex items-center gap-2"> | |
| <i class="fas fa-magic text-primary"></i> Nhập nhanh dữ liệu phim | |
| </h2> | |
| <p class="text-xs text-gray-400">Dán đường dẫn chi tiết phim từ <strong>KKPhim/OPhim</strong> hoặc link <strong>Youtube</strong> để hệ thống tự động tải và điền toàn bộ thông tin phim + danh sách tập phim.</p> | |
| <div class="flex flex-col gap-4 sm:flex-row"> | |
| <div class="relative flex-1"> | |
| <input | |
| type="text" | |
| id="quick-url-input" | |
| placeholder="Dán link phim (ví dụ: https://kkphim.vip/phim/tay-du-ky) hoặc link Youtube..." | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all pl-12" | |
| /> | |
| <i class="fas fa-link absolute left-5 top-1/2 -translate-y-1/2 text-gray-500"></i> | |
| </div> | |
| <button | |
| id="btn-quick-import" | |
| class="flex items-center justify-center gap-2 rounded-2xl bg-primary px-8 py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-105 active:scale-95 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100" | |
| data-txatooltip="Nhấn để tự động tải thông tin phim" | |
| > | |
| <i class="fas fa-cloud-download-alt"></i> Lấy dữ liệu | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 1. Thông tin chung --> | |
| <div class="glass rounded-[40px] p-8 md:p-10 border border-white/5 space-y-6"> | |
| <h2 class="text-lg font-black uppercase tracking-wider text-white border-b border-white/5 pb-4">Thông tin cơ bản</h2> | |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tên phim</label> | |
| <input type="text" id="mv-name" value={movieData.movie.name} placeholder="Ví dụ: TPHIMX VIP" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tên gốc (Tiếng Anh/Gốc)</label> | |
| <input type="text" id="mv-origin-name" value={movieData.movie.origin_name} placeholder="Ví dụ: TPHIMX Premium" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Đường dẫn tĩnh (Slug)</label> | |
| <input type="text" id="mv-slug" value={movieData.movie.slug} placeholder="vi-du-tphimx-vip" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all read-only:opacity-50" /> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Loại phim</label> | |
| <select id="mv-type" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all"> | |
| <option value="series" selected={movieData.movie.type === 'series'}>Phim Bộ</option> | |
| <option value="single" selected={movieData.movie.type === 'single'}>Phim Lẻ</option> | |
| <option value="hoathinh" selected={movieData.movie.type === 'hoathinh'}>Hoạt Hình</option> | |
| <option value="tvshows" selected={movieData.movie.type === 'tvshows'}>TV Shows</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Trạng thái</label> | |
| <select id="mv-status" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all"> | |
| <option value="ongoing" selected={movieData.movie.status === 'ongoing'}>Đang chiếu</option> | |
| <option value="completed" selected={movieData.movie.status === 'completed'}>Hoàn thành</option> | |
| <option value="trailer" selected={movieData.movie.status === 'trailer'}>Sắp chiếu</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Poster đứng (URL)</label> | |
| <input type="text" id="mv-poster" value={movieData.movie.poster_url} placeholder="Nhập liên kết ảnh đứng..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Thumb ngang (URL)</label> | |
| <input type="text" id="mv-thumb" value={movieData.movie.thumb_url} placeholder="Nhập liên kết ảnh ngang..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Trailer URL (Youtube hoặc liên kết khác)</label> | |
| <input type="text" id="mv-trailer" value="" placeholder="Ví dụ: https://www.youtube.com/watch?v=..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div class="flex flex-col gap-4 pt-6 pl-2"> | |
| <label class="relative inline-flex items-center cursor-pointer select-none"> | |
| <input type="checkbox" id="mv-only-login" class="sr-only peer" /> | |
| <div class="w-11 h-6 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div> | |
| <span class="ml-3 text-xs font-black uppercase tracking-widest text-gray-300">Yêu cầu Đăng nhập để xem</span> | |
| </label> | |
| <label class="relative inline-flex items-center cursor-pointer select-none"> | |
| <input type="checkbox" id="mv-is-slider" class="sr-only peer" /> | |
| <div class="w-11 h-6 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div> | |
| <span class="ml-3 text-xs font-black uppercase tracking-widest text-gray-300">Ghim lên Slider trang chủ</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-6 sm:grid-cols-3"> | |
| <div class="relative"> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Thể loại (Ngăn cách dấu phẩy)</label> | |
| <input type="text" id="mv-category" value={categoryValue} placeholder="Hành Động, Cổ Trang..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" autocomplete="off" /> | |
| <!-- Custom Glassmorphic Category Dropdown --> | |
| <div id="category-dropdown" class="hidden absolute left-0 right-0 mt-2 z-50 max-h-60 overflow-y-auto rounded-2xl border border-white/10 bg-[#1a1c24]/95 backdrop-blur-xl p-4 shadow-2xl space-y-3"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block border-b border-white/5 pb-2">Chọn nhanh thể loại có sẵn (hoặc tự gõ thêm)</span> | |
| <div class="grid grid-cols-2 gap-2 sm:grid-cols-3" id="category-options-grid"> | |
| <!-- Options generated dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="relative"> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Quốc gia</label> | |
| <input type="text" id="mv-country" value={countryValue} placeholder="Trung Quốc, Mỹ..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" autocomplete="off" /> | |
| <!-- Custom Glassmorphic Country Dropdown --> | |
| <div id="country-dropdown" class="hidden absolute left-0 right-0 mt-2 z-50 max-h-60 overflow-y-auto rounded-2xl border border-white/10 bg-[#1a1c24]/95 backdrop-blur-xl p-4 shadow-2xl space-y-3"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block border-b border-white/5 pb-2">Chọn quốc gia có sẵn (hoặc tự gõ thêm)</span> | |
| <div class="grid grid-cols-2 gap-2" id="country-options-grid"> | |
| <!-- Options generated dynamically --> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Năm phát hành</label> | |
| <input type="number" id="mv-year" value={movieData.movie.year} class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Đạo diễn</label> | |
| <input type="text" id="mv-director" value={directorValue} placeholder="Tên các đạo diễn..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Diễn viên</label> | |
| <input type="text" id="mv-actor" value={actorValue} placeholder="Tên các diễn viên..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tóm tắt nội dung</label> | |
| <textarea id="mv-content" rows="5" placeholder="Nhập tóm tắt giới thiệu nội dung phim..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-5 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all resize-none">{movieData.movie.content}</textarea> | |
| </div> | |
| </div> | |
| <!-- 2. Quản lý các Server & Tập Phim --> | |
| <div class="glass rounded-[40px] p-8 md:p-10 border border-white/5 space-y-6"> | |
| <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-white/5 pb-4"> | |
| <h2 class="text-lg font-black uppercase tracking-wider text-white">Danh sách Server & Tập phim</h2> | |
| <div class="flex flex-wrap gap-2"> | |
| <button id="add-server-btn" class="flex items-center gap-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 px-4 py-2.5 text-xs font-black uppercase tracking-widest text-white transition-all active:scale-95 cursor-pointer"> | |
| <i class="fas fa-server"></i> Thêm Server mới | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Server Container --> | |
| <div id="servers-wrapper" class="space-y-10"> | |
| <!-- Các server sẽ được render động bằng Javascript tại đây --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Cột Phải: Trạng thái, Lưu & Bộ đổi giây nhanh (30%) --> | |
| <div class="space-y-8 lg:sticky lg:top-28"> | |
| <!-- Panel chất lượng & ngôn ngữ --> | |
| <div class="glass rounded-[32px] p-6 border border-white/5 space-y-5"> | |
| <h3 class="text-sm font-black uppercase tracking-widest text-white border-b border-white/5 pb-3">Phân loại chất lượng</h3> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Chất lượng hiển thị</label> | |
| <input type="text" id="mv-quality" value={movieData.movie.quality} placeholder="Ví dụ: FHD, 4K, HD" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Ngôn ngữ / Phụ đề</label> | |
| <input type="text" id="mv-lang" value={movieData.movie.lang} placeholder="Ví dụ: Vietsub, Thuyết Minh" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tập hiện tại hiển thị</label> | |
| <input type="text" id="mv-episode-current" value={movieData.movie.episode_current || 'Tập 1'} placeholder="Ví dụ: Tập 12/12, FHD Vietsub" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tổng số tập dự kiến</label> | |
| <input type="number" id="mv-episode-total" value={movieData.movie.episode_total || ''} placeholder="Ví dụ: 50, 24, 1" min="1" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| </div> | |
| <!-- Đặt lịch chiếu tập tiếp theo --> | |
| <div class="glass rounded-[32px] p-6 border border-white/5 space-y-5"> | |
| <h3 class="text-sm font-black uppercase tracking-widest text-white border-b border-white/5 pb-3"> | |
| <i class="fas fa-calendar-alt text-primary mr-1"></i> Lịch chiếu tập tiếp | |
| </h3> | |
| <!-- Cảnh báo phim hoàn thành --> | |
| <div id="schedule-completed-warn" class="hidden rounded-xl bg-yellow-500/10 border border-yellow-500/20 p-4 text-xs text-yellow-400 font-bold"> | |
| <i class="fas fa-exclamation-triangle mr-1"></i> Phim đã ở trạng thái <strong>"Hoàn thành"</strong>. Lịch chiếu tập tiếp theo thường không cần thiết cho phim đã hoàn thành. | |
| </div> | |
| <p class="text-xs text-gray-500" id="schedule-desc">Đặt ngày giờ phát sóng tập tiếp theo. Thông tin này sẽ hiển thị banner thông báo trên trang chi tiết và xem phim.</p> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Ngày chiếu</label> | |
| <input type="date" id="mv-next-ep-date" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Giờ chiếu</label> | |
| <input type="time" id="mv-next-ep-time" value="20:00" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tên tập sắp chiếu (tuỳ chọn)</label> | |
| <input type="text" id="mv-next-ep-name" placeholder="Ví dụ: Tập 13, Tập cuối..." class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" /> | |
| </div> | |
| <!-- Preview countdown --> | |
| <div id="schedule-preview" class="bg-[#12141d]/85 rounded-2xl p-4 border border-white/5 text-center hidden"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Tập tiếp theo</span> | |
| <span class="text-sm font-bold text-white block mt-1" id="schedule-ep-label">---</span> | |
| <div class="flex items-center justify-center gap-3 mt-3"> | |
| <div class="text-center"> | |
| <span class="text-2xl font-black text-primary block" id="cd-days">00</span> | |
| <span class="text-[8px] font-bold uppercase tracking-widest text-gray-500">Ngày</span> | |
| </div> | |
| <span class="text-gray-600 text-lg font-bold">:</span> | |
| <div class="text-center"> | |
| <span class="text-2xl font-black text-primary block" id="cd-hours">00</span> | |
| <span class="text-[8px] font-bold uppercase tracking-widest text-gray-500">Giờ</span> | |
| </div> | |
| <span class="text-gray-600 text-lg font-bold">:</span> | |
| <div class="text-center"> | |
| <span class="text-2xl font-black text-primary block" id="cd-mins">00</span> | |
| <span class="text-[8px] font-bold uppercase tracking-widest text-gray-500">Phút</span> | |
| </div> | |
| <span class="text-gray-600 text-lg font-bold">:</span> | |
| <div class="text-center"> | |
| <span class="text-2xl font-black text-primary block" id="cd-secs">00</span> | |
| <span class="text-[8px] font-bold uppercase tracking-widest text-gray-500">Giây</span> | |
| </div> | |
| </div> | |
| </div> | |
| <button id="clear-schedule-btn" class="w-full flex items-center justify-center gap-2 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 py-3 text-xs font-black uppercase tracking-widest text-red-400 hover:text-red-300 transition-all active:scale-95 cursor-pointer hidden"> | |
| <i class="fas fa-trash-alt"></i> Xoá lịch chiếu | |
| </button> | |
| </div> | |
| <!-- Máy chuyển đổi giây nhanh Sidebar Widget --> | |
| <div class="glass rounded-[32px] p-6 border border-white/5 space-y-4"> | |
| <h3 class="text-sm font-black uppercase tracking-widest text-white border-b border-white/5 pb-3"> | |
| <i class="fas fa-calculator text-primary mr-1"></i> Máy đổi giây nhanh | |
| </h3> | |
| <p class="text-xs text-gray-500">Nhập giờ, phút, giây để tính nhanh tổng số giây.</p> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Giờ</label> | |
| <input type="number" id="calc-h" value="0" min="0" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-2 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Phút</label> | |
| <input type="number" id="calc-m" value="0" min="0" max="59" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-2 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Giây</label> | |
| <input type="number" id="calc-s" value="0" min="0" max="59" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-2 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| </div> | |
| <div class="bg-[#12141d]/85 rounded-2xl p-4 border border-white/5 text-center"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Kết quả</span> | |
| <span class="text-2xl font-black text-primary mt-1 block" id="calc-result">0 giây</span> | |
| </div> | |
| <button id="calc-copy-btn" class="w-full flex items-center justify-center gap-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 py-3 text-xs font-black uppercase tracking-widest text-white transition-all active:scale-95"> | |
| <i class="fas fa-copy"></i> Sao chép số giây | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- FLOATING EPISODE FORM (DRAWER) --> | |
| <div id="ep-drawer" class="fixed inset-y-0 right-0 z-[150] w-full max-w-lg bg-[#12141d]/98 border-l border-white/10 shadow-[0_0_50px_rgba(0,0,0,0.8)] backdrop-blur-2xl p-8 flex flex-col justify-between transform translate-x-full transition-transform duration-300 pointer-events-none"> | |
| <div class="flex-1 overflow-y-auto space-y-6"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-4"> | |
| <h3 class="text-lg font-black uppercase tracking-wider text-white flex items-center gap-2"> | |
| <i class="fas fa-play-circle text-primary"></i> | |
| <span id="ep-drawer-title">Cấu hình Tập phim</span> | |
| </h3> | |
| <button id="ep-drawer-close" class="h-10 w-10 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-5"> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Tên hiển thị tập (Ví dụ: 1, 2, Full, Thuyết Minh)</label> | |
| <input type="text" id="ep-name" class="w-full bg-[#1e2130] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Đường dẫn tập (Slug - thường tự tạo)</label> | |
| <input type="text" id="ep-slug" class="w-full bg-[#1e2130] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Link HLS (M3U8) - Trình phát Stream</label> | |
| <input type="text" id="ep-link-m3u8" placeholder="https://domain.com/path/index.m3u8" class="w-full bg-[#1e2130] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Link Embed (Nguồn Nhúng Iframe)</label> | |
| <input type="text" id="ep-link-embed" placeholder="https://domain.com/embed/..." class="w-full bg-[#1e2130] border border-white/5 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <!-- Intro & Outro skipping times --> | |
| <div class="border-t border-white/5 pt-4"> | |
| <h4 class="text-xs font-black uppercase tracking-widest text-white mb-4">Cấu hình bỏ qua đoạn phim (giây)</h4> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-2">Intro - Bắt đầu (giây)</label> | |
| <div class="flex gap-2"> | |
| <input type="number" id="ep-intro-start" value="0" min="0" class="w-20 bg-[#1e2130] border border-white/5 rounded-xl px-3 text-sm text-white focus:outline-none text-center" /> | |
| <button class="modal-calc-trigger px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[10px] font-bold text-gray-400 hover:text-white" data-target="ep-intro-start"> | |
| <i class="fas fa-calculator"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-2">Intro - Kết thúc (giây)</label> | |
| <div class="flex gap-2"> | |
| <input type="number" id="ep-intro-end" value="0" min="0" class="w-20 bg-[#1e2130] border border-white/5 rounded-xl px-3 text-sm text-white focus:outline-none text-center" /> | |
| <button class="modal-calc-trigger px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[10px] font-bold text-gray-400 hover:text-white" data-target="ep-intro-end"> | |
| <i class="fas fa-calculator"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4 mt-4"> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-2">Outro - Bắt đầu (giây)</label> | |
| <div class="flex gap-2"> | |
| <input type="number" id="ep-outro-start" value="0" min="0" class="w-20 bg-[#1e2130] border border-white/5 rounded-xl px-3 text-sm text-white focus:outline-none text-center" /> | |
| <button class="modal-calc-trigger px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[10px] font-bold text-gray-400 hover:text-white" data-target="ep-outro-start"> | |
| <i class="fas fa-calculator"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-2">Outro - Kết thúc (giây)</label> | |
| <div class="flex gap-2"> | |
| <input type="number" id="ep-outro-end" value="0" min="0" class="w-20 bg-[#1e2130] border border-white/5 rounded-xl px-3 text-sm text-white focus:outline-none text-center" /> | |
| <button class="modal-calc-trigger px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[10px] font-bold text-gray-400 hover:text-white" data-target="ep-outro-end"> | |
| <i class="fas fa-calculator"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="border-t border-white/5 pt-6 flex gap-4 mt-6"> | |
| <button id="ep-drawer-save" class="flex-1 py-4 rounded-xl bg-primary text-dark text-xs font-black uppercase tracking-widest transition-all hover:scale-[1.02]">Cập nhật tập</button> | |
| </div> | |
| </div> | |
| <!-- MODAL ĐỔI GIÂY NHANH TRỰC TIẾP QUA TXAMODAL --> | |
| <div id="txa-calc-modal-content" class="hidden text-left space-y-4"> | |
| <p class="text-xs text-gray-400">Nhập giờ, phút, giây để đổi sang số giây và điền trực tiếp vào ô input.</p> | |
| <div class="grid grid-cols-3 gap-3"> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Giờ</label> | |
| <input type="number" id="modal-calc-h" value="0" min="0" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-3 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Phút</label> | |
| <input type="number" id="modal-calc-m" value="0" min="0" max="59" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-3 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| <div> | |
| <label class="block text-[9px] font-black uppercase tracking-widest text-gray-500 mb-1 text-center">Giây</label> | |
| <input type="number" id="modal-calc-s" value="0" min="0" max="59" class="w-full bg-[#12141d] border border-white/5 rounded-xl py-3 text-center text-sm text-white focus:outline-none focus:border-primary/50" /> | |
| </div> | |
| </div> | |
| <div class="bg-[#12141d] rounded-2xl p-4 border border-white/5 text-center"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Số giây quy đổi</span> | |
| <span class="text-3xl font-black text-primary mt-1 block" id="modal-calc-result">0 giây</span> | |
| </div> | |
| </div> | |
| </AdminLayout> | |
| <!-- Gắn dữ liệu ban đầu cho Client-side Script --> | |
| <script define:vars={{ initialMovieData: movieData, initialServers: servers, localMovieSlugs: Object.keys(localMovies) }} is:inline> | |
| window.movieData = initialMovieData; | |
| window.initialServers = initialServers || []; | |
| window.localMovieSlugs = localMovieSlugs || []; | |
| </script> | |
| <script> | |
| let activeServerIndex = 0; | |
| let activeEpisodeIndex = 0; | |
| function init() { | |
| const { txamodal, txatoast, initTxaTooltips } = (window as any); | |
| if (!txatoast || !window.movieData) return; | |
| if (initTxaTooltips) initTxaTooltips(); | |
| // Helper trích xuất slug | |
| const extractSlug = (input: string) => { | |
| if (!input) return null; | |
| const trimmed = input.trim(); | |
| if (!trimmed) return null; | |
| try { | |
| if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { | |
| const url = new URL(trimmed); | |
| const pathname = url.pathname; | |
| const parts = pathname.split('/').filter(Boolean); | |
| if (parts.length > 0) { | |
| return parts[parts.length - 1]; | |
| } | |
| } | |
| } catch (e) {} | |
| const parts = trimmed.split('/').filter(Boolean); | |
| if (parts.length > 0) { | |
| return parts[parts.length - 1]; | |
| } | |
| return trimmed; | |
| }; | |
| // Hỗ trợ Nhập nhanh (Quick Auto-Import) cho KKPhim và Youtube | |
| const btnQuickImport = document.getElementById('btn-quick-import') as HTMLButtonElement; | |
| const quickUrlInput = document.getElementById('quick-url-input') as HTMLInputElement; | |
| if (btnQuickImport && quickUrlInput) { | |
| btnQuickImport.onclick = async () => { | |
| const inputVal = quickUrlInput.value.trim(); | |
| if (!inputVal) { | |
| txatoast.error('Vui lòng nhập đường dẫn hoặc slug phim!'); | |
| return; | |
| } | |
| // TH1: Xử lý link Youtube | |
| if (inputVal.includes('youtube.com') || inputVal.includes('youtu.be')) { | |
| let videoId = ''; | |
| try { | |
| if (inputVal.includes('youtu.be/')) { | |
| videoId = inputVal.split('youtu.be/')[1].split(/[?#]/)[0]; | |
| } else if (inputVal.includes('youtube.com/embed/')) { | |
| videoId = inputVal.split('youtube.com/embed/')[1].split(/[?#]/)[0]; | |
| } else { | |
| const urlParams = new URLSearchParams(new URL(inputVal).search); | |
| videoId = urlParams.get('v') || ''; | |
| } | |
| } catch(e){} | |
| if (videoId) { | |
| txatoast.success('Phát hiện liên kết Youtube! Đã tự động điền các thông tin cơ bản.'); | |
| // Tự tạo các thông tin cơ bản | |
| (document.getElementById('mv-name') as HTMLInputElement).value = 'Video Youtube'; | |
| (document.getElementById('mv-origin-name') as HTMLInputElement).value = 'Youtube Stream'; | |
| (document.getElementById('mv-slug') as HTMLInputElement).value = `youtube-${videoId}`; | |
| (document.getElementById('mv-poster') as HTMLInputElement).value = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; | |
| (document.getElementById('mv-thumb') as HTMLInputElement).value = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; | |
| (document.getElementById('mv-content') as HTMLTextAreaElement).value = 'Video phát từ Youtube.'; | |
| (document.getElementById('mv-type') as HTMLSelectElement).value = 'single'; | |
| (document.getElementById('mv-quality') as HTMLInputElement).value = 'FHD'; | |
| (document.getElementById('mv-lang') as HTMLInputElement).value = 'Vietsub'; | |
| (document.getElementById('mv-episode-current') as HTMLInputElement).value = 'Full'; | |
| (document.getElementById('mv-episode-total') as HTMLInputElement).value = '1'; | |
| // Tự động thêm server Youtube phát | |
| window.movieData.episodes = [{ | |
| server_name: 'Youtube', | |
| server_data: [{ | |
| name: 'Full', | |
| slug: 'full', | |
| filename: '', | |
| link_m3u8: '', | |
| link_embed: `https://www.youtube.com/embed/${videoId}`, | |
| time_intro_start: 0, | |
| time_intro_end: 0, | |
| time_outro_start: 0, | |
| time_outro_end: 0 | |
| }] | |
| }]; | |
| renderServers(); | |
| } else { | |
| txatoast.error('Không trích xuất được Video ID từ link Youtube!'); | |
| } | |
| return; | |
| } | |
| // TH2: Xử lý link hoặc slug KKPhim / OPhim | |
| const slug = extractSlug(inputVal); | |
| if (!slug) { | |
| txatoast.error('Đường dẫn hoặc slug phim không hợp lệ!'); | |
| return; | |
| } | |
| btnQuickImport.disabled = true; | |
| const originalText = btnQuickImport.innerHTML; | |
| btnQuickImport.innerHTML = ` Đang tải dữ liệu...`; | |
| try { | |
| txatoast.info('Đang gửi truy vấn lấy thông tin phim từ API...'); | |
| const res = await fetch(`https://phimapi.com/phim/${slug}`); | |
| if (!res.ok) { | |
| throw new Error('Phim không tồn tại trên hệ thống dữ liệu API!'); | |
| } | |
| const data = await res.json(); | |
| if (!data.status || !data.movie) { | |
| throw new Error('Dữ liệu trả về không đúng định dạng!'); | |
| } | |
| const m = data.movie; | |
| // Tự động điền các input trường cơ bản | |
| (document.getElementById('mv-name') as HTMLInputElement).value = m.name || ''; | |
| (document.getElementById('mv-origin-name') as HTMLInputElement).value = m.origin_name || ''; | |
| (document.getElementById('mv-slug') as HTMLInputElement).value = m.slug || ''; | |
| (document.getElementById('mv-poster') as HTMLInputElement).value = m.poster_url || ''; | |
| (document.getElementById('mv-thumb') as HTMLInputElement).value = m.thumb_url || ''; | |
| (document.getElementById('mv-year') as HTMLInputElement).value = m.year || new Date().getFullYear(); | |
| (document.getElementById('mv-content') as HTMLTextAreaElement).value = m.content || ''; | |
| (document.getElementById('mv-quality') as HTMLInputElement).value = m.quality || 'FHD'; | |
| (document.getElementById('mv-lang') as HTMLInputElement).value = m.lang || 'Vietsub'; | |
| (document.getElementById('mv-episode-current') as HTMLInputElement).value = m.episode_current || 'Tập 1'; | |
| (document.getElementById('mv-episode-total') as HTMLInputElement).value = m.episode_total || ''; | |
| // Thể loại, quốc gia, diễn viên, đạo diễn | |
| if (m.category) { | |
| (document.getElementById('mv-category') as HTMLInputElement).value = m.category.map((c: any) => c.name).join(', '); | |
| } | |
| if (m.country) { | |
| (document.getElementById('mv-country') as HTMLInputElement).value = m.country.map((c: any) => c.name).join(', '); | |
| } | |
| if (m.director) { | |
| (document.getElementById('mv-director') as HTMLInputElement).value = m.director.join(', '); | |
| } | |
| if (m.actor) { | |
| (document.getElementById('mv-actor') as HTMLInputElement).value = m.actor.join(', '); | |
| } | |
| // Set select values | |
| if (m.type) (document.getElementById('mv-type') as HTMLSelectElement).value = m.type; | |
| if (m.status) (document.getElementById('mv-status') as HTMLSelectElement).value = m.status; | |
| // Sync list servers & episodes | |
| if (data.episodes && data.episodes.length > 0) { | |
| window.movieData.episodes = data.episodes; | |
| renderServers(); | |
| } | |
| txatoast.success(`Đã cào nhanh toàn bộ thông tin phim "${m.name}"!`); | |
| } catch(err: any) { | |
| txatoast.error(`Không thể cào dữ liệu phim: ${err.message}`); | |
| } finally { | |
| btnQuickImport.disabled = false; | |
| btnQuickImport.innerHTML = originalText; | |
| } | |
| }; | |
| } | |
| // 1. Sidebar Calculator Logic | |
| const calcH = document.getElementById('calc-h') as HTMLInputElement; | |
| const calcM = document.getElementById('calc-m') as HTMLInputElement; | |
| const calcS = document.getElementById('calc-s') as HTMLInputElement; | |
| const calcResult = document.getElementById('calc-result'); | |
| const calcCopyBtn = document.getElementById('calc-copy-btn'); | |
| const updateCalcResult = () => { | |
| const h = parseInt(calcH.value || '0'); | |
| const m = parseInt(calcM.value || '0'); | |
| const s = parseInt(calcS.value || '0'); | |
| const total = (h * 3600) + (m * 60) + s; | |
| if (calcResult) calcResult.innerText = `${total} giây`; | |
| return total; | |
| }; | |
| if (calcH && calcM && calcS) { | |
| calcH.oninput = updateCalcResult; | |
| calcM.oninput = updateCalcResult; | |
| calcS.oninput = updateCalcResult; | |
| } | |
| if (calcCopyBtn) { | |
| calcCopyBtn.onclick = () => { | |
| const total = updateCalcResult(); | |
| navigator.clipboard.writeText(String(total)); | |
| txatoast.success(`Đã sao chép: ${total} giây!`); | |
| }; | |
| } | |
| // 2. Slug Auto Generation on Name Input | |
| const mvName = document.getElementById('mv-name') as HTMLInputElement; | |
| const mvSlug = document.getElementById('mv-slug') as HTMLInputElement; | |
| if (mvName && mvSlug) { | |
| mvName.oninput = () => { | |
| const slug = mvName.value | |
| .toLowerCase() | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .replace(/[đĐ]/g, 'd') | |
| .replace(/[^a-z0-9\s-]/g, '') | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-'); | |
| mvSlug.value = slug; | |
| }; | |
| } | |
| // 3. Render Servers & Episodes list | |
| const serversWrapper = document.getElementById('servers-wrapper'); | |
| const renderServers = () => { | |
| if (!serversWrapper) return; | |
| serversWrapper.innerHTML = ''; | |
| if (window.movieData.episodes.length === 0) { | |
| serversWrapper.innerHTML = ` | |
| <div class="py-12 text-center text-gray-500 border border-dashed border-white/10 rounded-3xl"> | |
| <i class="fas fa-server text-3xl mb-3 block opacity-30"></i> | |
| Chưa có Server và tập phim nào. Hãy dán link cào nhanh ở trên hoặc bấm thêm Server! | |
| </div> | |
| `; | |
| return; | |
| } | |
| window.movieData.episodes.forEach((server: any, sIdx: number) => { | |
| const serverDiv = document.createElement('div'); | |
| serverDiv.className = "glass rounded-3xl p-6 md:p-8 border border-white/5 space-y-6 relative group"; | |
| serverDiv.innerHTML = ` | |
| <!-- Header Server --> | |
| <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-white/5 pb-4"> | |
| <div class="flex items-center gap-4 flex-1"> | |
| <div class="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary text-xs font-black"> | |
| <i class="fas fa-server"></i> | |
| </div> | |
| <div class="flex-1 max-w-xs flex items-center"> | |
| <span class="text-sm font-black text-white tracking-wide uppercase bg-white/5 px-4 py-2.5 rounded-xl border border-white/5">${server.server_name} | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| </div> | |
| <!-- Bulk Action Section --> | |
| <div class="bg-white/[0.01] border border-white/5 rounded-2xl p-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | |
| <div class="flex items-center gap-2.5"> | |
| <input type="checkbox" id="chk-all-${sIdx}" class="chk-server-all h-4 w-4 rounded border-white/10 bg-white/5 text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer" data-idx="${sIdx}" /> | |
| <div class="flex items-center gap-3"> | |
| <select class="bulk-ep-action bg-[#12141d] border border-white/5 rounded-xl px-3 py-2 text-xs font-bold text-gray-400 focus:outline-none" data-idx="${sIdx}"> | |
| <!-- Grid Tập phim --> | |
| <!-- Thêm Tập mới --> | |
| <button class="add-ep-btn w-full py-4 rounded-2xl border border-dashed border-white/10 hover:border-primary/30 flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest text-gray-500 hover:text-primary transition-all active:scale-98" data-idx="${sIdx}"> | |
| `; | |
| const epGrid = serverDiv.querySelector(`#ep-grid-${sIdx}`); | |
| if (epGrid) { | |
| if (server.server_data.length === 0) { | |
| epGrid.innerHTML = ``; | |
| } else { | |
| server.server_data.forEach((ep: any, epIdx: number) => { | |
| const hasM3u8 = !!ep.link_m3u8; | |
| const hasEmbed = !!ep.link_embed; | |
| const hasSkips = (ep.time_intro_start > 0 || ep.time_intro_end > 0 || ep.time_outro_start > 0 || ep.time_outro_end > 0); | |
| const epCard = document.createElement('div'); | |
| epCard.className = "relative group/ep border border-white/5 bg-[#12141d]/50 rounded-2xl p-4 flex flex-col items-center text-center justify-between min-h-[120px] hover:border-primary/30 transition-all"; | |
| epCard.innerHTML = ` | |
| <div class="absolute top-2.5 left-2.5"> | |
| <input type="checkbox" class="chk-ep h-3.5 w-3.5 rounded border-white/10 bg-white/5 text-primary focus:ring-primary focus:ring-offset-0 cursor-pointer" data-sidx="${sIdx}" data-epidx="${epIdx} | |
| <div class="flex items-center gap-1.5 absolute top-2.5 right-2.5"> | |
| ${hasM3u8 ? `<span class="h-2 w-2 rounded-full bg-emerald-500" data-txatooltip="Có luồng HLS/M3U8"></span>` : ''} | |
| ${hasEmbed ? `<span class="h-2 w-2 rounded-full bg-blue-500" data-txatooltip="Có nguồn Nhúng/Embed"></span>` : ''} | |
| ${hasSkips ? `<span class="h-2 w-2 rounded-full bg-amber-500" data-txatooltip="Có cấu hình skip Intro/Outro"></span>` : ''} | |
| <span class="text-sm font-black text-white mt-5 group-hover/ep:text-primary transition-colors block">Tập ${ep.name} | |
| <div class="flex gap-1.5 mt-3 w-full"> | |
| <button class="delete-ep-btn h-6 w-6 rounded-lg bg-red-500/10 hover:bg-red-500 flex items-center justify-center text-red-400 hover:text-white transition-colors" data-sidx="${sIdx}" data-epidx="${epIdx}"><i class="fas fa-trash text-[9px]"></i> | |
| </div> | |
| `; | |
| epGrid.appendChild(epCard); | |
| }); | |
| } | |
| } | |
| serversWrapper.appendChild(serverDiv); | |
| }); | |
| // Gắn sự kiện cho các nút | |
| bindEvents(); | |
| if (initTxaTooltips) initTxaTooltips(); | |
| }; | |
| const bindEvents = () => { | |
| // Xóa Server | |
| document.querySelectorAll('.delete-server-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const idx = parseInt(b.getAttribute('data-idx') || '0'); | |
| txamodal.show({ | |
| title: 'Xóa Server phát', | |
| message: `Bạn muốn xóa toàn bộ server {window.movieData.episodes[idx].server_name} và các tập kèm theo? Thao tác này không thể hoàn tác.`, | |
| type: 'danger', | |
| confirmText: 'Xóa hoàn toàn', | |
| onConfirm: () => { | |
| window.movieData.episodes.splice(idx, 1); | |
| renderServers(); | |
| txatoast.success('Đã xóa Server!'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // Thêm Tập mới thủ công thông minh | |
| document.querySelectorAll('.add-ep-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const sIdx = parseInt(b.getAttribute('data-idx') || '0'); | |
| const currentCount = window.movieData.episodes[sIdx].server_data.length; | |
| const nextNum = currentCount + 1; | |
| txamodal.show({ | |
| title: 'Thêm tập phim thủ công', | |
| message: ` | |
| <div class="space-y-4 text-left"> | |
| <input type="text" id="manual-ep-name" value="${nextNum}" placeholder="Ví dụ: ${nextNum} | |
| `, | |
| type: 'success', | |
| confirmText: 'Xác nhận thêm', | |
| onConfirm: async () => { | |
| const nameVal = (document.getElementById('manual-ep-name') as HTMLInputElement)?.value.trim(); | |
| const urlVal = (document.getElementById('manual-ep-url') as HTMLInputElement)?.value.trim() || ''; | |
| if (!nameVal) { | |
| txatoast.error('Không được để trống Tên tập!'); | |
| return false; | |
| } | |
| let linkM3u8 = ''; | |
| let linkEmbed = ''; | |
| txatoast.info('Đang xử lý phân tích link...'); | |
| if (urlVal.includes('.m3u8')) { | |
| linkM3u8 = urlVal; | |
| } else if (urlVal.includes('embed') || urlVal.includes('iframe') || urlVal.includes('player')) { | |
| linkEmbed = urlVal; | |
| } else { | |
| linkM3u8 = urlVal; | |
| } | |
| const newEp = { | |
| name: nameVal, | |
| slug: `tap-${nameVal.toLowerCase().replace(/\s+/g, '-')}`, | |
| filename: '', | |
| link_m3u8: linkM3u8, | |
| link_embed: linkEmbed, | |
| time_intro_start: 0, | |
| time_intro_end: 0, | |
| time_outro_start: 0, | |
| time_outro_end: 0 | |
| }; | |
| window.movieData.episodes[sIdx].server_data.push(newEp); | |
| renderServers(); | |
| txatoast.success(`Đã thêm Tập ${nameVal}!`); | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // Tick chọn toàn bộ | |
| document.querySelectorAll('.chk-server-all').forEach(chk => { | |
| const c = chk as HTMLInputElement; | |
| c.onchange = () => { | |
| const sIdx = parseInt(c.getAttribute('data-idx') || '0'); | |
| const grid = document.getElementById(`ep-grid-${sIdx}`); | |
| if (grid) { | |
| grid.querySelectorAll('.chk-ep').forEach(item => { | |
| (item as HTMLInputElement).checked = c.checked; | |
| }); | |
| } | |
| }; | |
| }); | |
| // Xóa Tập đơn lẻ | |
| document.querySelectorAll('.delete-ep-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const sIdx = parseInt(b.getAttribute('data-sidx') || '0'); | |
| const epIdx = parseInt(b.getAttribute('data-epidx') || '0'); | |
| const epName = window.movieData.episodes[sIdx].server_data[epIdx].name; | |
| txamodal.show({ | |
| title: 'Xóa tập phim', | |
| message: `Bạn muốn xóa {epName}?`, | |
| type: 'danger', | |
| confirmText: 'Xóa', | |
| onConfirm: () => { | |
| window.movieData.episodes[sIdx].server_data.splice(epIdx, 1); | |
| renderServers(); | |
| txatoast.success('Đã xóa tập phim!'); | |
| return true; | |
| } | |
| }); | |
| }; | |
| }); | |
| // Sửa Tập (Mở Drawer chỉnh sửa) | |
| document.querySelectorAll('.edit-ep-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| activeServerIndex = parseInt(b.getAttribute('data-sidx') || '0'); | |
| activeEpisodeIndex = parseInt(b.getAttribute('data-epidx') || '0'); | |
| openEpisodeDrawer(); | |
| }; | |
| }); | |
| // Áp dụng Bulk Actions trên tập phim | |
| document.querySelectorAll('.apply-bulk-ep-btn').forEach(btn => { | |
| const b = btn as HTMLButtonElement; | |
| b.onclick = () => { | |
| const sIdx = parseInt(b.getAttribute('data-idx') || '0'); | |
| const selectEl = document.querySelector(`.bulk-ep-action[data-idx="${sIdx}"]`) as HTMLSelectElement; | |
| const action = selectEl?.value; | |
| if (!action) { | |
| txatoast.error('Vui lòng chọn một hành động!'); | |
| return; | |
| } | |
| const grid = document.getElementById(`ep-grid-${sIdx}`); | |
| if (!grid) return; | |
| const checkedBoxes = Array.from(grid.querySelectorAll('.chk-ep:checked')) as HTMLInputElement[]; | |
| if (checkedBoxes.length === 0) { | |
| txatoast.error('Chưa tick chọn tập phim nào để áp dụng!'); | |
| return; | |
| } | |
| const selectedIndices = checkedBoxes.map(chk => parseInt(chk.getAttribute('data-epidx') || '0')); | |
| if (action === 'delete') { | |
| txamodal.show({ | |
| title: 'Xóa hàng loạt tập phim', | |
| message: `Bạn chắc chắn muốn xóa {selectedIndices.length} tập đã chọn?`, | |
| type: 'danger', | |
| confirmText: 'Xóa', | |
| onConfirm: () => { | |
| selectedIndices.sort((a, b) => b - a).forEach(idx => { | |
| window.movieData.episodes[sIdx].server_data.splice(idx, 1); | |
| }); | |
| renderServers(); | |
| txatoast.success('Đã xóa hàng loạt tập!'); | |
| return true; | |
| } | |
| }); | |
| } else if (action === 'intro') { | |
| txamodal.show({ | |
| title: 'Cập nhật hàng loạt Intro', | |
| message: `Nhập khoảng thời gian Intro áp dụng cho {selectedIndices.length} tập đã chọn (đơn vị: giây): | |
| <div class="grid grid-cols-2 gap-3 mt-4 text-left"> | |
| `, | |
| type: 'warning', | |
| confirmText: 'Cập nhật', | |
| onConfirm: () => { | |
| const startVal = parseInt((document.getElementById('bulk-start') as HTMLInputElement)?.value || '0'); | |
| const endVal = parseInt((document.getElementById('bulk-end') as HTMLInputElement)?.value || '0'); | |
| selectedIndices.forEach(idx => { | |
| window.movieData.episodes[sIdx].server_data[idx].time_intro_start = startVal; | |
| window.movieData.episodes[sIdx].server_data[idx].time_intro_end = endVal; | |
| }); | |
| renderServers(); | |
| txatoast.success('Đã cập nhật hàng loạt Intro!'); | |
| return true; | |
| } | |
| }); | |
| } else if (action === 'outro') { | |
| txamodal.show({ | |
| title: 'Cập nhật hàng loạt Outro', | |
| message: `Nhập khoảng thời gian Outro áp dụng cho {selectedIndices.length} tập đã chọn (đơn vị: giây): | |
| <div class="grid grid-cols-2 gap-3 mt-4 text-left"> | |
| `, | |
| type: 'warning', | |
| confirmText: 'Cập nhật', | |
| onConfirm: () => { | |
| const startVal = parseInt((document.getElementById('bulk-start') as HTMLInputElement)?.value || '0'); | |
| const endVal = parseInt((document.getElementById('bulk-end') as HTMLInputElement)?.value || '0'); | |
| selectedIndices.forEach(idx => { | |
| window.movieData.episodes[sIdx].server_data[idx].time_outro_start = startVal; | |
| window.movieData.episodes[sIdx].server_data[idx].time_outro_end = endVal; | |
| }); | |
| renderServers(); | |
| txatoast.success('Đã cập nhật hàng loạt Outro!'); | |
| return true; | |
| } | |
| }); | |
| } else if (action === 'domain') { | |
| txamodal.show({ | |
| title: 'Thay thế tên miền hàng loạt', | |
| message: `Nhập tên miền cũ và tên miền mới cần thay thế trong link M3U8 của {selectedIndices.length} tập: | |
| <div class="space-y-3 mt-4 text-left"> | |
| `, | |
| type: 'warning', | |
| confirmText: 'Thay thế', | |
| onConfirm: () => { | |
| const oldDomain = (document.getElementById('domain-old') as HTMLInputElement)?.value.trim(); | |
| const newDomain = (document.getElementById('domain-new') as HTMLInputElement)?.value.trim(); | |
| if (!oldDomain || !newDomain) { | |
| txatoast.error('Không được để trống tên miền!'); | |
| return false; | |
| } | |
| selectedIndices.forEach(idx => { | |
| const ep = window.movieData.episodes[sIdx].server_data[idx]; | |
| if (ep.link_m3u8) ep.link_m3u8 = ep.link_m3u8.replace(oldDomain, newDomain); | |
| if (ep.link_embed) ep.link_embed = ep.link_embed.replace(oldDomain, newDomain); | |
| }); | |
| renderServers(); | |
| txatoast.success('Đã thay thế hàng loạt tên miền!'); | |
| return true; | |
| } | |
| }); | |
| } | |
| }; | |
| }); | |
| }; | |
| // 4. Episode Drawer Editing Control | |
| const drawer = document.getElementById('ep-drawer'); | |
| const drTitle = document.getElementById('ep-drawer-title'); | |
| const drClose = document.getElementById('ep-drawer-close'); | |
| const drSave = document.getElementById('ep-drawer-save'); | |
| const epNameInput = document.getElementById('ep-name') as HTMLInputElement; | |
| const epSlugInput = document.getElementById('ep-slug') as HTMLInputElement; | |
| const epM3u8Input = document.getElementById('ep-link-m3u8') as HTMLInputElement; | |
| const epEmbedInput = document.getElementById('ep-link-embed') as HTMLInputElement; | |
| const epIntroStart = document.getElementById('ep-intro-start') as HTMLInputElement; | |
| const epIntroEnd = document.getElementById('ep-intro-end') as HTMLInputElement; | |
| const epOutroStart = document.getElementById('ep-outro-start') as HTMLInputElement; | |
| const epOutroEnd = document.getElementById('ep-outro-end') as HTMLInputElement; | |
| const openEpisodeDrawer = () => { | |
| if (!drawer) return; | |
| const server = window.movieData.episodes[activeServerIndex]; | |
| const ep = server.server_data[activeEpisodeIndex]; | |
| if (drTitle) drTitle.innerText = `Sửa: Server ${server.server_name} - Tập ${ep.name}`; | |
| epNameInput.value = ep.name; | |
| epSlugInput.value = ep.slug; | |
| epM3u8Input.value = ep.link_m3u8 || ''; | |
| epEmbedInput.value = ep.link_embed || ''; | |
| epIntroStart.value = String(ep.time_intro_start || 0); | |
| epIntroEnd.value = String(ep.time_intro_end || 0); | |
| epOutroStart.value = String(ep.time_outro_start || 0); | |
| epOutroEnd.value = String(ep.time_outro_end || 0); | |
| drawer.classList.remove('translate-x-full', 'pointer-events-none'); | |
| drawer.classList.add('translate-x-0', 'pointer-events-auto'); | |
| }; | |
| const closeEpisodeDrawer = () => { | |
| if (drawer) { | |
| drawer.classList.add('translate-x-full', 'pointer-events-none'); | |
| drawer.classList.remove('translate-x-0', 'pointer-events-auto'); | |
| } | |
| }; | |
| if (drClose) drClose.onclick = closeEpisodeDrawer; | |
| // Save Episode edits | |
| if (drSave) { | |
| drSave.onclick = () => { | |
| const server = window.movieData.episodes[activeServerIndex]; | |
| const ep = server.server_data[activeEpisodeIndex]; | |
| ep.name = epNameInput.value.trim() || ep.name; | |
| ep.slug = epSlugInput.value.trim() || ep.slug; | |
| ep.link_m3u8 = epM3u8Input.value.trim(); | |
| ep.link_embed = epEmbedInput.value.trim(); | |
| ep.time_intro_start = parseInt(epIntroStart.value || '0'); | |
| ep.time_intro_end = parseInt(epIntroEnd.value || '0'); | |
| ep.time_outro_start = parseInt(epOutroStart.value || '0'); | |
| ep.time_outro_end = parseInt(epOutroEnd.value || '0'); | |
| closeEpisodeDrawer(); | |
| renderServers(); | |
| txatoast.success(`Đã cập nhật tập ${ep.name}!`); | |
| }; | |
| } | |
| // Direct Modal converter trigger | |
| document.addEventListener('click', (e) => { | |
| const target = e.target as HTMLElement; | |
| const btn = target.closest('.modal-calc-trigger'); | |
| if (!btn) return; | |
| const inputId = btn.getAttribute('data-target') || ''; | |
| const targetInput = document.getElementById(inputId) as HTMLInputElement; | |
| if (!targetInput) return; | |
| const modalContent = document.getElementById('txa-calc-modal-content'); | |
| if (!modalContent) return; | |
| const mCalcH = document.getElementById('modal-calc-h') as HTMLInputElement; | |
| const mCalcM = document.getElementById('modal-calc-m') as HTMLInputElement; | |
| const mCalcS = document.getElementById('modal-calc-s') as HTMLInputElement; | |
| const mCalcResult = document.getElementById('modal-calc-result'); | |
| const updateModalCalc = () => { | |
| const h = parseInt(mCalcH.value || '0'); | |
| const m = parseInt(mCalcM.value || '0'); | |
| const s = parseInt(mCalcS.value || '0'); | |
| const total = (h * 3600) + (m * 60) + s; | |
| if (mCalcResult) mCalcResult.innerText = `${total} giây`; | |
| return total; | |
| }; | |
| if (mCalcH && mCalcM && mCalcS) { | |
| mCalcH.value = '0'; | |
| mCalcM.value = '0'; | |
| mCalcS.value = '0'; | |
| mCalcH.oninput = updateModalCalc; | |
| mCalcM.oninput = updateModalCalc; | |
| mCalcS.oninput = updateModalCalc; | |
| updateModalCalc(); | |
| } | |
| txamodal.show({ | |
| title: 'Công cụ tính số giây', | |
| message: modalContent.innerHTML, | |
| type: 'warning', | |
| confirmText: 'Áp dụng số giây', | |
| onConfirm: () => { | |
| const activeH = document.getElementById('modal-calc-h') as HTMLInputElement; | |
| const activeM = document.getElementById('modal-calc-m') as HTMLInputElement; | |
| const activeS = document.getElementById('modal-calc-s') as HTMLInputElement; | |
| const h = parseInt(activeH?.value || '0'); | |
| const m = parseInt(activeM?.value || '0'); | |
| const s = parseInt(activeS?.value || '0'); | |
| const total = (h * 3600) + (m * 60) + s; | |
| targetInput.value = String(total); | |
| txatoast.success(`Đã áp dụng: ${total} giây!`); | |
| return true; | |
| } | |
| }); | |
| }); | |
| // Add Server control | |
| const addServerBtn = document.getElementById('add-server-btn'); | |
| if (addServerBtn) { | |
| addServerBtn.onclick = () => { | |
| const serversList = (window as any).initialServers || []; | |
| if (serversList.length === 0) { | |
| txamodal.show({ | |
| title: 'Thêm Server mới', | |
| message: ` | |
| `, | |
| type: 'warning', | |
| confirmText: 'Đi tới Quản lý Server', | |
| onConfirm: () => { | |
| window.location.href = '/admin/server'; | |
| return true; | |
| } | |
| }); | |
| return; | |
| } | |
| const serverOptions = serversList.map((s: any) => `{s.name}`).join(''); | |
| txamodal.show({ | |
| title: 'Thêm Server mới', | |
| message: `Chọn máy chủ phát: | |
| <select id="new-sv-name" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-3.5 mt-2 text-sm text-white focus:outline-none focus:border-primary/50"> | |
| ${serverOptions} | |
| `, | |
| type: 'success', | |
| confirmText: 'Thêm Server', | |
| onConfirm: () => { | |
| const nameVal = (document.getElementById('new-sv-name') as HTMLSelectElement)?.value || 'Server'; | |
| const exists = window.movieData.episodes.some((s: any) => s.server_name === nameVal); | |
| if (exists) { | |
| txatoast.warning(`Phim này đã có Server ${nameVal}!`); | |
| return false; | |
| } | |
| window.movieData.episodes.push({ | |
| server_name: nameVal, | |
| server_data: [] | |
| }); | |
| renderServers(); | |
| txatoast.success(`Đã thêm Server ${nameVal}!`); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Save Movie Action | |
| const saveMovieBtn = document.getElementById('save-movie-btn'); | |
| if (saveMovieBtn) { | |
| saveMovieBtn.onclick = async () => { | |
| const name = (document.getElementById('mv-name') as HTMLInputElement)?.value.trim(); | |
| const origin_name = (document.getElementById('mv-origin-name') as HTMLInputElement)?.value.trim(); | |
| const slug = (document.getElementById('mv-slug') as HTMLInputElement)?.value.trim(); | |
| if (!name || !slug) { | |
| txatoast.error('Không được để trống Tên phim và Slug!'); | |
| return; | |
| } | |
| const localSlugs = (window as any).localMovieSlugs || []; | |
| if (localSlugs.includes(slug)) { | |
| txatoast.error('Đường dẫn (Slug) này đã tồn tại trong hệ thống!'); | |
| return; | |
| } | |
| window.movieData.movie.name = name; | |
| window.movieData.movie.origin_name = origin_name; | |
| window.movieData.movie.slug = slug; | |
| window.movieData.movie.type = (document.getElementById('mv-type') as HTMLSelectElement).value; | |
| window.movieData.movie.status = (document.getElementById('mv-status') as HTMLSelectElement).value; | |
| window.movieData.movie.poster_url = (document.getElementById('mv-poster') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.thumb_url = (document.getElementById('mv-thumb') as HTMLInputElement).value.trim(); | |
| const slugify = (text: string) => { | |
| return text.toString().toLowerCase().trim() | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .replace(/[đĐ]/g, 'd') | |
| .replace(/[^a-z0-9 -]/g, '') | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-'); | |
| }; | |
| const rawCat = (document.getElementById('mv-category') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.category = rawCat.split(',') | |
| .map(v => v.trim()) | |
| .filter(Boolean) | |
| .map(name => ({ | |
| name, | |
| slug: slugify(name) | |
| })); | |
| const rawCountry = (document.getElementById('mv-country') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.country = rawCountry.split(',') | |
| .map(v => v.trim()) | |
| .filter(Boolean) | |
| .map(name => ({ | |
| name, | |
| slug: slugify(name) | |
| })); | |
| window.movieData.movie.year = parseInt((document.getElementById('mv-year') as HTMLInputElement).value || '2026'); | |
| window.movieData.movie.director = (document.getElementById('mv-director') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.actor = (document.getElementById('mv-actor') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.content = (document.getElementById('mv-content') as HTMLTextAreaElement).value.trim(); | |
| window.movieData.movie.quality = (document.getElementById('mv-quality') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.lang = (document.getElementById('mv-lang') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.episode_current = (document.getElementById('mv-episode-current') as HTMLInputElement).value.trim(); | |
| window.movieData.movie.episode_total = parseInt((document.getElementById('mv-episode-total') as HTMLInputElement).value) || null; | |
| window.movieData.movie.trailer_url = (document.getElementById('mv-trailer') as HTMLInputElement).value.trim() || ''; | |
| window.movieData.movie.only_login = (document.getElementById('mv-only-login') as HTMLInputElement).checked; | |
| window.movieData.movie.is_slider = (document.getElementById('mv-is-slider') as HTMLInputElement).checked; | |
| window.movieData.movie.require_login = (document.getElementById('mv-only-login') as HTMLInputElement).checked; | |
| // Schedule next episode | |
| const schedDate = (document.getElementById('mv-next-ep-date') as HTMLInputElement).value; | |
| const schedTime = (document.getElementById('mv-next-ep-time') as HTMLInputElement).value || '20:00'; | |
| const schedName = (document.getElementById('mv-next-ep-name') as HTMLInputElement).value.trim(); | |
| if (schedDate) { | |
| window.movieData.movie.next_episode_time = new Date(`${schedDate}T${schedTime}:00`).toISOString(); | |
| window.movieData.movie.next_episode_name = schedName || ''; | |
| } else { | |
| window.movieData.movie.next_episode_time = null; | |
| window.movieData.movie.next_episode_name = ''; | |
| } | |
| txatoast.info('Đang lưu thông tin phim...'); | |
| try { | |
| const res = await fetch('/api/admin/movie/save', { | |
| method: 'POST', | |
| body: JSON.stringify({ | |
| slug: window.movieData.movie.slug, | |
| movie: window.movieData.movie, | |
| episodes: window.movieData.episodes | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| txatoast.success(data.message); | |
| setTimeout(() => { | |
| window.location.href = '/admin/phim'; | |
| }, 1500); | |
| } else { | |
| txatoast.error(data.error); | |
| } | |
| } catch (e) { | |
| txatoast.error('Lỗi kết nối khi lưu phim!'); | |
| } | |
| }; | |
| } | |
| // Initial Render | |
| renderServers(); | |
| // === STATUS → SCHEDULE WARNING LOGIC === | |
| const statusSelect = document.getElementById('mv-status') as HTMLSelectElement; | |
| const completedWarn = document.getElementById('schedule-completed-warn'); | |
| function checkStatusForSchedule() { | |
| if (statusSelect?.value === 'completed') { | |
| completedWarn?.classList.remove('hidden'); | |
| } else { | |
| completedWarn?.classList.add('hidden'); | |
| } | |
| } | |
| statusSelect?.addEventListener('change', checkStatusForSchedule); | |
| checkStatusForSchedule(); | |
| // === SCHEDULE COUNTDOWN LOGIC === | |
| const schedDateInput = document.getElementById('mv-next-ep-date') as HTMLInputElement; | |
| const schedTimeInput = document.getElementById('mv-next-ep-time') as HTMLInputElement; | |
| const schedNameInput = document.getElementById('mv-next-ep-name') as HTMLInputElement; | |
| const schedPreview = document.getElementById('schedule-preview'); | |
| const schedLabel = document.getElementById('schedule-ep-label'); | |
| const clearSchedBtn = document.getElementById('clear-schedule-btn'); | |
| let countdownInterval: any = null; | |
| function updateCountdown() { | |
| const dateVal = schedDateInput?.value; | |
| const timeVal = schedTimeInput?.value || '20:00'; | |
| if (!dateVal) { | |
| schedPreview?.classList.add('hidden'); | |
| clearSchedBtn?.classList.add('hidden'); | |
| if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } | |
| return; | |
| } | |
| const target = new Date(`${dateVal}T${timeVal}:00`); | |
| if (isNaN(target.getTime())) return; | |
| const epName = schedNameInput?.value || 'Chưa đặt tên'; | |
| if (schedLabel) schedLabel.textContent = epName; | |
| schedPreview?.classList.remove('hidden'); | |
| clearSchedBtn?.classList.remove('hidden'); | |
| function tick() { | |
| const now = new Date(); | |
| const diff = target.getTime() - now.getTime(); | |
| if (diff <= 0) { | |
| document.getElementById('cd-days')!.textContent = '00'; | |
| document.getElementById('cd-hours')!.textContent = '00'; | |
| document.getElementById('cd-mins')!.textContent = '00'; | |
| document.getElementById('cd-secs')!.textContent = '00'; | |
| return; | |
| } | |
| const days = Math.floor(diff / 86400000); | |
| const hours = Math.floor((diff % 86400000) / 3600000); | |
| const mins = Math.floor((diff % 3600000) / 60000); | |
| const secs = Math.floor((diff % 60000) / 1000); | |
| document.getElementById('cd-days')!.textContent = String(days).padStart(2, '0'); | |
| document.getElementById('cd-hours')!.textContent = String(hours).padStart(2, '0'); | |
| document.getElementById('cd-mins')!.textContent = String(mins).padStart(2, '0'); | |
| document.getElementById('cd-secs')!.textContent = String(secs).padStart(2, '0'); | |
| } | |
| tick(); | |
| if (countdownInterval) clearInterval(countdownInterval); | |
| countdownInterval = setInterval(tick, 1000); | |
| } | |
| schedDateInput?.addEventListener('change', updateCountdown); | |
| schedTimeInput?.addEventListener('change', updateCountdown); | |
| schedNameInput?.addEventListener('input', () => { | |
| if (schedLabel) schedLabel.textContent = schedNameInput.value || 'Chưa đặt tên'; | |
| }); | |
| clearSchedBtn?.addEventListener('click', () => { | |
| schedDateInput.value = ''; | |
| schedTimeInput.value = '20:00'; | |
| schedNameInput.value = ''; | |
| schedPreview?.classList.add('hidden'); | |
| clearSchedBtn?.classList.add('hidden'); | |
| if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } | |
| txatoast.success('Đã xoá lịch chiếu tập tiếp theo!'); | |
| }); | |
| updateCountdown(); | |
| // === DYNAMIC PRESET CATEGORIES & COUNTRIES DROPDOWNS === | |
| const PRESETS_CATEGORIES = [ | |
| "Hành Động", "Cổ Trang", "Chiến Tranh", "Viễn Tưởng", "Kinh Dị", | |
| "Hài Hước", "Tình Cảm", "Tâm Lý", "Hình Sự", "Võ Thuật", | |
| "Phiêu Lưu", "Khoa Học", "Thần Thoại", "Chính Kịch", "Gia Đình", | |
| "Học Đường", "Thể Thao", "Âm Nhạc", "Bí Ẩn", "Hoạt Hinh", "Tài Liệu" | |
| ]; | |
| const PRESETS_COUNTRIES = [ | |
| "Trung Quốc", "Mỹ", "Hàn Quốc", "Nhật Bản", "Hồng Kông", | |
| "Đài Loan", "Thái Lan", "Việt Nam", "Ấn Độ", "Pháp", | |
| "Anh", "Đức", "Tây Ban Nha", "Canada", "Nga", "Úc" | |
| ]; | |
| const inputCategory = document.getElementById('mv-category') as HTMLInputElement; | |
| const dropdownCategory = document.getElementById('category-dropdown'); | |
| const gridCategory = document.getElementById('category-options-grid'); | |
| const inputCountry = document.getElementById('mv-country') as HTMLInputElement; | |
| const dropdownCountry = document.getElementById('country-dropdown'); | |
| const gridCountry = document.getElementById('country-options-grid'); | |
| const renderCategoryOptions = () => { | |
| if (!gridCategory || !inputCategory) return; | |
| const currentVals = inputCategory.value.split(',') | |
| .map(v => v.trim()) | |
| .filter(Boolean); | |
| gridCategory.innerHTML = ''; | |
| PRESETS_CATEGORIES.forEach(cat => { | |
| const isSelected = currentVals.includes(cat); | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = `flex items-center justify-between px-3 py-2.5 rounded-xl text-[11px] font-bold transition-all border text-left ${ | |
| isSelected | |
| ? 'bg-primary/20 text-primary border-primary/30 shadow-[0_0_10px_rgba(124,58,237,0.1)]' | |
| : 'bg-white/5 text-gray-300 border-white/5 hover:bg-white/10 hover:text-white' | |
| }`; | |
| btn.innerHTML = ` | |
| <span class="truncate">${cat} | |
| ${isSelected ? '<i class="fas fa-check text-[9px] shrink-0 ml-1 text-primary"></i>' : ''} | |
| `; | |
| btn.onclick = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| let vals = inputCategory.value.split(',') | |
| .map(v => v.trim()) | |
| .filter(Boolean); | |
| if (vals.includes(cat)) { | |
| vals = vals.filter(v => v !== cat); | |
| } else { | |
| vals.push(cat); | |
| } | |
| inputCategory.value = vals.join(', '); | |
| renderCategoryOptions(); | |
| inputCategory.dispatchEvent(new Event('input')); | |
| }; | |
| gridCategory.appendChild(btn); | |
| }); | |
| }; | |
| if (inputCategory && dropdownCategory) { | |
| inputCategory.addEventListener('focus', () => { | |
| dropdownCategory.classList.remove('hidden'); | |
| if (dropdownCountry) dropdownCountry.classList.add('hidden'); | |
| renderCategoryOptions(); | |
| }); | |
| inputCategory.addEventListener('input', renderCategoryOptions); | |
| } | |
| const renderCountryOptions = () => { | |
| if (!gridCountry || !inputCountry) return; | |
| const currentVal = inputCountry.value.trim(); | |
| gridCountry.innerHTML = ''; | |
| PRESETS_COUNTRIES.forEach(country => { | |
| const isSelected = currentVal === country; | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = `flex items-center justify-between px-3 py-2.5 rounded-xl text-[11px] font-bold transition-all border text-left ${ | |
| isSelected | |
| ? 'bg-primary/20 text-primary border-primary/30 shadow-[0_0_10px_rgba(124,58,237,0.1)]' | |
| : 'bg-white/5 text-gray-300 border-white/5 hover:bg-white/10 hover:text-white' | |
| }`; | |
| btn.innerHTML = ` | |
| <span class="truncate">${country} | |
| ${isSelected ? '<i class="fas fa-check text-[9px] shrink-0 ml-1 text-primary"></i>' : ''} | |
| `; | |
| btn.onclick = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| inputCountry.value = country; | |
| renderCountryOptions(); | |
| dropdownCountry?.classList.add('hidden'); | |
| inputCountry.dispatchEvent(new Event('input')); | |
| }; | |
| gridCountry.appendChild(btn); | |
| }); | |
| }; | |
| if (inputCountry && dropdownCountry) { | |
| inputCountry.addEventListener('focus', () => { | |
| dropdownCountry.classList.remove('hidden'); | |
| if (dropdownCategory) dropdownCategory.classList.add('hidden'); | |
| renderCountryOptions(); | |
| }); | |
| inputCountry.addEventListener('input', renderCountryOptions); | |
| } | |
| document.addEventListener('click', (e) => { | |
| const target = e.target; | |
| if (inputCategory && dropdownCategory && !inputCategory.contains(target) && !dropdownCategory.contains(target)) { | |
| dropdownCategory.classList.add('hidden'); | |
| } | |
| if (inputCountry && dropdownCountry && !inputCountry.contains(target) && !dropdownCountry.contains(target)) { | |
| dropdownCountry.classList.add('hidden'); | |
| } | |
| }); | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |