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}
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