pdfsplitter/public/app.js
Elijah 692ef068a1
All checks were successful
Automated Container Build / build-and-push (push) Successful in 17s
Admin panel changes and sidebar scrolling fix
2026-05-20 19:14:07 -07:00

951 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ══════════════════════════════════════════════════════════
// PDF Splitter Web Client
// ══════════════════════════════════════════════════════════
// ── State ──────────────────────────────────────────────────
let sessionId = null;
let pdfDoc = null;
let totalPages = 0;
let allChapters = [];
let chapters = [];
let selectedPages = new Set();
let maxDepth = 1;
let lightboxPages = [];
let lightboxIndex = -1;
// ── DOM References ─────────────────────────────────────────
const fileInput = document.getElementById('file-input-hidden');
const fileInfo = document.getElementById('file-info');
const fileName = document.getElementById('file-name');
const filePages = document.getElementById('file-pages');
const chapterLoad = document.getElementById('chapter-loading');
const chapterNone = document.getElementById('chapter-none');
const chapterList = document.getElementById('chapter-list');
const pageInput = document.getElementById('page-input');
const pageCount = document.getElementById('page-count');
const previewEmpty = document.getElementById('preview-empty');
const previewGrid = document.getElementById('preview-grid');
const depthSelect = document.getElementById('depth-select');
const depthControl = document.getElementById('depth-control');
const lightbox = document.getElementById('lightbox');
const lbCanvas = document.getElementById('lightbox-canvas');
const lbClose = document.getElementById('lightbox-close');
const lbPageInfo = document.getElementById('lightbox-page-info');
const lbPrev = document.getElementById('lightbox-prev');
const lbNext = document.getElementById('lightbox-next');
const resizeHandle = document.getElementById('resize-handle');
const sidebar = document.getElementById('sidebar');
const chapterSearch = document.getElementById('chapter-search');
const chapterSearchWrap = document.getElementById('chapter-search-wrap');
const btnSplit = document.getElementById('btn-split');
const dropOverlay = document.getElementById('drop-overlay');
const uploadOverlay = document.getElementById('upload-overlay');
const uploadStatus = document.getElementById('upload-status');
const loginOverlay = document.getElementById('login-overlay');
const loginForm = document.getElementById('login-form');
const loginPassword = document.getElementById('login-password');
const btnAdminSettings = document.getElementById('btn-admin-settings');
const adminModal = document.getElementById('admin-modal');
const adminClose = document.getElementById('admin-close');
const adminStatsContainer = document.getElementById('admin-stats-container');
const btnAdminClearCache = document.getElementById('btn-admin-clear-cache');
const btnAdminClearLogins = document.getElementById('btn-admin-clear-logins');
const adminSessionsContainer = document.getElementById('admin-sessions-container');
const adminIpLogContainer = document.getElementById('admin-ip-log-container');
// ── Authentication ─────────────────────────────────────────
let authToken = localStorage.getItem('pdf_auth_token') || '';
async function checkAuth() {
try {
const res = await fetch('/api/check-auth', {
headers: { 'x-auth-token': authToken }
});
if (res.ok) {
const data = await res.json();
loginOverlay.classList.add('hidden');
applyRole(data.role);
connectWebSocket();
} else {
loginOverlay.classList.remove('hidden');
}
} catch (e) {
loginOverlay.classList.remove('hidden');
}
}
function applyRole(role) {
if (role === 'admin') {
document.body.classList.add('role-admin');
document.body.classList.remove('role-user');
} else {
document.body.classList.add('role-user');
document.body.classList.remove('role-admin');
}
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = loginPassword.value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (res.ok) {
if (data.token !== 'no-auth') {
authToken = data.token;
localStorage.setItem('pdf_auth_token', authToken);
}
loginOverlay.classList.add('hidden');
applyRole(data.role);
connectWebSocket();
} else {
showToast(data.error || 'Invalid password', 'error');
}
} catch (err) {
showToast('Login failed', 'error');
}
});
checkAuth();
// ── Admin Panel ────────────────────────────────────────────
btnAdminSettings.addEventListener('click', () => {
adminModal.classList.remove('hidden');
btnAdminSettings.classList.remove('hidden'); // to be safe
fetchAdminStats();
});
adminClose.addEventListener('click', () => {
adminModal.classList.add('hidden');
});
async function fetchAdminStats() {
adminStatsContainer.innerHTML = '<div class="spinner"></div> Loading…';
adminSessionsContainer.innerHTML = '<div class="spinner"></div> Loading…';
adminIpLogContainer.innerHTML = '<div class="spinner"></div> Loading…';
try {
const res = await fetch('/api/admin/stats', {
headers: { 'x-auth-token': authToken }
});
if (!res.ok) throw new Error('Failed to load stats');
const stats = await res.json();
// Format uptime
const h = Math.floor(stats.uptime / 3600);
const m = Math.floor((stats.uptime % 3600) / 60);
const uptimeStr = `${h}h ${m}m`;
// Server stats
adminStatsContainer.innerHTML = `
<div class="admin-stats-row"><span>Uptime:</span> <strong>${uptimeStr}</strong></div>
<div class="admin-stats-row"><span>Memory Used:</span> <strong>${stats.memoryUsedMB} MB</strong></div>
<div class="admin-stats-row"><span>Active Sessions:</span> <strong>${stats.activeSessions}</strong></div>
<div class="admin-stats-row"><span>Tracked IPs:</span> <strong>${stats.loginAttemptsTracked}</strong></div>
`;
// Active sessions display
if (stats.sessionDetails && stats.sessionDetails.length > 0) {
let sessHtml = '';
stats.sessionDetails.forEach(s => {
const elapsed = s.startTime ? formatDuration(Date.now() - s.startTime) : 'N/A';
sessHtml += `
<div class="admin-session-card">
<div class="session-row">
<span class="session-icon">📄</span>
<span class="session-file" title="${escapeHtml(s.originalName)}">${escapeHtml(s.originalName)}</span>
</div>
<div class="session-details">
<div class="session-detail"><span class="detail-label">IP</span><span class="detail-value">${escapeHtml(s.ip)}</span></div>
<div class="session-detail"><span class="detail-label">Browser</span><span class="detail-value">${escapeHtml(s.browser)}</span></div>
<div class="session-detail"><span class="detail-label">OS</span><span class="detail-value">${escapeHtml(s.os)}</span></div>
<div class="session-detail"><span class="detail-label">Duration</span><span class="detail-value">${elapsed}</span></div>
<div class="session-detail"><span class="detail-label">Clients</span><span class="detail-value">${s.connectedClients}</span></div>
</div>
</div>
`;
});
adminSessionsContainer.innerHTML = sessHtml;
} else {
adminSessionsContainer.innerHTML = '<span class="admin-hint">No active sessions.</span>';
}
// Tracked IPs log
if (stats.trackedIps && stats.trackedIps.length > 0) {
let ipHtml = '<div class="ip-log-table">';
ipHtml += '<div class="ip-log-header"><span>IP Address</span><span>Attempts</span><span>Status</span></div>';
stats.trackedIps.forEach(t => {
const statusClass = t.locked ? 'ip-locked' : 'ip-tracking';
const statusText = t.locked ? `Locked (${t.lockoutRemaining}s)` : 'Tracking';
ipHtml += `
<div class="ip-log-row ${statusClass}">
<span class="ip-address">${escapeHtml(t.ip)}</span>
<span class="ip-attempts">${t.attempts}</span>
<span class="ip-status">${statusText}</span>
</div>
`;
});
ipHtml += '</div>';
adminIpLogContainer.innerHTML = ipHtml;
} else {
adminIpLogContainer.innerHTML = '<span class="admin-hint">No tracked IPs. All clear.</span>';
}
} catch (err) {
adminStatsContainer.innerHTML = `<span style="color: var(--danger)">Error loading stats</span>`;
adminSessionsContainer.innerHTML = '';
adminIpLogContainer.innerHTML = '';
}
}
function formatDuration(ms) {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
btnAdminClearCache.addEventListener('click', async () => {
if (!confirm('This will delete all uploaded PDFs and immediately disconnect all users. Continue?')) return;
try {
const res = await fetch('/api/admin/clear-cache', {
method: 'POST',
headers: { 'x-auth-token': authToken }
});
const data = await res.json();
if (res.ok) {
showToast(`Cache cleared. Deleted ${data.deletedCount} files.`, 'success');
resetClientState();
fetchAdminStats();
} else throw new Error(data.error);
} catch (err) {
showToast(err.message, 'error');
}
});
btnAdminClearLogins.addEventListener('click', async () => {
if (!confirm('Clear all IP lockouts and brute-force tracking?')) return;
try {
const res = await fetch('/api/admin/clear-logins', {
method: 'POST',
headers: { 'x-auth-token': authToken }
});
if (res.ok) {
showToast('Login tracking reset.', 'success');
fetchAdminStats();
} else throw new Error('Failed to reset logins');
} catch (err) {
showToast(err.message, 'error');
}
});
// (Password change form removed)
// ── WebSocket ──────────────────────────────────────────────
let ws = null;
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}`);
ws.onopen = () => {
if (sessionId) {
ws.send(JSON.stringify({ type: 'register', sessionId, token: authToken }));
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'cache-cleared') {
resetClientState();
showToast('Server cache cleared. Your session has been reset.', 'info');
}
} catch (e) { /* ignore */ }
};
ws.onclose = () => {
// Reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
}
// Reset all client-side PDF state (used when cache is cleared)
function resetClientState() {
sessionId = null;
pdfDoc = null;
totalPages = 0;
allChapters = [];
chapters = [];
selectedPages.clear();
// Reset UI
fileInfo.classList.add('hidden');
fileName.textContent = '';
filePages.textContent = '';
chapterList.innerHTML = '';
chapterNone.classList.add('hidden');
chapterLoad.classList.add('hidden');
chapterSearchWrap.classList.add('hidden');
depthControl.classList.add('hidden');
pageInput.value = '';
previewGrid.innerHTML = '';
previewGrid.classList.add('hidden');
previewEmpty.classList.remove('hidden');
thumbElements = [];
updateState();
}
// ── Sidebar Resize ─────────────────────────────────────────
const SIDEBAR_MIN = 250;
const SIDEBAR_MAX = 600;
let isResizing = false;
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
resizeHandle.classList.add('dragging');
document.body.classList.add('resizing');
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const appRect = document.getElementById('app').getBoundingClientRect();
let newWidth = e.clientX - appRect.left;
newWidth = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, newWidth));
document.documentElement.style.setProperty('--sidebar-width', newWidth + 'px');
});
document.addEventListener('mouseup', () => {
if (!isResizing) return;
isResizing = false;
resizeHandle.classList.remove('dragging');
document.body.classList.remove('resizing');
});
// ── File Upload ────────────────────────────────────────────
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) uploadFile(fileInput.files[0]);
});
// ── Drag & Drop ────────────────────────────────────────────
let dragCounter = 0;
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (dragCounter === 1) dropOverlay.classList.remove('hidden');
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropOverlay.classList.add('hidden');
}
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
document.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.add('hidden');
const file = e.dataTransfer.files[0];
if (!file) return;
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showToast('Please drop a PDF file', 'error');
return;
}
uploadFile(file);
});
// ── Upload & Load PDF ──────────────────────────────────────
async function uploadFile(file) {
uploadOverlay.classList.remove('hidden');
uploadStatus.textContent = `Uploading ${file.name}`;
try {
const formData = new FormData();
formData.append('pdf', file);
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'x-auth-token': authToken },
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Upload failed');
}
const data = await res.json();
sessionId = data.sessionId;
// Register with WebSocket
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'register', sessionId, token: authToken }));
}
uploadStatus.textContent = 'Loading preview…';
// Load PDF from server for client-side rendering
const pdfUrl = `/api/pdf/${sessionId}`;
pdfDoc = await pdfjsLib.getDocument({
url: pdfUrl,
httpHeaders: { 'x-auth-token': authToken }
}).promise;
totalPages = pdfDoc.numPages;
// Update UI
fileName.textContent = data.originalName;
fileInfo.classList.remove('hidden');
filePages.textContent = `${totalPages} pages`;
// Reset state
selectedPages.clear();
chapters = [];
allChapters = [];
chapterList.innerHTML = '';
previewGrid.innerHTML = '';
pageInput.value = '';
previewEmpty.classList.remove('hidden');
previewGrid.classList.add('hidden');
chapterNone.classList.add('hidden');
chapterLoad.classList.remove('hidden');
chapterSearchWrap.classList.add('hidden');
updateState();
await detectChapters();
uploadOverlay.classList.add('hidden');
showToast(`Loaded: ${data.originalName}`, 'success');
} catch (err) {
uploadOverlay.classList.add('hidden');
showToast(err.message, 'error');
console.error('Upload error:', err);
}
}
// ── Depth Control ──────────────────────────────────────────
depthSelect.addEventListener('change', () => {
maxDepth = parseInt(depthSelect.value);
filterAndRenderChapters();
});
// ── Chapter Detection ──────────────────────────────────────
async function detectChapters() {
allChapters = [];
chapters = [];
try {
const outline = await pdfDoc.getOutline();
if (outline && outline.length > 0) {
const flatEntries = await flattenOutlineRecursive(outline, 0);
if (flatEntries.length > 0) {
for (let i = 0; i < flatEntries.length; i++) {
let endPage = totalPages;
for (let j = i + 1; j < flatEntries.length; j++) {
if (flatEntries[j].depth <= flatEntries[i].depth) {
endPage = flatEntries[j].startPage - 1;
break;
}
}
allChapters.push({
title: flatEntries[i].title,
startPage: flatEntries[i].startPage,
endPage: Math.max(flatEntries[i].startPage, endPage),
depth: flatEntries[i].depth
});
}
}
}
} catch (e) {
console.warn('Outline extraction failed:', e);
}
if (allChapters.length === 0) {
await detectChaptersFromText();
}
chapterLoad.classList.add('hidden');
if (allChapters.length === 0) {
chapterNone.classList.remove('hidden');
depthControl.classList.add('hidden');
} else {
chapterNone.classList.add('hidden');
depthControl.classList.remove('hidden');
chapterSearchWrap.classList.remove('hidden');
chapterSearch.value = '';
filterAndRenderChapters();
}
}
function filterAndRenderChapters() {
let filtered = allChapters.filter(ch => ch.depth <= maxDepth);
const seen = new Set();
filtered = filtered.filter(ch => {
const key = `${ch.startPage}-${ch.endPage}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
chapters = filtered;
if (chapters.length === 0) {
chapterNone.classList.remove('hidden');
} else {
chapterNone.classList.add('hidden');
}
renderChapterList();
syncChapterCheckboxes();
}
async function flattenOutlineRecursive(outline, depth) {
const entries = [];
for (const item of outline) {
let pageNum = null;
try {
if (item.dest) {
let dest = item.dest;
if (typeof dest === 'string') {
dest = await pdfDoc.getDestination(dest);
}
if (dest && dest[0]) {
const pageIndex = await pdfDoc.getPageIndex(dest[0]);
pageNum = pageIndex + 1;
}
}
} catch (e) {
console.warn('Could not resolve dest for:', item.title, e);
}
if (pageNum !== null) {
entries.push({ title: item.title.trim(), startPage: pageNum, depth });
}
if (item.items && item.items.length > 0) {
const children = await flattenOutlineRecursive(item.items, depth + 1);
entries.push(...children);
}
}
return entries;
}
async function detectChaptersFromText() {
const chapterPattern = /(?:^|\n)\s*(?:CHAPTER|Chapter|chapter)\s+(\d+)[\s.:–—-]*(.*)/;
const unitPattern = /(?:^|\n)\s*(?:UNIT|Unit|unit)\s+(\d+)[\s.:–—-]*(.*)/;
const maxPagesToScan = Math.min(totalPages, 800);
const rawChapters = [];
for (let i = 1; i <= maxPagesToScan; i++) {
try {
const page = await pdfDoc.getPage(i);
const textContent = await page.getTextContent();
const text = textContent.items.map(item => item.str).join(' ');
let match = text.match(chapterPattern);
if (match) {
const title = `Chapter ${match[1]}${match[2] ? ': ' + match[2].trim() : ''}`;
if (!rawChapters.some(c => c.title === title)) {
rawChapters.push({ title, startPage: i });
}
}
match = text.match(unitPattern);
if (match) {
const title = `Unit ${match[1]}${match[2] ? ': ' + match[2].trim() : ''}`;
if (!rawChapters.some(c => c.title === title)) {
rawChapters.push({ title, startPage: i });
}
}
} catch (e) { /* skip */ }
}
rawChapters.sort((a, b) => a.startPage - b.startPage);
for (let i = 0; i < rawChapters.length; i++) {
const endPage = (i + 1 < rawChapters.length) ? rawChapters[i + 1].startPage - 1 : totalPages;
allChapters.push({
title: rawChapters[i].title,
startPage: rawChapters[i].startPage,
endPage: Math.max(rawChapters[i].startPage, endPage),
depth: 0
});
}
}
// ── Render Chapter List ────────────────────────────────────
function renderChapterList() {
chapterList.innerHTML = '';
chapters.forEach((ch, idx) => {
const item = document.createElement('label');
item.className = 'chapter-item';
item.style.paddingLeft = `${8 + (ch.depth || 0) * 16}px`;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.dataset.index = idx;
cb.addEventListener('change', () => onChapterToggle(idx, cb.checked));
const titleSpan = document.createElement('span');
titleSpan.className = 'chapter-title';
if (ch.depth >= 2) titleSpan.style.opacity = '0.75';
titleSpan.textContent = ch.title;
titleSpan.title = `${ch.title} (pages ${ch.startPage}${ch.endPage})`;
titleSpan.dataset.originalTitle = ch.title;
const pagesSpan = document.createElement('span');
pagesSpan.className = 'chapter-pages';
const count = ch.endPage - ch.startPage + 1;
pagesSpan.textContent = `${ch.startPage}${ch.endPage} (${count}p)`;
item.appendChild(cb);
item.appendChild(titleSpan);
item.appendChild(pagesSpan);
chapterList.appendChild(item);
});
}
// ── Chapter Search ─────────────────────────────────────────
chapterSearch.addEventListener('input', () => {
const query = chapterSearch.value.trim().toLowerCase();
const items = chapterList.querySelectorAll('.chapter-item');
items.forEach(item => {
const titleSpan = item.querySelector('.chapter-title');
const original = titleSpan.dataset.originalTitle || titleSpan.textContent;
if (!query) {
item.classList.remove('search-hidden');
titleSpan.textContent = original;
return;
}
const lowerTitle = original.toLowerCase();
if (lowerTitle.includes(query)) {
item.classList.remove('search-hidden');
const startIdx = lowerTitle.indexOf(query);
const before = original.substring(0, startIdx);
const match = original.substring(startIdx, startIdx + query.length);
const after = original.substring(startIdx + query.length);
titleSpan.innerHTML = `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`;
} else {
item.classList.add('search-hidden');
titleSpan.textContent = original;
}
});
});
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Chapter Toggle ─────────────────────────────────────────
function onChapterToggle(index, checked) {
const ch = chapters[index];
for (let p = ch.startPage; p <= ch.endPage; p++) {
if (checked) selectedPages.add(p);
else selectedPages.delete(p);
}
buildPageInputFromSelection();
updateState();
}
function syncChapterCheckboxes() {
const checkboxes = chapterList.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => {
const idx = parseInt(cb.dataset.index);
const ch = chapters[idx];
if (!ch) return;
let allSelected = true;
for (let p = ch.startPage; p <= ch.endPage; p++) {
if (!selectedPages.has(p)) { allSelected = false; break; }
}
cb.checked = allSelected;
});
}
function buildPageInputFromSelection() {
if (selectedPages.size === 0) {
pageInput.value = '';
return;
}
const sorted = [...selectedPages].sort((a, b) => a - b);
const ranges = [];
let start = sorted[0], end = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === end + 1) {
end = sorted[i];
} else {
ranges.push(start === end ? `${start}` : `${start}-${end}`);
start = end = sorted[i];
}
}
ranges.push(start === end ? `${start}` : `${start}-${end}`);
pageInput.value = ranges.join(', ');
}
// ── Page Input Parsing ─────────────────────────────────────
pageInput.addEventListener('input', () => {
selectedPages.clear();
const raw = pageInput.value;
if (!raw.trim()) { updateState(); return; }
const parts = raw.split(',');
for (const part of parts) {
const trimmed = part.trim();
const chMatch = trimmed.match(/^(?:ch(?:apter)?)\s*(\d+)(?:\s*[-]\s*(\d+))?$/i);
if (chMatch) {
const from = parseInt(chMatch[1]);
const to = chMatch[2] ? parseInt(chMatch[2]) : from;
for (let c = from; c <= to; c++) {
const ch = chapters.find(ch => {
const m = ch.title.match(/(\d+)/);
return m && parseInt(m[1]) === c;
});
if (ch) {
for (let p = ch.startPage; p <= ch.endPage; p++) selectedPages.add(p);
}
}
continue;
}
const rangeMatch = trimmed.match(/^(\d+)\s*[-]\s*(\d+)$/);
if (rangeMatch) {
const from = parseInt(rangeMatch[1]);
const to = parseInt(rangeMatch[2]);
for (let p = Math.max(1, from); p <= Math.min(totalPages, to); p++) {
selectedPages.add(p);
}
continue;
}
const single = parseInt(trimmed);
if (!isNaN(single) && single >= 1 && single <= totalPages) {
selectedPages.add(single);
}
}
updateState();
});
// ── Update State ───────────────────────────────────────────
function updateState() {
pageCount.textContent = selectedPages.size;
btnSplit.disabled = selectedPages.size === 0 || !sessionId;
syncChapterCheckboxes();
updatePreview();
}
// ── Preview Rendering ──────────────────────────────────────
let thumbElements = [];
function updatePreview() {
if (selectedPages.size === 0) {
previewEmpty.classList.remove('hidden');
previewGrid.classList.add('hidden');
previewGrid.innerHTML = '';
thumbElements = [];
return;
}
previewEmpty.classList.add('hidden');
previewGrid.classList.remove('hidden');
previewGrid.innerHTML = '';
thumbElements = [];
const sorted = [...selectedPages].sort((a, b) => a - b);
for (const pageNum of sorted) {
const wrapper = document.createElement('div');
wrapper.className = 'page-thumb';
wrapper.addEventListener('click', () => openLightbox(pageNum));
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
const label = document.createElement('div');
label.className = 'page-thumb-label';
label.textContent = `Page ${pageNum}`;
wrapper.appendChild(label);
previewGrid.appendChild(wrapper);
thumbElements.push({ canvas, pageNum, wrapper });
}
// Lazy rendering with IntersectionObserver
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const thumb = thumbElements.find(t => t.wrapper === entry.target);
if (thumb && !thumb.rendered) {
thumb.rendered = true;
renderPageThumb(thumb.canvas, thumb.pageNum);
}
}
});
}, { root: previewGrid, rootMargin: '200px' });
thumbElements.forEach(t => observer.observe(t.wrapper));
}
async function renderPageThumb(canvas, pageNum) {
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const thumbWidth = 180;
const scale = thumbWidth / viewport.width;
const scaledViewport = page.getViewport({ scale });
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
const ctx = canvas.getContext('2d');
await page.render({
canvasContext: ctx,
viewport: scaledViewport
}).promise;
} catch (e) {
console.warn('Render failed for page', pageNum, e);
}
}
// ── Lightbox ───────────────────────────────────────────────
function openLightbox(pageNum) {
lightboxPages = [...selectedPages].sort((a, b) => a - b);
lightboxIndex = lightboxPages.indexOf(pageNum);
if (lightboxIndex === -1) lightboxIndex = 0;
lightbox.classList.remove('hidden');
renderLightboxPage();
}
function closeLightbox() {
lightbox.classList.add('hidden');
}
async function renderLightboxPage() {
if (lightboxIndex < 0 || lightboxIndex >= lightboxPages.length) return;
const pageNum = lightboxPages[lightboxIndex];
lbPageInfo.textContent = `Page ${pageNum} · ${lightboxIndex + 1} of ${lightboxPages.length}`;
lbPrev.disabled = lightboxIndex <= 0;
lbNext.disabled = lightboxIndex >= lightboxPages.length - 1;
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const targetWidth = Math.min(viewport.width * 2, 1400);
const scale = targetWidth / viewport.width;
const scaledViewport = page.getViewport({ scale });
lbCanvas.width = scaledViewport.width;
lbCanvas.height = scaledViewport.height;
const ctx = lbCanvas.getContext('2d');
ctx.clearRect(0, 0, lbCanvas.width, lbCanvas.height);
await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise;
} catch (e) {
console.warn('Lightbox render failed for page', pageNum, e);
}
}
lbClose.addEventListener('click', closeLightbox);
document.querySelector('.lightbox-backdrop').addEventListener('click', closeLightbox);
lbPrev.addEventListener('click', () => { if (lightboxIndex > 0) { lightboxIndex--; renderLightboxPage(); } });
lbNext.addEventListener('click', () => { if (lightboxIndex < lightboxPages.length - 1) { lightboxIndex++; renderLightboxPage(); } });
document.addEventListener('keydown', (e) => {
if (lightbox.classList.contains('hidden')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft' && lightboxIndex > 0) { lightboxIndex--; renderLightboxPage(); }
if (e.key === 'ArrowRight' && lightboxIndex < lightboxPages.length - 1) { lightboxIndex++; renderLightboxPage(); }
});
// ── Split & Download ───────────────────────────────────────
btnSplit.addEventListener('click', async () => {
if (!sessionId || selectedPages.size === 0) return;
btnSplit.disabled = true;
btnSplit.innerHTML = `<div class="spinner"></div> Splitting…`;
try {
const res = await fetch('/api/split', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': authToken
},
body: JSON.stringify({
sessionId,
pageNumbers: [...selectedPages]
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Split failed');
}
// Download the result
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || 'split.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(`Split complete: ${selectedPages.size} pages`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btnSplit.disabled = false;
btnSplit.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Split &amp; Download
`;
}
});
// ── Toast ──────────────────────────────────────────────────
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 260);
}, 3500);
}