← back to Rentv
RENTV: move Property Closings to backend-only + editable override layer (add/edit/hide over live Sale feed) — TK-10251
0432aa44d990ba214a592999a0b4c1f80e3e0f45 · 2026-08-05 13:29:04 -0700 · Steve Abrams
- /closings now adminOnly + INTERNAL_PAGE; nav link hidden from user-tier fleet-wide
- new /api/closings CRUD (GET merge, POST add, PUT edit, DELETE hide/remove, restore) over closings-overrides.json
- closings.html rebuilt as backend editor: Add button, per-card Edit/Hide/Restore, modal form, manual/edited/hidden badges
- server.js changes were already swept into f598483 by a parallel add -A in the shared tree
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A data/closings-overrides.jsonM public/closings.html
Diff
commit 0432aa44d990ba214a592999a0b4c1f80e3e0f45
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:29:04 2026 -0700
RENTV: move Property Closings to backend-only + editable override layer (add/edit/hide over live Sale feed) — TK-10251
- /closings now adminOnly + INTERNAL_PAGE; nav link hidden from user-tier fleet-wide
- new /api/closings CRUD (GET merge, POST add, PUT edit, DELETE hide/remove, restore) over closings-overrides.json
- closings.html rebuilt as backend editor: Add button, per-card Edit/Hide/Restore, modal form, manual/edited/hidden badges
- server.js changes were already swept into f598483 by a parallel add -A in the shared tree
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/closings-overrides.json | 6 ++
public/closings.html | 221 ++++++++++++++++++++++++++++++++++++-------
2 files changed, 194 insertions(+), 33 deletions(-)
diff --git a/data/closings-overrides.json b/data/closings-overrides.json
new file mode 100644
index 00000000..8c79c720
--- /dev/null
+++ b/data/closings-overrides.json
@@ -0,0 +1,6 @@
+{
+ "adds": [],
+ "edits": {},
+ "hides": [],
+ "updated_at": "2026-08-05T20:27:46.764Z"
+}
\ No newline at end of file
diff --git a/public/closings.html b/public/closings.html
index 55e7b1b6..8db2e493 100644
--- a/public/closings.html
+++ b/public/closings.html
@@ -2,8 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>Recent Property Closings — RENTV.com</title>
-<meta name="description" content="Recent commercial real estate closings across the Western U.S. — closed sales with address, price, property type and date, parsed from live RENTV.com transaction reporting.">
+<title>Property Closings — Backend Editor · RENTV.com</title>
+<meta name="robots" content="noindex,nofollow">
+<meta name="description" content="Backend editor for RENTV property closings — the live Sale feed plus a manual add / edit / hide override layer. Admin-only.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
@@ -17,11 +18,12 @@
.util .live{display:inline-flex;align-items:center;gap:7px;letter-spacing:.05em;text-transform:uppercase;font-size:11px}
.util .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
- .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .util .acts a{margin-left:14px;color:#cfd4d9}
.mast{background:#fff;border-bottom:2px solid var(--ink);padding:16px 0 12px}
.mast .wrap{display:flex;align-items:center;justify-content:space-between}
.logo{font-family:var(--serif);font-weight:700;font-size:32px;letter-spacing:-1px}.logo span{color:var(--red)}
.logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ .backtag{font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#fff;background:var(--ink);padding:5px 11px;border-radius:999px}
nav{background:#fff;border-bottom:1px solid var(--line)}
nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:13px 8px;border-bottom:3px solid transparent;white-space:nowrap}
@@ -36,49 +38,78 @@
.ctl{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
.ctl select{font-family:var(--sans);font-size:13px;font-weight:600;text-transform:none;letter-spacing:0;color:var(--ink);padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}
.ctl.dens input[type=range]{width:110px;accent-color:var(--red);cursor:pointer}
+ .ctl.chk{cursor:pointer}.ctl.chk input{accent-color:var(--red);cursor:pointer}
.controls input[type=search]{margin-left:auto;font-family:var(--sans);font-size:13px;padding:8px 12px;border:1px solid var(--line);border-radius:6px;min-width:200px}
+ .btn{font-family:var(--sans);font-size:13px;font-weight:700;padding:8px 15px;border-radius:6px;border:1px solid var(--ink);background:var(--ink);color:#fff;cursor:pointer}
+ .btn.add{background:var(--red);border-color:var(--red)}
+ .btn.ghost{background:#fff;color:var(--ink)}
.count{font-size:12px;color:var(--sub);margin:6px 0 16px}
.grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
.card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s,transform .15s}
.card:hover{box-shadow:0 8px 24px rgba(0,0,0,.09);transform:translateY(-2px)}
+ .card.hid{opacity:.5;filter:grayscale(.5)}
+ .card.man{border-color:var(--red)}
.card .ph{aspect-ratio:16/9;background:var(--wash) center/cover no-repeat;position:relative}
- .card .badges{position:absolute;top:10px;left:10px;display:flex;gap:6px}
+ .card .badges{position:absolute;top:10px;left:10px;display:flex;gap:6px;flex-wrap:wrap;max-width:80%}
.badge{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:4px 8px;border-radius:4px;color:#fff;background:var(--ink)}
.badge.txn{background:var(--red)}
+ .badge.man{background:#7c3aed}.badge.hid{background:#6b7280}.badge.edt{background:#0369a1}
.card .date{position:absolute;top:10px;right:10px;background:rgba(255,255,255,.94);color:var(--ink);font-size:11px;font-weight:800;letter-spacing:.03em;padding:4px 9px;border-radius:4px}
.card .amt{position:absolute;bottom:10px;right:10px;background:rgba(20,23,26,.92);color:#fff;font-family:var(--serif);font-weight:700;font-size:18px;padding:4px 10px;border-radius:5px}
- .card .body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:7px;flex:1}
+ .card .body{padding:14px 16px 14px;display:flex;flex-direction:column;gap:7px;flex:1}
.card .addr{font-family:var(--serif);font-size:18px;line-height:1.2;font-weight:700}
.card h3{font-size:13px;color:var(--sub);font-weight:600;line-height:1.35}
.card .meta{font-size:12px;color:var(--sub);font-weight:600;display:flex;flex-wrap:wrap;gap:4px 10px}
.card .meta b{color:var(--ink)}
- .card .more{margin-top:auto;font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
- .empty{background:#fff;border:1px dashed var(--line);border-radius:10px;padding:40px;text-align:center;color:var(--sub)}
+ .card .cardfoot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:10px;border-top:1px solid var(--line)}
+ .card .more{font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:.04em}
+ .card .acts{display:flex;gap:6px}
+ .card .acts button{font-family:var(--sans);font-size:11px;font-weight:700;padding:5px 10px;border-radius:5px;border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer}
+ .card .acts button:hover{border-color:var(--ink)}
+ .card .acts button.del{color:var(--red);border-color:#f0c9ce}
+ .card .acts button.res{color:#0369a1;border-color:#bfe0f0}
+ .empty{background:#fff;border:1px dashed var(--line);border-radius:10px;padding:40px;text-align:center;color:var(--sub);grid-column:1/-1}
footer{background:var(--ink);color:#8b939b;margin-top:40px;padding:26px 0;font-size:12px}
footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
+ /* modal editor */
+ .ov{position:fixed;inset:0;background:rgba(20,23,26,.55);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto;z-index:50}
+ .ov.on{display:flex}
+ .modal{background:#fff;border-radius:14px;width:100%;max-width:640px;box-shadow:0 24px 60px rgba(0,0,0,.3)}
+ .modal .mh{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--line)}
+ .modal .mh h2{font-family:var(--serif);font-size:22px}
+ .modal .mh button{font-size:22px;background:none;border:none;cursor:pointer;color:var(--sub);line-height:1}
+ .modal form{padding:18px 22px 4px}
+ .modal .row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+ .modal label{display:block;margin-bottom:12px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub)}
+ .modal input,.modal select,.modal textarea{width:100%;font-family:var(--sans);font-size:14px;font-weight:500;text-transform:none;letter-spacing:0;color:var(--ink);margin-top:5px;padding:9px 11px;border:1px solid var(--line);border-radius:7px;background:#fff}
+ .modal textarea{min-height:70px;resize:vertical}
+ .modal .mf{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 22px 20px;border-top:1px solid var(--line);margin-top:8px}
+ .modal .mf .msg{font-size:12px;color:var(--sub)}
@media(max-width:900px){.grid{grid-template-columns:1fr 1fr}}
- @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}}
+ @media(max-width:600px){.grid{grid-template-columns:1fr!important}.controls input[type=search]{margin-left:0;width:100%}.modal .row{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="util"><div class="wrap">
- <span class="live"><span class="dot"></span> Live CRE closings</span>
- <div class="acts"><a href="/subscribe">Newsletters</a><a href="/advertise">Advertise</a><a class="sub" href="/subscribe">Subscribe</a></div>
+ <span class="live"><span class="dot"></span> Backend · Property Closings editor</span>
+ <div class="acts"><a href="/backend">← Backend hub</a><a href="/deals-log.html">Deals Log</a></div>
</div></div>
<header class="mast"><div class="wrap">
<a class="logo" href="/">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+ <span class="backtag">🔒 Backend only</span>
</div></header>
<nav><div class="wrap">
- <a href="/">Home</a><a href="/deals">Deals</a><a href="/closings" class="on">Closings</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/markets">Markets</a><a href="/desk">Broker & Owner Desk</a><a href="/conferences">Conferences</a><a href="/blog">Blog</a>
+ <a href="/backend">Backend</a><a href="/deals">Deals</a><a href="/closings" class="on">Closings</a><a href="/deals-log.html">Deals Log</a><a href="/desk">Broker & Owner Desk</a><a href="/admin">Admin</a><a href="/blog">Blog</a>
</div></nav>
<main class="wrap">
<div class="head">
- <h1>Recent Property Closings</h1>
- <p>Every closed commercial real estate sale RENTV.com is reporting — with the property address, closing price, type, size and date. Sorted newest first. Each card links to the full story.</p>
+ <h1>Property Closings</h1>
+ <p>The live closed-sale feed from RENTV.com reporting, plus your manual layer. <b>Add</b> a closing the scraper missed, <b>Edit</b> any card (your changes stick — the 20-min auto-pull never overwrites them), or <b>Hide</b> ones that are wrong. Backend-only; not shown to the public site.</p>
<div class="fresh" id="fresh">Loading…</div>
</div>
<div class="controls" id="types"></div>
<div class="controls">
+ <button class="btn add" id="addBtn">+ Add closing</button>
<label class="ctl"><span>Sort</span>
<select id="sort">
<option value="newest">Newest closing</option>
@@ -92,24 +123,71 @@
<label class="ctl dens"><span>Density</span>
<input id="dens" type="range" min="2" max="5" step="1" value="3" aria-label="Grid density (columns)">
</label>
+ <label class="ctl chk"><input type="checkbox" id="showHidden"> Show hidden</label>
<input id="q" type="search" placeholder="Search address, city, property, party…" autocomplete="off">
</div>
<div class="count" id="count"></div>
<div class="grid" id="grid"></div>
</main>
- <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News</span><span>Closings parsed from live RENTV.com reporting, refreshed every 20 min</span></div></footer>
+ <footer><div class="wrap"><span>© RENTV.com — Commercial Real Estate News · Backend</span><span>Base feed refreshed every 20 min · manual adds/edits/hides persist in closings-overrides.json</span></div></footer>
+
+ <!-- editor modal -->
+ <div class="ov" id="ov">
+ <div class="modal">
+ <div class="mh"><h2 id="mTitle">Add closing</h2><button id="mClose" aria-label="Close">×</button></div>
+ <form id="mForm" autocomplete="off">
+ <input type="hidden" id="f_id">
+ <label>Address / property name<input id="f_address" placeholder="1200 Wilshire Blvd"></label>
+ <div class="row">
+ <label>City<input id="f_city" placeholder="Los Angeles"></label>
+ <label>State<input id="f_state" placeholder="CA"></label>
+ </div>
+ <div class="row">
+ <label>Property type
+ <select id="f_property_type">
+ <option value="">—</option>
+ <option>Multifamily</option><option>Office</option><option>Industrial</option>
+ <option>Retail</option><option>Hospitality</option><option>Mixed-Use</option><option>Land</option>
+ </select>
+ </label>
+ <label>Closing price (label)<input id="f_amount_label" placeholder="$12.5M"></label>
+ </div>
+ <div class="row">
+ <label>Closing date<input id="f_close_date" type="date"></label>
+ <label>Size (label)<input id="f_size_label" placeholder="120,000 SF"></label>
+ </div>
+ <div class="row">
+ <label>Year built<input id="f_year_built" placeholder="1998"></label>
+ <label>Headline / title<input id="f_title" placeholder="Investor buys downtown tower"></label>
+ </div>
+ <label>Image URL<input id="f_image" placeholder="https://… (optional)"></label>
+ <label>Summary / notes<textarea id="f_summary" placeholder="Optional details about the transaction"></textarea></label>
+ </form>
+ <div class="mf">
+ <span class="msg" id="mMsg"></span>
+ <span><button class="btn ghost" id="mCancel">Cancel</button> <button class="btn" id="mSave">Save closing</button></span>
+ </div>
+ </div>
+ </div>
+
<script>
const $=s=>document.querySelector(s);
const MONTHS={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11};
let ALL=[], fType='', fQ='',
sVal=(localStorage.getItem('rentv.closings.sort')||'newest'),
- dens=(localStorage.getItem('rentv.closings.dens')||'3');
+ dens=(localStorage.getItem('rentv.closings.dens')||'3'),
+ showHidden=(localStorage.getItem('rentv.closings.hidden')==='1');
const TYPES=['Multifamily','Office','Industrial','Retail','Hospitality','Mixed-Use','Land'];
function esc(s){return String(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function amtNum(d){const s=String(d.amount_label||d.amount||'').replace(/[,\s$]/g,'');const m=s.match(/([\d.]+)\s*([MmKkBb]?)/);if(!m)return -1;let n=parseFloat(m[1]);const u=m[2].toUpperCase();if(u==='B')n*=1e9;else if(u==='M')n*=1e6;else if(u==='K')n*=1e3;return isNaN(n)?-1:n;}
-// closing date encoded in the image filename, e.g. ...Jul182026.jpg / ...June22026.jpg
-function dateOf(d){const m=String(d.image||'').match(/([A-Z][a-z]+?)(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/);if(!m)return null;const mo=MONTHS[m[1].slice(0,3)];if(mo==null)return null;return new Date(Date.UTC(+m[3],mo,+m[2]));}
+// Closing date: prefer explicit close_date (yyyy-mm-dd, from manual adds/edits), else parse
+// the date encoded in the image filename, e.g. ...Jul182026.jpg / ...June22026.jpg
+function dateOf(d){
+ if(d.close_date){const m=String(d.close_date).match(/^(\d{4})-(\d{2})-(\d{2})/);if(m)return new Date(Date.UTC(+m[1],+m[2]-1,+m[3]));}
+ const m=String(d.image||'').match(/([A-Z][a-z]+?)(\d{1,2})(\d{4})\.(?:jpg|jpeg|png|webp)/);if(!m)return null;const mo=MONTHS[m[1].slice(0,3)];if(mo==null)return null;return new Date(Date.UTC(+m[3],mo,+m[2]));
+}
const fmtDate=dt=>dt?dt.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
+const isManual=d=>d._manual||String(d.id||'').startsWith('mc');
function pills(){
$('#types').innerHTML='<button class="pill'+(fType?'':' on')+'" data-v="">All types</button>'+
@@ -124,7 +202,6 @@ function sorted(list){
if(s==='type')return (a.property_type||'').localeCompare(b.property_type||'');
if(s==='loc')return `${a.city} ${a.state}`.localeCompare(`${b.city} ${b.state}`);
if(s==='addr')return (a.address||'zzz').localeCompare(b.address||'zzz');
- // newest: by parsed closing date desc (dateless sort last)
const da=dateOf(a),db=dateOf(b);return (db?db.getTime():0)-(da?da.getTime():0);
});
}
@@ -133,40 +210,118 @@ function render(){
`${d.address} ${d.title} ${d.city} ${d.state} ${d.property_type} ${d.summary}`.toLowerCase().includes(fQ)));
rows=sorted(rows);
document.documentElement.style.setProperty('--cols',dens);
- $('#count').textContent=`${rows.length} closing${rows.length===1?'':'s'}${fType?' · '+fType:''}`;
+ const manC=ALL.filter(isManual).length, hidC=ALL.filter(d=>d._hidden).length;
+ $('#count').textContent=`${rows.length} closing${rows.length===1?'':'s'}${fType?' · '+fType:''} · ${manC} manual${hidC?` · ${hidC} hidden`:''}`;
$('#grid').innerHTML=rows.length?rows.map(d=>{
const loc=[d.city,d.state].filter(Boolean).join(', ');
const ph=d.image?`background-image:url('${esc(d.image)}')`:'';
const dt=fmtDate(dateOf(d));
- // HARD RULE: never link out to rentv.com — open OUR in-app reader (/news/:id).
- return `<a class="card" href="${d.id?('/news/'+encodeURIComponent(d.id)):'/closings'}">
+ const man=isManual(d), hidden=!!d._hidden, edited=!!d._edited;
+ const story=(!man&&d.id)?`<a class="more" href="/news/${encodeURIComponent(d.id)}">Read the full story →</a>`:'<span class="more" style="color:var(--sub)">Manual entry</span>';
+ return `<div class="card ${hidden?'hid':''} ${man?'man':''}" data-id="${esc(d.id)}">
<div class="ph" style="${ph}">
- <div class="badges"><span class="badge txn">Closed</span><span class="badge">${esc(d.property_type)}</span></div>
+ <div class="badges">
+ <span class="badge txn">Closed</span>
+ ${d.property_type?`<span class="badge">${esc(d.property_type)}</span>`:''}
+ ${man?'<span class="badge man">Manual</span>':''}
+ ${edited?'<span class="badge edt">Edited</span>':''}
+ ${hidden?'<span class="badge hid">Hidden</span>':''}
+ </div>
${dt?`<span class="date">🗓 ${esc(dt)}</span>`:''}
${d.amount_label?`<span class="amt">${esc(d.amount_label)}</span>`:''}
</div>
<div class="body">
<div class="addr">${esc(d.address||d.title)}</div>
- ${loc?`<div class="meta"><span>📍 <b>${esc(loc)}</b></span>${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${d.year_built}</span>`:''}</div>`:''}
- ${d.address?`<h3>${esc(d.title)}</h3>`:''}
- <div class="more">Read the full story →</div>
- </div></a>`;
- }).join(''):'<div class="empty">No closings match your filters yet. Clear them or check back — the feed refreshes every 20 minutes.</div>';
+ ${loc?`<div class="meta"><span>📍 <b>${esc(loc)}</b></span>${d.size_label?`<span>📐 ${esc(d.size_label)}</span>`:''}${d.year_built?`<span>🏗 ${esc(d.year_built)}</span>`:''}</div>`:''}
+ ${(d.address&&d.title)?`<h3>${esc(d.title)}</h3>`:''}
+ <div class="cardfoot">
+ ${story}
+ <div class="acts">
+ <button data-act="edit">Edit</button>
+ ${hidden?'<button class="res" data-act="restore">Restore</button>':`<button class="del" data-act="del">${man?'Delete':'Hide'}</button>`}
+ </div>
+ </div>
+ </div></div>`;
+ }).join(''):'<div class="empty">No closings match your filters yet. Add one with + Add closing, or clear the filters.</div>';
+ // wire per-card actions (buttons are NOT inside an <a>, so no navigation side-effects)
+ $('#grid').querySelectorAll('.card .acts button').forEach(b=>{
+ b.onclick=()=>{
+ const card=b.closest('.card'), id=card.dataset.id, act=b.dataset.act;
+ if(act==='edit')return openEdit(ALL.find(x=>String(x.id)===String(id)));
+ if(act==='del')return removeClosing(id);
+ if(act==='restore')return restoreClosing(id);
+ };
+ });
+}
+
+// ── editor modal ──
+const F=['id','address','city','state','property_type','amount_label','close_date','size_label','year_built','title','image','summary'];
+function openAdd(){
+ $('#mTitle').textContent='Add closing'; $('#mMsg').textContent='';
+ F.forEach(k=>{const el=$('#f_'+k);if(el)el.value='';});
+ $('#ov').classList.add('on'); $('#f_address').focus();
+}
+function openEdit(d){
+ if(!d)return; $('#mTitle').textContent='Edit closing'; $('#mMsg').textContent='';
+ F.forEach(k=>{const el=$('#f_'+k);if(el)el.value=d[k]||'';});
+ // close_date must be yyyy-mm-dd for <input type=date>; derive from parsed date if absent
+ if(!d.close_date){const dt=dateOf(d);if(dt)$('#f_close_date').value=dt.toISOString().slice(0,10);}
+ $('#ov').classList.add('on'); $('#f_address').focus();
}
-$('#sort').value=sVal; $('#dens').value=dens;
+function closeModal(){$('#ov').classList.remove('on');}
+async function saveClosing(){
+ const id=$('#f_id').value.trim();
+ const body={}; F.filter(k=>k!=='id').forEach(k=>body[k]=$('#f_'+k).value.trim());
+ if(!body.address&&!body.title){$('#mMsg').textContent='Address or title is required.';return;}
+ $('#mSave').disabled=true; $('#mMsg').textContent='Saving…';
+ try{
+ const url=id?('/api/closings/'+encodeURIComponent(id)):'/api/closings';
+ const r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ const j=await r.json();
+ if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+ closeModal(); await load();
+ }catch(e){$('#mMsg').textContent='Failed: '+e.message;}
+ finally{$('#mSave').disabled=false;}
+}
+async function removeClosing(id){
+ const man=isManual({id});
+ if(!confirm(man?'Delete this manual closing permanently?':'Hide this closing from the list? You can restore it with “Show hidden”.'))return;
+ try{
+ const r=await fetch('/api/closings/'+encodeURIComponent(id),{method:'DELETE'});
+ const j=await r.json(); if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+ await load();
+ }catch(e){alert('Failed: '+e.message);}
+}
+async function restoreClosing(id){
+ try{
+ const r=await fetch('/api/closings/'+encodeURIComponent(id)+'/restore',{method:'POST'});
+ const j=await r.json(); if(!r.ok||!j.ok)throw new Error(j.error||('HTTP '+r.status));
+ await load();
+ }catch(e){alert('Failed: '+e.message);}
+}
+
+$('#sort').value=sVal; $('#dens').value=dens; $('#showHidden').checked=showHidden;
$('#sort').onchange=e=>{sVal=e.target.value;localStorage.setItem('rentv.closings.sort',sVal);render();};
$('#dens').oninput=e=>{dens=e.target.value;localStorage.setItem('rentv.closings.dens',dens);render();};
$('#q').oninput=e=>{fQ=e.target.value.toLowerCase().trim();render();};
-(async()=>{
- pills();
+$('#showHidden').onchange=e=>{showHidden=e.target.checked;localStorage.setItem('rentv.closings.hidden',showHidden?'1':'0');load();};
+$('#addBtn').onclick=openAdd;
+$('#mClose').onclick=closeModal; $('#mCancel').onclick=closeModal; $('#mSave').onclick=saveClosing;
+$('#ov').onclick=e=>{if(e.target===$('#ov'))closeModal();};
+document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('#ov').classList.contains('on'))closeModal();});
+
+async function load(){
try{
- const r=await fetch('/api/deals?txn=Sale&limit=200');const data=await r.json();
+ const r=await fetch('/api/closings'+(showHidden?'?include_hidden=1':''));
+ if(r.status===403){$('#grid').innerHTML='<div class="empty">Admin login required — this is a backend-only page.</div>';return;}
+ const data=await r.json();
ALL=data.deals||[];
const dated=ALL.map(dateOf).filter(Boolean).sort((a,b)=>b-a);
- $('#fresh').textContent=`${ALL.length} closings${dated[0]?' · latest '+fmtDate(dated[0]):''} · live from RENTV.com reporting`;
+ $('#fresh').textContent=`${ALL.length} closings${dated[0]?' · latest '+fmtDate(dated[0]):''} · ${data.manual||0} manual · ${data.hidden||0} hidden · base feed ${data.fetched_at?('updated '+fmtDate(new Date(data.fetched_at))):'live'}`;
render();
}catch(e){$('#grid').innerHTML='<div class="empty">Failed to load closings: '+esc(e.message)+'</div>';}
-})();
+}
+(async()=>{pills();await load();})();
</script>
</body>
</html>
← f3a0f639 RENTV: News Playbook backend view + corpus/playbook API — TK
·
back to Rentv
·
RENTV: pull archive with LOCAL MODELS (qwen3:14b on Mac2+Mac b881b5e3 →