← back to Dw Hero Admin
server.js
211 lines
/**
* dw-hero-admin :9772
*
* Per-DW-site admin for the 2×2 rotating hero grid:
* - lists all 53 DW sister sites
* - shows each site's products sorted NEWEST FIRST
* - drag products into 4 slots (room/pattern/room/pattern)
* - writes per-site hero-4grid.json AND mirrors to prod /var/www/<site>.com/data/
*
* Hard rule: never displays a product image with a header band — applies the
* `object-fit:cover; object-position:center 12%` crop universally.
*/
const express = require('express');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const PORT = process.env.PORT || 9772;
const PROJECTS_DIR = process.env.PROJECTS_DIR || path.join(process.env.HOME, 'Projects');
const REMOTE_HOST = process.env.REMOTE_HOST || 'root@45.61.58.125';
// Canonical 53-site list — same source as /tmp/dw-sites.txt (auto-discovers
// every project under ~/Projects/ whose public/index.html has nsContactOpen
// or .ns-modal markers — i.e. the DW family).
function loadTossedDomains() {
try {
const buf = fs.readFileSync(path.join(PROJECTS_DIR, '_shared', 'data', 'dw-tossed-domains.json'), 'utf8');
return new Set((JSON.parse(buf).tossed || []).map(t => t.domain.replace(/\.com$/, '')));
} catch (e) { return new Set(); }
}
function discoverSites() {
const tossed = loadTossedDomains();
const sites = [];
const entries = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
if (tossed.has(e.name)) continue; // honor toss list
const idx = path.join(PROJECTS_DIR, e.name, 'public', 'index.html');
if (!fs.existsSync(idx)) continue;
try {
const buf = fs.readFileSync(idx, 'utf8');
if (buf.includes('nsContactOpen') || buf.includes('ns-modal')) {
sites.push(e.name);
}
} catch (err) { /* skip */ }
}
return sites.sort();
}
let SITES = discoverSites();
console.log(`[hero-admin] discovered ${SITES.length} DW sites`);
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// ── list sites ───────────────────────────────────────────────────────────
app.get('/api/sites', (req, res) => {
res.json({ sites: SITES });
});
// ── per-site products + current grid config ──────────────────────────────
app.get('/api/site/:site', (req, res) => {
const site = req.params.site;
if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
const projDir = path.join(PROJECTS_DIR, site);
const productsPath = path.join(projDir, 'data', 'products.json');
const gridPath = path.join(projDir, 'public', 'hero-4grid.json');
let products = [];
try {
const raw = fs.readFileSync(productsPath, 'utf8');
products = JSON.parse(raw);
} catch (e) {
return res.status(500).json({ error: 'cannot read products.json', detail: e.message });
}
// Newest first — products.json doesn't have created_at, so the LAST entries
// in the file are typically the most-recently-added by the catalog sync.
// We reverse to put them first.
products = products.slice().reverse();
// Drop products without image_url or with obvious header-watermarked sources.
products = products.filter(p => p.image_url && /^https:/.test(p.image_url));
let grid = null;
try { grid = JSON.parse(fs.readFileSync(gridPath, 'utf8')); } catch (e) { grid = null; }
res.json({
site,
productCount: products.length,
// Trim payload — only ship the fields the admin UI needs.
products: products.slice(0, 500).map(p => ({
sku: p.sku,
handle: p.handle,
title: p.title,
vendor: p.vendor || '',
image_url: p.image_url,
product_type: p.product_type || '',
tags: Array.isArray(p.tags) ? p.tags.slice(0, 6) : [],
})),
grid: grid || defaultGrid(products),
});
});
// Default — first 4 valid products: room, pattern, room, pattern slots.
// Steve will reassign via drag-drop.
function defaultGrid(products) {
const picks = products.slice(0, 4);
return {
cells: [
{ kind: 'room', sku: picks[0]?.sku || null, image_url: picks[0]?.image_url || null, title: picks[0]?.title || null },
{ kind: 'pattern', sku: picks[1]?.sku || null, image_url: picks[1]?.image_url || null, title: picks[1]?.title || null },
{ kind: 'room', sku: picks[2]?.sku || null, image_url: picks[2]?.image_url || null, title: picks[2]?.title || null },
{ kind: 'pattern', sku: picks[3]?.sku || null, image_url: picks[3]?.image_url || null, title: picks[3]?.title || null },
],
rotation_pool: products.slice(0, 16).map(p => ({ sku: p.sku, image_url: p.image_url, title: p.title })),
autocycle_seconds: 6,
updated_at: null,
};
}
// ── save grid for a site ────────────────────────────────────────────────
// Schema check — req.body must have `cells` array of length 4, each cell
// containing image_url + (optional) overlay text. Prevents a malformed
// payload from writing a broken hero-4grid.json that then gets rsynced to
// prod by pushToProd() and breaks the live homepage hero.
function validateGrid(body) {
if (!body || typeof body !== 'object') return 'body must be an object';
if (!Array.isArray(body.cells)) return 'body.cells must be an array';
if (body.cells.length !== 4) return `body.cells must have exactly 4 entries (got ${body.cells.length})`;
for (let i = 0; i < 4; i++) {
const c = body.cells[i];
if (!c || typeof c !== 'object') return `cells[${i}] must be an object`;
if (typeof c.image_url !== 'string' || !c.image_url) return `cells[${i}].image_url must be a non-empty string`;
if (!/^https?:\/\//.test(c.image_url)) return `cells[${i}].image_url must be http(s)`;
}
return null;
}
app.post('/api/site/:site/grid', (req, res) => {
const site = req.params.site;
if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
const grid = req.body || {};
const validationError = validateGrid(grid);
if (validationError) return res.status(400).json({ error: 'invalid grid payload', detail: validationError });
const projDir = path.join(PROJECTS_DIR, site);
const pubDir = path.join(projDir, 'public');
if (!fs.existsSync(pubDir)) fs.mkdirSync(pubDir, { recursive: true });
const gridPath = path.join(pubDir, 'hero-4grid.json');
grid.updated_at = new Date().toISOString();
fs.writeFileSync(gridPath, JSON.stringify(grid, null, 2));
// Mirror to prod (non-blocking)
pushToProd(site, gridPath).catch(e => console.warn(`[hero-admin] push failed ${site}:`, e.message));
res.json({ ok: true, site, written: gridPath });
});
function pushToProd(site, localPath) {
return new Promise((resolve, reject) => {
// Try both layouts — /var/www/<site>.com/public/ for static-served sites,
// /root/Projects/<site>/public/ for the 9 with .deploy.conf at /root.
const remote = `${REMOTE_HOST}:/var/www/${site}.com/public/hero-4grid.json`;
execFile('rsync', ['-az', '-e', 'ssh -o BatchMode=yes -o ConnectTimeout=8', localPath, remote], (err, stdout, stderr) => {
if (err) return reject(new Error(stderr || err.message));
resolve(stdout);
});
});
}
// ── fashion-house style guides (proxied from fashion-style-guides project) ──
const BRANDS_JSON = path.join(PROJECTS_DIR, 'fashion-style-guides', 'data', 'brands.json');
const BRAND_ASSIGN_FILE = path.join(__dirname, 'data', 'brand-assignments.json');
app.get('/api/brands', (req, res) => {
try {
const buf = fs.readFileSync(BRANDS_JSON, 'utf8');
res.json(JSON.parse(buf));
} catch (e) {
res.status(500).json({ error: 'cannot read brands.json', detail: e.message, path: BRANDS_JSON });
}
});
function loadAssignments() {
try { return JSON.parse(fs.readFileSync(BRAND_ASSIGN_FILE, 'utf8')); }
catch (e) { return {}; }
}
function saveAssignments(obj) {
const dir = path.dirname(BRAND_ASSIGN_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(BRAND_ASSIGN_FILE, JSON.stringify(obj, null, 2));
}
app.get('/api/brand-assignments', (req, res) => res.json(loadAssignments()));
app.post('/api/site/:site/brand', (req, res) => {
const site = req.params.site;
if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
const brandId = (req.body && req.body.brand_id) || null;
const all = loadAssignments();
if (brandId) all[site] = { brand_id: brandId, updated_at: new Date().toISOString() };
else delete all[site];
saveAssignments(all);
res.json({ ok: true, site, brand_id: brandId });
});
// ── ping / health ───────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ ok: true, sites: SITES.length, ts: Date.now() }));
app.listen(PORT, '127.0.0.1', () => {
console.log(`[hero-admin] http://127.0.0.1:${PORT} (${SITES.length} sites)`);
});