Rich solo composer: WYSIWYG editor, sanitized HTML bodies, media uploads, CTA labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 15:38:46 -05:00
parent f6a3befe09
commit 2cc2855488
8 changed files with 150 additions and 16 deletions
+53 -2
View File
@@ -330,6 +330,46 @@
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
}
$('cBudget').addEventListener('input', soloHint);
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
let soloMedia = null; // { url, type } from /api/my/upload
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); }));
$('edLinkBtn').addEventListener('click', () => {
const url = prompt('Link URL (https://…)');
if (!url) return;
$('cSoloEd').focus();
document.execCommand('createLink', false, url);
});
$('edAttachBtn').addEventListener('click', () => $('cSoloFile').click());
$('cSoloFile').addEventListener('change', async () => {
const f = $('cSoloFile').files[0];
if (!f) return;
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST',
headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
soloMedia = r;
$('edMediaInfo').textContent = f.name + ' attached';
$('edMediaRemove').hidden = false;
const pv = $('edMediaPrev');
pv.hidden = false;
pv.innerHTML = r.type === 'video'
? '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>'
: '<img src="' + r.url + '" alt="attachment preview" style="max-width:320px;border-radius:10px">';
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
$('cSoloFile').value = '';
});
$('edMediaRemove').addEventListener('click', () => {
soloMedia = null;
$('edMediaInfo').textContent = '';
$('edMediaRemove').hidden = true;
$('edMediaPrev').hidden = true;
$('edMediaPrev').innerHTML = '';
});
$('cType').addEventListener('change', () => {
const t = $('cType').value;
$('cImageRow').hidden = t === 'text' || t === 'solo';
@@ -344,10 +384,14 @@
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
title: $('cTitle').value,
body: $('cType').value === 'solo' ? $('cSoloBody').value : $('cBody').value,
body: $('cType').value === 'solo' ? $('cSoloEd').innerHTML : $('cBody').value,
mediaUrl: $('cType').value === 'solo' && soloMedia ? soloMedia.url : '',
ctaLabel: $('cCtaLabel').value,
budget: Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
$('cName').value = ''; $('cBudget').value = '';
$('cSoloEd').innerHTML = ''; $('cCtaLabel').value = '';
if ($('edMediaRemove') && !$('edMediaRemove').hidden) $('edMediaRemove').click();
await loadCampaigns();
}));
// defers the busy() lookup to click time (busy is declared below)
@@ -395,8 +439,15 @@
$('inboxReadCard').hidden = false;
$('ibSubject').textContent = r.subject || '(no subject)';
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
$('ibBody').textContent = r.body || '';
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
const mv = $('ibMedia');
mv.hidden = !r.mediaUrl;
mv.innerHTML = !r.mediaUrl ? ''
: r.mediaType === 'video'
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
$('ibVisit').href = r.url;
$('ibVisit').textContent = r.ctaLabel || 'Learn more';
const btn = $('ibClaimBtn');
clearInterval(ibTimer);
if (r.rewarded) {