ui: modernize UI to Mainframe design system

This commit is contained in:
hiep200311
2026-06-17 16:58:50 +07:00
parent ba68e5c15c
commit ff47d331ae
13 changed files with 2292 additions and 1822 deletions
@@ -0,0 +1,436 @@
/* ═══════════════════════════════════════════════════════════
scraperZLibraryMain.css · Mainframe Design System v3
═══════════════════════════════════════════════════════════ */
:root {
--font-heading: 'HelveticaNowDisplay-Medium','Helvetica Neue',Arial,sans-serif;
--font-body: 'HelveticaNowDisplayW01-Rg','Helvetica Neue',Arial,sans-serif;
--ink: #0a0a0a;
--ink-70: rgba(10,10,10,0.70);
--ink-45: rgba(10,10,10,0.45);
--ink-20: rgba(10,10,10,0.20);
--ink-10: rgba(10,10,10,0.10);
--ink-06: rgba(10,10,10,0.06);
--ink-03: rgba(10,10,10,0.03);
--surface: #f4f3ef;
--surface-2: #eeecea;
--card: #ffffff;
--radius-pill: 9999px;
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 12px;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.06);
--shadow-sm: 0 2px 10px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 6px 28px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.05);
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.8);
}
@keyframes fade-up {
from { opacity:0; transform:translateY(14px); }
to { opacity:1; transform:none; }
}
@keyframes spin { to { transform:rotate(360deg); } }
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
html {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
background: var(--surface);
color: var(--ink);
}
body {
min-height: 100vh;
background: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.028'/%3E%3C/svg%3E");
padding: 0 28px 100px;
font-family: var(--font-body);
}
/* ─── Layout ─────────────────────────────────────────────── */
.container {
max-width: 1260px;
margin: 0 auto;
animation: fade-up .65s cubic-bezier(.22,1,.36,1) backwards;
animation-delay: .04s;
}
/* ─── Page Header ────────────────────────────────────────── */
.header {
padding: 42px 0 28px;
border-bottom: 1px solid var(--ink-10);
margin-bottom: 0;
}
/* Eyebrow */
.header::before {
content: '✦ Z-Library Search';
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--ink-45);
margin-bottom: 14px;
padding: 6px 14px;
background: var(--card);
border: 1px solid var(--ink-10);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-xs);
}
.header h1 {
font-family: var(--font-heading);
font-size: clamp(30px, 5.5vw, 50px);
font-weight: 500;
letter-spacing: -0.055em;
line-height: 1.0;
color: var(--ink);
margin-bottom: 8px;
}
.header p {
font-size: 14px;
color: var(--ink-45);
line-height: 1.5;
}
/* ─── Search Section ─────────────────────────────────────── */
.search-section {
padding: 26px 0;
border-bottom: 1px solid var(--ink-06);
}
.search-form {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group { flex:1; min-width:200px; }
.form-group label {
display: block;
margin-bottom: 7px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--ink-45);
}
.form-group input {
width: 100%;
padding: 11px 18px;
border: 1px solid var(--ink-10);
border-radius: var(--radius-pill);
font-size: 14px;
font-family: var(--font-body);
background: var(--card);
color: var(--ink);
box-shadow: var(--shadow-xs), var(--shadow-inset);
transition: border-color .2s, box-shadow .2s;
-webkit-appearance: none;
}
.form-group input:focus {
outline: none;
border-color: var(--ink-45);
box-shadow: 0 0 0 3px rgba(10,10,10,.07), var(--shadow-inset);
}
.form-group input::placeholder { color: var(--ink-20); }
.form-group small {
display: block;
margin-top: 6px;
font-size: 11px;
color: var(--ink-45);
padding-left: 10px;
}
/* ─── Checkbox pills ─────────────────────────────────────── */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 16px;
}
.checkbox-group label {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
letter-spacing: -0.01em;
color: var(--ink-70);
background: var(--card);
border: 1px solid var(--ink-10);
border-radius: var(--radius-pill);
padding: 6px 16px;
cursor: pointer;
box-shadow: var(--shadow-xs), var(--shadow-inset);
user-select: none;
transition: background .18s, color .18s, border-color .18s, box-shadow .18s;
}
.checkbox-group label:hover {
background: var(--ink);
color: #fff;
border-color: var(--ink);
box-shadow: var(--shadow-sm);
}
.checkbox-group input[type="checkbox"] {
accent-color: var(--ink);
width: 13px; height: 13px; cursor: pointer;
}
/* ─── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 22px;
border-radius: var(--radius-pill);
font-size: 13px;
font-family: var(--font-body);
letter-spacing: -0.01em;
cursor: pointer;
border: 1px solid var(--ink-10);
text-decoration: none;
white-space: nowrap;
background: var(--card);
color: var(--ink);
box-shadow: var(--shadow-xs), var(--shadow-inset);
margin: 0 5px 5px 0;
line-height: 1;
position: relative;
overflow: hidden;
transition: background .2s, color .2s, border-color .2s, box-shadow .2s,
transform .18s cubic-bezier(.22,1,.36,1);
}
.btn:hover:not(:disabled) {
background: var(--ink);
color: #fff;
border-color: var(--ink);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.btn:active:not(:disabled) { transform:scale(.97) translateY(0); box-shadow:var(--shadow-xs); }
.btn:disabled { opacity:.35; cursor:not-allowed; }
.btn-primary { background:var(--ink); color:#fff; border-color:var(--ink); box-shadow:var(--shadow-sm); }
.btn-primary:hover:not(:disabled) { background:#1a1a1a; }
.btn-download { background:var(--ink); color:#fff; border-color:var(--ink); box-shadow:var(--shadow-sm); }
.btn-download:hover:not(:disabled) { background:#1a1a1a; }
/* ─── Results Section ────────────────────────────────────── */
.results-section { padding: 26px 0; }
/* ─── Stats Bar — horizontal card row ───────────────────── */
.stats-bar {
display: flex;
background: var(--card);
border: 1px solid var(--ink-10);
border-radius: var(--radius-xl);
margin-bottom: 26px;
overflow: hidden;
box-shadow: var(--shadow-sm), var(--shadow-inset);
flex-wrap: wrap;
}
.stat-item {
flex: 1;
min-width: 120px;
padding: 22px 26px;
border-right: 1px solid var(--ink-06);
position: relative;
}
.stat-item:last-child { border-right: none; }
.stat-item::before {
content: '';
position: absolute;
top: 0; left: 26px; right: 26px;
height: 1.5px;
background: linear-gradient(90deg, transparent 0%, var(--ink-10) 50%, transparent 100%);
}
.stat-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--ink-45);
margin-bottom: 6px;
}
.stat-value {
font-family: var(--font-heading);
font-size: clamp(24px, 3.5vw, 34px);
font-weight: 500;
letter-spacing: -0.05em;
color: var(--ink);
line-height: 1.05;
}
/* ─── Books Grid ─────────────────────────────────────────── */
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
gap: 1px;
background: var(--ink-10);
border: 1px solid var(--ink-10);
border-radius: var(--radius-xl);
overflow: hidden;
margin-top: 18px;
box-shadow: var(--shadow-sm);
}
.book-card {
background: var(--card);
padding: 22px 20px 18px;
position: relative;
transition: background .18s;
}
.book-card:hover { background: #fdfcf9; }
.book-card.selected {
background: rgba(10,10,10,0.025);
box-shadow: inset 3px 0 0 var(--ink);
}
.book-checkbox {
position: absolute;
top: 18px; right: 18px;
width: 15px; height: 15px;
accent-color: var(--ink);
cursor: pointer;
}
.book-title {
font-size: 14px;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--ink);
margin-bottom: 4px;
line-height: 1.4;
padding-right: 26px;
}
.book-authors {
font-size: 12px;
color: var(--ink-45);
margin-bottom: 14px;
}
.book-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 10px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--ink-06);
}
.detail-item { font-size: 11px; }
.detail-label {
color: var(--ink-45);
text-transform: uppercase;
font-size: 9.5px;
letter-spacing: .07em;
font-weight: 700;
margin-bottom: 1px;
}
.detail-value { color: var(--ink-70); }
.book-link {
display: inline-block;
font-size: 12px;
color: var(--ink);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: var(--ink-20);
transition: text-decoration-color .18s;
}
.book-link:hover { text-decoration-color: var(--ink); }
/* ─── Loading ─────────────────────────────────────────────── */
.loading { text-align:center; padding:80px 20px; display:none; }
.spinner {
width:26px; height:26px;
border:1.5px solid var(--ink-10);
border-top-color: var(--ink);
border-radius:50%;
animation:spin .7s linear infinite;
margin:0 auto 16px;
}
.loading p { font-size:13px; color:var(--ink-45); }
/* ─── Misc ───────────────────────────────────────────────── */
.progress-container { margin-top:12px; }
.progress-text { font-size:11px; color:var(--ink-45); margin-top:5px; }
.error-message {
background:var(--card);
border:1px solid var(--ink-10);
color:var(--ink-70);
padding:12px 18px;
border-radius:var(--radius-md);
margin:14px 0;
font-size:13px;
display:none;
box-shadow:var(--shadow-xs);
}
.empty-state { text-align:center; padding:80px 24px; color:var(--ink-45); }
.empty-state h3 {
font-family:var(--font-heading);
font-size:20px;
font-weight:500;
letter-spacing:-0.04em;
color:var(--ink);
margin-bottom:8px;
}
.empty-state p { font-size:14px; }
.download-section { display:flex; gap:6px; flex-wrap:wrap; margin:18px 0; }
.selection-bar {
display:flex;
align-items:center;
justify-content:space-between;
padding:12px 0;
border-bottom:1px solid var(--ink-06);
margin-bottom:14px;
flex-wrap:wrap;
gap:10px;
}
.selection-controls { display:flex; align-items:center; gap:10px; }
.selection-controls label {
display:inline-flex;
align-items:center;
gap:7px;
font-size:13px;
color:var(--ink);
cursor:pointer;
}
.selection-controls label input { accent-color:var(--ink); }
.selection-info { font-size:12px; color:var(--ink-45); }
.stats-detail { margin-top:30px; padding-top:22px; border-top:1px solid var(--ink-10); }
.stats-detail h3 {
font-size:10px;
font-weight:700;
letter-spacing:.1em;
text-transform:uppercase;
color:var(--ink-45);
margin-bottom:16px;
}
hr { border:none; border-top:1px solid var(--ink-06); margin:14px 0; }
.load-more-container { margin-top:28px; text-align:center; }
.load-more-btn { width:100%; padding:13px; font-size:13px; }
#resultCountHeader {
font-size:10px;
font-weight:700;
letter-spacing:.1em;
text-transform:uppercase;
color:var(--ink-45);
margin:20px 0 14px;
}
@@ -0,0 +1,753 @@
let currentResults = null;
let searchInProgress = false;
let downloadInProgress = false;
let loadMoreInProgress = false;
let selectedBooks = new Set();
let currentQuery = "";
let currentPage = 1;
let totalPagesAvailable = 0;
let headlessMode = true;
let totalBooksCount = 0;
async function searchBooks(resetResults = true) {
if (searchInProgress) {
return;
}
const query = document.getElementById("query").value.trim();
const headless = document.getElementById("headless").checked;
headlessMode = headless;
currentQuery = query;
if (!query) {
showError("Vui lòng nhập từ khóa tìm kiếm");
return;
}
if (query.length < 2) {
showError("Từ khóa phải có ít nhất 2 ký tự");
return;
}
searchInProgress = true;
if (resetResults) {
selectedBooks.clear();
currentPage = 1;
currentResults = null;
}
document.getElementById("loadingIndicator").style.display = "block";
document.getElementById("loadingMessage").textContent = resetResults
? "Đang tìm kiếm sách..."
: `Đang tải trang ${currentPage + 1}...`;
document.getElementById("errorMessage").style.display = "none";
if (resetResults) {
document.getElementById("resultsContent").innerHTML = "";
}
document.getElementById("progressText").textContent =
"Đang khởi tạo...";
const searchBtn = document.getElementById("searchBtn");
searchBtn.disabled = true;
searchBtn.textContent = resetResults ? "Đang tìm..." : "Đang tải...";
try {
const nextPage = resetResults ? 1 : currentPage + 1;
const response = await fetch("/scraperZLibrary/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: query,
page: nextPage,
headless: headless,
}),
});
const data = await response.json();
if (data.success) {
if (resetResults) {
currentResults = data;
currentPage = data.current_page || 1;
totalPagesAvailable = data.total_pages || 1;
totalBooksCount = data.total_books_count || data.books.length;
displayResults(data);
} else {
// Merge new books with existing results
if (currentResults) {
currentResults.books = [...currentResults.books, ...data.books];
currentResults.total_count = currentResults.books.length;
currentPage = data.current_page;
totalPagesAvailable = data.total_pages;
totalBooksCount = data.total_books_count || totalBooksCount;
appendBooksToGrid(
data.books,
currentResults.books.length - data.books.length,
);
updateStatsBar(currentResults);
updateResultCount();
updateSelectionInfo();
} else {
currentResults = data;
currentPage = data.current_page || 1;
totalPagesAvailable = data.total_pages || 1;
totalBooksCount = data.total_books_count || data.books.length;
displayResults(data);
}
}
} else {
showError(data.error || "Có lỗi xảy ra khi tìm kiếm");
}
} catch (error) {
console.error("Search error:", error);
showError("Không thể kết nối đến máy chủ.");
} finally {
searchInProgress = false;
loadMoreInProgress = false;
document.getElementById("loadingIndicator").style.display = "none";
searchBtn.disabled = false;
searchBtn.textContent = "Tìm kiếm";
}
}
async function loadMoreBooks() {
if (loadMoreInProgress || searchInProgress) {
return;
}
if (currentPage >= totalPagesAvailable) {
alert("Đã tải hết tất cả các trang!");
return;
}
loadMoreInProgress = true;
await searchBooks(false);
}
function appendBooksToGrid(newBooks, startIndex) {
const booksGrid = document.querySelector(".books-grid");
if (!booksGrid) return;
let html = "";
newBooks.forEach((book, idx) => {
const index = startIndex + idx;
const bookId = `book-${index}`;
html += `
<div class="book-card" id="${bookId}">
<input
type="checkbox"
class="book-checkbox"
data-book-index="${index}"
onchange="toggleBookSelection(${index}, this.checked)"
>
<div class="book-title">${index + 1}. ${escapeHtml(book.title)}</div>
<div class="book-authors">${escapeHtml(book.authors.join(", ") || "Không rõ")}</div>
<div class="book-details">
<div class="detail-item">
<div class="detail-label">NXB</div>
<div class="detail-value">${escapeHtml(book.publisher)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Năm</div>
<div class="detail-value">${escapeHtml(book.year)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Số trang</div>
<div class="detail-value">${escapeHtml(book.pages)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Ngôn ngữ</div>
<div class="detail-value">${escapeHtml(book.language)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Định dạng</div>
<div class="detail-value">${escapeHtml(book.file)}</div>
</div>
</div>
${
book.link !== "N/A"
? `<a href="${escapeHtml(book.link)}" target="_blank" class="book-link" rel="noopener noreferrer">Xem sách →</a>`
: '<span style="color: #999; font-size: 0.85em;">Không có liên kết</span>'
}
</div>
`;
});
booksGrid.insertAdjacentHTML("beforeend", html);
// Update load more button
updateLoadMoreButton();
}
function updateResultCount() {
const resultHeader = document.querySelector("#resultCountHeader");
if (resultHeader) {
resultHeader.textContent = `Kết quả (${currentResults.books.length} sách đã tải / ${totalBooksCount} tổng cộng)`;
}
}
function updateStatsBar(data) {
const statsBar = document.querySelector(".stats-bar");
if (statsBar) {
statsBar.innerHTML = `
<div class="stat-item">
<div class="stat-label">Tổng số sách</div>
<div class="stat-value">${totalBooksCount}</div>
</div>
<div class="stat-item">
<div class="stat-label">Từ khóa</div>
<div class="stat-value" style="font-size: 1.5em;">"${escapeHtml(data.query)}"</div>
</div>
<div class="stat-item">
<div class="stat-label">Trang hiện tại</div>
<div class="stat-value" style="font-size: 1.5em;">${currentPage} / ${totalPagesAvailable}</div>
</div>
`;
}
}
function updateLoadMoreButton() {
const loadMoreContainer = document.getElementById("loadMoreContainer");
if (loadMoreContainer) {
if (currentPage >= totalPagesAvailable) {
loadMoreContainer.innerHTML = `
<button class="btn btn-secondary load-more-btn" disabled>
✅ Đã tải hết ${totalPagesAvailable} trang (${currentResults.books.length} sách)
</button>
`;
} else {
loadMoreContainer.innerHTML = `
<button class="btn btn-secondary load-more-btn" onclick="loadMoreBooks()">
📄 Tải thêm trang ${currentPage + 1} / ${totalPagesAvailable} (mỗi trang 50 sách)
</button>
`;
}
}
}
function displayResults(data) {
const resultsDiv = document.getElementById("resultsContent");
if (!data.books || data.books.length === 0) {
resultsDiv.innerHTML = `
<div class="empty-state">
<div style="font-size: 3em; margin-bottom: 20px; font-weight: 300;">🔍</div>
<h3>Không tìm thấy sách</h3>
<p>Thử từ khóa khác hoặc kiểm tra lại chính tả</p>
<p style="margin-top: 10px; font-size: 0.9em;">Từ khóa: "${escapeHtml(data.query)}"</p>
</div>
`;
return;
}
currentPage = data.current_page || 1;
totalPagesAvailable = data.total_pages || 1;
totalBooksCount = data.total_books_count || data.books.length;
let html = `
<div class="stats-bar">
<div class="stat-item">
<div class="stat-label">Tổng số sách</div>
<div class="stat-value">${totalBooksCount}</div>
</div>
<div class="stat-item">
<div class="stat-label">Từ khóa</div>
<div class="stat-value" style="font-size: 1.5em;">"${escapeHtml(data.query)}"</div>
</div>
<div class="stat-item">
<div class="stat-label">Trang hiện tại</div>
<div class="stat-value" style="font-size: 1.5em;">${currentPage} / ${totalPagesAvailable}</div>
</div>
</div>
<div class="download-section">
<button class="btn btn-secondary" onclick="downloadResults('txt')">
↓ Tải TXT
</button>
<button class="btn btn-secondary" onclick="downloadResults('json')">
↓ Tải JSON
</button>
<button class="btn btn-secondary" onclick="copyResultsToClipboard()">
📋 Sao chép
</button>
<button class="btn btn-download" onclick="downloadSelectedBooks()">
📦 Tải sách đã chọn
</button>
</div>
<div class="selection-bar">
<div class="selection-controls">
<label>
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)">
Chọn tất cả sách đã tải
</label>
<button class="btn btn-secondary" style="padding: 6px 16px; font-size: 12px;" onclick="clearSelection()">
Bỏ chọn
</button>
</div>
<div class="selection-info" id="selectionInfo">
Đã chọn 0 sách
</div>
</div>
<h3 id="resultCountHeader" style="font-weight: 400; margin: 24px 0 16px 0; text-transform: uppercase; font-size: 0.9em; letter-spacing: 0.5px;">
Kết quả (${data.books.length} sách đã tải / ${totalBooksCount} tổng cộng)
</h3>
<div class="books-grid">
`;
data.books.forEach((book, index) => {
const bookId = `book-${index}`;
html += `
<div class="book-card" id="${bookId}">
<input
type="checkbox"
class="book-checkbox"
data-book-index="${index}"
onchange="toggleBookSelection(${index}, this.checked)"
>
<div class="book-title">${index + 1}. ${escapeHtml(book.title)}</div>
<div class="book-authors">${escapeHtml(book.authors.join(", ") || "Không rõ")}</div>
<div class="book-details">
<div class="detail-item">
<div class="detail-label">NXB</div>
<div class="detail-value">${escapeHtml(book.publisher)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Năm</div>
<div class="detail-value">${escapeHtml(book.year)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Số trang</div>
<div class="detail-value">${escapeHtml(book.pages)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Ngôn ngữ</div>
<div class="detail-value">${escapeHtml(book.language)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Định dạng</div>
<div class="detail-value">${escapeHtml(book.file)}</div>
</div>
</div>
${
book.link !== "N/A"
? `<a href="${escapeHtml(book.link)}" target="_blank" class="book-link" rel="noopener noreferrer">Xem sách →</a>`
: '<span style="color: #999; font-size: 0.85em;">Không có liên kết</span>'
}
</div>
`;
});
html += "</div>";
// Add load more button
html += `<div id="loadMoreContainer" class="load-more-container">`;
if (currentPage < totalPagesAvailable) {
html += `
<button class="btn btn-secondary load-more-btn" onclick="loadMoreBooks()">
📄 Tải thêm trang ${currentPage + 1} / ${totalPagesAvailable} (mỗi trang 50 sách)
</button>
`;
} else {
html += `
<button class="btn btn-secondary load-more-btn" disabled>
✅ Đã tải hết ${totalPagesAvailable} trang (${data.books.length} sách)
</button>
`;
}
html += `</div>`;
// Add statistics if available
if (data.statistics && Object.keys(data.statistics).length > 0) {
html += '<div class="stats-detail">';
html += "<h3>Thống kê</h3>";
html += '<div class="stats-bar" style="border-bottom: none;">';
const stats = data.statistics;
if (stats.languages && Object.keys(stats.languages).length > 0) {
const langHtml = Object.entries(stats.languages)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([lang, count]) => {
const percentage = ((count / totalBooksCount) * 100).toFixed(1);
return `<div style="margin-bottom: 4px;">${escapeHtml(lang)}: ${count} (${percentage}%)</div>`;
})
.join("");
html += `
<div class="stat-item">
<div class="stat-label">Ngôn ngữ phổ biến</div>
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${langHtml}</div>
</div>
`;
}
if (stats.formats && Object.keys(stats.formats).length > 0) {
const formatHtml = Object.entries(stats.formats)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([fmt, count]) => {
const percentage = ((count / totalBooksCount) * 100).toFixed(1);
return `<div style="margin-bottom: 4px;">${escapeHtml(fmt)}: ${count} (${percentage}%)</div>`;
})
.join("");
html += `
<div class="stat-item">
<div class="stat-label">Định dạng phổ biến</div>
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${formatHtml}</div>
</div>
`;
}
if (stats.years && Object.keys(stats.years).length > 0) {
const yearHtml = Object.entries(stats.years)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0]))
.slice(0, 5)
.map(
([year, count]) =>
`<div style="margin-bottom: 4px;">${escapeHtml(year)}: ${count} sách</div>`,
)
.join("");
html += `
<div class="stat-item">
<div class="stat-label">Năm xuất bản gần đây</div>
<div class="stat-value" style="font-size: 0.9em; font-weight: 400;">${yearHtml}</div>
</div>
`;
}
html += "</div>";
html += "</div>";
}
resultsDiv.innerHTML = html;
updateSelectionInfo();
resultsDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function toggleBookSelection(index, checked) {
if (checked) {
selectedBooks.add(index);
const card = document.getElementById(`book-${index}`);
if (card) card.classList.add("selected");
} else {
selectedBooks.delete(index);
const card = document.getElementById(`book-${index}`);
if (card) card.classList.remove("selected");
}
updateSelectionInfo();
updateSelectAllCheckbox();
}
function toggleSelectAll(checked) {
if (!currentResults || !currentResults.books) return;
if (checked) {
currentResults.books.forEach((_, index) => {
selectedBooks.add(index);
const card = document.getElementById(`book-${index}`);
if (card) card.classList.add("selected");
const checkbox = document.querySelector(
`[data-book-index="${index}"]`,
);
if (checkbox) checkbox.checked = true;
});
} else {
selectedBooks.clear();
document
.querySelectorAll(".book-card")
.forEach((card) => card.classList.remove("selected"));
document
.querySelectorAll(".book-checkbox")
.forEach((cb) => (cb.checked = false));
}
updateSelectionInfo();
}
function clearSelection() {
selectedBooks.clear();
document
.querySelectorAll(".book-card")
.forEach((card) => card.classList.remove("selected"));
document
.querySelectorAll(".book-checkbox")
.forEach((cb) => (cb.checked = false));
const selectAllCheckbox = document.getElementById("selectAllCheckbox");
if (selectAllCheckbox) selectAllCheckbox.checked = false;
updateSelectionInfo();
}
function updateSelectionInfo() {
const count = selectedBooks.size;
const infoElement = document.getElementById("selectionInfo");
if (infoElement) {
infoElement.textContent = `Đã chọn ${count} / ${currentResults?.books.length || 0} sách`;
}
}
function updateSelectAllCheckbox() {
if (!currentResults || !currentResults.books) return;
const selectAll = document.getElementById("selectAllCheckbox");
if (selectAll) {
selectAll.checked =
selectedBooks.size === currentResults.books.length &&
currentResults.books.length > 0;
}
}
async function downloadSelectedBooks() {
if (downloadInProgress) {
alert("Đang tải sách, vui lòng đợi...");
return;
}
if (!currentResults || !currentResults.books) {
alert("Không có sách để tải");
return;
}
if (selectedBooks.size === 0) {
alert("Vui lòng chọn ít nhất một cuốn sách");
return;
}
const selectedBooksList = Array.from(selectedBooks)
.sort((a, b) => a - b)
.map((index) => currentResults.books[index]);
downloadInProgress = true;
const headless = document.getElementById("headless").checked;
// Show progress UI
document.getElementById("loadingIndicator").style.display = "block";
document.getElementById("loadingMessage").textContent =
`Đang tải ${selectedBooksList.length} sách...`;
let successCount = 0;
let failedCount = 0;
try {
// POST to get the SSE stream
const response = await fetch("/scraperZLibrary/api/download/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
books: selectedBooksList,
query: currentResults.query,
headless: headless,
}),
});
if (!response.ok) throw new Error("Không thể bắt đầu tải xuống");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop(); // keep incomplete chunk
for (const chunk of lines) {
const line = chunk.replace(/^data:\s*/, "").trim();
if (!line) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event.type === "progress") {
document.getElementById("loadingMessage").textContent =
`[${event.index}/${event.total}] Đang tải: ${event.title.substring(0, 50)}...`;
document.getElementById("progressText").textContent =
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount}`;
} else if (event.type === "ready") {
successCount++;
document.getElementById("progressText").textContent =
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount}`;
// Trigger instant download via hidden <a>
const a = document.createElement("a");
a.href = `/scraperZLibrary/api/download/file/${event.token}`;
a.download = event.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Small gap so the browser doesn't block rapid-fire downloads
await new Promise((r) => setTimeout(r, 600));
} else if (event.type === "error") {
failedCount++;
console.warn(`Failed: ${event.title}${event.message}`);
document.getElementById("progressText").textContent =
`Đã xong: ${successCount} ✓ Lỗi: ${failedCount}`;
} else if (event.type === "done") {
document.getElementById("loadingMessage").textContent =
`Hoàn tất! ${event.success} thành công, ${event.failed} thất bại`;
}
}
}
} catch (error) {
console.error("Stream download error:", error);
alert("Không thể tải sách: " + error.message);
} finally {
downloadInProgress = false;
setTimeout(() => {
document.getElementById("loadingIndicator").style.display = "none";
}, 2000);
}
}
function showError(message) {
const errorDiv = document.getElementById("errorMessage");
errorDiv.textContent = message;
errorDiv.style.display = "block";
document.getElementById("resultsContent").innerHTML = `
<div class="empty-state">
<div style="font-size: 3em; margin-bottom: 20px; font-weight: 300;">✕</div>
<h3>Tìm kiếm thất bại</h3>
<p>${escapeHtml(message)}</p>
<p style="margin-top: 20px; font-size: 0.9em;">Vui lòng thử lại với từ khóa khác.</p>
</div>
`;
errorDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function downloadResults(format) {
if (!currentResults) {
alert("Không có kết quả để tải xuống");
return;
}
fetch(`/scraperZLibrary/api/download/${format}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(currentResults),
})
.then((response) => {
if (!response.ok) {
throw new Error("Tải xuống thất bại");
}
return response.blob();
})
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `zlib_${currentResults.query.replace(/\s+/g, "_")}_ket_qua.${format}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
})
.catch((error) => {
console.error("Download error:", error);
alert("Không thể tải file: " + error.message);
});
}
function copyResultsToClipboard() {
if (!currentResults) {
alert("Không có kết quả để sao chép");
return;
}
let summary = `KẾT QUẢ TÌM KIẾM Z-LIBRARY\n`;
summary += `========================\n`;
summary += `Từ khóa: ${currentResults.query}\n`;
summary += `Tổng số sách: ${totalBooksCount}\n`;
summary += `Đã tải: ${currentResults.books.length} sách\n`;
summary += `========================\n\n`;
currentResults.books.slice(0, 20).forEach((book, index) => {
summary += `${index + 1}. ${book.title}\n`;
summary += ` Tác giả: ${book.authors.join(", ")}\n`;
summary += ` Năm: ${book.year} | Ngôn ngữ: ${book.language} | Định dạng: ${book.file}\n`;
summary += ` Link: ${book.link}\n\n`;
});
if (currentResults.books.length > 20) {
summary += `... và ${currentResults.books.length - 20} sách khác\n`;
}
navigator.clipboard
.writeText(summary)
.then(() => {
alert("Đã sao chép tóm tắt!");
})
.catch((err) => {
console.error("Failed to copy:", err);
alert("Không thể sao chép");
});
}
function escapeHtml(text) {
if (typeof text !== "string") return text;
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
document
.getElementById("query")
.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
searchBooks(true);
}
});
document.addEventListener("keydown", function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
document.getElementById("query").focus();
}
});
const progressMessages = [
"Đang khởi tạo...",
"Đang kết nối Z-Library...",
"Đang lấy dữ liệu sách...",
"Đang xử lý kết quả...",
"Sắp xong...",
];
let messageIndex = 0;
const progressInterval = setInterval(() => {
if (searchInProgress || downloadInProgress) {
messageIndex = (messageIndex + 1) % progressMessages.length;
const progressElement = document.getElementById("progressText");
if (progressElement) {
progressElement.textContent = progressMessages[messageIndex];
}
}
}, 2000);
window.addEventListener("beforeunload", () => {
clearInterval(progressInterval);
});