← back to Stars of Design
routes/public.js
794 lines
const express = require('express');
const { Pool } = require('pg');
const router = express.Router();
const data = require('../lib/data');
const clientsLib = require('../lib/clients');
const firmsLib = require('../lib/firms');
const feedLib = require('../lib/feed');
const { sortDesigners, SORTS } = require('../lib/sort');
// Shared pool just for the small JSON stats endpoint — the lib/* modules
// each carry their own pool and we don't want to leak handles per request.
const statsPool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 2,
});
// Cached version metadata: package.json version + git short SHA (best-effort).
// Resolved once at module-load so /api/version stays sub-1ms.
const PKG_VERSION = (() => {
try { return require('../package.json').version || '0.0.0'; }
catch (_) { return '0.0.0'; }
})();
const GIT_SHA = (() => {
try {
return require('child_process')
.execSync('git rev-parse --short HEAD', { cwd: require('path').resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'ignore'] })
.toString().trim() || 'unknown';
} catch (_) { return 'unknown'; }
})();
const STARTED_AT_ISO = new Date().toISOString();
const SITE_TITLE = 'Stars of Design';
const META_DESC = 'Stars of Design — a curated profile directory of the world\'s most influential interior designers, decade by decade, with the wallcoverings that match each designer\'s signature style.';
router.use((req, res, next) => {
res.locals.site_title = SITE_TITLE;
res.locals.meta_desc = META_DESC;
res.locals.canonical = `https://starsofdesign.com${req.path}`;
res.locals.path = req.path;
next();
});
// /favicon.ico fallback for bots/legacy clients that don't honor
// <link rel="icon" href="/favicon.svg">. Serves the same SVG file.
router.get('/favicon.ico', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(require('path').join(__dirname, '..', 'public', 'favicon.svg'));
});
// k8s-style cheap liveness probe — no db check, mirrors asim.
// no-store so monitors always see a fresh liveness signal.
router.get('/healthz', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({ ok: true, ts: Date.now() });
});
// /api/version — deploy fingerprint. Lets Steve verify which build is live
// without inspecting pm2 logs. Static at boot, so no DB hit.
router.get('/api/version', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({
name: 'starsofdesign',
version: PKG_VERSION,
git: GIT_SHA,
node: process.version,
started_at: STARTED_AT_ISO,
});
});
// /api/health — slightly heavier liveness + count snapshot. Mirrors asim
// /api/health pattern; uses the same shared statsPool. no-store so monitor
// tools always see fresh state. Includes uptime_s and as_of for ops dashboards.
router.get('/api/health', async (_req, res) => {
try {
res.setHeader('Cache-Control', 'no-store');
const r = await statsPool.query(`
SELECT
(SELECT count(*) FROM sod_designers WHERE role IS NULL OR role <> 'Manufacturer Rep') AS designers,
(SELECT count(*) FROM sod_firms) AS firms`);
const row = r.rows[0] || {};
res.json({
ok: true,
counts: { designers: Number(row.designers || 0), firms: Number(row.firms || 0) },
uptime_s: Math.floor(process.uptime()),
ts: Date.now(),
as_of: new Date().toISOString(),
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Editorial era index for the homepage — four anchored era cards (Early 20th,
// Mid-Century, Late 20th, Contemporary) with a count and three exemplar
// names per era, each linking to /designers?era=<bucket>. Built once at
// request time from the in-memory dataset; cheap, no DB hit.
const ERA_ORDER = ['Early 20th Century', 'Mid-Century', 'Late 20th Century', 'Contemporary'];
const ERA_BLURB = {
'Early 20th Century': 'When the profession of decoration was invented — chintz, pale silk, the Edith Wharton school.',
'Mid-Century': 'Plaster, jungle prints, glazed bamboo — the era of Draper, Frances Elkins, and the Greenbrier.',
'Late 20th Century': 'Chintz on chintz on chintz — Buatta, Hicks, Denning & Fourcade, the maximalist American salon.',
'Contemporary': 'The working bench today — Wearstler, McGee, Vervoordt, Tarlow — pattern paired to current practice.',
};
function buildEraIndex(all) {
const buckets = {};
for (const d of all) {
if (!d.era) continue;
(buckets[d.era] = buckets[d.era] || []).push(d.name);
}
return ERA_ORDER
.filter((era) => buckets[era] && buckets[era].length)
.map((era) => ({
era,
blurb: ERA_BLURB[era] || '',
count: buckets[era].length,
exemplars:buckets[era].slice(0, 3),
href: `/designers?era=${encodeURIComponent(era)}`,
}));
}
router.get('/', (req, res, next) => {
try {
const all = data.load();
const sortKey = SORTS[req.query.sort] ? req.query.sort : 'newest';
const eraIndex = buildEraIndex(all);
// WebSite + Organization JSON-LD. SearchAction declares the site's
// search endpoint to Google for sitelinks search-box eligibility.
// /designers acts as the search target (it accepts ?q=).
const json_ld = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': 'https://starsofdesign.com/#website',
url: 'https://starsofdesign.com/',
name: 'Stars of Design',
description: 'A curated profile directory of the interior designers who shape interiors — public-data seed; designers may claim and edit their profile.',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://starsofdesign.com/designers?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
},
{
'@type': 'Organization',
'@id': 'https://starsofdesign.com/#org',
name: 'Designer Wallcoverings',
url: 'https://designerwallcoverings.com/',
},
],
};
res.render('public/home', {
title: 'Stars of Design — A Profile Directory of the Designers Who Shape Interiors',
designers: sortDesigners(all, sortKey),
total: all.length,
sortKey,
filter: {},
eraIndex,
json_ld,
});
} catch (e) { next(e); }
});
router.get('/designers', (req, res, next) => {
try {
const filt = {
style: req.query.style,
era: req.query.era,
q: req.query.q,
};
const rows = data.filter(filt);
const sortKey = SORTS[req.query.sort] ? req.query.sort : 'newest';
const sortedRows = sortDesigners(rows, sortKey);
const json_ld = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: sortedRows.slice(0, 60).map((d, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Person',
name: d.name,
url: `https://starsofdesign.com/designers/${d.slug}`,
},
})),
numberOfItems: rows.length,
};
res.render('public/designers', {
title: 'Browse Designers — Stars of Design',
designers: sortedRows,
total: rows.length,
sortKey,
filter: filt,
styles: data.allStyles(),
eras: data.allEras(),
json_ld,
});
} catch (e) { next(e); }
});
router.get('/designers/:slug', (req, res, next) => {
try {
const slug = req.params.slug;
if (!/^[a-z0-9-]{1,80}$/.test(slug)) {
return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
}
const d = data.bySlug(slug);
if (!d) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
const designerVideos = data.videosByDesigner(slug);
const json_ld = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Person',
name: d.name,
url: `https://starsofdesign.com/designers/${d.slug || slug}`,
...(d.headline ? { description: d.headline } : {}),
...(d.city ? {
homeLocation: {
'@type': 'Place',
name: d.city,
}
} : {}),
...(d.styles && d.styles.length ? { knowsAbout: d.styles } : {}),
jobTitle: 'Interior Designer',
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://starsofdesign.com/' },
{ '@type': 'ListItem', position: 2, name: 'Designers', item: 'https://starsofdesign.com/designers' },
{ '@type': 'ListItem', position: 3, name: d.name, item: `https://starsofdesign.com/designers/${d.slug || slug}` },
],
},
],
};
res.render('public/designer', {
title: `${d.name} — Profile, Signature Style & Wallcovering Pairings | Stars of Design`,
meta_desc_override: `${d.name} — ${d.headline || (d.styles || []).join(', ')} interior designer based in ${d.city || 'unknown'}. Profile, era, signature works, and wallcoverings that pair with their style.`,
designer: d,
designerVideos,
json_ld,
});
} catch (e) { next(e); }
});
// ── DW client tier — real designers/firms who actually buy from DW. Pulled
// from George info@designerwallcoverings.com Gmail recon + sig-extract. The
// 150-entry editorial JSON list at /designers stays as the curated marquee
// (Dorothy Draper / Kelly Wearstler / Studio Sofield etc.); this surfaces the
// active client list as its own tier.
router.get('/clients', async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
const area = typeof req.query.area === 'string' && req.query.area.length < 80 ? req.query.area : null;
const sort = clientsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'default';
const [clients, total] = await Promise.all([
clientsLib.listClients({ q, area, sort, limit: 300 }),
clientsLib.totalCount(),
]);
const json_ld = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: clients.slice(0, 60).map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Person',
name: c.full_name,
url: `https://starsofdesign.com/clients/${c.slug}`,
},
})),
numberOfItems: total,
};
res.render('public/clients', {
title: 'DW Client Designers — Active Buyer List | Stars of Design',
meta_desc_override: 'The active list of interior designers and architecture firms who buy wallcoverings from Designer Wallcoverings. Pulled from order history, trade signups, and sample requests. City + state + areas served per profile (no street addresses).',
clients,
total,
sortKey: sort,
filter: { q, area },
json_ld,
});
} catch (e) { next(e); }
});
router.get('/clients/:slug', async (req, res, next) => {
try {
const slug = req.params.slug;
if (!/^[a-z0-9-]{1,120}$/.test(slug)) {
return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
}
const c = await clientsLib.getClient(slug);
if (!c) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
// JSON-LD Person schema for the DW client tier. Firm affiliation, if
// known, becomes worksFor → Organization. Memory rule: city + state
// only, never street/zip/phone — schema mirrors that exactly.
const json_ld = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Person',
name: c.full_name,
url: `https://starsofdesign.com/clients/${c.slug || slug}`,
jobTitle: c.role || 'Interior Designer',
...(c.city || c.state ? {
homeLocation: {
'@type': 'Place',
name: [c.city, c.state].filter(Boolean).join(', '),
}
} : {}),
...(c.firm ? {
worksFor: {
'@type': 'Organization',
name: c.firm.name,
...(c.firm.slug ? { url: `https://starsofdesign.com/firms/${c.firm.slug}` } : {}),
}
} : {}),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://starsofdesign.com/' },
{ '@type': 'ListItem', position: 2, name: 'Clients', item: 'https://starsofdesign.com/clients' },
{ '@type': 'ListItem', position: 3, name: c.full_name, item: `https://starsofdesign.com/clients/${c.slug || slug}` },
],
},
],
};
res.render('public/dwclient', {
title: `${c.full_name} — DW Client Designer | Stars of Design`,
meta_desc_override: `${c.full_name}${c.firm ? ' · ' + c.firm.name : ''}${c.city ? ' · ' + c.city : ''}${c.state ? ', ' + c.state : ''}. Active Designer Wallcoverings client.`,
profile: c,
json_ld,
});
} catch (e) { next(e); }
});
// ── Firm tier — seeded top-US architecture + interior design firms
// (Gensler, Studio Sofield, Kelly Wearstler, ...) plus any firms
// auto-materialized from sig-extract. Keyed on sod_firms not sod_designers.
router.get('/firms', async (req, res, next) => {
try {
const q = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
const sort = firmsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'claimed';
const [firms, total] = await Promise.all([
firmsLib.listFirms({ q, sort, limit: 400 }),
firmsLib.totalCount(),
]);
// ItemList JSON-LD for the index page — surfaces each firm as a
// schema.org/Organization ListItem with position + url. Rich-snippet
// candidates in Google. numberOfItems uses the full total, not page slice.
const json_ld = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: firms.slice(0, 60).map((f, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Organization',
name: f.name,
url: `https://starsofdesign.com/firms/${f.slug}`,
},
})),
numberOfItems: total,
};
res.render('public/firms', {
title: 'Top US Design Firms — Architecture + Interiors | Stars of Design',
meta_desc_override: 'Top US architecture and interior design firms — Gensler, HOK, Perkins&Will, Studio Sofield, Kelly Wearstler, Studio McGee, and more. Public-data seed; firms may claim and edit their profile.',
firms,
total,
sortKey: sort,
filter: { q },
json_ld,
});
} catch (e) { next(e); }
});
router.get('/firms/:slug', async (req, res, next) => {
try {
const slug = req.params.slug;
if (!/^[a-z0-9-]{1,120}$/.test(slug)) {
return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
}
const f = await firmsLib.getFirm(slug);
if (!f) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
// JSON-LD @graph — Organization (the firm itself) + BreadcrumbList
// (Home > Firms > {firm}) + ItemList of associated designers, all in
// one script tag for cleaner head injection.
const json_ld = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
name: f.name,
url: `https://starsofdesign.com/firms/${f.slug}`,
...(f.website ? { sameAs: [f.website] } : {}),
...(f.founded_year ? { foundingDate: String(f.founded_year) } : {}),
...(f.bio ? { description: f.bio.slice(0, 800) } : {}),
...(f.city || f.state ? {
address: {
'@type': 'PostalAddress',
...(f.city ? { addressLocality: f.city } : {}),
...(f.state ? { addressRegion: f.state } : {}),
...(f.country ? { addressCountry: f.country } : {}),
}
} : {}),
...(f.areas && f.areas.length ? { areaServed: f.areas } : {}),
...(f.designers && f.designers.length ? {
employee: f.designers.map(d => ({
'@type': 'Person',
name: d.full_name,
url: `https://starsofdesign.com/clients/${d.slug}`,
})),
} : {}),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://starsofdesign.com/' },
{ '@type': 'ListItem', position: 2, name: 'Firms', item: 'https://starsofdesign.com/firms' },
{ '@type': 'ListItem', position: 3, name: f.name, item: `https://starsofdesign.com/firms/${f.slug}` },
],
},
],
};
res.render('public/firm', {
title: `${f.name} — Design Firm | Stars of Design`,
meta_desc_override: `${f.name}${f.city ? ' · ' + f.city : ''}${f.state ? ', ' + f.state : ''}${f.founded_year ? ' · founded ' + f.founded_year : ''}.`,
firm: f,
json_ld,
});
} catch (e) { next(e); }
});
// ── Phone-shaped social feed for the /clients tier — Facebook/X.com vibe.
// Anonymous likes via httpOnly=false cookie (so client JS can show liked-state
// optimistically). Anonymous comments require a name + body; we collect
// optional email for follow-up but don't authenticate it.
router.get('/feed', async (req, res, next) => {
try {
const anonId = feedLib.ensureAnonId(req, res);
const page = Math.max(1, parseInt(req.query.page || '1', 10));
const limit = 15;
const offset = (page - 1) * limit;
const items = await feedLib.feedPage({ anonId, limit, offset });
res.render('public/feed', {
title: 'DW Designer Feed — Stars of Design',
meta_desc_override: 'A scrolling feed of the interior designers and architecture firms who buy from Designer Wallcoverings. Like, comment, follow your favorites.',
items,
page,
anonId,
});
} catch (e) { next(e); }
});
// /api — JSON discovery doc for sod public/api endpoints. Mirrors asim.
router.get('/api', (_req, res) => {
res.json({
name: 'Stars of Design API',
base: 'https://starsofdesign.com',
endpoints: {
'GET /api/health': 'Liveness probe (SELECT 1) — JSON ok+ts',
'GET /api/version': 'Deploy fingerprint — JSON {name, version, git, node, started_at}',
'GET /api/stats': 'Aggregate counts (designers/firms/claimed/links/likes/comments/candidates)',
'GET /api/clients/:id/comments': 'Comments on a DW client profile (chronological)',
'POST /api/clients/:id/like': 'Toggle like on a DW client (anonymous ID via cookie)',
'POST /api/clients/:id/comment': 'Post a comment on a DW client (name + body, optional email)',
'GET /api/designers/random': 'Random designer — JSON {slug, display_name}',
'GET /api/firms/random': 'Random firm — JSON {slug, name}',
'GET /api/clients/random': 'Random DW client — JSON {slug, name}',
'GET /api/designers/featured': 'N random designers (?n=6, max 24) — multi-row companion to /api/designers/random',
'GET /api/firms/featured': 'N random firms (?n=6, max 24)',
'GET /api/clients/featured': 'N random DW clients (?n=6, max 24)',
'GET /random/designer': '302 redirect to a random designer detail page',
'GET /random/firm': '302 redirect to a random firm detail page',
'GET /random/client': '302 redirect to a random DW client detail page',
},
notes: [
'Profiles are city + state only; no street addresses, no phone numbers (PII policy).',
'Likes + comments use anonymous browser IDs — no account required.',
'Profile content is editorial; designers may submit edits via /submit.',
],
});
});
router.get('/api/clients/:id/comments', async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
const rows = await feedLib.commentsFor(id, 100);
// 30s Cache-Control — comments can move quickly during active threads.
// SWR lets a stale list render while the refresh fires in the background.
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({ comments: rows });
} catch (e) { next(e); }
});
// JSON stats — counts surfaced by the homepage + cross-consumed by status-loop
// and external dashboards. Public, no auth, returns DW-client tier only
// (excluding Manufacturer Reps which are gated out everywhere else too).
router.get('/api/stats', async (_req, res, next) => {
try {
// 30s Cache-Control + 60s SWR so the endpoint can absorb status-loop
// polling + dashboard refreshes without hammering pg every hit.
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
const r = await statsPool.query(`
SELECT
(SELECT count(*) FROM sod_designers WHERE role IS NULL OR role <> 'Manufacturer Rep') AS designers,
(SELECT count(*) FROM sod_designers WHERE claimed_at IS NOT NULL AND (role IS NULL OR role <> 'Manufacturer Rep')) AS designers_claimed,
(SELECT count(*) FROM sod_firms) AS firms,
(SELECT count(*) FROM sod_firms WHERE claimed_at IS NOT NULL) AS firms_claimed,
(SELECT count(*) FROM sod_links) AS links,
(SELECT count(*) FROM sod_likes) AS likes,
(SELECT count(*) FROM sod_comments WHERE is_hidden = false) AS comments,
(SELECT count(*) FROM sod_email_candidates) AS candidates_total,
(SELECT count(*) FROM sod_email_candidates WHERE status = 'new') AS candidates_unreviewed`);
const row = r.rows[0] || {};
res.json({
designers: Number(row.designers || 0),
designers_claimed: Number(row.designers_claimed || 0),
firms: Number(row.firms || 0),
firms_claimed: Number(row.firms_claimed || 0),
links: Number(row.links || 0),
likes: Number(row.likes || 0),
comments: Number(row.comments || 0),
candidates_total: Number(row.candidates_total || 0),
candidates_unreviewed:Number(row.candidates_unreviewed || 0),
as_of: new Date().toISOString(),
});
} catch (e) { next(e); }
});
// One-shot random discovery endpoints. Used by /random/* redirect helpers
// (parity with asim) and by humans hitting "feeling lucky"-style UX.
// 30s/60s SWR — cache lifespan is short by design so the random feel
// stays fresh while keeping pg hits cheap under repeat polling.
// /api/{designers,firms,clients}/featured — N (default 6, max 24) random rows.
// Used by widgets that want a small grid inline. 60s cache + 120s SWR.
function pickN(arr, n) {
if (arr.length <= n) return arr.slice();
const out = [];
const seen = new Set();
while (out.length < n) {
const i = Math.floor(Math.random() * arr.length);
if (seen.has(i)) continue;
seen.add(i);
out.push(arr[i]);
}
return out;
}
router.get('/api/designers/featured', (req, res, next) => {
try {
const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
const all = data.load();
const picks = pickN(all, n).map((d) => ({ slug: d.slug, display_name: d.name || d.full_name || null }));
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ designers: picks });
} catch (e) { next(e); }
});
router.get('/api/firms/featured', async (req, res, next) => {
try {
const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
const rows = await firmsLib.listFirms({ limit: 1000 });
const picks = pickN(rows, n).map((f) => ({ slug: f.slug, name: f.name || null }));
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ firms: picks });
} catch (e) { next(e); }
});
router.get('/api/clients/featured', async (req, res, next) => {
try {
const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
const rows = await clientsLib.listClients({ limit: 1000 });
const picks = pickN(rows, n).map((c) => ({ slug: c.slug, name: c.full_name || c.name || null }));
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ clients: picks });
} catch (e) { next(e); }
});
router.get('/api/designers/random', (_req, res, next) => {
try {
const all = data.load();
if (!all.length) return res.status(503).json({ error: 'no-data' });
const pick = all[Math.floor(Math.random() * all.length)];
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({ slug: pick.slug, display_name: pick.name || pick.full_name || null });
} catch (e) { next(e); }
});
router.get('/api/firms/random', async (_req, res, next) => {
try {
const rows = await firmsLib.listFirms({ limit: 1000 });
if (!rows.length) return res.status(503).json({ error: 'no-data' });
const pick = rows[Math.floor(Math.random() * rows.length)];
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({ slug: pick.slug, name: pick.name || null });
} catch (e) { next(e); }
});
router.get('/api/clients/random', async (_req, res, next) => {
try {
const rows = await clientsLib.listClients({ limit: 1000 });
if (!rows.length) return res.status(503).json({ error: 'no-data' });
const pick = rows[Math.floor(Math.random() * rows.length)];
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({ slug: pick.slug, name: pick.full_name || pick.name || null });
} catch (e) { next(e); }
});
router.post('/api/clients/:id/like', async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
const anonId = feedLib.ensureAnonId(req, res);
const result = await feedLib.toggleLike({ designerId: id, anonId });
res.json(result);
} catch (e) { next(e); }
});
router.post('/api/clients/:id/comment', async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
const anonId = feedLib.ensureAnonId(req, res);
const ipHash = feedLib.hashIp(req);
const { author_name, author_email, body } = req.body || {};
const row = await feedLib.postComment({
designerId: id, anonId, ipHash,
author_name, author_email, body,
});
res.json({ ok: true, comment: row });
} catch (e) {
if (/spam-or-empty|name-required|rate-limit/.test(e.message)) {
return res.status(400).json({ error: e.message });
}
next(e);
}
});
router.get('/videos', (req, res, next) => {
try {
const filt = {
category: req.query.category,
source_org: req.query.source_org,
q: req.query.q,
};
const rows = data.filterVideos(filt);
res.render('public/videos', {
title: 'Designer Videos — Home Tours, Interviews & Project Showcases | Stars of Design',
meta_desc_override: 'Curated YouTube video gallery of US interior designer home tours, sit-down interviews, and project showcases from Architectural Digest, House Beautiful, Studio McGee, and the firms themselves. Cross-linked to the Stars of Design directory.',
videos: rows,
total: rows.length,
filter: filt,
categories: data.videoCategories(),
sources: data.videoSources(),
});
} catch (e) { next(e); }
});
router.get('/about', (req, res) => {
res.render('public/about', { title: 'About — Stars of Design' });
});
router.get('/submit', (req, res) => {
res.render('public/submit', { title: 'Submit a designer — Stars of Design' });
});
router.get('/privacy', (req, res) => {
res.render('public/privacy', {
title: 'Privacy — Stars of Design',
meta_desc_override: 'Stars of Design privacy notice — no accounts, no identifying cookies, anonymous like/comment, city+state only (no street addresses).',
});
});
router.get('/terms', (req, res) => {
res.render('public/terms', {
title: 'Terms of use — Stars of Design',
meta_desc_override: 'Stars of Design terms of use — editorial directory, public-data sourcing, user submissions, claim your profile, liability.',
});
});
// /random/* — 302 to a random detail page. Parity with asim /random/movie etc.
// Falls through to the index page if the dataset is somehow empty.
router.get('/random/designer', (_req, res) => {
try {
const all = data.load();
if (!all.length) return res.redirect('/designers');
const pick = all[Math.floor(Math.random() * all.length)];
res.redirect(`/designers/${pick.slug}`);
} catch (_e) { res.redirect('/designers'); }
});
router.get('/random/firm', async (_req, res) => {
try {
const rows = await firmsLib.listFirms({ limit: 1000 });
if (!rows.length) return res.redirect('/firms');
const pick = rows[Math.floor(Math.random() * rows.length)];
res.redirect(`/firms/${pick.slug}`);
} catch (_e) { res.redirect('/firms'); }
});
router.get('/random/client', async (_req, res) => {
try {
const rows = await clientsLib.listClients({ limit: 1000 });
if (!rows.length) return res.redirect('/clients');
const pick = rows[Math.floor(Math.random() * rows.length)];
res.redirect(`/clients/${pick.slug}`);
} catch (_e) { res.redirect('/clients'); }
});
router.get('/sitemap.xml', async (req, res, next) => {
try {
const base = 'https://starsofdesign.com';
const all = data.load();
const today = new Date().toISOString().slice(0, 10);
const [firms, clients] = await Promise.all([
firmsLib.listFirms({ limit: 1000 }),
clientsLib.listClients({ limit: 1000 }),
]);
const urls = [
{ loc: `${base}/`, priority: '1.0', changefreq: 'weekly' },
{ loc: `${base}/designers`, priority: '0.9', changefreq: 'weekly' },
{ loc: `${base}/firms`, priority: '0.9', changefreq: 'weekly' },
{ loc: `${base}/clients`, priority: '0.9', changefreq: 'weekly' },
{ loc: `${base}/feed`, priority: '0.7', changefreq: 'daily' },
{ loc: `${base}/videos`, priority: '0.8', changefreq: 'weekly' },
{ loc: `${base}/about`, priority: '0.5', changefreq: 'monthly' },
{ loc: `${base}/submit`, priority: '0.4', changefreq: 'monthly' },
{ loc: `${base}/privacy`, priority: '0.3', changefreq: 'yearly' },
{ loc: `${base}/terms`, priority: '0.3', changefreq: 'yearly' },
...all.map((d) => ({
loc: `${base}/designers/${d.slug}`,
priority: '0.8',
changefreq: 'monthly',
})),
...firms.map((f) => ({
loc: `${base}/firms/${f.slug}`,
priority: '0.7',
changefreq: 'monthly',
})),
...clients.map((c) => ({
loc: `${base}/clients/${c.slug}`,
priority: '0.7',
changefreq: 'monthly',
})),
];
const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
urls.map((u) =>
` <url><loc>${u.loc}</loc><lastmod>${today}</lastmod><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`
).join('\n') +
'\n</urlset>\n';
res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
res.setHeader('Last-Modified', new Date().toUTCString());
res.type('application/xml').send(xml);
} catch (e) { next(e); }
});
// /sitemap.txt — plain-text Google+Bing-supported sitemap. Mirrors asim
// /sitemap.txt. Pulls URLs from /sitemap.xml on the fly (cheap, fetches
// only the local route via in-process call to the XML generator below).
router.get('/sitemap.txt', async (req, res, next) => {
try {
const base = 'https://starsofdesign.com';
const all = data.load();
const [firms, clients] = await Promise.all([
firmsLib.listFirms({ limit: 1000 }),
clientsLib.listClients({ limit: 1000 }),
]);
const urls = [
`${base}/`,
`${base}/designers`,
`${base}/firms`,
`${base}/clients`,
`${base}/feed`,
`${base}/videos`,
`${base}/about`,
`${base}/submit`,
`${base}/privacy`,
`${base}/terms`,
...all.map((d) => `${base}/designers/${d.slug}`),
...firms.map((f) => `${base}/firms/${f.slug}`),
...clients.map((c) => `${base}/clients/${c.slug}`),
];
res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
res.setHeader('Last-Modified', new Date().toUTCString());
res.type('text/plain').send(urls.join('\n') + '\n');
} catch (e) { next(e); }
});
module.exports = router;