// ══════════════════════════════════════════════════════════ // 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 adminPasswordForm = document.getElementById('admin-password-form'); const adminNewPassword = document.getElementById('admin-new-password'); // ── 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 = '
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`; let html = `
Uptime: ${uptimeStr}
Memory Used: ${stats.memoryUsedMB} MB
Active Uploads/Sessions: ${stats.activeSessions}
Tracked IPs (Brute Force): ${stats.loginAttemptsTracked}
`; if (stats.blockedIps.length > 0) { html += `
Currently Locked Out IPs:
`; stats.blockedIps.forEach(b => { html += `
${b.ip} (${b.attempts} fails): ${b.lockoutRemaining}s left
`; }); } adminStatsContainer.innerHTML = html; } catch (err) { adminStatsContainer.innerHTML = `Error loading stats`; } } 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'); 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'); } }); adminPasswordForm.addEventListener('submit', async (e) => { e.preventDefault(); const newPassword = adminNewPassword.value; try { const res = await fetch('/api/admin/change-password', { method: 'POST', headers: { 'x-auth-token': authToken, 'Content-Type': 'application/json' }, body: JSON.stringify({ newPassword }) }); if (res.ok) { showToast('User password updated successfully!', 'success'); adminNewPassword.value = ''; } else throw new Error('Failed to update password'); } catch (err) { showToast(err.message, 'error'); } }); // ── 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.onclose = () => { // Reconnect after 3 seconds setTimeout(connectWebSocket, 3000); }; } // ── 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)}${escapeHtml(match)}${escapeHtml(after)}`; } else { item.classList.add('search-hidden'); titleSpan.textContent = original; } }); }); function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>'); } // ── 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 = `
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 = ` Split & 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); }