diff --git a/public/suite-page.html b/public/suite-page.html index 1999503..ffce348 100644 --- a/public/suite-page.html +++ b/public/suite-page.html @@ -29,6 +29,13 @@ border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px} @keyframes sp{to{transform:rotate(360deg)}} @media (prefers-reduced-motion:reduce){.spin{animation:none}} + .pg-page{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap; + border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:9px} + .pg-page.main{border-color:var(--gold)} + .pg-page .nm{font-weight:800;font-size:15px} + .pg-page .u{font-size:12.5px;color:var(--muted);word-break:break-all;margin-top:2px} + .pg-page .meta{font-size:12px;color:var(--muted);margin-top:3px} + .pg-acts{display:flex;gap:7px;flex-wrap:wrap} @@ -45,6 +52,12 @@
Open it โ†’
+ +
diff --git a/public/suite-page.js b/public/suite-page.js index 7ce3828..21de3da 100644 --- a/public/suite-page.js +++ b/public/suite-page.js @@ -5,6 +5,8 @@ 'use strict'; var $ = function (id) { return document.getElementById(id); }; var angle = 'overview', busy = false, myId = null; + // null = building a NEW page; '' = rebuilding the main one; 'slug' = that page + var replacing = null, pages = [], cap = 0; function renderAngles(angles) { var host = $('pgAngles'); @@ -19,6 +21,93 @@ }); } + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function setMode(mode, label) { + replacing = mode; + $('pgGo').textContent = mode === null ? '๐Ÿงฑ Build a new page' : '๐Ÿ” Rebuild ' + (label || 'this page'); + if (mode !== null) $('pgCard').scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function renderPages() { + var wrap = $('pgPagesWrap'), host = $('pgPages'); + if (!pages.length) { wrap.style.display = 'none'; return; } + wrap.style.display = 'block'; + host.innerHTML = ''; + pages.forEach(function (p) { + var row = document.createElement('div'); + row.className = 'pg-page' + (p.slug ? '' : ' main'); + var left = document.createElement('div'); + left.style.minWidth = '210px'; + left.innerHTML = '
' + esc(p.name || 'Your page') + + (p.slug ? '' : ' MAIN') + '
' + + '
rmcircle.team' + esc(p.url) + '
' + + '
' + esc(p.angle || '') + + (p.updatedAt ? ' ยท updated ' + esc(String(p.updatedAt).slice(0, 10)) : '') + '
'; + row.appendChild(left); + + var acts = document.createElement('div'); + acts.className = 'pg-acts'; + var view = document.createElement('a'); + view.className = 'btn btn-secondary btn-sm'; + view.href = p.url; view.target = '_blank'; view.rel = 'noopener'; + view.textContent = 'View'; + acts.appendChild(view); + + var copy = document.createElement('button'); + copy.className = 'btn btn-secondary btn-sm'; + copy.textContent = 'Copy link'; + copy.addEventListener('click', function () { + if (navigator.clipboard) navigator.clipboard.writeText('https://rmcircle.team' + p.url); + copy.textContent = 'Copied'; + setTimeout(function () { copy.textContent = 'Copy link'; }, 1400); + }); + acts.appendChild(copy); + + var re = document.createElement('button'); + re.className = 'btn btn-secondary btn-sm'; + re.textContent = 'Rebuild'; + re.addEventListener('click', function () { setMode(p.slug || '', p.name || 'this page'); }); + acts.appendChild(re); + + // The main page keeps its address forever โ€” it is on banners and printed + // flyers โ€” so only the extra pages can be removed. + if (p.slug) { + var del = document.createElement('button'); + del.className = 'btn btn-secondary btn-sm'; + del.textContent = 'Delete'; + del.addEventListener('click', function () { removePage(p.slug, p.name, del); }); + acts.appendChild(del); + } + row.appendChild(acts); + host.appendChild(row); + }); + + var left = Math.max(0, cap - pages.length); + $('pgCapNote').innerHTML = 'Holding ' + pages.length + ' of ' + cap + ' pages your level allows' + + (left ? ' โ€” room for ' + left + ' more.' : ' โ€” delete one to add another, or upgrade to hold more.'); + if (!left && replacing === null) setMode(pages.length ? (pages[0].slug || '') : null, pages[0] && pages[0].name); + } + + async function removePage(slug, name, btn) { + if (!window.confirm('Delete "' + (name || slug) + '"? Anything pointing at that address will fall back to your main page.')) return; + btn.disabled = true; + try { + var r = await fetch('/api/public/suite-page', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ remove: slug }) + }); + var d = await r.json(); + if (r.ok) { pages = d.pages || []; cap = d.cap || cap; renderPages(); } + else btn.disabled = false; + } catch (e) { btn.disabled = false; } + } + function showMeter(m) { if (!m || !m.limit) { $('pgMeter').textContent = ''; return; } $('pgMeter').innerHTML = '' + m.remaining + ' of ' + m.limit + ' page builds left this month'; @@ -34,7 +123,6 @@ // cache-bust so a rebuild always shows the NEW page, not the old one $('pgFrame').src = url + '?preview=' + Date.now(); $('pgPrev').style.display = 'block'; - $('pgGo').textContent = '๐Ÿ” Rebuild my page'; } // Live status under the button. A silent 40-second wait reads as "frozen", @@ -90,6 +178,13 @@ return; } showMeter(d.meter); + pages = d.pages || []; + cap = d.cap || 0; + // First-time member: no pages yet, so the button builds their main one. + // Anyone with pages starts in "add another" mode; renderPages flips that + // to rebuild if they are already at their level's limit. + setMode(pages.length ? null : '', null); + renderPages(); if (d.page) { if (d.page.name) $('pgName').value = d.page.name; if (d.page.audience) $('pgAudience').value = d.page.audience; @@ -114,12 +209,7 @@ try { var r = await fetch('/api/public/suite-page', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: $('pgName').value.trim(), - audience: $('pgAudience').value.trim(), - story: $('pgStory').value.trim(), - angle: angle - }) + body: JSON.stringify({ name: $('pgName').value.trim(), audience: $('pgAudience').value.trim(), story: $('pgStory').value.trim(), angle: angle, replace: replacing === null ? undefined : replacing }) }); var d = await r.json(); if (!r.ok) { @@ -129,6 +219,8 @@ } else { showMeter(d.meter); showLive(d.url); + if (d.pages) { pages = d.pages; cap = d.cap || cap; renderPages(); } + setMode(d.canAddMore ? null : (d.slug || ''), null); $('pgLive').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } catch (e) { @@ -138,7 +230,7 @@ stopProgress(); busy = false; $('pgGo').disabled = false; - $('pgGo').textContent = built ? '๐Ÿ” Rebuild my page' : '๐Ÿงฑ Build my page'; + setMode(replacing, null); } document.addEventListener('DOMContentLoaded', function () { diff --git a/server.js b/server.js index 45aea67..5e384d8 100644 --- a/server.js +++ b/server.js @@ -736,13 +736,28 @@ async function handleApi(req,res,pathname){ if(e.error)return json(res,e.code||500,{error:e.error}); if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); const gate=suiteMeter.check(e.d.id,e.d.level,'page'); - return json(res,200,{page:suitePages.load(e.d.id),meter:gate,angles:suitePages.ANGLES,level:e.d.level,id:e.d.id}); + const pages=suitePages.list(e.d.id); + const cap=suitePages.pageLimit(e.d.level); + return json(res,200,{pages:pages,page:suitePages.load(e.d.id),meter:gate, + angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, + cap:cap,held:pages.length,canAddMore:pages.length({error:'Chain read hiccup โ€” try again.',code:500})); if(e.error)return json(res,e.code||500,{error:e.error}); if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); const b=await bodyJson(req)||{}; + + // Deleting one of their own pages. + if(b.remove!==undefined){ + const slug=String(b.remove||''); + if(!slug)return json(res,400,{error:'The main page cannot be deleted โ€” rebuild it instead.'}); + const gone=suitePages.remove(e.d.id,slug); + return json(res,gone?200:404,gone + ?{removed:slug,pages:suitePages.list(e.d.id),held:suitePages.list(e.d.id).length,cap:suitePages.pageLimit(e.d.level)} + :{error:'That page is not there.'}); + } + const input={ name:String(b.name||'').trim().slice(0,60), audience:String(b.audience||'').trim().slice(0,300), @@ -750,6 +765,21 @@ async function handleApi(req,res,pathname){ angle:suitePages.ANGLES[b.angle]?b.angle:'overview' }; if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up โ€” try again shortly.'}); + + // Two different limits, and they used to contradict each other: the monthly + // BUILD quota is how often the writer may run, the HOLD cap is how many + // pages the level lets them keep. Rebuilding an existing page spends a + // build but does not need hold room; adding a new one needs both. + const existing=suitePages.list(e.d.id); + const cap=suitePages.pageLimit(e.d.level); + const replacing=b.replace!==undefined?String(b.replace||''):null; + const isNew=replacing===null; + if(isNew&&existing.length>=cap){ + return json(res,429,{error:cap<=1 + ? 'Your level allows one page. Rebuild the one you have, or upgrade to hold more.' + : 'You are holding all '+cap+' pages your level allows. Delete one, or upgrade to hold more.', + cap:cap,held:existing.length}); + } const gate=suiteMeter.check(e.d.id,e.d.level,'page'); if(!gate.allowed){ return json(res,429,{error:gate.reason==='locked' @@ -758,9 +788,23 @@ async function handleApi(req,res,pathname){ } try{ const copy=await suitePages.generate(input); - const rec=suitePages.save(e.d.id,{id:e.d.id,name:input.name,angle:input.angle,audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()}); + // The first page a member builds stays at /p/ โ€” that address is on + // banners, flyers and in people's DMs, so it must never move. Extra pages + // get a slug from the headline the writer produced, so the address + // describes the page without asking anyone to invent one. + const hasMain=existing.some(function(x){return !x.slug}); + let slug; + if(replacing!==null) slug=replacing; + else if(!hasMain) slug=''; + else slug=suitePages.slugFromTitle(e.d.id,copy.headline,input.angle); + + const rec=suitePages.save(e.d.id,{id:e.d.id,slug:slug||undefined,name:input.name,angle:input.angle, + audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug||undefined); suiteMeter.record(e.d.id,'page',1); - return json(res,200,{page:rec,url:'https://rmcircle.team/p/'+e.d.id,meter:suiteMeter.check(e.d.id,e.d.level,'page')}); + const url='https://rmcircle.team/p/'+e.d.id+(slug?'/'+slug:''); + const after=suitePages.list(e.d.id); + return json(res,200,{page:rec,url:url,slug:slug||'',pages:after,held:after.length,cap:cap, + canAddMore:after.length