← back to Designtradelive

server.js

123 lines

'use strict';

const path = require('path');
const fs = require('fs');
const express = require('express');
const compression = require('compression');

const app = express();
const PORT = parseInt(process.env.PORT || '9926', 10);
const DATA_FILE = path.join(__dirname, 'data', 'events.json');

const GA_ID = 'G-E8R8V9RDXN';
const SITE_NAME = 'DesignTradeLive';
const DOMAIN = 'designtradelive.com';

let EVENTS = [];
function loadEvents() {
  try {
    EVENTS = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
  } catch (err) {
    console.error('[events] failed to load events.json:', err.message);
    EVENTS = [];
  }
}
loadEvents();
fs.watchFile(DATA_FILE, { interval: 5000 }, () => loadEvents());

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(compression());

// Snapshot-file 404 guard — refactor/editor leftovers must never serve from
// the static root even if one accidentally lands in public/.
const SNAPSHOT_PATH_RE = /\.(bak|orig|rej|swp)(\..*)?$|\.pre-|~$/i;
app.use((req, res, next) => {
  if (SNAPSHOT_PATH_RE.test(req.path)) return res.status(404).end();
  next();
});

app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));

app.use((req, res, next) => {
  res.set('Cache-Control', 'no-store, must-revalidate');
  next();
});

function categorize(events) {
  const counts = {};
  for (const e of events) counts[e.category] = (counts[e.category] || 0) + 1;
  return Object.entries(counts).sort((a, b) => b[1] - a[1]);
}

function sortEvents(events, sort) {
  const arr = events.slice();
  switch (sort) {
    case 'name':
      return arr.sort((a, b) => a.name.localeCompare(b.name));
    case 'city':
      return arr.sort((a, b) => a.city.localeCompare(b.city));
    case 'category':
      return arr.sort((a, b) => a.category.localeCompare(b.category) || a.start_date.localeCompare(b.start_date));
    case 'tier':
      return arr.sort((a, b) => {
        const order = { major: 0, regional: 1, local: 2 };
        return (order[a.tier] ?? 9) - (order[b.tier] ?? 9) || a.start_date.localeCompare(b.start_date);
      });
    case 'date':
    default:
      return arr.sort((a, b) => a.start_date.localeCompare(b.start_date));
  }
}

app.get('/', (req, res) => {
  const q = (req.query.q || '').toLowerCase().trim();
  const cat = req.query.category || '';
  const sort = req.query.sort || 'date';
  let filtered = EVENTS;
  if (q) {
    filtered = filtered.filter(e =>
      e.name.toLowerCase().includes(q) ||
      e.city.toLowerCase().includes(q) ||
      e.country.toLowerCase().includes(q) ||
      e.category.toLowerCase().includes(q) ||
      (e.summary || '').toLowerCase().includes(q)
    );
  }
  if (cat) filtered = filtered.filter(e => e.category === cat);
  filtered = sortEvents(filtered, sort);
  res.render('index', {
    events: filtered,
    total: EVENTS.length,
    categories: categorize(EVENTS),
    q, cat, sort,
    GA_ID, SITE_NAME, DOMAIN
  });
});

app.get('/events/:slug', (req, res) => {
  const ev = EVENTS.find(e => e.slug === req.params.slug);
  if (!ev) return res.status(404).render('404', { GA_ID, SITE_NAME, DOMAIN });
  res.render('event', { ev, GA_ID, SITE_NAME, DOMAIN });
});

app.get('/api/events', (req, res) => {
  res.set('Cache-Control', 'public, max-age=300');
  res.json({ count: EVENTS.length, events: EVENTS });
});

app.get('/healthz', (req, res) => res.json({ ok: true, count: EVENTS.length }));

// Clean-URL aliases for nav links that don't have dedicated pages yet.
// The footer carries the about copy as an anchor (#about); the hamburger
// menu links to /about. Redirect /about to the footer anchor so the link
// never 404s.
app.get('/about', (req, res) => res.redirect(301, '/#about'));
app.get('/contact', (req, res) => res.redirect(301, '/#about'));

app.use((req, res) => res.status(404).render('404', { GA_ID, SITE_NAME, DOMAIN }));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`[${SITE_NAME}] listening on http://127.0.0.1:${PORT} — ${EVENTS.length} events loaded`);
});