← back to Rentv
rentv-v1: BLOG BACKEND — file-backed original articles (posts API create/edit/delete/publish), admin editor /admin/publish, public reader /post/:id, /blog index; Blog wired into nav; posts.json rsync-excluded so deploys never clobber published articles
026bc4eeed3dcdee38abd2db13bae5b074f6aeaf · 2026-07-21 15:34:56 -0700 · Steve Abrams
Files touched
M .deploy.confM .gitignoreM build.mjsM data/markets.jsonM data/news.jsonM data/ticker.jsonM public/about.htmlA public/admin/publish.htmlM public/advertise.htmlM public/article.htmlA public/blog.htmlM public/conferences.htmlM public/cre-talk.htmlM public/index.htmlM public/markets.htmlA public/post.htmlM public/review.htmlM public/section.htmlM public/subscribe.htmlM server.js
Diff
commit 026bc4eeed3dcdee38abd2db13bae5b074f6aeaf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 21 15:34:56 2026 -0700
rentv-v1: BLOG BACKEND — file-backed original articles (posts API create/edit/delete/publish), admin editor /admin/publish, public reader /post/:id, /blog index; Blog wired into nav; posts.json rsync-excluded so deploys never clobber published articles
---
.deploy.conf | 2 +-
.gitignore | 1 +
build.mjs | 2 +-
data/markets.json | 2 +-
data/news.json | 2 +-
data/ticker.json | 2 +-
public/about.html | 2 +-
public/admin/publish.html | 66 +++++++++++++++++++++++++++++++++++++++++++++++
public/advertise.html | 2 +-
public/article.html | 2 +-
public/blog.html | 32 +++++++++++++++++++++++
public/conferences.html | 2 +-
public/cre-talk.html | 2 +-
public/index.html | 2 +-
public/markets.html | 2 +-
public/post.html | 41 +++++++++++++++++++++++++++++
public/review.html | 2 +-
public/section.html | 2 +-
public/subscribe.html | 2 +-
server.js | 51 ++++++++++++++++++++++++++++++++++++
20 files changed, 206 insertions(+), 15 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index a5b25240..42a5d01f 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -5,4 +5,4 @@ HEALTH_URL=http://localhost:9704/api/health
INSTALL_CMD="npm ci --production"
BUILD_CMD=""
# Never overwrite prod's accumulated real newsletter signups with the local file.
-RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl"
+RSYNC_EXTRA_EXCLUDES="data/subscribers.jsonl data/posts.json"
diff --git a/.gitignore b/.gitignore
index 67aa2005..961ccec7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ node_modules/
*.log
.DS_Store
data/subscribers.jsonl
+data/posts.json
diff --git a/build.mjs b/build.mjs
index c4ecf934..0cf512f1 100644
--- a/build.mjs
+++ b/build.mjs
@@ -95,7 +95,7 @@ const masthead = `
<div class="datebox"><span>Western U.S. Edition</span><br><span id="today"></span></div>
</div></header>`;
-const NAV = [['/', 'Home'], ['/sales', 'Sales'], ['/leases', 'Leases'], ['/financing', 'Financing'], ['/development', 'Development'], ['/multifamily', 'Multifamily'], ['/industrial', 'Industrial'], ['/retail', 'Retail'], ['/markets', 'Markets'], ['/review', 'The REview'], ['/cre-talk', 'CRE Talk'], ['/conferences', 'Conferences'], ['/versions', 'Versions']];
+const NAV = [['/', 'Home'], ['/sales', 'Sales'], ['/leases', 'Leases'], ['/financing', 'Financing'], ['/development', 'Development'], ['/multifamily', 'Multifamily'], ['/industrial', 'Industrial'], ['/retail', 'Retail'], ['/markets', 'Markets'], ['/review', 'The REview'], ['/cre-talk', 'CRE Talk'], ['/conferences', 'Conferences'], ['/versions', 'Versions'], ['/blog', 'Blog']];
const nav = (active) => `
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
diff --git a/data/markets.json b/data/markets.json
index 70eed234..73e9ff88 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-07-21T22:03:35.067Z",
+ "fetched_at": "2026-07-21T22:23:36.013Z",
"source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
"curve": [
{
diff --git a/data/news.json b/data/news.json
index de1d25ad..6d3bd60e 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live",
- "fetched_at": "2026-07-21T22:03:34.763Z",
+ "fetched_at": "2026-07-21T22:23:35.670Z",
"count": 30,
"items": [
{
diff --git a/data/ticker.json b/data/ticker.json
index ac5df199..784d002e 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-07-21T22:03:35.030Z",
+ "fetched_at": "2026-07-21T22:23:35.985Z",
"la_office_vac": {
"label": "LA OFFICE VAC",
"value": "23.1%",
diff --git a/public/about.html b/public/about.html
index a9c91176..1c6ae7c8 100644
--- a/public/about.html
+++ b/public/about.html
@@ -82,7 +82,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a>
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/public/admin/publish.html b/public/admin/publish.html
new file mode 100644
index 00000000..47d90e02
--- /dev/null
+++ b/public/admin/publish.html
@@ -0,0 +1,66 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV Admin — Publish</title>
+<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Playfair+Display:wght@700&display=swap" rel="stylesheet">
+<style>
+:root{--a:#c8102e;--bg:#0e1116;--pan:#161b22;--ln:#242b34;--sub:#8b949e;--ink:#e6edf3}
+*{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5}
+a{color:inherit;text-decoration:none}.wrap{max-width:1180px;margin:0 auto;padding:0 22px}
+.abar{background:#0a0d11;border-bottom:1px solid var(--ln)}.abar .wrap{display:flex;justify-content:space-between;align-items:center;height:52px}
+.abrand{font-family:'Playfair Display',serif;font-size:20px}.abrand span{color:var(--a)}.abar a{font-size:12px;color:var(--sub)}.abar a:hover{color:var(--ink)}
+.lay{display:grid;grid-template-columns:1fr 380px;gap:26px;padding:24px 0 60px}@media(max-width:900px){.lay{grid-template-columns:1fr}}
+.card{background:var(--pan);border:1px solid var(--ln);border-radius:10px;padding:20px}
+h1{font-size:20px;margin-bottom:4px}.mut{color:var(--sub);font-size:13px}
+label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--sub);margin:14px 0 5px}
+input,textarea,select{width:100%;background:var(--bg);border:1px solid var(--ln);border-radius:6px;color:var(--ink);font:inherit;font-size:14px;padding:10px 12px}
+textarea{min-height:260px;resize:vertical;line-height:1.6}
+.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+.btn{background:var(--a);color:#fff;border:0;border-radius:6px;font-weight:800;font-size:13px;text-transform:uppercase;letter-spacing:.05em;padding:12px 20px;cursor:pointer;margin-top:16px}
+.btn.g{background:transparent;border:1px solid var(--ln);color:var(--ink);margin-left:8px}
+.ok{display:none;margin-top:12px;color:#3fb950;font-size:13px}
+.pl{list-style:none}.pl li{border-bottom:1px solid var(--ln);padding:12px 0}.pl h4{font-size:15px}.pl .m{font-size:11px;color:var(--sub);margin-top:3px}
+.badge{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:2px 7px;border-radius:10px;font-weight:800}
+.badge.pub{background:rgba(63,185,80,.15);color:#3fb950}.badge.dr{background:rgba(139,148,158,.15);color:var(--sub)}
+.pl .act{margin-top:6px;display:flex;gap:12px}.pl .act a{color:var(--a);font-size:12px;font-weight:700;cursor:pointer}.pl .act a.del{color:var(--sub)}
+.hd{padding:20px 0 0}.hd h1{font-size:22px}
+</style></head><body>
+<div class="abar"><div class="wrap"><div class="abrand">REN<span>TV</span> Admin · Publish</div><a href="/admin">← admin</a> <a href="/blog">view blog →</a></div></div>
+<div class="wrap"><div class="hd"><h1>Publish a RENTV Original</h1><p class="mut">Write and publish original articles. They appear on <a href="/blog" style="color:var(--a)">/blog</a> and open at /post/<id>.</p></div>
+<div class="lay">
+ <form class="card" id="f">
+ <input type="hidden" id="pid">
+ <label>Headline</label><input id="title" required placeholder="MG Properties Buys 188-Unit Portland Res Asset">
+ <div class="row"><div><label>Section</label><input id="cat" placeholder="Multifamily"></div><div><label>Author</label><input id="author" placeholder="RENTV Staff"></div></div>
+ <label>Dek (one-line summary)</label><input id="dek" placeholder="A short standfirst under the headline.">
+ <label>Lead image URL (optional)</label><input id="image" placeholder="https://…/photo.jpg">
+ <label>Body — separate paragraphs with a blank line</label><textarea id="body" placeholder="First paragraph…
+
+Second paragraph…"></textarea>
+ <label>Status</label><select id="status"><option value="draft">Draft</option><option value="published">Published (live on /blog)</option></select>
+ <div><button class="btn" type="submit" id="save">Save post</button><button class="btn g" type="button" id="reset">New / clear</button></div>
+ <div class="ok" id="ok">✓ Saved.</div>
+ </form>
+ <div class="card"><h1 style="font-size:16px">Existing posts <span class="mut" id="cnt"></span></h1><ul class="pl" id="list"><li class="mut">Loading…</li></ul></div>
+</div></div>
+<script>
+var $=function(id){return document.getElementById(id);};
+var esc=function(s){return (s||'').replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});};
+function load(){fetch('/api/posts').then(function(r){return r.json();}).then(function(d){
+ $('cnt').textContent='· '+d.count;
+ $('list').innerHTML=(d.items||[]).map(function(p){return '<li><span class="badge '+(p.status==='published'?'pub">Live':'dr">Draft')+'</span> <h4 style="display:inline;margin-left:6px">'+esc(p.title)+'</h4>'+
+ '<div class="m">'+esc(p.cat)+' · '+esc(p.author)+' · '+esc((p.updated_at||'').slice(0,16).replace("T"," "))+' · '+esc(p.id)+'</div>'+
+ '<div class="act"><a onclick="edit(\''+p.id+'\')">Edit</a><a onclick="pub(\''+p.id+'\',\''+(p.status==='published'?'draft':'published')+'\')">'+(p.status==='published'?'Unpublish':'Publish')+'</a><a class="del" onclick="del(\''+p.id+'\')">Delete</a> <a href="/post/'+p.id+'" target="_blank">Open ↗</a></div></li>';}).join('')||'<li class="mut">No posts yet — write your first above.</li>';
+});}
+window.edit=function(id){fetch('/api/posts/'+id).then(function(r){return r.json();}).then(function(p){
+ $('pid').value=p.id;$('title').value=p.title||'';$('cat').value=p.cat||'';$('author').value=p.author||'';$('dek').value=p.dek||'';$('image').value=p.image||'';$('body').value=p.body||'';$('status').value=p.status||'draft';
+ window.scrollTo({top:0,behavior:'smooth'});$('save').textContent='Update post';});};
+window.pub=function(id,st){fetch('/api/posts/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:st})}).then(load);};
+window.del=function(id){if(confirm('Delete this post?'))fetch('/api/posts/'+id,{method:'DELETE'}).then(load);};
+$('reset').onclick=function(){$('f').reset();$('pid').value='';$('save').textContent='Save post';};
+$('f').addEventListener('submit',function(e){e.preventDefault();
+ var body={title:$('title').value,cat:$('cat').value,author:$('author').value,dek:$('dek').value,image:$('image').value,body:$('body').value,status:$('status').value};
+ var id=$('pid').value,url=id?'/api/posts/'+id:'/api/posts',m=id?'PUT':'POST';
+ fetch(url,{method:m,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json();}).then(function(j){
+ if(j.ok){$('ok').style.display='block';setTimeout(function(){$('ok').style.display='none';},2500);$('f').reset();$('pid').value='';$('save').textContent='Save post';load();}
+ else alert(j.error||'Save failed');});});
+load();
+</script></body></html>
diff --git a/public/advertise.html b/public/advertise.html
index adf3191a..1b4e14d6 100644
--- a/public/advertise.html
+++ b/public/advertise.html
@@ -82,7 +82,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a>
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/public/article.html b/public/article.html
index 2aca6ff1..7223914b 100644
--- a/public/article.html
+++ b/public/article.html
@@ -69,7 +69,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
- <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a></div>
+ <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a></div>
</div></nav>
<div class="wrap"><article class="art" id="art">
diff --git a/public/blog.html b/public/blog.html
new file mode 100644
index 00000000..1ffc0eba
--- /dev/null
+++ b/public/blog.html
@@ -0,0 +1,32 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV Originals — The Blog</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+:root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8}
+*{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',system-ui,sans-serif;color:var(--ink);background:var(--bg);line-height:1.5}
+a{color:inherit;text-decoration:none}.wrap{max-width:1100px;margin:0 auto;padding:0 20px}
+.util{background:var(--ink);color:#cfd4d9;font-size:12px}.util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}.util a{color:#cfd4d9}
+.mast{border-bottom:2px solid var(--ink);padding:18px 0 12px;text-align:center}.logo{font-family:'Playfair Display',serif;font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+.logo small{display:block;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:var(--sub);margin-top:2px}
+.hd{padding:26px 0 6px}.hd h1{font-family:'Playfair Display',serif;font-size:32px}.hd p{color:var(--sub);margin-top:4px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:22px;padding:22px 0 60px}
+.pc{border:1px solid var(--line);border-radius:8px;overflow:hidden;display:block;transition:.2s}.pc:hover{transform:translateY(-4px);box-shadow:0 16px 40px rgba(0,0,0,.08)}
+.pc .img{aspect-ratio:16/9;background:linear-gradient(135deg,#475569,#c8102e)}.pc .img img{width:100%;height:100%;object-fit:cover;display:block}
+.pc .b{padding:16px 18px}.pc .chip{font-size:11px;font-weight:800;text-transform:uppercase;color:var(--red)}.pc h3{font-family:'Playfair Display',serif;font-size:20px;margin:6px 0}.pc .dek{font-size:14px;color:var(--sub)}.pc .byl{font-size:12px;color:var(--sub);margin-top:8px}
+.empty{color:var(--sub);padding:40px 0}
+footer{border-top:1px solid var(--line);color:var(--sub);font-size:12px;padding:26px 0;text-align:center}
+</style></head><body>
+<div class="util"><div class="wrap"><a href="/">RENTV.com · Commercial Real Estate News</a><a href="/admin/publish">Publish →</a></div></div>
+<header class="mast"><div class="wrap"><a class="logo" href="/">REN<span>TV</span>.com<small>RENTV Originals</small></a></div></header>
+<div class="wrap"><div class="hd"><h1>RENTV Originals</h1><p>Original reporting and analysis from the RENTV newsroom.</p></div>
+<div class="grid" id="grid"><p class="empty">Loading…</p></div></div>
+<footer><div class="wrap">RENTV.com · <a href="/" style="color:var(--red)">back to the front page</a></div></footer>
+<script>
+var esc=function(s){return (s||'').replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});};
+fetch('/api/posts?status=published').then(function(r){return r.json();}).then(function(d){
+ var g=document.getElementById('grid'),items=d.items||[];
+ if(!items.length){g.innerHTML='<p class="empty">No published articles yet. <a href="/admin/publish" style="color:var(--red)">Publish the first →</a></p>';return;}
+ g.innerHTML=items.map(function(p){var img=p.image?'<div class="img"><img src="'+esc(p.image)+'" alt="" onerror="this.parentNode.style.background=\'linear-gradient(135deg,#475569,#c8102e)\';this.remove()"></div>':'<div class="img"></div>';
+ return '<a class="pc" href="/post/'+p.id+'">'+img+'<div class="b"><span class="chip">'+esc(p.cat)+'</span><h3>'+esc(p.title)+'</h3>'+(p.dek?'<div class="dek">'+esc(p.dek)+'</div>':'')+'<div class="byl">By '+esc(p.author)+' · '+esc((p.created_at||'').slice(0,10))+'</div></div></a>';}).join('');
+}).catch(function(){document.getElementById('grid').innerHTML='<p class="empty">Blog temporarily unavailable.</p>';});
+</script></body></html>
diff --git a/public/conferences.html b/public/conferences.html
index c3d94201..ab7b5077 100644
--- a/public/conferences.html
+++ b/public/conferences.html
@@ -82,7 +82,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences" class="on">Conferences</a><a href="/versions">Versions</a>
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences" class="on">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/public/cre-talk.html b/public/cre-talk.html
index 6e35f90f..61d27ca1 100644
--- a/public/cre-talk.html
+++ b/public/cre-talk.html
@@ -82,7 +82,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk" class="on">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a>
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk" class="on">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/public/index.html b/public/index.html
index 1e745760..d88741c1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -95,7 +95,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a data-cat="all" class="on">Home</a><a data-cat="Sales">Sales</a><a data-cat="Leases">Leases</a><a data-cat="Financing">Financing</a><a data-cat="Development">Development</a><a data-cat="Multifamily">Multifamily</a><a data-cat="Industrial">Industrial</a><a data-cat="Retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a>
+ <a data-cat="all" class="on">Home</a><a data-cat="Sales">Sales</a><a data-cat="Leases">Leases</a><a data-cat="Financing">Financing</a><a data-cat="Development">Development</a><a data-cat="Multifamily">Multifamily</a><a data-cat="Industrial">Industrial</a><a data-cat="Retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/public/markets.html b/public/markets.html
index 28e69b7e..87efbe1e 100644
--- a/public/markets.html
+++ b/public/markets.html
@@ -58,7 +58,7 @@ footer .cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:28px}footer
</div></header>
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
- <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets" class="on">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a></div>
+ <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets" class="on">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a></div>
</div></nav>
<main class="wrap">
diff --git a/public/post.html b/public/post.html
new file mode 100644
index 00000000..2c4ee23d
--- /dev/null
+++ b/public/post.html
@@ -0,0 +1,41 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV — Original</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+:root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8}
+*{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',system-ui,sans-serif;color:var(--ink);background:var(--bg);line-height:1.6}
+a{color:inherit;text-decoration:none}.wrap{max-width:760px;margin:0 auto;padding:0 20px}
+.util{background:var(--ink);color:#cfd4d9;font-size:12px}.util .w{max-width:1180px;margin:0 auto;padding:0 20px;display:flex;justify-content:space-between;align-items:center;height:34px}.util a{color:#cfd4d9}
+.mast{border-bottom:2px solid var(--ink);padding:18px 0 12px;text-align:center}.logo{font-family:'Playfair Display',serif;font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+.logo small{display:block;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:var(--sub);margin-top:2px}
+.art{padding:28px 0 40px}.crumb{font-size:12px;color:var(--sub);margin-bottom:14px}.crumb a{color:var(--red);font-weight:700}
+.chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red)}
+.orig{background:var(--red);color:#fff;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;padding:2px 8px;border-radius:10px;margin-left:8px}
+h1{font-family:'Playfair Display',serif;font-size:clamp(28px,4vw,42px);line-height:1.12;margin:8px 0 8px}
+.dek{font-size:19px;color:var(--sub);margin-bottom:12px}.byl{font-size:13px;color:var(--sub);border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:20px}
+.hero{aspect-ratio:16/8;border-radius:8px;overflow:hidden;margin-bottom:22px;background:linear-gradient(135deg,#1f2937,#c8102e)}.hero img{width:100%;height:100%;object-fit:cover;display:block}
+.body p{font-size:18px;margin-bottom:18px}.body p:first-of-type::first-letter{font-family:'Playfair Display',serif;font-size:54px;font-weight:700;float:left;line-height:.8;padding:6px 10px 0 0;color:var(--red)}
+.back{display:inline-block;margin-top:14px;color:var(--red);font-weight:700;font-size:13px}
+.err{color:var(--sub);padding:30px 0}.err a{color:var(--red);font-weight:700}
+footer{border-top:1px solid var(--line);color:var(--sub);font-size:12px;padding:26px 0;text-align:center}
+</style></head><body>
+<div class="util"><div class="w"><a href="/">RENTV.com · Commercial Real Estate News</a><a href="/blog">The Blog →</a></div></div>
+<header class="mast"><div class="wrap"><a class="logo" href="/">REN<span>TV</span>.com<small>RENTV Original</small></a></div></header>
+<div class="wrap"><article class="art" id="art"><p class="err">Loading…</p></article></div>
+<footer><div class="wrap">RENTV.com · Original reporting — <a href="/blog" style="color:var(--red)">more RENTV Originals</a></div></footer>
+<script>
+var esc=function(s){return (s||'').replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];});};
+var id=location.pathname.split('/').filter(Boolean).pop();
+fetch('/api/posts/'+encodeURIComponent(id)).then(function(r){return r.ok?r.json():null;}).then(function(p){
+ var a=document.getElementById('art');
+ if(!p){a.innerHTML='<p class="err">This story isn\'t available. <a href="/blog">← Back to the blog</a></p>';return;}
+ document.title=p.title+' — RENTV.com';
+ var hero=p.image?'<div class="hero"><img src="'+esc(p.image)+'" alt="" onerror="this.parentNode.style.display=\'none\'"></div>':'';
+ var body=(p.body||'').split(/\n\s*\n/).map(function(x){return x.trim();}).filter(Boolean).map(function(x){return '<p>'+esc(x)+'</p>';}).join('')||'<p>'+esc(p.dek||'')+'</p>';
+ a.innerHTML='<div class="crumb"><a href="/">Home</a> › <a href="/blog">Blog</a> › <span>'+esc(p.cat)+'</span></div>'+
+ '<span class="chip">'+esc(p.cat)+'</span><span class="orig">RENTV Original</span><h1>'+esc(p.title)+'</h1>'+
+ (p.dek?'<div class="dek">'+esc(p.dek)+'</div>':'')+
+ '<div class="byl">By '+esc(p.author)+' · '+esc((p.created_at||'').slice(0,10))+'</div>'+hero+'<div class="body">'+body+'</div>'+
+ '<a class="back" href="/blog">← All RENTV Originals</a>';
+}).catch(function(){document.getElementById('art').innerHTML='<p class="err">Temporarily unavailable. <a href="/blog">← Back</a></p>';});
+</script></body></html>
diff --git a/public/review.html b/public/review.html
index 8384dfb0..15a3bdb3 100644
--- a/public/review.html
+++ b/public/review.html
@@ -92,7 +92,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
- <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review" class="on">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a></div>
+ <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review" class="on">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a></div>
</div></nav>
<section class="rhero"><div class="wrap">
diff --git a/public/section.html b/public/section.html
index fec2aa6e..1242562c 100644
--- a/public/section.html
+++ b/public/section.html
@@ -72,7 +72,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
- <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a></div>
+ <div class="navlinks" id="nav"><a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a></div>
</div></nav>
<div class="wrap">
diff --git a/public/subscribe.html b/public/subscribe.html
index fcd5b8c1..6b06cb59 100644
--- a/public/subscribe.html
+++ b/public/subscribe.html
@@ -82,7 +82,7 @@
<nav><div class="wrap">
<button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
<div class="navlinks" id="nav">
- <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a>
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/markets">Markets</a><a href="/review">The REview</a><a href="/cre-talk">CRE Talk</a><a href="/conferences">Conferences</a><a href="/versions">Versions</a><a href="/blog">Blog</a>
</div>
</div></nav>
diff --git a/server.js b/server.js
index ef979ad4..18d50ff5 100644
--- a/server.js
+++ b/server.js
@@ -88,6 +88,54 @@ app.get('/api/subscribers', (_q, res) => {
res.json({ count: items.length, unique: uniq, items: items.slice(-500).reverse() });
});
+// ── BLOG BACKEND — original RENTV articles (file-backed, no external DB). ──
+const POSTS = path.join(DATA, 'posts.json');
+const readPosts = () => { try { return JSON.parse(fs.readFileSync(POSTS, 'utf8')); } catch { return []; } };
+const writePosts = (a) => fs.writeFileSync(POSTS, JSON.stringify(a, null, 1));
+const clean = (s, n) => String(s == null ? '' : s).trim().slice(0, n || 500);
+// list — public callers pass ?status=published; admin gets everything (newest first)
+app.get('/api/posts', (req, res) => {
+ let a = readPosts();
+ if (req.query.status) a = a.filter(p => p.status === req.query.status);
+ a.sort((x, y) => (y.created_at || '').localeCompare(x.created_at || ''));
+ res.json({ count: a.length, items: a });
+});
+app.get('/api/posts/:id', (req, res) => {
+ const p = readPosts().find(x => x.id === req.params.id);
+ if (!p) return res.status(404).json({ error: 'not found' });
+ res.json(p);
+});
+app.post('/api/posts', (req, res) => {
+ const b = req.body || {};
+ if (!clean(b.title)) return res.status(400).json({ ok: false, error: 'title required' });
+ const now = new Date().toISOString();
+ const post = {
+ id: 'rp' + Date.now().toString(36),
+ title: clean(b.title, 200), cat: clean(b.cat, 40) || 'RENTV', author: clean(b.author, 80) || 'RENTV Staff',
+ dek: clean(b.dek, 300), image: clean(b.image, 500),
+ body: clean(b.body, 40000), status: b.status === 'published' ? 'published' : 'draft',
+ created_at: now, updated_at: now,
+ };
+ const a = readPosts(); a.push(post);
+ try { writePosts(a); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, post });
+});
+app.put('/api/posts/:id', (req, res) => {
+ const a = readPosts(), i = a.findIndex(x => x.id === req.params.id);
+ if (i < 0) return res.status(404).json({ ok: false, error: 'not found' });
+ const b = req.body || {}, f = ['title', 'cat', 'author', 'dek', 'image', 'body'];
+ f.forEach(k => { if (b[k] != null) a[i][k] = clean(b[k], k === 'body' ? 40000 : 500); });
+ if (b.status) a[i].status = b.status === 'published' ? 'published' : 'draft';
+ a[i].updated_at = new Date().toISOString();
+ try { writePosts(a); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, post: a[i] });
+});
+app.delete('/api/posts/:id', (req, res) => {
+ const a = readPosts(), n = a.filter(x => x.id !== req.params.id);
+ try { writePosts(n); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, removed: a.length - n.length });
+});
+
// ── Article reader proxy: fetch a single rentv.com story on demand + cache it. ──
const artCache = new Map(); // id -> { at, data }
const ART_TTL = 15 * 60 * 1000;
@@ -154,6 +202,9 @@ app.get('/review', (_q, r) => r.sendFile(path.join(PUB, 'review.html')));
app.get('/versions', (_q, r) => r.sendFile(path.join(PUB, 'versions', 'index.html')));
// 10 ADMIN versions — picker + per-version dashboards over the real gated data
app.get('/admin', (_q, r) => r.sendFile(path.join(PUB, 'admin', 'index.html')));
+// Blog — public index of original RENTV articles + the single-post reader
+app.get('/blog', (_q, r) => r.sendFile(path.join(PUB, 'blog.html')));
+app.get('/post/:id', (_q, r) => r.sendFile(path.join(PUB, 'post.html')));
app.use(express.static(PUB, { extensions: ['html'] }));
app.listen(PORT, () => console.log('rentv-v1 (full site, gated, live data) on ' + PORT));
← c7117dee rentv-v1: 10 ADMIN dashboard versions (distinct layouts over
·
back to Rentv
·
rentv-v1: merge RENTV Originals into the homepage feed — pub c5fcc479 →