Files
palette/internal/web/static/new.js
T
fen a4e1abfaae
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
#260: replace Success! feedback text with a checkmark so buttons do not resize
2026-09-17 14:29:02 -05:00

427 lines
16 KiB
JavaScript

// #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('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', '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 = '✓';
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 = '<span class="file-chip-name"></span>' +
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
'<button type="button" class="file-chip-remove" title="Remove file">&times;</button>';
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(); }
});