[object Object]

← back to NationalPaperHangers

watch page: ops-curated YT videos about pro installations + social_verified gate on per-installer handles (no fake seeds)

a7e5ecfa8344a6dc7a088ffc5156de6256477f6c · 2026-05-06 11:43:13 -0700 · Steve

Files touched

Diff

commit a7e5ecfa8344a6dc7a088ffc5156de6256477f6c
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 11:43:13 2026 -0700

    watch page: ops-curated YT videos about pro installations + social_verified gate on per-installer handles (no fake seeds)
---
 routes/admin.js                  | 126 +++++++++++++++++++++++++++++++++++++++
 routes/public.js                 |  35 +++++++++++
 views/admin/ops-watch.ejs        | 102 +++++++++++++++++++++++++++++++
 views/partials/header.ejs        |   1 +
 views/partials/social-videos.ejs |   8 ++-
 views/public/book.ejs            |  62 ++++++++++++++++---
 views/public/watch.ejs           | 120 +++++++++++++++++++++++++++++++++++++
 7 files changed, 445 insertions(+), 9 deletions(-)

diff --git a/routes/admin.js b/routes/admin.js
index c645c22..f92c997 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -471,6 +471,132 @@ router.post('/ops/credentials/:id(\\d+)/reject', requireOpsInstaller, async (req
   } catch (err) { next(err); }
 });
 
+// ===================================================================
+// OPS — curated /watch video queue
+// ===================================================================
+//
+// /watch is editorial — every video listed must be reviewed by ops before
+// publish. Ops pastes a YouTube URL, the parser extracts the ID, and the
+// row is added is_published=false until ops flips it. No automatic ingest.
+
+const ALLOWED_WATCH_CATEGORIES = new Set([
+  'hand_painted','silk','grasscloth','mural','metallic_leaf','vinyl',
+  'technique','historical','brand_showcase','installer_at_work'
+]);
+
+function parseYouTubeIdForOps(input) {
+  const v = String(input || '').trim();
+  if (!v) return null;
+  // Bare ID
+  if (/^[A-Za-z0-9_-]{6,20}$/.test(v)) return v;
+  try {
+    const u = new URL(v);
+    if (u.hostname === 'youtu.be') return u.pathname.replace(/^\//, '').split('/')[0];
+    const okHost = u.hostname === 'youtube.com' || u.hostname === 'www.youtube.com'
+      || u.hostname === 'm.youtube.com' || u.hostname === 'www.youtube-nocookie.com';
+    if (!okHost) return null;
+    if (u.pathname === '/watch') return u.searchParams.get('v');
+    if (u.pathname.startsWith('/embed/')) return u.pathname.split('/')[2];
+    if (u.pathname.startsWith('/shorts/')) return u.pathname.split('/')[2];
+  } catch {}
+  return null;
+}
+
+router.get('/ops/watch', requireOpsInstaller, async (req, res, next) => {
+  try {
+    const all = await db.many(
+      `SELECT id, youtube_id, title, channel_name, channel_url, category,
+              materials, brands, description, license_note, display_order,
+              added_by, added_at, verified_at, is_published
+         FROM curated_videos
+        ORDER BY is_published DESC, display_order, added_at DESC`
+    );
+    res.render('admin/ops-watch', {
+      title: 'Ops · Curated /watch · National Paper Hangers',
+      videos: all,
+      categories: Array.from(ALLOWED_WATCH_CATEGORIES)
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/ops/watch', requireOpsInstaller, async (req, res, next) => {
+  try {
+    const f = req.body || {};
+    const ytId = parseYouTubeIdForOps(f.youtube_url || f.youtube_id);
+    if (!ytId) {
+      req.session.flash = { error: 'Could not parse a YouTube ID from that input.' };
+      return res.redirect('/admin/ops/watch');
+    }
+    const title = clampLen((f.title || '').trim(), 240);
+    if (!title) {
+      req.session.flash = { error: 'Title is required.' };
+      return res.redirect('/admin/ops/watch');
+    }
+    const category = ALLOWED_WATCH_CATEGORIES.has(f.category) ? f.category : 'technique';
+    const channelName = f.channel_name ? clampLen(f.channel_name.trim(), 120) : null;
+    const channelUrl = sanitizeWebsite(f.channel_url);
+    const description = f.description ? clampLen(f.description.trim(), 600) : null;
+    const sourceUrl = `https://www.youtube.com/watch?v=${ytId}`;
+    const licenseNote = f.license_note ? clampLen(f.license_note.trim(), 200) : 'Standard YouTube embed';
+    const brands = (f.brands || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 12);
+    const materials = (f.materials || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 8);
+
+    await db.query(
+      `INSERT INTO curated_videos
+         (youtube_id, title, channel_name, channel_url, category,
+          materials, brands, description, source_url, license_note,
+          added_by, is_published)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false)
+       ON CONFLICT (youtube_id) DO UPDATE
+         SET title = EXCLUDED.title,
+             channel_name = EXCLUDED.channel_name,
+             channel_url = EXCLUDED.channel_url,
+             category = EXCLUDED.category,
+             materials = EXCLUDED.materials,
+             brands = EXCLUDED.brands,
+             description = EXCLUDED.description,
+             license_note = EXCLUDED.license_note`,
+      [
+        ytId, title, channelName, channelUrl, category,
+        materials, brands, description, sourceUrl, licenseNote,
+        req.installer.email || ('installer:' + req.installer.id)
+      ]
+    );
+    req.session.flash = { ok: 'Video staged — flip Publish when ready.' };
+    res.redirect('/admin/ops/watch');
+  } catch (err) { next(err); }
+});
+
+router.post('/ops/watch/:id(\\d+)/publish', requireOpsInstaller, async (req, res, next) => {
+  try {
+    await db.query(
+      `UPDATE curated_videos
+          SET is_published = true,
+              verified_at = COALESCE(verified_at, now())
+        WHERE id = $1`,
+      [req.params.id]
+    );
+    req.session.flash = { ok: 'Published — now visible on /watch.' };
+    res.redirect('/admin/ops/watch');
+  } catch (err) { next(err); }
+});
+
+router.post('/ops/watch/:id(\\d+)/unpublish', requireOpsInstaller, async (req, res, next) => {
+  try {
+    await db.query(`UPDATE curated_videos SET is_published = false WHERE id = $1`, [req.params.id]);
+    req.session.flash = { ok: 'Unpublished — hidden from /watch.' };
+    res.redirect('/admin/ops/watch');
+  } catch (err) { next(err); }
+});
+
+router.post('/ops/watch/:id(\\d+)/delete', requireOpsInstaller, async (req, res, next) => {
+  try {
+    await db.query(`DELETE FROM curated_videos WHERE id = $1`, [req.params.id]);
+    req.session.flash = { ok: 'Video removed.' };
+    res.redirect('/admin/ops/watch');
+  } catch (err) { next(err); }
+});
+
 // ===================================================================
 // Template chooser (the 6 page designs the studio can pick from)
 // ===================================================================
diff --git a/routes/public.js b/routes/public.js
index 7d4253f..3788c6d 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -279,6 +279,7 @@ router.get('/sitemap.xml', async (req, res, next) => {
       `<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}/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 =>
@@ -356,6 +357,40 @@ router.get('/healthz', async (req, res) => {
   }
 });
 
+// Curated /watch — verified YouTube videos about professional wallcovering
+// installation. Hand-picked by NPH ops; per-installer social handles are a
+// SEPARATE feature gated on social_verified.
+router.get('/watch', async (req, res, next) => {
+  try {
+    const cat = (req.query.category || '').trim();
+    const allowed = new Set(['hand_painted','silk','grasscloth','mural','metallic_leaf','vinyl','technique','historical','brand_showcase','installer_at_work']);
+    const where = ['is_published = true'];
+    const params = [];
+    if (cat && allowed.has(cat)) {
+      params.push(cat);
+      where.push(`category = $${params.length}`);
+    }
+    const videos = await db.many(
+      `SELECT id, youtube_id, title, channel_name, channel_url, category,
+              materials, brands, description, duration_seconds, source_url, license_note
+         FROM curated_videos
+        WHERE ${where.join(' AND ')}
+        ORDER BY display_order, added_at DESC
+        LIMIT 200`,
+      params
+    );
+    const counts = await db.many(
+      `SELECT category, COUNT(*)::int AS n FROM curated_videos WHERE is_published = true GROUP BY category ORDER BY n DESC`
+    );
+    res.render('public/watch', {
+      title: 'Watch · Professional wallcovering installation videos · National Paper Hangers',
+      metaDescription: `${videos.length}+ hand-picked installation videos from manufacturer brand channels and craft educators. Curated by NPH ops — every entry reviewed before publish.`,
+      canonicalPath: '/watch',
+      videos, counts, currentCategory: cat
+    });
+  } catch (err) { next(err); }
+});
+
 router.get('/about', (req, res) => {
   res.render('public/about', { title: 'About · National Paper Hangers' });
 });
diff --git a/views/admin/ops-watch.ejs b/views/admin/ops-watch.ejs
new file mode 100644
index 0000000..4781ea2
--- /dev/null
+++ b/views/admin/ops-watch.ejs
@@ -0,0 +1,102 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+
+<section class="admin-main">
+  <header class="admin-page-head">
+    <h1>Ops · /watch curation</h1>
+    <p class="muted">Hand-picked YouTube videos for the public <a href="/watch" target="_blank">/watch</a> page. Every entry must be reviewed before publish.</p>
+  </header>
+
+  <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+  <% if (flash && flash.ok) { %><div class="callout callout-ok"><%= flash.ok %></div><% } %>
+
+  <section class="admin-section">
+    <h2 style="margin-bottom:8px">Add a video</h2>
+    <p class="muted" style="margin-bottom:16px;font-size:13px">Paste any YouTube URL (watch / embed / youtu.be / shorts) or bare video ID. Stages as unpublished — review before flipping live.</p>
+    <form method="post" action="/admin/ops/watch" class="profile-form">
+      <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+      <label>YouTube URL or ID *
+        <input type="text" name="youtube_url" required placeholder="https://www.youtube.com/watch?v=...">
+      </label>
+      <label>Title *
+        <input type="text" name="title" required placeholder="How a hand-painted silk panel goes up · Fromental London">
+      </label>
+      <div class="row3">
+        <label>Channel name
+          <input type="text" name="channel_name" placeholder="Fromental London">
+        </label>
+        <label>Channel URL
+          <input type="url" name="channel_url" placeholder="https://www.youtube.com/@fromentallondon">
+        </label>
+        <label>Category *
+          <select name="category" required>
+            <% categories.forEach(function(c){ %>
+              <option value="<%= c %>"><%= c.replace(/_/g, ' ') %></option>
+            <% }); %>
+          </select>
+        </label>
+      </div>
+      <label>Description
+        <textarea name="description" rows="2" maxlength="600" placeholder="What this video shows — one sentence."></textarea>
+      </label>
+      <div class="row3">
+        <label>Brands (comma-sep)
+          <input type="text" name="brands" placeholder="de Gournay, Fromental">
+        </label>
+        <label>Materials (comma-sep)
+          <input type="text" name="materials" placeholder="silk, hand_painted">
+        </label>
+        <label>License note
+          <input type="text" name="license_note" placeholder="Standard YouTube embed">
+        </label>
+      </div>
+      <button type="submit" class="btn btn-primary">Stage video</button>
+    </form>
+  </section>
+
+  <section class="admin-section">
+    <h2>Library (<%= videos.length %>)</h2>
+    <% if (!videos.length) { %>
+      <div class="empty-state">No videos yet. Paste a URL above to start the curated list.</div>
+    <% } else { %>
+      <table class="data-table">
+        <thead><tr><th>Status</th><th>Preview</th><th>Title / channel</th><th>Category</th><th>Added</th><th>Actions</th></tr></thead>
+        <tbody>
+        <% videos.forEach(function(v){ %>
+          <tr>
+            <td>
+              <% if (v.is_published) { %>
+                <span class="status-badge deposit-paid">PUBLISHED</span>
+              <% } else { %>
+                <span class="status-badge deposit-requires_payment">DRAFT</span>
+              <% } %>
+            </td>
+            <td style="width:140px">
+              <a href="https://www.youtube.com/watch?v=<%= v.youtube_id %>" target="_blank" rel="noopener" style="display:block">
+                <img src="https://i.ytimg.com/vi/<%= v.youtube_id %>/mqdefault.jpg" alt="" style="width:120px;border:1px solid var(--border)">
+              </a>
+            </td>
+            <td>
+              <strong><%= v.title %></strong>
+              <% if (v.channel_name) { %><br><span class="muted" style="font-size:12px"><%= v.channel_name %></span><% } %>
+              <% if (v.description) { %><br><span class="muted" style="font-size:12px"><%= v.description.slice(0, 140) %></span><% } %>
+            </td>
+            <td><%= v.category.replace(/_/g, ' ') %></td>
+            <td><span class="muted" style="font-size:12px"><%= new Date(v.added_at).toLocaleDateString() %><br>by <%= v.added_by || '—' %></span></td>
+            <td class="actions">
+              <% if (!v.is_published) { %>
+                <form method="post" action="/admin/ops/watch/<%= v.id %>/publish" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-primary btn-sm">Publish</button></form>
+              <% } else { %>
+                <form method="post" action="/admin/ops/watch/<%= v.id %>/unpublish" class="inline-form"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-ghost btn-sm">Unpublish</button></form>
+              <% } %>
+              <form method="post" action="/admin/ops/watch/<%= v.id %>/delete" class="inline-form" onsubmit="return confirm('Remove this video permanently?')"><input type="hidden" name="_csrf" value="<%= csrfToken %>"><button class="btn btn-ghost btn-sm" style="color:#c44646">Delete</button></form>
+            </td>
+          </tr>
+        <% }); %>
+        </tbody>
+      </table>
+    <% } %>
+  </section>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 5ee7720..342e1a8 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="/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>
     </nav>
diff --git a/views/partials/social-videos.ejs b/views/partials/social-videos.ejs
index e6770c5..386a37f 100644
--- a/views/partials/social-videos.ejs
+++ b/views/partials/social-videos.ejs
@@ -6,7 +6,13 @@
   // typeof guard: every installer template currently passes installer, but a
   // future error/fallback render path could omit it. Match the featuredVideos
   // pattern. (claude-codex r3 finding HIGH#5)
-  var _hasHandle = (typeof installer !== 'undefined') && installer && (installer.youtube_handle || installer.instagram_handle || installer.tiktok_handle);
+  // Gate on social_verified — a self-claimed handle that NPH ops hasn't
+  // confirmed must NOT render publicly. Anyone could put any handle on their
+  // profile; surfacing it as if it's "the studio's work" is brand-fraud risk.
+  // Steve's standing rule: handles render only after ops verifies the account
+  // belongs to the studio (mirror of the credential review queue).
+  var _socialVerified = (typeof installer !== 'undefined') && installer && installer.social_verified === true;
+  var _hasHandle = _socialVerified && (installer.youtube_handle || installer.instagram_handle || installer.tiktok_handle);
 %>
 <% if (_hasEmbed || _hasHandle) { %>
 <section class="profile-videos" aria-labelledby="videos-heading">
diff --git a/views/public/book.ejs b/views/public/book.ejs
index 1ac4132..4fe8634 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -25,6 +25,16 @@
   .signin-card .who strong{display:block;font-size:15px}
   .signin-card .gbtn{display:inline-flex;align-items:center;gap:8px;background:#fff;color:#222;border:1px solid #ccc;padding:10px 18px;border-radius:8px;font-weight:600;text-decoration:none;font-size:14px}
   .signin-card .gbtn svg{width:18px;height:18px}
+  .visit-picker{border:0;padding:0;margin:0 0 18px}
+  .vp-legend{font-size:14px;font-weight:700;color:var(--ink,#0a0a0a);margin:0 0 10px;padding:0;letter-spacing:-0.01em}
+  .vp-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
+  .vp-card{display:block;border:2px solid var(--border,#d4d2c8);border-radius:14px;padding:18px 20px;cursor:pointer;background:var(--card-bg,#fff);transition:all .15s;position:relative}
+  .vp-card:hover{border-color:#9aa39a}
+  .vp-card.is-checked{border-color:var(--ink,#0a0a0a);background:#fff8e6;box-shadow:0 0 0 1px var(--ink,#0a0a0a)}
+  .vp-card input{position:absolute;opacity:0;pointer-events:none}
+  .vp-card .vp-icon{font-size:24px;margin:0 0 8px;line-height:1}
+  .vp-card strong{display:block;font-size:16px;margin:0 0 4px;letter-spacing:-0.01em}
+  .vp-card small{display:block;font-size:13px;color:var(--muted,#666);line-height:1.45}
 </style>
 
 <section class="book-page">
@@ -97,6 +107,28 @@
         <input type="hidden" name="installer_slug" value="<%= installer.slug %>">
         <input type="hidden" name="scheduled_start" id="scheduled_start" required>
         <input type="hidden" name="scheduled_end" id="scheduled_end" required>
+        <!-- The visit-vs-install picker drives this. Existing /book bookings
+             API already validates project_type values (consultation|site_visit|
+             quote|install). -->
+        <input type="hidden" name="project_type" id="project_type" value="consultation">
+
+        <fieldset class="visit-picker" data-visit-picker>
+          <legend class="vp-legend">What kind of appointment?</legend>
+          <div class="vp-cards">
+            <label class="vp-card is-checked" data-vp-card="consultation">
+              <input type="radio" name="_visit_kind" value="consultation" checked>
+              <div class="vp-icon" aria-hidden="true">🔍</div>
+              <strong>Initial visit</strong>
+              <small>Walk-through, measurement, recommendations. The studio brings samples and shares a quote after.</small>
+            </label>
+            <label class="vp-card" data-vp-card="install">
+              <input type="radio" name="_visit_kind" value="install">
+              <div class="vp-icon" aria-hidden="true">📅</div>
+              <strong>Schedule install</strong>
+              <small>You've selected the wallpaper and you're ready for the studio to install. (You'll confirm brand &amp; pattern in step 2.)</small>
+            </label>
+          </div>
+        </fieldset>
 
         <ol class="intake-steps">
           <li class="is-active" data-step-pill="1">Scope</li>
@@ -223,14 +255,8 @@
           <label>Email<input type="email" name="customer_email" required value="<%= consumer ? consumer.email : '' %>"></label>
           <label>Phone<input type="tel" name="customer_phone" autocomplete="tel"></label>
 
-          <label>Project type
-            <select name="project_type">
-              <option value="consultation">Consultation</option>
-              <option value="site_visit">Site visit / measurement</option>
-              <option value="quote">Quote review</option>
-              <option value="install">Install</option>
-            </select>
-          </label>
+          <!-- project_type is set at the top by the visit-vs-install picker;
+               the select that lived here would override it on submit. -->
           <label>Budget band
             <select name="budget_band">
               <option value="">Prefer not to say</option>
@@ -316,6 +342,26 @@
     }
     form.querySelectorAll('input[name="product_sourced"]').forEach(function(r){ r.addEventListener('change', syncProductFields); });
     syncProductFields();
+
+    // Initial visit vs Schedule install picker — sets project_type and, when
+    // install is chosen, force-checks product_sourced=Yes (you don't schedule
+    // an install without the wallpaper picked) and reveals the brand fields.
+    var ptHidden = document.getElementById('project_type');
+    var psYes = form.querySelector('input[name="product_sourced"][value="true"]');
+    var psNo  = form.querySelector('input[name="product_sourced"][value="false"]');
+    form.querySelectorAll('[data-vp-card]').forEach(function(card){
+      card.addEventListener('click', function(){
+        form.querySelectorAll('.vp-card').forEach(function(c){ c.classList.remove('is-checked'); });
+        card.classList.add('is-checked');
+        var kind = card.getAttribute('data-vp-card');  // 'consultation' | 'install'
+        if (ptHidden) ptHidden.value = kind === 'install' ? 'install' : 'consultation';
+        if (kind === 'install' && psYes) {
+          psYes.checked = true;
+          if (psNo) psNo.checked = false;
+          syncProductFields();
+        }
+      });
+    });
   })();
 
   gtag('event', 'booking_started', {
diff --git a/views/public/watch.ejs b/views/public/watch.ejs
new file mode 100644
index 0000000..538f8fc
--- /dev/null
+++ b/views/public/watch.ejs
@@ -0,0 +1,120 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<%
+  var CAT_LABELS = {
+    hand_painted: 'Hand-painted',
+    silk: 'Silk',
+    grasscloth: 'Grasscloth',
+    mural: 'Mural & scenic',
+    metallic_leaf: 'Metallic leaf',
+    vinyl: 'Vinyl',
+    technique: 'Technique',
+    historical: 'Historical',
+    brand_showcase: 'Brand showcase',
+    installer_at_work: 'Installer at work'
+  };
+  var orderedCats = ['installer_at_work','technique','hand_painted','silk','grasscloth','mural','metallic_leaf','vinyl','brand_showcase','historical'];
+  var totalCount = (counts || []).reduce(function(s, r){ return s + (r.n || 0); }, 0);
+%>
+
+<section class="watch-page">
+  <header class="watch-head">
+    <p class="kicker">Curated · NPH ops-reviewed</p>
+    <h1 class="display-sm">Watch professional installations</h1>
+    <p class="lede">
+      Hand-picked YouTube videos showing how luxury wallcoverings are actually installed —
+      manufacturer brand channels, craft educators, and verified installers at work.
+      Every video on this page is reviewed by NPH ops before publish; no algorithmic feed,
+      no scraped content, no embedded ads we don't control.
+    </p>
+  </header>
+
+  <% if (totalCount === 0) { %>
+    <div class="empty-state" style="margin-top:32px">
+      <h3>The library is being curated.</h3>
+      <p>NPH ops is reviewing manufacturer channels and educator playlists right now. The first batch goes live shortly — bookmark this page.</p>
+      <p class="muted" style="margin-top:16px;font-size:13px">If you're a wallcovering installer or manufacturer with a video to suggest, email <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a> with the YouTube link and a one-sentence description.</p>
+    </div>
+  <% } else { %>
+    <nav class="watch-tabs" aria-label="Categories">
+      <a href="/watch" class="<%= currentCategory ? '' : 'is-current' %>">All <span class="muted">(<%= totalCount %>)</span></a>
+      <% orderedCats.forEach(function(c){
+        var found = (counts || []).find(function(r){ return r.category === c; });
+        if (!found) return; %>
+        <a href="/watch?category=<%= c %>" class="<%= currentCategory === c ? 'is-current' : '' %>">
+          <%= CAT_LABELS[c] || c %> <span class="muted">(<%= found.n %>)</span>
+        </a>
+      <% }); %>
+    </nav>
+
+    <div class="watch-grid">
+      <% videos.forEach(function(v){ %>
+        <article class="watch-card">
+          <div class="watch-thumb">
+            <iframe
+              src="https://www.youtube-nocookie.com/embed/<%= v.youtube_id %>?rel=0&modestbranding=1"
+              title="<%= v.title %>"
+              loading="lazy"
+              allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+              allowfullscreen
+              referrerpolicy="strict-origin-when-cross-origin"></iframe>
+          </div>
+          <div class="watch-meta">
+            <p class="watch-cat"><%= CAT_LABELS[v.category] || v.category %></p>
+            <h3 class="watch-title"><%= v.title %></h3>
+            <% if (v.description) { %><p class="watch-desc"><%= v.description %></p><% } %>
+            <p class="watch-credit">
+              <% if (v.channel_url) { %>
+                <a href="<%= v.channel_url %>" target="_blank" rel="noopener nofollow">
+                  <%= v.channel_name || 'YouTube channel' %> ↗
+                </a>
+              <% } else if (v.channel_name) { %>
+                <%= v.channel_name %>
+              <% } %>
+              <% if (v.brands && v.brands.length) { %>
+                <span class="watch-tag-row">· brands:
+                  <% v.brands.forEach(function(b, i){ %><%= i ? ', ' : ' ' %><%= b %><% }); %>
+                </span>
+              <% } %>
+            </p>
+            <p class="watch-license"><%= v.license_note %></p>
+          </div>
+        </article>
+      <% }); %>
+    </div>
+
+    <p class="watch-foot muted">
+      Videos are embedded via YouTube's standard player. Selection is editorial, not paid.
+      Notice an issue with a listing? Email <a href="mailto:info@nationalpaperhangers.com">info@nationalpaperhangers.com</a>.
+    </p>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>
+
+<style>
+  .watch-page { padding: 48px 32px 96px; max-width: 1200px; margin: 0 auto; }
+  .watch-head { max-width: 800px; margin-bottom: 32px; }
+  .watch-head .lede { margin-top: 12px; }
+  .watch-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 32px; padding: 0 0 16px; border-bottom: 1px solid var(--border); }
+  .watch-tabs a { padding: 8px 14px; border-radius: 999px; font-size: 13px; text-decoration: none; color: var(--fg-muted); border: 1px solid var(--border); }
+  .watch-tabs a:hover { color: var(--fg); border-color: var(--border-strong); opacity: 1; }
+  .watch-tabs a.is-current { background: var(--fg); color: var(--bg); border-color: var(--fg); }
+  .watch-tabs a .muted { color: inherit; opacity: 0.65; font-size: 11px; margin-left: 2px; }
+  .watch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 32px; }
+  .watch-card { display: flex; flex-direction: column; border: 1px solid var(--border); background: var(--bg); transition: border-color 0.15s; }
+  .watch-card:hover { border-color: var(--border-strong); }
+  .watch-thumb { position: relative; aspect-ratio: 16/9; background: #000; border-bottom: 1px solid var(--border); overflow: hidden; }
+  .watch-thumb iframe { width: 100%; height: 100%; display: block; border: 0; }
+  .watch-meta { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 6px; }
+  .watch-cat { margin: 0; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--brass, #b8860b); }
+  .watch-title { margin: 0; font-family: var(--serif); font-size: 20px; font-weight: 500; line-height: 1.25; }
+  .watch-desc { margin: 0; font-size: 14px; line-height: 1.5; color: var(--fg); }
+  .watch-credit { margin: 4px 0 0; font-size: 12px; color: var(--fg-muted); }
+  .watch-credit a { color: var(--fg-muted); border-bottom: 1px solid var(--border); }
+  .watch-credit a:hover { color: var(--fg); }
+  .watch-tag-row { display: inline; }
+  .watch-license { margin: 4px 0 0; font-size: 11px; color: var(--fg-muted); letter-spacing: 0.04em; }
+  .watch-foot { margin-top: 48px; font-size: 12px; }
+</style>

← 350a7bb claude-codex r3 (cont): guard installer in social-videos par  ·  back to NationalPaperHangers  ·  admin/uploads: per-installer rate limit (30/hr) keyed on ins 8c19628 →