← back to NationalPaperHangers
yolo tick 2: public /papers list + /papers/:slug detail (read-only)
4492052553e8a53a33b7a2a87fb1265ed2965a7b · 2026-05-10 17:44:11 -0700 · SteveStudio2
Continues UX backlog #3. Tick 1 landed the schema; this tick exposes
the 5 seed threads via public read-only routes.
- GET /papers — list page sorted by comment_count DESC, brand/paper_name
fallback. Category filter pills (chinoiserie/grasscloth/specialty/
metallic). Renders 2-line description preview + paste/category badges
+ note count per row.
- GET /papers/:slug — thread detail. Header with brand + paper_name +
category/paste/SKU/comment-count meta row, blockquote-style description,
comments section ordered by helpful_count DESC then created_at ASC.
Each comment attributes to its installer with link to profile + city
+ verified badge — gives designers a selection signal (which studios
contribute thoughtful craft knowledge).
- 404 on missing slug.
- /papers added to primary nav (between Map and Watch).
- /papers + /papers/:slug added to sitemap.xml with weekly/0.6.
- Read-only: no comment submission yet. Detail page CTA tells logged-in
installers "comment submission ships next tick"; non-logged-in users
get an /login CTA.
Next tick: POST /papers/:slug/comments + admin moderation queue.
Files touched
M routes/public.jsM views/partials/header.ejsA views/public/papers-detail.ejsA views/public/papers-list.ejs
Diff
commit 4492052553e8a53a33b7a2a87fb1265ed2965a7b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Sun May 10 17:44:11 2026 -0700
yolo tick 2: public /papers list + /papers/:slug detail (read-only)
Continues UX backlog #3. Tick 1 landed the schema; this tick exposes
the 5 seed threads via public read-only routes.
- GET /papers — list page sorted by comment_count DESC, brand/paper_name
fallback. Category filter pills (chinoiserie/grasscloth/specialty/
metallic). Renders 2-line description preview + paste/category badges
+ note count per row.
- GET /papers/:slug — thread detail. Header with brand + paper_name +
category/paste/SKU/comment-count meta row, blockquote-style description,
comments section ordered by helpful_count DESC then created_at ASC.
Each comment attributes to its installer with link to profile + city
+ verified badge — gives designers a selection signal (which studios
contribute thoughtful craft knowledge).
- 404 on missing slug.
- /papers added to primary nav (between Map and Watch).
- /papers + /papers/:slug added to sitemap.xml with weekly/0.6.
- Read-only: no comment submission yet. Detail page CTA tells logged-in
installers "comment submission ships next tick"; non-logged-in users
get an /login CTA.
Next tick: POST /papers/:slug/comments + admin moderation queue.
---
routes/public.js | 87 ++++++++++++++++++++++++++++++++++++++++++
views/partials/header.ejs | 1 +
views/public/papers-detail.ejs | 75 ++++++++++++++++++++++++++++++++++++
views/public/papers-list.ejs | 63 ++++++++++++++++++++++++++++++
4 files changed, 226 insertions(+)
diff --git a/routes/public.js b/routes/public.js
index f61666a..8856ec3 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -199,6 +199,84 @@ router.get('/api/find', async (req, res, next) => {
} catch (err) { next(err); }
});
+// ─── /papers — "This Paper" peer-installer commentary (UX backlog #3) ────
+// Public list of wallcovering threads with comment-count badges. Read-only
+// in this tick — comment submission lands in tick 3.
+router.get('/papers', async (req, res, next) => {
+ try {
+ const category = (req.query.category || '').trim();
+ const params = [];
+ const where = [];
+ let i = 1;
+ if (category) {
+ params.push(category);
+ where.push(`category = $${i}`);
+ i++;
+ }
+ const sql = `
+ SELECT id, slug, brand, paper_name, sku, paste_type, category,
+ description, comment_count, updated_at
+ FROM paper_threads
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+ ORDER BY comment_count DESC, brand, paper_name
+ LIMIT 200`;
+ const threads = await db.many(sql, params);
+
+ // Category facets — surface what's available so the filter pill row
+ // doesn't show empty buckets.
+ const catRows = await db.many(
+ `SELECT category, COUNT(*)::int AS n
+ FROM paper_threads
+ WHERE category IS NOT NULL
+ GROUP BY category
+ ORDER BY n DESC, category`
+ );
+
+ res.render('public/papers-list', {
+ title: 'Wallcovering papers · install threads · National Paper Hangers',
+ threads,
+ categories: catRows,
+ activeCategory: category || null
+ });
+ } catch (err) { next(err); }
+});
+
+router.get('/papers/:slug', async (req, res, next) => {
+ try {
+ const thread = await db.one(
+ `SELECT id, slug, brand, paper_name, sku, paste_type, category,
+ description, seeded_by, comment_count, created_at, updated_at
+ FROM paper_threads
+ WHERE slug = $1`,
+ [req.params.slug]
+ );
+ if (!thread) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ // Public-readable comments — exclude flagged. Join installer for
+ // attribution + selection signal (designers see which installers
+ // contribute thoughtful answers).
+ const comments = await db.many(
+ `SELECT c.id, c.body, c.helpful_count, c.created_at, c.edited_at,
+ i.slug AS installer_slug, i.business_name AS installer_business_name,
+ i.city AS installer_city, i.state AS installer_state,
+ i.tier AS installer_tier, i.verified AS installer_verified
+ FROM paper_comments c
+ JOIN installers i ON i.id = c.installer_id
+ WHERE c.thread_id = $1 AND c.flagged = false
+ AND (i.status = 'active' OR i.claim_status = 'unclaimed')
+ ORDER BY c.helpful_count DESC, c.created_at ASC
+ LIMIT 200`,
+ [thread.id]
+ );
+
+ res.render('public/papers-detail', {
+ title: `${thread.brand} ${thread.paper_name} — install thread · National Paper Hangers`,
+ thread,
+ comments
+ });
+ } catch (err) { next(err); }
+});
+
router.get('/installer/:slug', async (req, res, next) => {
try {
const installer = await db.one(
@@ -526,17 +604,26 @@ router.get('/sitemap.xml', async (req, res, next) => {
WHERE status = 'active' OR claim_status = 'unclaimed'
ORDER BY lastmod DESC`
);
+ const paperRows = await db.many(
+ `SELECT slug, GREATEST(updated_at, created_at) AS lastmod
+ FROM paper_threads
+ ORDER BY lastmod DESC`
+ );
const base = (process.env.PUBLIC_URL || 'https://www.nationalpaperhangers.com').replace(/\/+$/, '');
const fmt = d => (d ? new Date(d).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10));
const urls = [
`<url><loc>${base}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>`,
`<url><loc>${base}/find</loc><changefreq>daily</changefreq><priority>0.9</priority></url>`,
`<url><loc>${base}/map</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`,
+ `<url><loc>${base}/papers</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`,
`<url><loc>${base}/watch</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>`,
`<url><loc>${base}/for-installers</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>`,
`<url><loc>${base}/about</loc><changefreq>monthly</changefreq><priority>0.5</priority></url>`,
...rows.map(r =>
`<url><loc>${base}/installer/${encodeURIComponent(r.slug)}</loc><lastmod>${fmt(r.lastmod)}</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>`
+ ),
+ ...paperRows.map(r =>
+ `<url><loc>${base}/papers/${encodeURIComponent(r.slug)}</loc><lastmod>${fmt(r.lastmod)}</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>`
)
].join('\n ');
res.set('Content-Type', 'application/xml; charset=utf-8');
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 342e1a8..46adfcd 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -8,6 +8,7 @@
<nav class="primary-nav" aria-label="Primary">
<a href="/find" class="<%= _navPath === '/find' ? 'is-current' : '' %>">Find an installer</a>
<a href="/map" class="<%= _navPath === '/map' ? 'is-current' : '' %>">Map</a>
+ <a href="/papers" class="<%= _navPath.startsWith('/papers') ? 'is-current' : '' %>">Papers</a>
<a href="/watch" class="<%= _navPath === '/watch' ? 'is-current' : '' %>">Watch</a>
<a href="/for-installers" class="<%= _navPath === '/for-installers' ? 'is-current' : '' %>">For installers</a>
<a href="/about" class="<%= _navPath === '/about' ? 'is-current' : '' %>">About</a>
diff --git a/views/public/papers-detail.ejs b/views/public/papers-detail.ejs
new file mode 100644
index 0000000..9cde425
--- /dev/null
+++ b/views/public/papers-detail.ejs
@@ -0,0 +1,75 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="papers-detail" style="max-width:760px;margin:0 auto;padding:48px 24px">
+
+ <nav style="margin-bottom:24px;font-size:13px">
+ <a href="/papers" style="color:inherit">← All papers</a>
+ </nav>
+
+ <header style="margin:0 0 28px">
+ <div style="font-size:13px;text-transform:uppercase;letter-spacing:0.08em;color:#888"><%= thread.brand %></div>
+ <h1 style="margin:6px 0 12px;font-size:34px;letter-spacing:0.005em;line-height:1.15"><%= thread.paper_name %></h1>
+
+ <dl style="display:flex;gap:24px;flex-wrap:wrap;margin:0;font-size:13px">
+ <% if (thread.category) { %>
+ <div><dt class="muted" style="display:inline">Category:</dt> <dd style="display:inline;margin:0;text-transform:capitalize"><%= thread.category %></dd></div>
+ <% } %>
+ <% if (thread.paste_type) { %>
+ <div><dt class="muted" style="display:inline">Paste:</dt> <dd style="display:inline;margin:0"><%= thread.paste_type %></dd></div>
+ <% } %>
+ <% if (thread.sku) { %>
+ <div><dt class="muted" style="display:inline">SKU:</dt> <dd style="display:inline;margin:0"><%= thread.sku %></dd></div>
+ <% } %>
+ <div><dt class="muted" style="display:inline">Notes:</dt> <dd style="display:inline;margin:0"><%= thread.comment_count %></dd></div>
+ </dl>
+
+ <% if (thread.description) { %>
+ <div style="margin:24px 0 0;padding:18px 22px;border-left:3px solid var(--accent,#0e0e0e);background:var(--bg-subtle,#f7f6f3);border-radius:0 4px 4px 0">
+ <p style="margin:0;font-size:15px;line-height:1.6"><%= thread.description %></p>
+ <% if (thread.seeded_by && thread.seeded_by !== 'nph_ops') { %>
+ <p class="muted" style="margin:8px 0 0;font-size:12px">— seeded by <%= thread.seeded_by %></p>
+ <% } %>
+ </div>
+ <% } %>
+ </header>
+
+ <h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.1em;margin:32px 0 14px;color:#999">Notes from working installers</h2>
+
+ <% if (!comments || !comments.length) { %>
+ <div class="callout" style="border:1px dashed var(--border,#ddd);padding:24px;border-radius:8px;text-align:center">
+ <p style="margin:0 0 6px">No notes on this paper yet.</p>
+ <p class="muted" style="margin:0;font-size:13px">Working installers — <a href="/login">log in</a> to contribute.</p>
+ </div>
+ <% } else { %>
+ <ul style="list-style:none;padding:0;margin:0;display:grid;gap:18px">
+ <% comments.forEach(function(c){ %>
+ <li style="padding:18px 20px;border:1px solid var(--border,#ddd);border-radius:8px">
+ <p style="margin:0 0 12px;font-size:15px;line-height:1.6;white-space:pre-wrap"><%= c.body %></p>
+ <footer style="display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:13px">
+ <a href="/installer/<%= c.installer_slug %>" style="color:inherit;text-decoration:none">
+ <strong><%= c.installer_business_name %></strong>
+ <% if (c.installer_verified) { %><span style="color:#888"> · verified</span><% } %>
+ <% if (c.installer_city) { %><span class="muted"> · <%= c.installer_city %><% if (c.installer_state) { %>, <%= c.installer_state %><% } %></span><% } %>
+ </a>
+ <span class="muted" style="font-size:12px">
+ <%= new Date(c.created_at).toLocaleDateString('en-US', {year:'numeric', month:'short', day:'numeric'}) %>
+ <% if (c.helpful_count > 0) { %> · <%= c.helpful_count %> found helpful<% } %>
+ </span>
+ </footer>
+ </li>
+ <% }); %>
+ </ul>
+ <% } %>
+
+ <% if (locals.currentInstaller && !locals.currentInstaller.unclaimed) { %>
+ <div style="margin-top:32px;padding:20px;border:1px dashed var(--border,#ddd);border-radius:8px;text-align:center">
+ <p style="margin:0 0 6px">Comment submission ships in the next tick.</p>
+ <p class="muted" style="margin:0;font-size:13px">For now, this thread is read-only.</p>
+ </div>
+ <% } else { %>
+ <p class="muted" style="margin-top:32px;font-size:13px;text-align:center"><a href="/login">Log in as a verified installer</a> to contribute notes.</p>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/papers-list.ejs b/views/public/papers-list.ejs
new file mode 100644
index 0000000..0c5000b
--- /dev/null
+++ b/views/public/papers-list.ejs
@@ -0,0 +1,63 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="papers-page" style="max-width:980px;margin:0 auto;padding:48px 24px">
+ <div style="margin-bottom:32px">
+ <h1 class="display-sm" style="margin:0 0 10px">Paper threads</h1>
+ <p class="lede" style="margin:0;max-width:680px">Working installers — not marketing copy — talking about the install quirks of the papers that matter. Drop %, paste type, soak time, what fails in humid environments, what to bring on the second visit.</p>
+ </div>
+
+ <% if (categories && categories.length) { %>
+ <nav class="papers-cats" aria-label="Filter by category" style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 24px;padding:0 0 18px;border-bottom:1px solid var(--border,#ddd)">
+ <a href="/papers" class="<%= !activeCategory ? 'is-current' : '' %>"
+ style="padding:6px 14px;border:1px solid <%= !activeCategory ? '#0e0e0e' : 'var(--border,#ddd)' %>;border-radius:18px;font-size:13px;color:<%= !activeCategory ? '#fff' : 'inherit' %>;background:<%= !activeCategory ? '#0e0e0e' : 'transparent' %>;text-decoration:none">
+ All
+ </a>
+ <% categories.forEach(function(c){ %>
+ <a href="/papers?category=<%= encodeURIComponent(c.category) %>"
+ style="padding:6px 14px;border:1px solid <%= activeCategory === c.category ? '#0e0e0e' : 'var(--border,#ddd)' %>;border-radius:18px;font-size:13px;color:<%= activeCategory === c.category ? '#fff' : 'inherit' %>;background:<%= activeCategory === c.category ? '#0e0e0e' : 'transparent' %>;text-decoration:none;text-transform:capitalize">
+ <%= c.category %> <span style="opacity:0.6">· <%= c.n %></span>
+ </a>
+ <% }); %>
+ </nav>
+ <% } %>
+
+ <% if (!threads || !threads.length) { %>
+ <div class="callout">
+ <p style="margin:0">No threads here yet.<% if (activeCategory) { %> <a href="/papers">See all papers →</a><% } %></p>
+ </div>
+ <% } else { %>
+ <ul style="list-style:none;padding:0;margin:0;display:grid;gap:14px">
+ <% threads.forEach(function(t){ %>
+ <li>
+ <a href="/papers/<%= t.slug %>" style="display:block;padding:20px 22px;border:1px solid var(--border,#ddd);border-radius:8px;text-decoration:none;color:inherit;transition:border-color 0.15s">
+ <div style="display:flex;justify-content:space-between;align-items:start;gap:16px;flex-wrap:wrap">
+ <div style="flex:1;min-width:220px">
+ <div style="font-size:12px;text-transform:uppercase;letter-spacing:0.08em;color:#888"><%= t.brand %></div>
+ <h2 style="margin:4px 0 0;font-size:20px;letter-spacing:0.01em"><%= t.paper_name %></h2>
+ <% if (t.description) { %>
+ <p class="muted" style="margin:8px 0 0;font-size:14px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden"><%= t.description %></p>
+ <% } %>
+ </div>
+ <div style="text-align:right;font-size:13px">
+ <% if (t.category) { %>
+ <div class="muted" style="text-transform:capitalize;margin-bottom:4px"><%= t.category %></div>
+ <% } %>
+ <% if (t.paste_type) { %>
+ <div class="muted">paste: <%= t.paste_type %></div>
+ <% } %>
+ <div style="margin-top:8px;font-weight:500"><%= t.comment_count %> <%= t.comment_count === 1 ? 'note' : 'notes' %></div>
+ </div>
+ </div>
+ </a>
+ </li>
+ <% }); %>
+ </ul>
+ <% } %>
+
+ <p class="muted" style="margin-top:36px;font-size:13px;line-height:1.6;max-width:680px">
+ Threads are public-readable. Only verified installer members can post — the goal is craft-quality, signal-rich exchange between practitioners. If a paper you work with isn't here yet, <a href="/login">log in</a> and add it.
+ </p>
+</section>
+
+<%- include('../partials/footer') %>
← 4dca72e yolo tick 1: paper_threads + paper_comments schema + 5 seed
·
back to NationalPaperHangers
·
yolo tick 3: paper-thread comment submission + ops moderatio 4a79e6f →