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) {
+12
View File
@@ -295,6 +295,18 @@ textarea{resize:vertical;font:inherit}
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
.donut-center{font-family:var(--disp);font-weight:800}
/* ── solo composer: toolbar + contenteditable editor ── */
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
padding:5px 11px;font-size:12.5px;cursor:pointer}
.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none}
.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px;
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
.ed-body:focus{border-color:var(--mint)}
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
.ed-body a,.ib-rich a{color:var(--mint)}
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
/* ── solo-ads inbox ── */
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
border-radius:999px;padding:1px 8px;line-height:1.5}
+31 -7
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
<link rel="stylesheet" href="/assets/site.css?v=20260905o">
</head>
<body class="bo-body">
@@ -222,7 +222,30 @@
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
<p id="cSoloRow" hidden><textarea id="cSoloBody" placeholder="Your message (40–1000 characters). Write it like an email worth reading." rows="7" style="width:100%"></textarea></p>
<div id="cSoloRow" hidden>
<div class="ed-bar" aria-label="Formatting">
<button type="button" data-cmd="bold" title="Bold"><b>B</b></button>
<button type="button" data-cmd="italic" title="Italic"><i>I</i></button>
<button type="button" data-cmd="underline" title="Underline"><u>U</u></button>
<button type="button" data-block="h3" title="Heading">Heading</button>
<button type="button" data-block="p" title="Normal text">Normal</button>
<button type="button" data-cmd="insertUnorderedList" title="Bullet list">• List</button>
<button type="button" data-cmd="insertOrderedList" title="Numbered list">1. List</button>
<button type="button" id="edLinkBtn" title="Insert link">Link</button>
<button type="button" data-cmd="removeFormat" title="Clear formatting">Clear</button>
</div>
<div id="cSoloEd" class="ed-body" contenteditable="true"
data-ph="Write it like an email worth reading. Bold the promise, list the proof, link the receipts."></div>
<p style="margin:10px 0 0"><input id="cCtaLabel" maxlength="30" style="width:100%"
placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL"></p>
<p class="ed-media">
<input type="file" id="cSoloFile" accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm" hidden>
<button type="button" class="btn small sec" id="edAttachBtn">Attach image or video</button>
<span id="edMediaInfo" class="small muted"></span>
<button type="button" class="btn small sec" id="edMediaRemove" hidden>Remove</button>
</p>
<div id="edMediaPrev" hidden style="margin-top:8px"></div>
</div>
<p class="small muted" id="cSoloHint" hidden></p>
<button class="btn" id="createCampBtn">Launch campaign</button>
</div>
@@ -239,7 +262,8 @@
<p><a href="#" id="ibBack">← Back to inbox</a></p>
<h3 id="ibSubject"></h3>
<p class="small muted" id="ibMeta"></p>
<div id="ibBody" class="promo-block" style="white-space:pre-wrap"></div>
<div id="ibMedia" hidden style="margin:12px 0"></div>
<div id="ibBody" class="promo-block ib-rich"></div>
<p style="margin-top:14px">
<a class="btn" id="ibVisit" target="_blank" rel="noopener nofollow">Visit the advertiser</a>
<button class="btn sec" id="ibClaimBtn" type="button" hidden>…</button></p>
@@ -355,9 +379,9 @@
</div>
</div>
<script src="/assets/common.js?v=20260905n"></script>
<script src="/assets/wallet.js?v=20260905n"></script>
<script src="/assets/my.js?v=20260905n"></script>
<script src="/assets/chat.js?v=20260905n"></script>
<script src="/assets/common.js?v=20260905o"></script>
<script src="/assets/wallet.js?v=20260905o"></script>
<script src="/assets/my.js?v=20260905o"></script>
<script src="/assets/chat.js?v=20260905o"></script>
</body>
</html>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title>
<link rel="stylesheet" href="/assets/site.css?v=20260905n">
<link rel="stylesheet" href="/assets/site.css?v=20260905o">
<style>
html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
@@ -43,6 +43,6 @@
</div>
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
</div>
<script src="/assets/view.js?v=20260905n"></script>
<script src="/assets/view.js?v=20260905o"></script>
</body>
</html>