// #139: editor page logic (gutter, expiry, burn, attachments, create). const $ = id => document.getElementById(id); const content = $('content'), gutter = $('gutter'); function updateGutter() { const lines = content.value.split('\n').length; let s = ''; for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n'; gutter.textContent = s; } content.addEventListener('input', updateGutter); updateGutter(); function toast(msg, kind) { let t = document.querySelector('.toast'); if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.remove('success', 'error'); if (kind === 'success') t.classList.add('success'); if (kind === 'error') t.classList.add('error'); t.classList.add('show'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('show'), 2000); } const setHidden = (id, hid) => { const el = $(id); if (el) el.classList.toggle('hidden', hid); }; $('haspw').addEventListener('change', e => setHidden('pwrow', !e.target.checked)); $('burn').addEventListener('change', e => setHidden('burnrow', !e.target.checked)); document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => { setHidden('customexp-row', document.querySelector('input[name="exp"]:checked').value !== 'custom'); setHidden('customexp-err', true); })); // compose the expires_in Go-duration string when Custom is checked (#48). // Returns the string, or null with an inline error shown. function composeCustomExpiry() { const n = parseInt($('expnum').value, 10); const unit = $('expunit').value; let mins = NaN; if (n > 0) { if (unit === 'm') mins = n; else if (unit === 'h') mins = n * 60; else if (unit === 'd') mins = n * 1440; else if (unit === 'w') mins = n * 10080; else if (unit === 'mo') mins = n * 43200; // months counted as 30 days } const err = $('customexp-err'); if (!(mins >= 1)) { err.textContent = 'Enter a duration of at least 1 minute.'; err.classList.remove('hidden'); return null; } if (mins > 525600) { // more than 1 year err.textContent = 'Custom expiry cannot exceed 1 year.'; err.classList.remove('hidden'); return null; } err.classList.add('hidden'); // compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine const hours = Math.floor(mins / 60), rem = mins % 60; if (rem === 0) return hours + 'h'; if (hours === 0) return rem + 'm'; return hours + 'h' + rem + 'm'; } $('pwreveal').addEventListener('click', () => { const pw = $('password'); const show = pw.type === 'password'; pw.type = show ? 'text' : 'password'; $('pwreveal').classList.toggle('off', !show); $('pwreveal').title = show ? 'Hide password' : 'Show password'; }); let guessed = ''; // last auto-detected language, '' = user override // #105: map backend machine-readable error codes to plain-language guidance. // Unknown codes fall back to a generic message; the technical detail stays // in the API response for API consumers. const ERROR_MESSAGES = { slug_taken: 'That Custom URL is already taken. Try another.', slug_reserved: 'That Custom URL is reserved. Try another.', slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.', content_empty: 'Write or paste something first.', content_too_large: 'This paste is too large. The limit is 5 MB.', file_too_large: 'File is too large. The limit is 25 MB.', one_file_only: 'Choose either text or a file for now.', expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.', rate_limited: 'Too many tries. Wait a minute and try again.', }; const GENERIC_ERROR = 'Something went wrong. Please try again.'; function friendlyError(data) { return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR; } // #105: color the result box by outcome — success (ok), error (err), // warning (warn) — with a colored left border (CSS .result-ok/.result-err). function setResultKind(kind) { const card = $('result-card'); card.classList.remove('result-ok', 'result-err', 'result-warn'); if (kind) card.classList.add('result-' + kind); } function showResult(html, kind) { $('result').innerHTML = html; $('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || ''); setHidden('result-card', false); setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err')); } function defaultFilename(lang) { const names = { python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts', rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh', sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html', css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb', perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml', ini: 'config.ini', diff: 'changes.diff', markdown: 'notes.md', text: 'Text.txt', }; return names[lang] || ''; } // fill default filename when title is still blank function maybeSetDefaultTitle(lang) { const title = $('title'); if (lang && !title.value.trim()) { const fn = defaultFilename(lang); if (fn) title.value = fn; } } async function guessLang() { if (!content.value.trim()) return; try { const res = await fetch('/api/guess-language', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({content: content.value}), }); const data = await res.json(); if (res.ok && data.language) { guessed = data.language; $('language').value = data.language; maybeSetDefaultTitle(data.language); } } catch(e) {} } // refresh button: always re-detect, even if user picked something $('reguess').addEventListener('click', guessLang); // auto-guess when pasting into the editor content.addEventListener('paste', () => setTimeout(guessLang, 0)); async function create() { // #38: file attached -> file paste (1 file = 1 paste; text is ignored) if (attachedFile) return createFilePaste(); const body = { content: content.value, title: $('title').value || null, language: $('language').value || null, custom_slug: $('custom').value || null, burn_after_read: $('burn').checked, }; if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1; if ($('haspw').checked) body.password = $('password').value; const exp = document.querySelector('input[name="exp"]:checked').value; if (exp === 'custom') { const dur = composeCustomExpiry(); if (dur === null) { toast('Check the custom expiry', 'error'); return; } body.expires_in = dur; } else if (exp) { body.expires_in = exp; } const res = await fetch('/api/pastes', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { showResult(friendlyError(data), 'err'); toast('Create failed', 'error'); return; } finishCreate(data); } // shared success handling for both create paths (#38): result box, copy // button, password auto-unlock, then redirect to the paste. function finishCreate(data) { const url = location.origin + '/' + (data.custom_slug || data.id); showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; const copyBtn = document.getElementById('result-copy'); copyBtn.addEventListener('click', () => { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) copyBtn.textContent = 'Success!'; setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); } catch(e) { toast('Copy failed', 'error'); } }); // token carried via sessionStorage, never in the URL (#143) const dest = '/' + data.id + '?created=1'; try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {} // password-protected: unlock now with the password we already have (#26) if ($('haspw').checked && data.id) { const fd = new FormData(); fd.append('password', $('password').value); fd.append('next', dest); try { fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; }); return; } catch(e) {} } // show the paste location.href = dest; } $('create').addEventListener('click', create); // #38: file attachment support — 1 file = 1 paste. Three input paths: // Ctrl+V paste of a file, upload button (file picker), drag-and-drop. const MAX_FILE_BYTES = 25 * 1024 * 1024; let attachedFile = null; // the single attached File, or null function humanSize(n) { if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'; return n + ' B'; } // #171: text-file extensions -> language key for title placeholder // convention (e.g. Python.py); fallback Text. const LANG_BY_EXT = { py: 'python', go: 'go', js: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript', rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', java: 'java', cs: 'csharp', sh: 'bash', bash: 'bash', sql: 'sql', yml: 'yaml', yaml: 'yaml', json: 'json', html: 'html', htm: 'html', css: 'css', xml: 'xml', php: 'php', rb: 'ruby', pl: 'perl', lua: 'lua', toml: 'toml', ini: 'ini', diff: 'diff', md: 'markdown', txt: 'text', log: 'text', }; const TEXT_EXTS = new Set(Object.keys(LANG_BY_EXT)); function extOf(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; } // #171: when a file is attached it takes over the main editing area. // Images render fitted into the editor area; text files load their // content into the editor. Title auto-fills only if still blank (house // rule: never overwrite a typed title). const IMAGE_RE = /^image\//; let previewURL = null; // #171: CSP img-src only allows 'self' and data:, so blob: URLs are // blocked — read the file as a data: URL via FileReader instead. function readFileDataURL(file, cb) { const r = new FileReader(); r.onload = () => cb(r.result); r.readAsDataURL(file); } async function showFileInEditor(file) { const wrap = document.querySelector('.editor-wrap'); const img = $('file-preview'); if (IMAGE_RE.test(file.type)) { readFileDataURL(file, (dataURL) => { previewURL = dataURL; img.src = dataURL; img.alt = file.name; wrap.classList.add('previewing'); img.classList.remove('hidden'); }); return; } wrap.classList.remove('previewing'); img.classList.add('hidden'); if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone try { const text = await file.text(); // #184: the file replaces the main editing area, so the text always // loads over whatever was in the editor (same rule as images, which // hide the editor entirely). content.value = text; updateGutter(); guessLang(); } catch (e) {} } function clearPreview() { const wrap = document.querySelector('.editor-wrap'); const img = $('file-preview'); wrap.classList.remove('previewing'); img.classList.add('hidden'); img.removeAttribute('src'); if (previewURL) { URL.revokeObjectURL(previewURL); previewURL = null; } } function setAttachedFile(file) { if (!file) return clearAttachedFile(); if (file.size > MAX_FILE_BYTES) { toast('File is too large. The limit is 25 MB.', 'error'); return; } attachedFile = file; renderFileChip(); setHidden('file-text-note', false); // #233: title auto-fill — the file's own name always wins when the // title is still blank; fallback is date.fileextension (e.g. // 2026-09-10.txt) when the name is missing or unusable. Never // overwrite a typed title. if (!$('title').value.trim()) { const raw = (file.name || '').trim(); if (raw) { $('title').value = raw; } else { const d = new Date(); const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); const ext = extOf(raw || file.name); $('title').value = ext ? iso + '.' + ext : iso; } } showFileInEditor(file); } function clearAttachedFile() { attachedFile = null; $('file-input').value = ''; renderFileChip(); setHidden('file-text-note', true); clearPreview(); } function renderFileChip() { const slot = $('file-chip-slot'); slot.innerHTML = ''; if (!attachedFile) return; const chip = document.createElement('div'); chip.className = 'file-chip'; chip.innerHTML = '' + '' + humanSize(attachedFile.size) + '' + ''; chip.querySelector('.file-chip-name').textContent = attachedFile.name; chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile); slot.appendChild(chip); } // (1) file picker via the dropzone click $('dropzone').addEventListener('click', () => $('file-input').click()); $('file-input').addEventListener('change', () => { if ($('file-input').files.length) setAttachedFile($('file-input').files[0]); }); // (2) drag-and-drop onto the dropzone (and the page broadly) const dz = $('dropzone'); ['dragenter', 'dragover'].forEach(ev => { document.addEventListener(ev, e => { if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return; e.preventDefault(); dz.classList.add('dragover'); }); }); ['dragleave', 'drop'].forEach(ev => { document.addEventListener(ev, e => { if (ev === 'drop') e.preventDefault(); if (e.target === dz || ev === 'drop') dz.classList.remove('dragover'); }); }); document.addEventListener('drop', e => { if (!e.dataTransfer || !e.dataTransfer.files.length) return; e.preventDefault(); setAttachedFile(e.dataTransfer.files[0]); }); // (3) Ctrl+V of a file anywhere on the page document.addEventListener('paste', e => { const files = e.clipboardData && e.clipboardData.files; if (!files || !files.length) return; // normal text paste into the editor e.preventDefault(); setAttachedFile(files[0]); }); // file create path: POST multipart. 1 file = 1 paste: when a file is // attached the editor text is ignored (server enforces this too). async function createFilePaste() { const fd = new FormData(); fd.append('file', attachedFile); if ($('title').value) fd.append('title', $('title').value); if ($('custom').value) fd.append('custom_slug', $('custom').value); if ($('haspw').checked) fd.append('password', $('password').value); if ($('burn').checked) { fd.append('burn_after_read', 'true'); fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1)); } if ($('unlisted').checked) fd.append('visibility', 'unlisted'); const exp = document.querySelector('input[name="exp"]:checked').value; if (exp === 'custom') { const dur = composeCustomExpiry(); if (dur === null) { toast('Check the custom expiry', 'error'); return; } fd.append('expires_in', dur); } else if (exp) { fd.append('expires_in', exp); } const res = await fetch('/api/pastes', { method: 'POST', body: fd }); const data = await res.json(); if (!res.ok) { showResult(friendlyError(data), 'err'); toast('Create failed', 'error'); return; } finishCreate(data); } // reset stale result state when returning via Back (bfcache) (#28) window.addEventListener('pageshow', e => { if (!e.persisted) return; const rc = document.getElementById('result-card'); if (rc) rc.classList.add('hidden'); const r = document.getElementById('result'); if (r) { r.innerHTML = 'empty'; delete r.dataset.token; } }); document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); } });