← back to Wallco Ai
public/admin/rooms.html
95 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Room Renders — wallco.ai admin</title>
<style>
:root{--cols:4;--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;--accent:#7bc96f;}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
header{position:sticky;top:0;z-index:20;background:rgba(14,15,16,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;}
h1{font-size:16px;margin:0;font-weight:650;} h1 .leaf{color:var(--accent);}
.pill{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:12px;color:var(--mut);} .pill b{color:var(--ink);}
label.ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);}
select,input[type=range]{accent-color:var(--accent);} select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:13px;}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:14px 16px 80px;}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;}
.card a{display:block;aspect-ratio:1/1;background:#000;}
.card img{width:100%;height:100%;object-fit:cover;display:block;}
.meta{padding:7px 9px;font-size:12px;display:flex;justify-content:space-between;align-items:center;}
.rt{color:var(--ink);text-transform:capitalize;font-weight:600;}
.when{color:var(--mut);font-size:10.5px;font-variant-numeric:tabular-nums;}
.did{color:var(--mut);font-size:11px;}
#empty,#err{padding:60px 16px;text-align:center;color:var(--mut);} #err{color:#ff9a9a;}
</style>
</head>
<body>
<header>
<h1><span class="leaf">🛋</span> Room Renders</h1>
<span class="pill" id="stat">loading…</span>
<label class="ctl">Room
<select id="filter"><option value="">all types</option></select>
</label>
<label class="ctl">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="pattern">Pattern (group by design)</option>
<option value="oldest">Oldest</option>
<option value="rtype">Room type</option>
</select>
</label>
<label class="ctl">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
</header>
<div id="err" style="display:none"></div>
<div id="empty" style="display:none">No room renders yet — hit 🪑 Furniture in the curator.</div>
<div class="grid" id="grid"></div>
<script>
const ADMIN=new URLSearchParams(location.search).get('admin');
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
const path = s.startsWith('/') ? location.origin + s : s;
return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
const $=id=>document.getElementById(id);
let ALL=[];
$('density').value=+(localStorage.getItem('rooms_density')||4); document.documentElement.style.setProperty('--cols',$('density').value);
$('density').addEventListener('input',e=>{localStorage.setItem('rooms_density',e.target.value);document.documentElement.style.setProperty('--cols',e.target.value);});
$('filter').addEventListener('change',render);
$('sort').value=localStorage.getItem('rooms_sort')||'newest';
$('sort').addEventListener('change',e=>{localStorage.setItem('rooms_sort',e.target.value);render();});
function fmt(iso){ if(!iso)return''; const d=new Date(iso); return isNaN(d)?iso:d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
function render(){
const f=$('filter').value, s=$('sort').value;
let list=ALL.filter(r=>!f||r.room_type===f);
if(s==='pattern'){ // group every render of the same design together; ties → newest first
list=list.slice().sort((a,b)=> (b.design_id-a.design_id) || (a.id<b.id?1:-1));
} else if(s==='oldest'){ list=list.slice().sort((a,b)=> a.id-b.id);
} else if(s==='rtype'){ list=list.slice().sort((a,b)=> (a.room_type||'').localeCompare(b.room_type||'') || (b.id-a.id));
} /* newest = server's natural order (id DESC) */
$('empty').style.display=list.length?'none':'block';
$('grid').innerHTML=list.map(r=>`
<div class="card">
<a href="${q('/design/'+r.design_id)}" target="_blank" rel="noopener noreferrer" title="open design #${r.design_id}"><img loading="lazy" src="/designs/room/design_${r.design_id}_${r.room_type}.png" onerror="this.onerror=null;this.src='${r.image_path}'" alt="room ${r.id}"></a>
<div class="meta"><span class="rt">${(r.room_type||'').replace(/_/g,' ')}</span><span class="did">#${r.design_id}</span></div>
<div class="meta" style="padding-top:0"><span class="when">🕓 ${fmt(r.created_at)}</span></div>
</div>`).join('');
$('stat').innerHTML=`<b>${ALL.length}</b> renders · <b>${list.length}</b> shown`;
}
(async function(){
try{
const r=await fetch(q('/api/admin/rooms/list')); if(!r.ok) throw new Error('list '+r.status+(r.status===404?' — append ?admin=TOKEN':''));
ALL=(await r.json()).items||[];
const types=[...new Set(ALL.map(x=>x.room_type).filter(Boolean))].sort();
for(const t of types){ const o=document.createElement('option'); o.value=t; o.textContent=t.replace(/_/g,' '); $('filter').appendChild(o); }
render();
}catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>