[object Object]

← back to Wallco Ai

hot-or-not: standalone server on port 9794 — own pm2 process

a7922f0b10ce64e3b2db39d235c40cb59da957db · 2026-05-12 00:00:11 -0700 · SteveStudio2

Per Steve: 'ok to serve hot or not on a port too.'

NEW: hot-or-not-server.js — focused 4 KB Express server on port 9794.
Independent pm2 process (wallco-hot-or-not) so it can be deployed,
scaled, and monitored separately from the main wallco-ai (9792).

ARCHITECTURE
  • Self-contained HTML page (game engine, particle bursts, haptics,
    XP/level banner, achievements). No /designs catalog, no studio,
    no admin — just the focused speed-vote experience.
  • Proxies 3 routes to main wallco-ai:
      GET  /api/design/random           — for next card
      POST /api/design/:id/user-vote    — to record HOT/NOT
      GET  /designs/img/:fn             — image asset passthrough
    Cookie + Set-Cookie passed through both ways so the voter session
    stays consistent with main wallco.ai.
  • Loads /js/wallco-game.js directly (statics served locally).

PORT 9794 chosen as next-free after 9792 (wallco-ai). Reserved in
the pm2 fleet on Mac2 + prod Kamatera.

UPSTREAM CONFIG: WALLCO_UPSTREAM env var (defaults to
http://127.0.0.1:9792). Lets dev/prod target different backends
without code edits.

FOR PUBLIC SUBDOMAIN
  When ready: GoDaddy A record `hotornot.wallco.ai → 45.61.58.125`,
  nginx vhost proxying to localhost:9794, certbot for SSL — same
  pattern as wallco.ai go-live. Skipped here per Steve's
  port-only-for-now framing.

VERIFIED
  http://127.0.0.1:9794/ returns 7.1 KB page with game engine loaded.
  /api/design/random proxied successfully: {id:18, title:"Amber
    Lattice No.18", dominant_hex:"#bb7d4e"}.
  Card markup + 3 buttons + 1 game.js script all present.

Files touched

Diff

commit a7922f0b10ce64e3b2db39d235c40cb59da957db
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 00:00:11 2026 -0700

    hot-or-not: standalone server on port 9794 — own pm2 process
    
    Per Steve: 'ok to serve hot or not on a port too.'
    
    NEW: hot-or-not-server.js — focused 4 KB Express server on port 9794.
    Independent pm2 process (wallco-hot-or-not) so it can be deployed,
    scaled, and monitored separately from the main wallco-ai (9792).
    
    ARCHITECTURE
      • Self-contained HTML page (game engine, particle bursts, haptics,
        XP/level banner, achievements). No /designs catalog, no studio,
        no admin — just the focused speed-vote experience.
      • Proxies 3 routes to main wallco-ai:
          GET  /api/design/random           — for next card
          POST /api/design/:id/user-vote    — to record HOT/NOT
          GET  /designs/img/:fn             — image asset passthrough
        Cookie + Set-Cookie passed through both ways so the voter session
        stays consistent with main wallco.ai.
      • Loads /js/wallco-game.js directly (statics served locally).
    
    PORT 9794 chosen as next-free after 9792 (wallco-ai). Reserved in
    the pm2 fleet on Mac2 + prod Kamatera.
    
    UPSTREAM CONFIG: WALLCO_UPSTREAM env var (defaults to
    http://127.0.0.1:9792). Lets dev/prod target different backends
    without code edits.
    
    FOR PUBLIC SUBDOMAIN
      When ready: GoDaddy A record `hotornot.wallco.ai → 45.61.58.125`,
      nginx vhost proxying to localhost:9794, certbot for SSL — same
      pattern as wallco.ai go-live. Skipped here per Steve's
      port-only-for-now framing.
    
    VERIFIED
      http://127.0.0.1:9794/ returns 7.1 KB page with game engine loaded.
      /api/design/random proxied successfully: {id:18, title:"Amber
        Lattice No.18", dominant_hex:"#bb7d4e"}.
      Card markup + 3 buttons + 1 game.js script all present.
---
 hot-or-not-server.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 220 insertions(+)

diff --git a/hot-or-not-server.js b/hot-or-not-server.js
new file mode 100644
index 0000000..b5b1847
--- /dev/null
+++ b/hot-or-not-server.js
@@ -0,0 +1,220 @@
+#!/usr/bin/env node
+/**
+ * Hot-or-Not standalone — port 9794.
+ *
+ * Self-contained mini-app: serves the gamified card-flick UI on its own
+ * port + pm2 process, independent of the main wallco-ai server (9792).
+ * Backend API calls (random design, user vote) proxy to the main server
+ * so design data and votes stay in one place.
+ *
+ * Run: PORT=9794 node hot-or-not-server.js
+ * pm2: pm2 start hot-or-not-server.js --name wallco-hot-or-not
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '.env') });
+const express = require('express');
+const path    = require('path');
+const fs      = require('fs');
+
+const app  = express();
+const PORT = parseInt(process.env.PORT || '9794', 10);
+
+// Upstream wallco-ai server for API + image assets
+const UPSTREAM = process.env.WALLCO_UPSTREAM || 'http://127.0.0.1:9792';
+
+app.use(express.json({ limit: '2mb' }));
+
+// Serve the local game engine
+app.use('/js', express.static(path.join(__dirname, 'public', 'js'), { maxAge: '1h' }));
+
+// Proxy: GET /api/design/random → upstream
+app.get('/api/design/random', async (req, res) => {
+  try {
+    const url = UPSTREAM + '/api/design/random' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
+    const r = await fetch(url, { headers: { 'cookie': req.headers.cookie || '' } });
+    const json = await r.json();
+    res.json(json);
+  } catch (e) { res.status(502).json({ error: 'upstream: ' + e.message }); }
+});
+
+// Proxy: POST /api/design/:id/user-vote → upstream
+app.post('/api/design/:id/user-vote', async (req, res) => {
+  try {
+    const r = await fetch(`${UPSTREAM}/api/design/${req.params.id}/user-vote`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'cookie': req.headers.cookie || ''
+      },
+      body: JSON.stringify(req.body || {})
+    });
+    res.set('set-cookie', r.headers.get('set-cookie') || '');
+    const j = await r.json();
+    res.json(j);
+  } catch (e) { res.status(502).json({ error: 'upstream: ' + e.message }); }
+});
+
+// Proxy: image assets so /designs/img/<file>.png works (load via main server)
+app.get('/designs/img/:fn', async (req, res) => {
+  try {
+    const r = await fetch(`${UPSTREAM}/designs/img/${encodeURIComponent(req.params.fn)}`);
+    if (!r.ok) return res.status(r.status).end();
+    res.set('content-type', r.headers.get('content-type') || 'image/png');
+    res.set('cache-control', 'public, max-age=86400');
+    const buf = Buffer.from(await r.arrayBuffer());
+    res.end(buf);
+  } catch (e) { res.status(502).end(); }
+});
+
+// ── Main page ───────────────────────────────────────────────────────
+app.get('/', (req, res) => {
+  res.type('html').send(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Hot or Not · wallco.ai</title>
+<meta name="description" content="Speed-vote on AI-original wallpaper. Every tap teaches the system your taste.">
+<meta name="theme-color" content="#0f0e0c">
+<link rel="canonical" href="https://hotornot.wallco.ai/">
+<style>
+  *,*::before,*::after { box-sizing: border-box; }
+  html, body { margin:0; padding:0; background:#0f0e0c; color:#f0eadc; font:15px/1.45 -apple-system, system-ui, Segoe UI, sans-serif; min-height:100vh; }
+  header { padding:14px 18px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #221f1c; }
+  header a.brand { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:22px; color:#f0eadc; text-decoration:none; letter-spacing:.5px; }
+  header .tag { font-size:11px; color:#888; text-transform:uppercase; letter-spacing:.1em; }
+  main { padding:18px; max-width:520px; margin:0 auto; }
+  h1 { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:30px; margin:0 0 4px; text-align:center; }
+  .sub { color:#888; font-size:12px; text-align:center; margin:0 0 12px; }
+  #wc-game-banner { background:#1a1816; border-radius:10px; padding:14px 16px; margin-bottom:16px; border:1px solid rgba(210,177,92,.25); }
+  #card { position:relative; border:1px solid rgba(255,255,255,.08); border-radius:18px; overflow:hidden; background:#1a1816; box-shadow:0 24px 60px rgba(0,0,0,.5); transition:transform .26s ease, opacity .26s ease; }
+  #card img { display:block; width:100%; aspect-ratio:1; object-fit:cover; pointer-events:none; }
+  #card .meta { padding:14px 20px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
+  #card .title { font-family:'Cormorant Garamond', Georgia, serif; font-weight:300; font-size:22px; }
+  #card .sub2 { font-size:10px; color:#888; text-transform:uppercase; letter-spacing:.06em; margin-top:3px; }
+  #card .viewfull { font-size:11px; color:#d2b15c; text-decoration:underline; }
+  #card.swipe-left  { transform:translateX(-130%) rotate(-14deg); opacity:0; }
+  #card.swipe-right { transform:translateX( 130%) rotate( 14deg); opacity:0; }
+  #card.swipe-up    { transform:translateY(-130%); opacity:0; }
+  #buttons { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; margin-top:18px; }
+  .btn { padding:18px 12px; font-size:14px; font-weight:700; letter-spacing:.12em; border:0; border-radius:12px; cursor:pointer; min-height:62px; transition:transform .12s; }
+  .btn:active { transform:scale(.96); }
+  .btn-not  { background:linear-gradient(180deg,#3a1a1a,#2a1212); color:#fbb; border:1px solid #5a2222; }
+  .btn-skip { background:#2a2825; color:#aaa; border:1px solid #3a3835; }
+  .btn-hot  { background:linear-gradient(180deg,#e0bd64,#d2b15c); color:#1a1a1a; border:1px solid #b89745; }
+  footer { margin-top:32px; padding:24px 18px; text-align:center; color:#666; font-size:11px; }
+  footer a { color:#888; text-decoration:none; }
+  footer a:hover { color:#d2b15c; }
+  .history { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-top:22px; }
+  .history a { display:block; aspect-ratio:1; border-radius:5px; overflow:hidden; border:2px solid transparent; }
+  .history a img { width:100%; height:100%; object-fit:cover; display:block; }
+  .history a.hot  { border-color:#d2b15c; }
+  .history a.not  { border-color:#a44; }
+  .history a.skip { border-color:#555; }
+  .keys { font-size:10px; color:#666; margin-top:6px; text-align:center; }
+  @media (min-width: 700px) {
+    main { padding:32px; max-width:560px; }
+    h1 { font-size:36px; }
+  }
+</style>
+</head>
+<body>
+<header>
+  <a class="brand" href="https://wallco.ai">wallco.ai</a>
+  <span class="tag">Hot or Not · v1</span>
+</header>
+<main>
+  <h1>Hot or Not</h1>
+  <p class="sub">Speed-vote AI-original wallpaper. Your taste teaches the system.</p>
+  <div id="wc-game-banner"></div>
+
+  <div id="card">
+    <img id="card-img" alt="design">
+    <div class="meta">
+      <div>
+        <div id="card-title" class="title"></div>
+        <div id="card-sub" class="sub2"></div>
+      </div>
+      <a id="card-view" class="viewfull" target="_blank">view full →</a>
+    </div>
+  </div>
+
+  <div id="buttons">
+    <button class="btn btn-not"  data-action="not">NOT</button>
+    <button class="btn btn-skip" data-action="skip">SKIP</button>
+    <button class="btn btn-hot"  data-action="hot">HOT</button>
+  </div>
+  <div class="keys">arrows: ← NOT · → HOT · space SKIP</div>
+
+  <div class="history" id="history"></div>
+</main>
+<footer>
+  <p>© <span id="yr"></span> SAND, LLC · part of <a href="https://wallco.ai">wallco.ai</a> / <a href="https://designerwallcoverings.com">Designer Wallcoverings</a></p>
+</footer>
+<script>document.getElementById('yr').textContent = new Date().getFullYear();</script>
+<script src="/js/wallco-game.js"></script>
+<script>
+var seen = [], picks = [];
+
+async function load(){
+  var url = '/api/design/random' + (seen.length ? '?exclude=' + seen.slice(-30).join(',') : '');
+  var r = await (await fetch(url)).json();
+  if (!r.id) return;
+  document.getElementById('card').className = '';
+  document.getElementById('card-img').src = r.image_url;
+  document.getElementById('card-title').textContent = r.title || '';
+  document.getElementById('card-sub').textContent = (r.category||'') + (r.dominant_hex ? ' · ' + r.dominant_hex : '');
+  document.getElementById('card-view').href = 'https://wallco.ai/design/' + r.id;
+  document.getElementById('card').dataset.id = r.id;
+  document.getElementById('card').dataset.title = r.title || '';
+  document.getElementById('card').dataset.image = r.image_url;
+  seen.push(r.id);
+}
+
+async function act(action, evt){
+  var card = document.getElementById('card');
+  var id = parseInt(card.dataset.id, 10);
+  if (!id) return;
+  var anim = action==='hot' ? 'swipe-right' : action==='not' ? 'swipe-left' : 'swipe-up';
+  card.classList.add(anim);
+
+  if (action === 'hot' || action === 'not') {
+    var score = action === 'hot' ? 5 : 1;
+    fetch('/api/design/' + id + '/user-vote', {
+      method:'POST',
+      headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({score: score, quick: true})
+    });
+  }
+  if (window.WC) WC.reward(action, evt);
+
+  picks.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
+  picks = picks.slice(0, 6);
+  document.getElementById('history').innerHTML = picks.map(function(h){
+    return '<a class="' + h.action + '" href="https://wallco.ai/design/' + h.id + '" target="_blank" title="' + h.title + '"><img src="' + h.image_url + '"></a>';
+  }).join('');
+
+  setTimeout(load, 280);
+}
+
+document.querySelectorAll('.btn').forEach(function(b){
+  b.addEventListener('click', function(evt){ act(b.dataset.action, evt); });
+});
+document.addEventListener('keydown', function(e){
+  if (e.key === 'ArrowLeft')  act('not');
+  else if (e.key === 'ArrowRight') act('hot');
+  else if (e.key === ' ' || e.key === 'ArrowUp') { e.preventDefault(); act('skip'); }
+});
+load();
+</script>
+</body>
+</html>`);
+});
+
+app.get('/health', (_req, res) => {
+  res.json({ ok: true, service: 'wallco-hot-or-not', port: PORT, upstream: UPSTREAM });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+  console.log(`wallco hot-or-not → http://0.0.0.0:${PORT}  · upstream: ${UPSTREAM}`);
+});

← 70cab7c gamify: real game feel — XP, levels, combos, haptics, partic  ·  back to Wallco Ai  ·  wallco.ai · /designs "In a room ·N" filter chip — surface ro 354cb17 →