'use strict'; /** * paste_view.js — Logic for the paste view page. * Depends on: crypto.js (PasteCrypto), app.js (copyToClipboard) * * Reads the site name from so no Jinja injection * is needed in this external file. */ let _decryptedPaste = null; let _pasteIsEncrypted = false; let _keyBase64 = null; document.addEventListener('DOMContentLoaded', async () => { let rawPayload; try { const island = document.getElementById('encryptedPayload'); if (!island) return; // Not a paste view page rawPayload = JSON.parse(island.textContent); } catch (e) { showError('Bad Data', 'Could not read the paste payload.'); return; } // Detect format from the data itself. // Encrypted pastes: "base64url:base64url" string. // Plaintext pastes: a JSON object. const isEncrypted = typeof rawPayload === 'string' && /^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload); if (isEncrypted) { const keyBase64 = window.location.hash.slice(1); if (!keyBase64) { showError('No Key', 'The decryption key is missing from the URL. ' + 'Use the full link including the # part.'); return; } try { const key = await PasteCrypto.importKey(keyBase64); const plaintext = await PasteCrypto.decrypt(rawPayload, key); _decryptedPaste = JSON.parse(plaintext); _pasteIsEncrypted = true; _keyBase64 = keyBase64; } catch (e) { showError('Decryption Failed', 'Wrong key or tampered data.'); return; } } else { // Plaintext paste — rawPayload is already the parsed JSON object. try { _decryptedPaste = (typeof rawPayload === 'object') ? rawPayload : JSON.parse(rawPayload); } catch (e) { showError('Bad Data', 'Could not parse paste data.'); return; } } renderPaste(_decryptedPaste); initPasteActions(); initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism initLineNumbers(); initDiscussions(); initDeletion(); }); function initPasteActions() { const rawBtn = document.getElementById('rawBtn'); const copyBtn = document.getElementById('copyBtn'); const downloadBtn = document.getElementById('downloadBtn'); if (rawBtn) rawBtn.addEventListener('click', rawView); if (copyBtn) copyBtn.addEventListener('click', copyPaste); if (downloadBtn) downloadBtn.addEventListener('click', downloadPaste); } function initLineNumbers() { const toggle = document.getElementById('lineNoToggle'); const viewPre = document.getElementById('viewPre'); if (!toggle || !viewPre) return; // Load preference from localStorage (default to checked) const stored = localStorage.getItem('show_line_numbers'); const isEnabled = (stored === null) ? true : (stored === 'true'); toggle.checked = isEnabled; const updateLines = () => { const checked = toggle.checked; if (checked) { viewPre.classList.add('line-numbers'); } else { viewPre.classList.remove('line-numbers'); const existing = viewPre.querySelector('.line-numbers-rows'); if (existing) existing.remove(); } localStorage.setItem('show_line_numbers', checked); // Always re-highlight so Prism's line-numbers plugin runs (works for plain text too) const code = document.getElementById('codeBlock'); if (code) { Prism.highlightElement(code); } }; toggle.addEventListener('change', updateLines); // Apply initial state (calls Prism so line numbers render without needing a toggle) updateLines(); } function renderPaste(paste) { const siteName = document.querySelector('meta[name="site-name"]')?.content || ''; const title = paste.title || 'Untitled'; document.title = title + (siteName ? ' \u2014 ' + siteName : ''); document.getElementById('navPasteTitle').textContent = title; const lang = paste.language || 'text'; const prismLangMap = { text: false, html: 'markup', xml: 'markup', docker: 'docker' }; const prismLang = (lang in prismLangMap) ? prismLangMap[lang] : lang; const codeBlock = document.getElementById('codeBlock'); const viewPre = document.getElementById('viewPre'); codeBlock.textContent = paste.content || ''; if (prismLang) { codeBlock.className = 'language-' + prismLang; viewPre.className = 'language-' + prismLang; Prism.highlightElement(codeBlock); } document.getElementById('viewFull').style.display = 'block'; } function showError(title, detail) { document.getElementById('errorTitle').textContent = title; document.getElementById('errorDetail').textContent = detail; document.getElementById('errorState').style.display = 'block'; } function rawView() { const key = window.location.hash; const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key; window.open(url, '_blank'); } async function copyPaste() { if (!_decryptedPaste) return; const ok = await copyToClipboard(_decryptedPaste.content); const btn = document.getElementById('copyBtn'); if (btn) { const t = btn.textContent; btn.textContent = ok ? 'Copied!' : 'Failed'; setTimeout(() => btn.textContent = t, 1500); } } function downloadPaste() { if (!_decryptedPaste) return; const { title = 'untitled', content = '', language = 'text' } = _decryptedPaste; const extMap = { javascript: '.js', typescript: '.ts', python: '.py', java: '.java', c: '.c', cpp: '.cpp', csharp: '.cs', html: '.html', css: '.css', scss: '.scss', sql: '.sql', json: '.json', yaml: '.yaml',xml: '.xml', bash: '.sh', powershell: '.ps1', php: '.php', ruby: '.rb', go: '.go', rust: '.rs', swift: '.swift',kotlin: '.kt', markdown:'.md', diff: '.diff', docker: '', nginx: '.conf', toml: '.toml', ini: '.ini', }; const filename = title.replace(/[^a-z0-9.\-]/gi, '_') + (extMap[language] ?? '.txt'); const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement('a'), { href: url, download: filename }); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function initDeletion() { const pasteId = window.location.pathname.split('/').pop(); const token = localStorage.getItem('del_' + pasteId); const deleteBtn = document.getElementById('deleteBtn'); if (token && deleteBtn) { deleteBtn.style.display = 'inline-block'; deleteBtn.addEventListener('click', async () => { if (!confirm('Are you sure you want to delete this paste? This action cannot be undone.')) return; deleteBtn.disabled = true; deleteBtn.textContent = '...'; try { const resp = await fetch('/api/delete/' + pasteId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }); const result = await resp.json(); if (result.success) { localStorage.removeItem('del_' + pasteId); window.location.href = '/'; } else { alert('Error: ' + (result.error || 'Unknown error')); deleteBtn.disabled = false; deleteBtn.textContent = 'Delete'; } } catch (err) { console.error(err); alert('Failed to delete paste. Connection error.'); deleteBtn.disabled = false; deleteBtn.textContent = 'Delete'; } }); } } // ── Line highlight / linking ────────────────────────────────────────────────── let _hlAnchor = null; // anchor line for shift-click range let _hlStart = null; let _hlEnd = null; let _hlScrolled = false; function initLineHighlight() { // Parse ?L=5 or ?L=5-12 from the URL const lParam = new URLSearchParams(window.location.search).get('L'); if (lParam) { const m = lParam.match(/^(\d+)(?:-(\d+))?$/); if (m) { _hlStart = parseInt(m[1], 10); _hlEnd = m[2] ? parseInt(m[2], 10) : _hlStart; _hlAnchor = _hlStart; } } // After every Prism highlight, wire gutter clicks and redraw highlight bars. // initLineNumbers() calls Prism, which fires this hook. Prism.hooks.add('complete', function (env) { if (env.element && env.element.id === 'codeBlock') { _wireLineClicks(); if (_hlStart !== null) { _drawHighlight(_hlStart, _hlEnd); if (!_hlScrolled) { _scrollToLine(_hlStart); _hlScrolled = true; } } } }); } function _wireLineClicks() { const viewPre = document.getElementById('viewPre'); if (!viewPre) return; viewPre.querySelectorAll('.line-numbers-rows > span').forEach((span, i) => { if (span.dataset.lineWired) return; span.dataset.lineWired = '1'; const lineNum = i + 1; span.title = `Line ${lineNum}`; span.addEventListener('click', (e) => { if (e.shiftKey && _hlAnchor !== null) { const a = Math.min(_hlAnchor, lineNum); const b = Math.max(_hlAnchor, lineNum); _hlStart = a; _hlEnd = b; _drawHighlight(a, b); _updateLineUrl(a, b); } else if (_hlStart === lineNum && _hlEnd === lineNum) { // Clicking the sole highlighted line → clear _hlAnchor = null; _hlStart = null; _hlEnd = null; _clearHighlight(); _clearLineUrl(); } else { _hlAnchor = lineNum; _hlStart = lineNum; _hlEnd = lineNum; _drawHighlight(lineNum, lineNum); _updateLineUrl(lineNum, lineNum); } }); }); } function _clearHighlight() { const viewPre = document.getElementById('viewPre'); if (!viewPre) return; viewPre.querySelectorAll('.line-hl').forEach(el => el.remove()); viewPre.querySelectorAll('.line-numbers-rows > span').forEach( s => s.classList.remove('hl-active') ); } function _clearLineUrl() { const url = new URL(window.location.href); url.searchParams.delete('L'); history.replaceState(null, '', url.toString()); } function _drawHighlight(start, end) { const viewPre = document.getElementById('viewPre'); const code = document.getElementById('codeBlock'); if (!viewPre || !code) return; // Remove old bars and gutter classes viewPre.querySelectorAll('.line-hl').forEach(el => el.remove()); viewPre.querySelectorAll('.line-numbers-rows > span').forEach( (s, i) => s.classList.toggle('hl-active', i + 1 >= start && i + 1 <= end) ); const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4; const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16; const frag = document.createDocumentFragment(); for (let i = start; i <= end; i++) { const bar = document.createElement('div'); bar.className = 'line-hl'; bar.style.top = (padTop + (i - 1) * lineH) + 'px'; bar.style.height = lineH + 'px'; frag.appendChild(bar); } // Insert before so bars sit below code text in z-order viewPre.insertBefore(frag, viewPre.firstChild); } function _scrollToLine(lineNum) { const code = document.getElementById('codeBlock'); const viewPre = document.getElementById('viewPre'); const scroller = document.querySelector('.view-full'); if (!code || !viewPre || !scroller) return; const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4; const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16; scroller.scrollTop = Math.max(0, padTop + (lineNum - 1) * lineH - 80); } function _updateLineUrl(start, end) { const url = new URL(window.location.href); url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`); history.replaceState(null, '', url.toString()); } // ── Discussions ─────────────────────────────────────────────────────────────── function initDiscussions() { if (!_decryptedPaste?.discussions) return; const panel = document.getElementById('discussionsPanel'); if (!panel) return; panel.style.display = 'flex'; const pasteId = window.location.pathname.replace(/^\//, '').split('/')[0]; loadComments(pasteId); // Collapse / expand via header click const header = document.getElementById('discussionsToggle'); if (header) { header.addEventListener('click', () => { panel.classList.toggle('collapsed'); const chevron = document.getElementById('discussionsChevron'); if (chevron) chevron.textContent = panel.classList.contains('collapsed') ? '▲' : '▼'; }); } const submitBtn = document.getElementById('commentSubmit'); if (submitBtn) submitBtn.addEventListener('click', () => postComment(pasteId)); const input = document.getElementById('commentInput'); if (input) { input.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); postComment(pasteId); } }); } // Restore saved nick const nickInput = document.getElementById('commentNick'); if (nickInput) { const savedNick = localStorage.getItem('comment_nick') || ''; nickInput.value = savedNick; nickInput.addEventListener('change', () => localStorage.setItem('comment_nick', nickInput.value.trim())); } } async function loadComments(pasteId) { const list = document.getElementById('commentsList'); const panel = document.getElementById('discussionsPanel'); if (!list) return; list.innerHTML = '
Loading\u2026
'; try { const resp = await fetch(`/api/comments/${pasteId}`); if (resp.status === 403 || resp.status === 404) { // Discussions disabled or route unknown — hide the panel entirely if (panel) panel.style.display = 'none'; return; } if (!resp.ok) { list.innerHTML = '
Could not load comments.
'; return; } const data = await resp.json(); await renderComments(data.comments || []); const countEl = document.getElementById('discussionsCount'); if (countEl) countEl.textContent = (data.comments || []).length; } catch (e) { list.innerHTML = '
Could not load comments.
'; } } async function renderComments(comments) { const list = document.getElementById('commentsList'); if (!list) return; if (!comments.length) { list.innerHTML = '
No comments yet. Be the first!
'; return; } // Import key once for all comments if paste is encrypted let decryptKey = null; if (_pasteIsEncrypted && _keyBase64) { try { decryptKey = await PasteCrypto.importKey(_keyBase64); } catch (e) {} } list.innerHTML = ''; const frag = document.createDocumentFragment(); for (const c of comments) { let text, nick; if (decryptKey) { try { const plain = await PasteCrypto.decrypt(c.content, decryptKey); const parsed = JSON.parse(plain); text = parsed.content || ''; nick = parsed.nick || ''; } catch (e) { text = '[Could not decrypt comment]'; nick = ''; } } else { try { const parsed = JSON.parse(c.content); text = parsed.content || ''; nick = parsed.nick || ''; } catch (e) { text = c.content; nick = ''; } } const item = document.createElement('div'); item.className = 'comment-item'; const meta = document.createElement('div'); meta.className = 'comment-meta'; const timeStr = new Date(c.created_at.replace(' ', 'T') + 'Z').toLocaleString(); meta.textContent = nick ? `${nick} · ${timeStr}` : timeStr; if (nick) meta.querySelector ? null : (meta.dataset.nick = '1'); if (nick) meta.classList.add('has-nick'); const body = document.createElement('div'); body.className = 'comment-content'; body.textContent = text; item.appendChild(meta); item.appendChild(body); frag.appendChild(item); } list.appendChild(frag); } async function postComment(pasteId) { const input = document.getElementById('commentInput'); const nickInput = document.getElementById('commentNick'); const btn = document.getElementById('commentSubmit'); if (!input || !btn) return; const text = input.value.trim(); if (!text) { input.focus(); return; } const nick = (nickInput?.value.trim() || '').slice(0, 32); if (nick) localStorage.setItem('comment_nick', nick); btn.disabled = true; btn.textContent = '\u2026'; try { let content; const payload = { content: text, ...(nick ? { nick } : {}) }; if (_pasteIsEncrypted && _keyBase64) { const key = await PasteCrypto.importKeyBidirectional(_keyBase64); content = await PasteCrypto.encrypt(JSON.stringify(payload), key); } else { content = JSON.stringify(payload); } const resp = await fetch(`/api/comments/${pasteId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }); let result; try { result = await resp.json(); } catch (_) { result = {}; } if (!resp.ok || result.error) { alert('Error: ' + (result.error || `Server error ${resp.status}`)); } else { input.value = ''; await loadComments(pasteId); } } catch (e) { alert('Failed to post comment: ' + e.message); } finally { btn.disabled = false; btn.textContent = 'Post'; } }