[object Object]

← back to Stars of Design

feat(starsofdesign): /feed phone UI — vertical scroll, Like + Comment

5927cb63eff442df68c247bee619ab482486de00 · 2026-05-12 11:18:47 -0700 · Steve Abrams

Facebook / X.com-style mobile-first feed for the /clients tier. One
designer per card stacked vertically, anonymous like (cookie-tracked),
anonymous comment thread (name + body required, optional email).

Schema (idempotent CREATE TABLE):
  sod_likes      (designer_id, anon_id, created_at)
                 UNIQUE (designer_id, anon_id) — one like per visitor per post
  sod_comments   (designer_id, parent_id, author_name, author_email, body,
                  is_hidden, anon_id, ip_hash, created_at)
                 partial index on (designer_id, created_at DESC) WHERE NOT hidden

lib/feed.js
  - ensureAnonId(req,res) — sets long-lived sod_anon cookie (12 hex,
    httpOnly=false so client JS can show liked-state immediately)
  - feedPage({anonId, limit, offset}) — pulls designers with embedded
    like_count, comment_count, i_liked (boolean per caller), grouped
    links. Ordered by created_at DESC (newest first).
  - commentsFor(designerId, limit) — top-level comments only for v1
  - toggleLike — flips like state, returns new count + liked boolean
  - postComment — name+body validation, 1500-char body cap, 5/hour
    burst limit per anon, simple spam regex
                 (viagra/crypto/loan/casino/etc.), max 2 URLs allowed

Routes (in routes/public.js):
  GET  /feed                              — page=N (15 per page)
  GET  /api/clients/:id/comments          — JSON list, top-level comments
  POST /api/clients/:id/like              — toggle, JSON {liked, like_count}
  POST /api/clients/:id/comment           — JSON {author_name, author_email?, body}

views/public/feed.ejs — max-width 520px column. Each card:
  - Avatar circle (made-with-ai fallback for unclaimed)
  - Name (links to /clients/:slug) + firm/role · city,state
  - Areas-served chips
  - Site/LinkedIn/Instagram link row
  - Like / Comment / Share action row
  - Collapsed comments section that lazy-loads on toggle
  - Inline comment form (name+email+body, optimistic insert on success)
  - At <520px width, posts go edge-to-edge (FB-style mobile)

server.js — added cookie-parser middleware.

Smoke-tested:
  GET  /feed                                → 200, 15 posts rendered
  GET  /api/clients/45/comments             → 200, JSON list
  POST /api/clients/45/like                 → 200 {liked:true, like_count:1}
  POST /api/clients/45/comment              → 200, comment landed

v1 scope cuts (defer to next ticks if Steve wants):
  - Nested replies (parent_id is in schema but not used yet)
  - Infinite scroll (page=N + Older/Newer pagers for now)
  - User accounts (anon cookie is enough)
  - Notifications + email-on-comment
  - Designer claim flow to enable Verified badge on their post

Files touched

Diff

commit 5927cb63eff442df68c247bee619ab482486de00
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 11:18:47 2026 -0700

    feat(starsofdesign): /feed phone UI — vertical scroll, Like + Comment
    
    Facebook / X.com-style mobile-first feed for the /clients tier. One
    designer per card stacked vertically, anonymous like (cookie-tracked),
    anonymous comment thread (name + body required, optional email).
    
    Schema (idempotent CREATE TABLE):
      sod_likes      (designer_id, anon_id, created_at)
                     UNIQUE (designer_id, anon_id) — one like per visitor per post
      sod_comments   (designer_id, parent_id, author_name, author_email, body,
                      is_hidden, anon_id, ip_hash, created_at)
                     partial index on (designer_id, created_at DESC) WHERE NOT hidden
    
    lib/feed.js
      - ensureAnonId(req,res) — sets long-lived sod_anon cookie (12 hex,
        httpOnly=false so client JS can show liked-state immediately)
      - feedPage({anonId, limit, offset}) — pulls designers with embedded
        like_count, comment_count, i_liked (boolean per caller), grouped
        links. Ordered by created_at DESC (newest first).
      - commentsFor(designerId, limit) — top-level comments only for v1
      - toggleLike — flips like state, returns new count + liked boolean
      - postComment — name+body validation, 1500-char body cap, 5/hour
        burst limit per anon, simple spam regex
                     (viagra/crypto/loan/casino/etc.), max 2 URLs allowed
    
    Routes (in routes/public.js):
      GET  /feed                              — page=N (15 per page)
      GET  /api/clients/:id/comments          — JSON list, top-level comments
      POST /api/clients/:id/like              — toggle, JSON {liked, like_count}
      POST /api/clients/:id/comment           — JSON {author_name, author_email?, body}
    
    views/public/feed.ejs — max-width 520px column. Each card:
      - Avatar circle (made-with-ai fallback for unclaimed)
      - Name (links to /clients/:slug) + firm/role · city,state
      - Areas-served chips
      - Site/LinkedIn/Instagram link row
      - Like / Comment / Share action row
      - Collapsed comments section that lazy-loads on toggle
      - Inline comment form (name+email+body, optimistic insert on success)
      - At <520px width, posts go edge-to-edge (FB-style mobile)
    
    server.js — added cookie-parser middleware.
    
    Smoke-tested:
      GET  /feed                                → 200, 15 posts rendered
      GET  /api/clients/45/comments             → 200, JSON list
      POST /api/clients/45/like                 → 200 {liked:true, like_count:1}
      POST /api/clients/45/comment              → 200, comment landed
    
    v1 scope cuts (defer to next ticks if Steve wants):
      - Nested replies (parent_id is in schema but not used yet)
      - Infinite scroll (page=N + Older/Newer pagers for now)
      - User accounts (anon cookie is enough)
      - Notifications + email-on-comment
      - Designer claim flow to enable Verified badge on their post
---
 lib/feed.js           | 135 +++++++++++++++
 package-lock.json     |  20 +++
 package.json          |   1 +
 routes/public.js      |  61 +++++++
 server.js             |   2 +
 views/public/feed.ejs | 443 ++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 662 insertions(+)

diff --git a/lib/feed.js b/lib/feed.js
new file mode 100644
index 0000000..15366e2
--- /dev/null
+++ b/lib/feed.js
@@ -0,0 +1,135 @@
+'use strict';
+// Phone-shaped social feed for the StarsOfDesign /clients tier. One designer
+// per card, vertical scroll, anonymous Like (cookie-tracked) + Comment thread.
+//
+// Anon ID — a random opaque cookie set on first visit. Used to:
+//   - dedupe Likes per visitor (one like per visitor per designer)
+//   - tag comments so a visitor can see/delete their own
+// Anonymous comments allow Steve to capture commenter name + (optional) email
+// for follow-up without standing up a full auth system.
+
+const crypto = require('crypto');
+const { Pool } = require('pg');
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+  max: 4,
+});
+
+function ensureAnonId(req, res) {
+  let a = req.cookies && req.cookies.sod_anon;
+  if (!a || !/^[a-f0-9]{16,32}$/.test(a)) {
+    a = crypto.randomBytes(12).toString('hex');
+    res.cookie('sod_anon', a, {
+      maxAge: 1000 * 60 * 60 * 24 * 365 * 2,  // 2 years
+      httpOnly: false,                          // need JS to fetch likes-state
+      sameSite: 'lax',
+      secure: false,
+    });
+  }
+  return a;
+}
+
+function hashIp(req) {
+  const ip = req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || '';
+  return crypto.createHash('sha256').update(String(ip)).digest('hex').slice(0, 24);
+}
+
+// Page of designers for the feed, with like-count + has-liked state per
+// caller. Ordered by recency (created_at DESC) so latest profiles bubble up.
+async function feedPage({ anonId, limit = 20, offset = 0 }) {
+  const r = await pool.query(`
+    SELECT d.id, d.slug, d.full_name, d.role,
+           COALESCE(f.city, d.city) AS city,
+           COALESCE(f.state_or_region, d.state_or_region) AS state,
+           COALESCE(d.areas_served, f.areas_served, '{}'::text[]) AS areas,
+           f.name AS firm_name, f.slug AS firm_slug,
+           d.headshot_source, d.headshot_url,
+           d.created_at,
+           (SELECT count(*) FROM sod_likes l WHERE l.designer_id = d.id) AS like_count,
+           EXISTS (SELECT 1 FROM sod_likes l WHERE l.designer_id = d.id AND l.anon_id = $3) AS i_liked,
+           (SELECT count(*) FROM sod_comments c WHERE c.designer_id = d.id AND c.is_hidden = false) AS comment_count,
+           (SELECT json_agg(json_build_object('url', l.url, 'kind', l.kind))
+              FROM sod_links l WHERE l.designer_id = d.id OR l.firm_id = f.id) AS links
+      FROM sod_designers d
+      LEFT JOIN sod_designer_firm df ON df.designer_id = d.id AND df.is_current = true
+      LEFT JOIN sod_firms f          ON f.id = df.firm_id
+     WHERE (d.role IS NULL OR d.role <> 'Manufacturer Rep')
+     ORDER BY d.created_at DESC, d.id DESC
+     LIMIT $1 OFFSET $2`, [limit, offset, anonId]);
+  return r.rows.map(row => ({
+    id: row.id,
+    slug: row.slug,
+    full_name: row.full_name,
+    role: row.role,
+    city: row.city,
+    state: row.state,
+    areas: row.areas || [],
+    firm_name: row.firm_name,
+    firm_slug: row.firm_slug,
+    headshot: (row.headshot_source === 'made-with-ai' || !row.headshot_url) ? '/img/made-with-ai.svg' : row.headshot_url,
+    like_count: parseInt(row.like_count, 10),
+    i_liked: row.i_liked,
+    comment_count: parseInt(row.comment_count, 10),
+    created_at: row.created_at,
+    links: (row.links || []).reduce((acc, l) => { (acc[l.kind] = acc[l.kind] || []).push(l.url); return acc; }, {}),
+  }));
+}
+
+async function commentsFor(designerId, limit = 40) {
+  const r = await pool.query(`
+    SELECT id, parent_id, author_name, body, created_at
+      FROM sod_comments
+     WHERE designer_id = $1 AND is_hidden = false
+     ORDER BY created_at ASC
+     LIMIT $2`, [designerId, limit]);
+  return r.rows;
+}
+
+async function toggleLike({ designerId, anonId }) {
+  const existing = await pool.query(
+    `SELECT id FROM sod_likes WHERE designer_id=$1 AND anon_id=$2`, [designerId, anonId]
+  );
+  if (existing.rows[0]) {
+    await pool.query(`DELETE FROM sod_likes WHERE id=$1`, [existing.rows[0].id]);
+  } else {
+    await pool.query(
+      `INSERT INTO sod_likes (designer_id, anon_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`,
+      [designerId, anonId]
+    );
+  }
+  const c = await pool.query(`SELECT count(*)::int AS n FROM sod_likes WHERE designer_id=$1`, [designerId]);
+  return { liked: !existing.rows[0], like_count: c.rows[0].n };
+}
+
+const SPAM_RE = /\b(viagra|cialis|crypto|bitcoin|seo services|loan|casino|porn|nude)\b/i;
+function isSpamy(body) {
+  if (!body) return true;
+  if (body.length < 2 || body.length > 1500) return true;
+  // Way too many URLs
+  if ((body.match(/https?:\/\//g) || []).length > 2) return true;
+  if (SPAM_RE.test(body)) return true;
+  return false;
+}
+
+async function postComment({ designerId, anonId, ipHash, author_name, author_email, body }) {
+  author_name = String(author_name || '').trim().slice(0, 80);
+  author_email = author_email ? String(author_email).trim().slice(0, 160) : null;
+  body = String(body || '').trim();
+  if (!author_name || author_name.length < 2) throw new Error('name-required');
+  if (isSpamy(body)) throw new Error('spam-or-empty');
+  // Burst limit — at most 5 comments per anonId per hour
+  const burst = await pool.query(
+    `SELECT count(*) AS n FROM sod_comments WHERE anon_id=$1 AND created_at > NOW() - INTERVAL '1 hour'`,
+    [anonId]
+  );
+  if (parseInt(burst.rows[0].n, 10) >= 5) throw new Error('rate-limit');
+  const r = await pool.query(`
+    INSERT INTO sod_comments (designer_id, author_name, author_email, body, anon_id, ip_hash)
+    VALUES ($1, $2, $3, $4, $5, $6)
+    RETURNING id, author_name, body, created_at`,
+    [designerId, author_name, author_email, body, anonId, ipHash]);
+  return r.rows[0];
+}
+
+module.exports = { ensureAnonId, hashIp, feedPage, commentsFor, toggleLike, postComment };
diff --git a/package-lock.json b/package-lock.json
index 4a8b2f8..b93b82d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
       "name": "starsofdesign",
       "version": "0.1.0",
       "dependencies": {
+        "cookie-parser": "^1.4.7",
         "dotenv": "^16.6.1",
         "ejs": "^3.1.10",
         "express": "^4.21.0",
@@ -185,6 +186,25 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/cookie-parser": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "0.7.2",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-parser/node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "license": "MIT"
+    },
     "node_modules/cookie-signature": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
diff --git a/package.json b/package.json
index dd8889d..afb1fff 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "dev": "node server.js"
   },
   "dependencies": {
+    "cookie-parser": "^1.4.7",
     "dotenv": "^16.6.1",
     "ejs": "^3.1.10",
     "express": "^4.21.0",
diff --git a/routes/public.js b/routes/public.js
index dd59dd5..c618492 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -3,6 +3,7 @@ const router = express.Router();
 
 const data = require('../lib/data');
 const clientsLib = require('../lib/clients');
+const feedLib = require('../lib/feed');
 const { sortDesigners, SORTS } = require('../lib/sort');
 
 const SITE_TITLE = 'Stars of Design';
@@ -108,6 +109,66 @@ router.get('/clients/:slug', async (req, res, next) => {
   } 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); }
+});
+
+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);
+    res.json({ comments: rows });
+  } 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 = {
diff --git a/server.js b/server.js
index 98befd2..186209c 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const path = require('path');
 const morgan = require('morgan');
 const helmet = require('helmet');
 const rateLimit = require('express-rate-limit');
+const cookieParser = require('cookie-parser');
 
 const publicRoutes = require('./routes/public');
 
@@ -53,6 +54,7 @@ app.use(rateLimit({
 app.use(morgan(IS_PROD ? 'combined' : 'dev'));
 app.use(express.json({ limit: '256kb' }));
 app.use(express.urlencoded({ extended: true, limit: '256kb' }));
+app.use(cookieParser());
 
 app.use(express.static(path.join(__dirname, 'public'), {
   maxAge: '1h',
diff --git a/views/public/feed.ejs b/views/public/feed.ejs
new file mode 100644
index 0000000..f3b3b15
--- /dev/null
+++ b/views/public/feed.ejs
@@ -0,0 +1,443 @@
+<%- include('../partials/head', { title: title, meta_desc_override: typeof meta_desc_override !== 'undefined' ? meta_desc_override : undefined }) %>
+<%- include('../partials/header') %>
+
+<style>
+  .feed-shell {
+    max-width: 520px;
+    margin: 0 auto;
+    padding: 16px 12px 96px;
+    background: var(--bg, #0e0e0e);
+  }
+  .feed-head {
+    text-align: center;
+    padding: 18px 0 8px;
+    border-bottom: 1px solid var(--line, #2a2620);
+    margin-bottom: 16px;
+  }
+  .feed-head h1 {
+    font-family: 'Cormorant Garamond', Georgia, serif;
+    font-weight: 400;
+    font-size: 32px;
+    margin: 0 0 4px;
+    color: var(--gold, #c9a14b);
+    letter-spacing: -0.01em;
+  }
+  .feed-head p {
+    margin: 0;
+    color: var(--ink-faint, #7a706a);
+    font-size: 12px;
+    letter-spacing: 0.04em;
+  }
+  .post {
+    background: var(--card, #1a1714);
+    border: 1px solid var(--line, #2a2620);
+    border-radius: 12px;
+    margin: 0 0 14px;
+    overflow: hidden;
+  }
+  .post-head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px;
+  }
+  .post-avatar {
+    width: 44px;
+    height: 44px;
+    border-radius: 50%;
+    background: #14110e no-repeat center/cover;
+    border: 1px solid var(--line, #2a2620);
+    flex-shrink: 0;
+  }
+  .post-meta {
+    flex: 1;
+    min-width: 0;
+  }
+  .post-name {
+    color: var(--ink, #f0ebe3);
+    font-weight: 500;
+    font-size: 15px;
+    margin: 0;
+    text-decoration: none;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    display: block;
+  }
+  .post-name:hover { color: var(--gold, #c9a14b); }
+  .post-sub {
+    color: var(--ink-faint, #7a706a);
+    font-size: 12px;
+    margin: 1px 0 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .post-body {
+    padding: 0 12px 12px;
+  }
+  .post-areas {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin: 4px 0 0;
+    padding: 0;
+    list-style: none;
+  }
+  .post-areas li {
+    padding: 3px 9px;
+    background: rgba(201, 161, 75, 0.08);
+    border: 1px solid var(--line, #2a2620);
+    border-radius: 999px;
+    color: var(--ink-soft, #c8beb2);
+    font-size: 11px;
+    letter-spacing: 0.04em;
+  }
+  .post-links {
+    margin: 10px 0 0;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 12px;
+  }
+  .post-links a {
+    color: var(--ink-faint, #7a706a);
+    font-size: 12px;
+    text-decoration: none;
+  }
+  .post-links a:hover { color: var(--gold, #c9a14b); }
+  .post-actions {
+    display: flex;
+    align-items: center;
+    border-top: 1px solid var(--line, #2a2620);
+    padding: 4px 4px;
+  }
+  .post-actions button {
+    background: transparent;
+    border: 0;
+    color: var(--ink-soft, #c8beb2);
+    font: inherit;
+    font-size: 13px;
+    padding: 10px 14px;
+    cursor: pointer;
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    border-radius: 6px;
+  }
+  .post-actions button:hover { background: rgba(255, 255, 255, 0.04); }
+  .post-actions button[aria-pressed="true"] { color: var(--gold, #c9a14b); font-weight: 500; }
+  .post-actions .ico { font-size: 16px; line-height: 1; }
+  .like-count { font-variant-numeric: tabular-nums; }
+  .comments {
+    display: none;
+    padding: 6px 12px 12px;
+    border-top: 1px solid var(--line, #2a2620);
+  }
+  .comments.open { display: block; }
+  .comment {
+    padding: 8px 0;
+    border-bottom: 1px dotted var(--line, #2a2620);
+  }
+  .comment:last-child { border-bottom: 0; }
+  .comment-author {
+    color: var(--gold, #c9a14b);
+    font-size: 12px;
+    font-weight: 500;
+    margin: 0 0 2px;
+  }
+  .comment-body {
+    color: var(--ink-soft, #c8beb2);
+    font-size: 13.5px;
+    margin: 0;
+    line-height: 1.5;
+    word-wrap: break-word;
+  }
+  .comment-time {
+    color: var(--ink-faint, #7a706a);
+    font-size: 11px;
+    margin-left: 8px;
+  }
+  .comment-form {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    margin-top: 12px;
+  }
+  .comment-form input, .comment-form textarea {
+    background: rgba(0, 0, 0, 0.25);
+    border: 1px solid var(--line, #2a2620);
+    color: var(--ink, #f0ebe3);
+    border-radius: 6px;
+    padding: 7px 10px;
+    font: inherit;
+    font-size: 13px;
+    width: 100%;
+    box-sizing: border-box;
+    resize: vertical;
+    font-family: inherit;
+  }
+  .comment-form textarea { min-height: 60px; }
+  .comment-form .row {
+    display: flex;
+    gap: 6px;
+    align-items: center;
+  }
+  .comment-form .row input { flex: 1; }
+  .comment-form button {
+    background: var(--gold, #c9a14b);
+    color: #1a1410;
+    border: 0;
+    padding: 6px 14px;
+    font-size: 12px;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+    border-radius: 4px;
+    cursor: pointer;
+    font-weight: 500;
+  }
+  .comment-form button:disabled { opacity: 0.5; cursor: not-allowed; }
+  .comment-form .err {
+    color: #e07a7a;
+    font-size: 12px;
+    margin: 0;
+  }
+  .pager {
+    display: flex;
+    justify-content: center;
+    gap: 12px;
+    margin-top: 24px;
+  }
+  .pager a {
+    color: var(--ink-soft, #c8beb2);
+    text-decoration: none;
+    padding: 8px 18px;
+    border: 1px solid var(--line, #2a2620);
+    border-radius: 6px;
+    font-size: 12px;
+    letter-spacing: 0.08em;
+  }
+  .pager a:hover { color: var(--gold, #c9a14b); border-color: var(--gold, #c9a14b); }
+  .pager .disabled { opacity: 0.35; pointer-events: none; }
+
+  @media (max-width: 520px) {
+    .feed-shell { padding: 8px 8px 80px; }
+    .post { border-radius: 0; border-left: 0; border-right: 0; margin-left: -8px; margin-right: -8px; }
+  }
+</style>
+
+<main class="feed-shell">
+  <div class="feed-head">
+    <h1>DW Feed</h1>
+    <p>Like, comment, follow your favorite design houses.</p>
+  </div>
+
+  <% items.forEach(function(it) { %>
+    <article class="post" data-id="<%= it.id %>">
+      <div class="post-head">
+        <div class="post-avatar" style="background-image:url('<%= it.headshot %>')"></div>
+        <div class="post-meta">
+          <a class="post-name" href="/clients/<%= it.slug %>"><%= it.full_name %></a>
+          <p class="post-sub">
+            <% if (it.firm_name) { %><%= it.firm_name %><% if (it.role) { %> · <%= it.role %><% } %><% } else if (it.role) { %><%= it.role %><% } %>
+            <% if ((it.firm_name || it.role) && (it.city || it.state)) { %> · <% } %>
+            <% if (it.city || it.state) { %><%= [it.city, it.state].filter(Boolean).join(', ') %><% } %>
+          </p>
+        </div>
+      </div>
+
+      <% if (it.areas && it.areas.length || it.links && (it.links['firm-site'] || it.links.linkedin || it.links.instagram)) { %>
+      <div class="post-body">
+        <% if (it.areas && it.areas.length) { %>
+          <ul class="post-areas">
+            <% it.areas.slice(0,5).forEach(function(a) { %><li><%= a %></li><% }); %>
+          </ul>
+        <% } %>
+        <% var siteHref = (it.links['firm-site']||[])[0]; var liHref = (it.links.linkedin||[])[0]; var igHref = (it.links.instagram||[])[0]; %>
+        <% if (siteHref || liHref || igHref) { %>
+          <div class="post-links">
+            <% if (siteHref) { %><a href="<%= siteHref %>" target="_blank" rel="nofollow noopener">↗ Site</a><% } %>
+            <% if (liHref)   { %><a href="<%= liHref %>" target="_blank" rel="nofollow noopener">↗ LinkedIn</a><% } %>
+            <% if (igHref)   { %><a href="<%= igHref %>" target="_blank" rel="nofollow noopener">↗ Instagram</a><% } %>
+          </div>
+        <% } %>
+      </div>
+      <% } %>
+
+      <div class="post-actions">
+        <button class="js-like" type="button" aria-pressed="<%= it.i_liked %>">
+          <span class="ico">♥</span> <span class="like-count"><%= it.like_count %></span> <span>Like</span>
+        </button>
+        <button class="js-comment-toggle" type="button" aria-expanded="false">
+          <span class="ico">💬</span> <span class="comment-count"><%= it.comment_count %></span> <span>Comment</span>
+        </button>
+        <button class="js-share" type="button" data-share-url="/clients/<%= it.slug %>">
+          <span class="ico">↗</span> <span>Share</span>
+        </button>
+      </div>
+
+      <section class="comments">
+        <div class="comments-list"><!-- lazy-loaded --></div>
+        <form class="comment-form js-comment-form">
+          <div class="row">
+            <input name="author_name" placeholder="Your name *" maxlength="80" required>
+            <input name="author_email" placeholder="Email (optional)" maxlength="160" type="email">
+          </div>
+          <textarea name="body" placeholder="Add a comment…" maxlength="1500" required></textarea>
+          <p class="err" hidden></p>
+          <div style="display:flex;justify-content:flex-end">
+            <button type="submit">Post</button>
+          </div>
+        </form>
+      </section>
+    </article>
+  <% }); %>
+
+  <div class="pager">
+    <a class="<%= page <= 1 ? 'disabled' : '' %>" href="?page=<%= Math.max(1, page-1) %>">← Newer</a>
+    <a class="<%= items.length < 15 ? 'disabled' : '' %>" href="?page=<%= page+1 %>">Older →</a>
+  </div>
+</main>
+
+<script>
+(function(){
+  // Like toggle
+  document.querySelectorAll('.js-like').forEach(function(btn){
+    btn.addEventListener('click', async function(){
+      var post = btn.closest('.post');
+      var id = post.dataset.id;
+      btn.disabled = true;
+      try {
+        var r = await fetch('/api/clients/' + id + '/like', { method: 'POST' });
+        if (!r.ok) throw new Error('like-failed');
+        var j = await r.json();
+        btn.querySelector('.like-count').textContent = j.like_count;
+        btn.setAttribute('aria-pressed', j.liked ? 'true' : 'false');
+      } catch(e) {
+        // silent fail
+      } finally {
+        btn.disabled = false;
+      }
+    });
+  });
+
+  // Comment toggle + lazy load
+  document.querySelectorAll('.js-comment-toggle').forEach(function(btn){
+    btn.addEventListener('click', async function(){
+      var post = btn.closest('.post');
+      var sect = post.querySelector('.comments');
+      var open = !sect.classList.contains('open');
+      sect.classList.toggle('open', open);
+      btn.setAttribute('aria-expanded', String(open));
+      if (open && !sect.dataset.loaded) {
+        var list = sect.querySelector('.comments-list');
+        list.textContent = 'Loading…';
+        try {
+          var r = await fetch('/api/clients/' + post.dataset.id + '/comments');
+          if (!r.ok) throw new Error('load');
+          var j = await r.json();
+          list.innerHTML = '';
+          (j.comments || []).forEach(function(c){
+            var el = document.createElement('div');
+            el.className = 'comment';
+            var au = document.createElement('p');
+            au.className = 'comment-author';
+            au.textContent = c.author_name;
+            var t = document.createElement('span');
+            t.className = 'comment-time';
+            t.textContent = new Date(c.created_at).toLocaleString();
+            au.appendChild(t);
+            var bo = document.createElement('p');
+            bo.className = 'comment-body';
+            bo.textContent = c.body;
+            el.appendChild(au); el.appendChild(bo);
+            list.appendChild(el);
+          });
+          if (!j.comments || !j.comments.length) {
+            list.innerHTML = '<p style="color:var(--ink-faint,#7a706a);font-size:12.5px;margin:8px 0 0">Be the first to comment.</p>';
+          }
+          sect.dataset.loaded = '1';
+        } catch (e) {
+          list.textContent = 'Couldn\'t load comments.';
+        }
+      }
+    });
+  });
+
+  // Comment submit
+  document.querySelectorAll('.js-comment-form').forEach(function(form){
+    form.addEventListener('submit', async function(ev){
+      ev.preventDefault();
+      var post = form.closest('.post');
+      var btn = form.querySelector('button[type=submit]');
+      var err = form.querySelector('.err');
+      err.hidden = true;
+      btn.disabled = true;
+      try {
+        var body = {
+          author_name: form.author_name.value.trim(),
+          author_email: form.author_email.value.trim(),
+          body: form.body.value.trim()
+        };
+        var r = await fetch('/api/clients/' + post.dataset.id + '/comment', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify(body)
+        });
+        if (!r.ok) {
+          var j = await r.json().catch(function(){ return {}; });
+          throw new Error(j.error || 'failed');
+        }
+        // Insert into list
+        var list = post.querySelector('.comments-list');
+        var el = document.createElement('div');
+        el.className = 'comment';
+        var au = document.createElement('p');
+        au.className = 'comment-author';
+        au.textContent = body.author_name;
+        var t = document.createElement('span');
+        t.className = 'comment-time';
+        t.textContent = new Date().toLocaleString();
+        au.appendChild(t);
+        var bo = document.createElement('p');
+        bo.className = 'comment-body';
+        bo.textContent = body.body;
+        el.appendChild(au); el.appendChild(bo);
+        list.appendChild(el);
+        form.body.value = '';
+        // Bump comment count
+        var cc = post.querySelector('.comment-count');
+        cc.textContent = String(parseInt(cc.textContent || '0', 10) + 1);
+      } catch (e) {
+        err.hidden = false;
+        err.textContent =
+          e.message === 'name-required' ? 'Name is required.' :
+          e.message === 'spam-or-empty' ? 'Comment looks empty or spammy.' :
+          e.message === 'rate-limit'    ? 'Too many comments. Slow down.' :
+          'Could not post. Try again.';
+      } finally {
+        btn.disabled = false;
+      }
+    });
+  });
+
+  // Share — Web Share API where available, fallback to copy
+  document.querySelectorAll('.js-share').forEach(function(btn){
+    btn.addEventListener('click', async function(){
+      var url = location.origin + btn.dataset.shareUrl;
+      try {
+        if (navigator.share) {
+          await navigator.share({ url, title: document.title });
+        } else {
+          await navigator.clipboard.writeText(url);
+          btn.querySelector('span:last-child').textContent = 'Copied!';
+          setTimeout(function(){ btn.querySelector('span:last-child').textContent = 'Share'; }, 1500);
+        }
+      } catch (e) { /* user-canceled */ }
+    });
+  });
+})();
+</script>
+
+<%- include('../partials/footer') %>

← 1f43d2b feat(starsofdesign): regex pass extracts URLs from stored em  ·  back to Stars of Design  ·  feat(starsofdesign/deep-recon-v2): --windows={v1|v2|all} for da8a343 →