← back to Wallco Ai
wallco_rooms persistence + gallery + admin remove/restore/marketing
e8f73bfb05b9ff2916f939c0b73a14a0df404ab5 · 2026-05-11 19:38:46 -0700 · SteveStudio2
Files touched
Diff
commit e8f73bfb05b9ff2916f939c0b73a14a0df404ab5
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 19:38:46 2026 -0700
wallco_rooms persistence + gallery + admin remove/restore/marketing
---
server.js | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 96 insertions(+), 6 deletions(-)
diff --git a/server.js b/server.js
index 8e7053d..52e9212 100644
--- a/server.js
+++ b/server.js
@@ -66,7 +66,7 @@ function pgEsc(v) {
// Admin-token check for destructive room ops. Empty/unset token => admin features locked off.
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
-function isAdmin(req) {
+function isRoomsAdmin(req) {
if (!ADMIN_TOKEN) return false;
return (req.headers['x-admin-token'] === ADMIN_TOKEN)
|| (req.query && req.query.admin === ADMIN_TOKEN);
@@ -1958,9 +1958,99 @@ ${htmlHeader('/designs')}
</div>` : ''}
</div>
</div>
+
+ <!-- Generated Rooms gallery — hydrates from /api/rooms?design_id={{id}} -->
+ <section id="rooms-gallery" class="rooms-gallery" data-design-id="${design.id}" style="margin:48px auto 0;max-width:1200px;padding:0 24px;">
+ <div class="rg-head" style="display:flex;align-items:baseline;gap:16px;border-top:1px solid var(--rule,#e5e5e0);padding-top:24px;margin-bottom:18px;">
+ <h2 style="margin:0;font:500 22px/1.2 ui-serif,Georgia,serif;">Generated rooms</h2>
+ <span id="rg-count" style="font:11px/1.4 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#6e6e68);"></span>
+ <span id="rg-admin-badge" style="display:none;margin-left:auto;font:10px/1.4 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#b00020;">admin mode</span>
+ </div>
+ <div id="rg-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;"></div>
+ <div id="rg-empty" style="display:none;padding:32px;text-align:center;color:var(--ink-faint,#6e6e68);font-style:italic;">No rooms generated for this pattern yet. Use the "Generate Room" button above to create one.</div>
+ </section>
</main>
${FOOTER}
+<script>
+(function(){
+ const sec = document.getElementById('rooms-gallery');
+ if (!sec) return;
+ const designId = sec.dataset.designId;
+ const params = new URLSearchParams(window.location.search);
+ let adminToken = params.get('admin') || localStorage.getItem('wallco_admin_token') || '';
+ if (params.get('admin')) localStorage.setItem('wallco_admin_token', adminToken);
+ if (adminToken) document.getElementById('rg-admin-badge').style.display = 'block';
+
+ async function refresh() {
+ const q = new URLSearchParams({ design_id: designId });
+ if (adminToken) { q.set('admin', adminToken); q.set('include_removed', '1'); }
+ let data;
+ try { data = await (await fetch('/api/rooms?' + q.toString())).json(); }
+ catch (e) { document.getElementById('rg-grid').innerHTML = '<div style="color:#b00020">' + e.message + '</div>'; return; }
+ const rooms = data.rooms || [];
+ const isAdmin = !!data.admin;
+ document.getElementById('rg-count').textContent = rooms.filter(r => !r.removed_at).length + ' saved';
+ document.getElementById('rg-empty').style.display = rooms.length ? 'none' : 'block';
+ document.getElementById('rg-grid').innerHTML = rooms.map(function(r){
+ const removed = !!r.removed_at;
+ const marketing = !!r.selected_for_marketing;
+ const tag = r.room_type ? r.room_type.replace(/_/g,' ') : '';
+ const dim = removed ? 'opacity:0.35;filter:grayscale(0.7)' : '';
+ const star = marketing ? '★' : '☆';
+ const adminCtrls = isAdmin
+ ? '<div style="position:absolute;top:6px;right:6px;display:flex;gap:4px;">' +
+ '<button data-id="' + r.id + '" data-act="marketing" title="Toggle marketing" style="background:rgba(0,0,0,0.75);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:13px;cursor:pointer;">' + star + '</button>' +
+ (removed
+ ? '<button data-id="' + r.id + '" data-act="restore" title="Restore" style="background:rgba(0,0,0,0.75);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:11px;cursor:pointer;">↺</button>'
+ : '<button data-id="' + r.id + '" data-act="remove" title="Remove" style="background:rgba(176,0,32,0.85);color:#fff;border:0;border-radius:3px;padding:3px 7px;font-size:11px;cursor:pointer;">×</button>'
+ ) +
+ '</div>'
+ : '';
+ return '<figure style="margin:0;background:var(--paper,#faf8f2);border:1px solid var(--rule,#e5e5e0);position:relative;' + dim + '">' +
+ '<a href="' + r.image_path + '" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;">' +
+ '<img src="' + r.image_path + '" alt="Room with this pattern — ' + tag + '" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;" />' +
+ '</a>' +
+ adminCtrls +
+ '<figcaption style="padding:8px 10px 10px;font:11px/1.4 ui-sans-serif,system-ui,sans-serif;color:var(--ink-faint,#6e6e68);display:flex;justify-content:space-between;gap:8px;">' +
+ '<span>' + tag + (marketing ? ' · <strong style="color:#9c7100;">marketing</strong>' : '') + '</span>' +
+ '<time>' + new Date(r.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}) + '</time>' +
+ '</figcaption>' +
+ '</figure>';
+ }).join('');
+
+ if (isAdmin) {
+ document.querySelectorAll('#rg-grid button[data-act]').forEach(function(btn){
+ btn.addEventListener('click', async function(){
+ const id = btn.dataset.id, act = btn.dataset.act;
+ const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
+ let url, body = '{}';
+ if (act === 'remove') {
+ if (!confirm('Remove this room? (soft-delete — recoverable)')) return;
+ url = '/api/rooms/' + id + '/remove';
+ } else if (act === 'restore') {
+ url = '/api/rooms/' + id + '/restore';
+ } else if (act === 'marketing') {
+ const fig = btn.closest('figure');
+ const wasStar = btn.textContent.trim() === '★';
+ url = '/api/rooms/' + id + '/marketing';
+ body = JSON.stringify({ selected: !wasStar });
+ }
+ try {
+ const r = await fetch(url, { method: 'POST', headers: headers, body: body });
+ const j = await r.json();
+ if (!j.ok) alert('failed: ' + (j.error||'unknown'));
+ refresh();
+ } catch (e) { alert(e.message); }
+ });
+ });
+ }
+ }
+ refresh();
+ window.addEventListener('wallco:room-generated', refresh);
+})();
+</script>
+
<script type="application/ld+json">${schema}</script>
${HAMBURGER_JS}
</body>
@@ -2617,7 +2707,7 @@ app.post('/api/room', async (req, res) => {
// Returns generated rooms for a design (or all, if no design_id) with metadata.
app.get('/api/rooms', (req, res) => {
const designId = req.query.design_id ? parseInt(req.query.design_id, 10) : null;
- const includeRemoved = req.query.include_removed === '1' && isAdmin(req);
+ const includeRemoved = req.query.include_removed === '1' && isRoomsAdmin(req);
const marketingOnly = req.query.marketing_only === '1';
try {
const where = [];
@@ -2629,7 +2719,7 @@ app.get('/api/rooms', (req, res) => {
FROM (SELECT id, design_id, room_type, angle, image_path, filename, file_size_bytes,
selected_for_marketing, removed_at, removed_reason, notes, created_at
FROM wallco_rooms ${whereSQL}) t;`);
- res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isAdmin(req) });
+ res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isRoomsAdmin(req) });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
@@ -2639,7 +2729,7 @@ app.get('/api/rooms', (req, res) => {
// Soft-deletes; the image file stays on disk so prior page links continue to work for caching.
// Pass { hard: true, reason: "..." } to also unlink the file.
app.post('/api/rooms/:id/remove', (req, res) => {
- if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
const { reason = '', hard = false } = req.body || {};
@@ -2659,7 +2749,7 @@ app.post('/api/rooms/:id/remove', (req, res) => {
// ── POST /api/rooms/:id/restore (admin only) — reverses a soft-delete.
app.post('/api/rooms/:id/restore', (req, res) => {
- if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
try {
@@ -2674,7 +2764,7 @@ app.post('/api/rooms/:id/restore', (req, res) => {
// ── POST /api/rooms/:id/marketing (admin only) — toggle marketing flag + optional notes.
app.post('/api/rooms/:id/marketing', (req, res) => {
- if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ if (!isRoomsAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
const { selected = true, notes = null } = req.body || {};
← c9365f5 design/:id: Photoshop-style edit panel — sliders, palette do
·
back to Wallco Ai
·
Fix /age-themes after debate-team review — strip fabricated f3c9acd →