← 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
M routes/admin.jsM routes/public.jsA views/admin/ops-watch.ejsM views/partials/header.ejsM views/partials/social-videos.ejsM views/public/book.ejsA views/public/watch.ejs
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 & 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 →