← 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
A lib/feed.jsM package-lock.jsonM package.jsonM routes/public.jsM server.jsA views/public/feed.ejs
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 →