bastebin/static/js/paste_view.js

495 lines
19 KiB
JavaScript

'use strict';
/**
* paste_view.js — Logic for the paste view page.
* Depends on: crypto.js (PasteCrypto), app.js (copyToClipboard)
*
* Reads the site name from <meta name="site-name"> 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 <code> 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 = '<div class="comments-loading">Loading\u2026</div>';
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 = '<div class="comments-empty">Could not load comments.</div>'; 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 = '<div class="comments-empty">Could not load comments.</div>';
}
}
async function renderComments(comments) {
const list = document.getElementById('commentsList');
if (!list) return;
if (!comments.length) {
list.innerHTML = '<div class="comments-empty">No comments yet. Be the first!</div>';
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';
}
}