[object Object]

← 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

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 &amp; 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 →