[object Object]

← 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

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 →