← back to NationalPaperHangers
watch: linkedin embed support + industry company-page strip (verified manufacturer + WIA pages)
939d55bff2b30b4c929fe62f534351a7bae96ee7 · 2026-05-06 12:52:26 -0700 · Steve
Files touched
M routes/admin.jsM routes/public.jsM server.jsM views/public/watch.ejs
Diff
commit 939d55bff2b30b4c929fe62f534351a7bae96ee7
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 12:52:26 2026 -0700
watch: linkedin embed support + industry company-page strip (verified manufacturer + WIA pages)
---
routes/admin.js | 66 +++++++++++++++++++++++++++++++++++---------------
routes/public.js | 17 +++++++++++--
server.js | 4 ++-
views/public/watch.ejs | 46 +++++++++++++++++++++++++++--------
4 files changed, 101 insertions(+), 32 deletions(-)
diff --git a/routes/admin.js b/routes/admin.js
index 3b3be36..31b819d 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -485,24 +485,47 @@ const ALLOWED_WATCH_CATEGORIES = new Set([
'technique','historical','brand_showcase','installer_at_work'
]);
-function parseYouTubeIdForOps(input) {
+// Returns {platform, id} or null. Accepts YouTube + LinkedIn URLs/IDs.
+function parseVideoIdForOps(input) {
const v = String(input || '').trim();
if (!v) return null;
- // Bare ID
- if (/^[A-Za-z0-9_-]{6,20}$/.test(v)) return v;
+ // Bare YouTube-shaped ID (also matches LI numeric URNs but those are 15+ digits)
+ if (/^[A-Za-z0-9_-]{6,14}$/.test(v)) return { platform: 'youtube', id: v };
+ if (/^\d{15,25}$/.test(v)) return { platform: 'linkedin', id: 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];
+ // YouTube
+ if (u.hostname === 'youtu.be') {
+ const id = u.pathname.replace(/^\//, '').split('/')[0];
+ return id ? { platform: 'youtube', id } : null;
+ }
+ if (/^(www\.|m\.)?youtube(-nocookie)?\.com$/.test(u.hostname)) {
+ let id = null;
+ if (u.pathname === '/watch') id = u.searchParams.get('v');
+ else if (u.pathname.startsWith('/embed/')) id = u.pathname.split('/')[2];
+ else if (u.pathname.startsWith('/shorts/')) id = u.pathname.split('/')[2];
+ return id ? { platform: 'youtube', id } : null;
+ }
+ // LinkedIn
+ if (u.hostname.endsWith('linkedin.com')) {
+ let m = u.pathname.match(/-activity-(\d{15,25})-/);
+ if (m) return { platform: 'linkedin', id: m[1] };
+ m = u.pathname.match(/urn:li:(?:activity|share|ugcPost):(\d{10,25})/);
+ if (m) return { platform: 'linkedin', id: m[1] };
+ m = u.pathname.match(/\/(\d{15,25})(?:\/|$)/);
+ if (m) return { platform: 'linkedin', id: m[1] };
+ }
} catch {}
return null;
}
+// Back-compat shim — older /admin/ops/watch posts pre-LinkedIn called the
+// old name; keep it working by delegating to the new parser.
+function parseYouTubeIdForOps(input) {
+ const r = parseVideoIdForOps(input);
+ return r && r.platform === 'youtube' ? r.id : null;
+}
+
router.get('/ops/watch', requireOpsInstaller, async (req, res, next) => {
try {
const all = await db.many(
@@ -523,11 +546,12 @@ router.get('/ops/watch', requireOpsInstaller, async (req, res, next) => {
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.' };
+ const parsed = parseVideoIdForOps(f.youtube_url || f.youtube_id || f.video_url);
+ if (!parsed) {
+ req.session.flash = { error: 'Could not parse a YouTube or LinkedIn video from that input.' };
return res.redirect('/admin/ops/watch');
}
+ const { platform, id: extId } = parsed;
const title = clampLen((f.title || '').trim(), 240);
if (!title) {
req.session.flash = { error: 'Title is required.' };
@@ -537,19 +561,23 @@ router.post('/ops/watch', requireOpsInstaller, async (req, res, next) => {
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 sourceUrl = platform === 'linkedin'
+ ? `https://www.linkedin.com/feed/update/urn:li:share:${extId}/`
+ : `https://www.youtube.com/watch?v=${extId}`;
+ const defaultLicense = platform === 'linkedin' ? 'Standard LinkedIn embed' : 'Standard YouTube embed';
+ const licenseNote = f.license_note ? clampLen(f.license_note.trim(), 200) : defaultLicense;
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,
+ (youtube_id, platform, 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)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,false)
ON CONFLICT (youtube_id) DO UPDATE
- SET title = EXCLUDED.title,
+ SET platform = EXCLUDED.platform,
+ title = EXCLUDED.title,
channel_name = EXCLUDED.channel_name,
channel_url = EXCLUDED.channel_url,
category = EXCLUDED.category,
@@ -558,7 +586,7 @@ router.post('/ops/watch', requireOpsInstaller, async (req, res, next) => {
description = EXCLUDED.description,
license_note = EXCLUDED.license_note`,
[
- ytId, title, channelName, channelUrl, category,
+ extId, platform, title, channelName, channelUrl, category,
materials, brands, description, sourceUrl, licenseNote,
req.installer.email || ('installer:' + req.installer.id)
]
diff --git a/routes/public.js b/routes/public.js
index 3788c6d..8cf1935 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -371,7 +371,7 @@ router.get('/watch', async (req, res, next) => {
where.push(`category = $${params.length}`);
}
const videos = await db.many(
- `SELECT id, youtube_id, title, channel_name, channel_url, category,
+ `SELECT id, youtube_id, platform, title, channel_name, channel_url, category,
materials, brands, description, duration_seconds, source_url, license_note
FROM curated_videos
WHERE ${where.join(' AND ')}
@@ -379,14 +379,27 @@ router.get('/watch', async (req, res, next) => {
LIMIT 200`,
params
);
+ // Resolve each row to an embeddable iframe (YouTube nocookie or LinkedIn).
+ const socialEmbed = require('../lib/social-embed');
+ for (const v of videos) {
+ const e = socialEmbed.embedFromRow(v);
+ v.embed_html = e ? e.html : null;
+ }
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`
);
+ const industryLinks = await db.many(
+ `SELECT id, platform, name, url, blurb, category
+ FROM industry_links
+ WHERE is_published = true
+ ORDER BY display_order, added_at DESC
+ LIMIT 60`
+ );
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
+ videos, counts, currentCategory: cat, industryLinks
});
} catch (err) { next(err); }
});
diff --git a/server.js b/server.js
index 46f15b7..dd21b81 100644
--- a/server.js
+++ b/server.js
@@ -75,7 +75,9 @@ app.use(helmet({
'https://www.tiktok.com',
// lib/social-embed.js emits player.vimeo.com iframes; without this CSP
// silently strips them. (claude-codex r3 finding HIGH#2)
- 'https://player.vimeo.com'
+ 'https://player.vimeo.com',
+ // LinkedIn embeds for /watch industry posts.
+ 'https://www.linkedin.com'
],
frameAncestors: ["'none'"],
formAction: ["'self'"],
diff --git a/views/public/watch.ejs b/views/public/watch.ejs
index 538f8fc..0428045 100644
--- a/views/public/watch.ejs
+++ b/views/public/watch.ejs
@@ -50,15 +50,10 @@
<div class="watch-grid">
<% videos.forEach(function(v){ %>
- <article class="watch-card">
+ <article class="watch-card watch-platform-<%= v.platform || 'youtube' %>">
<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>
+ <%- v.embed_html %>
+ <span class="watch-pill"><%= (v.platform || 'youtube').toUpperCase() %></span>
</div>
<div class="watch-meta">
<p class="watch-cat"><%= CAT_LABELS[v.category] || v.category %></p>
@@ -67,7 +62,7 @@
<p class="watch-credit">
<% if (v.channel_url) { %>
<a href="<%= v.channel_url %>" target="_blank" rel="noopener nofollow">
- <%= v.channel_name || 'YouTube channel' %> ↗
+ <%= v.channel_name || (v.platform === 'linkedin' ? 'LinkedIn page' : 'YouTube channel') %> ↗
</a>
<% } else if (v.channel_name) { %>
<%= v.channel_name %>
@@ -84,6 +79,23 @@
<% }); %>
</div>
+ <% if (typeof industryLinks !== 'undefined' && industryLinks && industryLinks.length) { %>
+ <section class="watch-industry">
+ <h2 class="section-title">Industry on LinkedIn & beyond</h2>
+ <p class="muted" style="margin:-12px 0 18px;font-size:13px">Verified company pages, installer associations, and educator profiles. Click through to follow on the source platform.</p>
+ <div class="watch-industry-grid">
+ <% industryLinks.forEach(function(l){ %>
+ <a class="watch-industry-card platform-<%= l.platform %>" href="<%= l.url %>" target="_blank" rel="noopener nofollow">
+ <span class="watch-industry-platform"><%= l.platform.toUpperCase() %></span>
+ <strong class="watch-industry-name"><%= l.name %></strong>
+ <% if (l.blurb) { %><p class="watch-industry-blurb"><%= l.blurb %></p><% } %>
+ <span class="watch-industry-cat"><%= l.category.replace(/_/g, ' ') %></span>
+ </a>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
<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>.
@@ -105,8 +117,22 @@
.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 { position: relative; background: #000; border-bottom: 1px solid var(--border); overflow: hidden; aspect-ratio: 16/9; }
+ .watch-platform-linkedin .watch-thumb { aspect-ratio: 504/700; background: #f5f1e8; }
.watch-thumb iframe { width: 100%; height: 100%; display: block; border: 0; }
+ .watch-pill { position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7); color: #fff; padding: 3px 8px; font-size: 10px; letter-spacing: 0.08em; border-radius: 3px; pointer-events: none; }
+ .watch-platform-linkedin .watch-pill { background: #0a66c2; }
+ .watch-industry { margin-top: 64px; padding-top: 48px; border-top: 1px solid var(--border); }
+ .watch-industry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
+ .watch-industry-card { display: block; padding: 18px 20px; border: 1px solid var(--border); background: var(--bg); text-decoration: none; transition: border-color 0.15s, transform 0.15s; }
+ .watch-industry-card:hover { border-color: var(--border-strong); transform: translateY(-1px); opacity: 1; }
+ .watch-industry-platform { display: inline-block; font-size: 10px; letter-spacing: 0.12em; padding: 2px 8px; background: var(--bg-alt); color: var(--fg-muted); border-radius: 999px; margin-bottom: 10px; }
+ .watch-industry-card.platform-linkedin .watch-industry-platform { background: #0a66c2; color: #fff; }
+ .watch-industry-card.platform-instagram .watch-industry-platform { background: #e4405f; color: #fff; }
+ .watch-industry-card.platform-youtube .watch-industry-platform { background: #ff0000; color: #fff; }
+ .watch-industry-name { display: block; font-family: var(--serif); font-size: 18px; font-weight: 500; line-height: 1.25; }
+ .watch-industry-blurb { margin: 6px 0 8px; font-size: 13px; color: var(--fg-muted); line-height: 1.5; }
+ .watch-industry-cat { display: block; margin-top: 8px; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
.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; }
← fdd57f9 templates: hide all /book schedule CTAs for unclaimed (direc
·
back to NationalPaperHangers
·
v0.4 #5: load theme-toggle.js + /find↔/map filter parity + t 9bcbde8 →