1/* ============================================================ 2 * CONFIG â edit these values 3 * ============================================================ */ 4var CONFIG = { 5 CN_HOST: 'https://www.cambsnews.co.uk', // CambsNews WordPress origin 6 DP_HOST: 'https://discoverpeterborough.co.uk', // Discover Peterborough origin 7 YT_API_KEY: 'AIzaSyCTm5rbjf_NUsTuPZxGuo_d89JcKRI303I', // YouTube Data API v3 key â restrict to YouTube API + your domain in Google Cloud Console 8 YT_HANDLE: '@CambsNewsLive', // YouTube channel handle (used only if YT_PLAYLIST_ID is empty) 9 YT_CHANNEL_ID: '', // optional: paste channel UC⦠id to skip handle lookup 10 YT_PLAYLIST_ID: 'PLziiiFRX4IFD9m_B-SZYVrYnHYRPHzbC3', // explicit playlist to pull videos from (sorted newest first by publish date) 11 YT_FALLBACK_VIDEOS: [], // intentionally empty â if API fails we show a clean channel CTA instead of fake videos 12 CORS_PROXY: 'https://corsproxy.io/?', // fallback for cross-origin (not needed when uploaded to cambsnews) 13 POSTS_PER_PAGE: 10, 14 HERO_TAG: 'homepage', // posts tagged with this slug get pinned to the hero (set '' to disable) 15 PLUS_TAG: 'cn-plus', // WP tag slug that triggers the premium "CN+" badge (long reads / club content) 16 PLUS_BRAND: 'CN+', // Label shown on the premium badge (e.g. 'CN+', 'Cambs+', 'CN Insider') 17 18 /* --- Ad integration --- 19 * Each slot value can be either: 20 * (a) a NUMBER â looked up from CONFIG.QUADS_ENDPOINT (needs quads-rest.php mu-plugin), OR 21 * (b) an OBJECT â {code:'<...AdSense or HTML...>', fallback:'<...HTML shown when ad-blocker detected...>'} 22 * Use (b) to skip the WordPress plugin entirely and paste your ad code right here. 23 */ 24 QUADS_ENDPOINT: '/wp-json/quads/v1/ads', 25 QUADS_SLOTS: { 26 /* Each value can be EITHER: 27 * (a) a NUMBER â QUADS ad ID looked up via QUADS_ENDPOINT (needs the mu-plugin) 28 * (b) an OBJECT â { code: '<your ad HTML>', fallback: '<HTML if ad-blocker on>' } 29 * 30 * Defaults below are working house-ad placeholders so the slots are visible. 31 * Replace `code` with AdSense / direct deal HTML when ready, keep `fallback` as a house ad. */ 32 33 after_hero: { 34 // 728x90 leaderboard â Support CambsNews (brand red) 35 code: '<a class="ad-house ad-728 ad-red" href="#/page/support-our-work" target="_blank" rel="noopener"><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/><\/svg><\/div><div class="ad-text"><div class="ad-eyebrow">House Ad<\/div><div class="ad-title">Help keep local news free<\/div><div class="ad-tagline">Independent reporting for Peterborough & Cambridgeshire<\/div><\/div><div class="ad-cta">Support â<\/div><\/a>', 36 fallback: '<a class="ad-house ad-728 ad-red ad-fallback" href="#/page/support-our-work" target="_blank" rel="noopener"><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/><\/svg><\/div><div class="ad-text"><div class="ad-eyebrow">House Ad<\/div><div class="ad-title">Help keep local news free<\/div><\/div><div class="ad-cta">Support â<\/div><\/a>' 37 }, 38 39 sidebar: { 40 // 300x250 â Discover Peterborough (green, place-pin icon) 41 code: '<a class="ad-house ad-300 ad-green" href="https://discoverpeterborough.co.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/><\/svg><\/div><div class="ad-title">Discover<br>Peterborough<\/div><div class="ad-tagline">More than you imagine â plan your visit<\/div><div class="ad-cta">Visit â<\/div><\/a>', 42 fallback: '<a class="ad-house ad-300 ad-green ad-fallback" href="https://discoverpeterborough.co.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/><\/svg><\/div><div class="ad-title">Discover<br>Peterborough<\/div><div class="ad-tagline">More than you imagine<\/div><div class="ad-cta">Visit â<\/div><\/a>' 43 }, 44 45 sidebar2: { 46 // 300x250 â Peterborough Cathedral (brown, church icon) 47 code: '<a class="ad-house ad-300 ad-brown" href="https://peterborough-cathedral.org.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 9V7.13c0-1.18-.86-2.19-2.05-2.34L13 4.3V3c0-.55-.45-1-1-1s-1 .45-1 1v1.3l-3.95.49C5.86 4.94 5 5.95 5 7.13V9H2v12h20V9h-3zm1 10h-5v-3c0-1.66-1.34-3-3-3s-3 1.34-3 3v3H4V11h4V7.16l4-.5 4 .5V11h4v8z"/><\/svg><\/div><div class="ad-title">Peterborough<br>Cathedral<\/div><div class="ad-tagline">900 years of history â visit today<\/div><div class="ad-cta">Plan your visit â<\/div><\/a>', 48 fallback: '<a class="ad-house ad-300 ad-brown ad-fallback" href="https://peterborough-cathedral.org.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 9V7.13c0-1.18-.86-2.19-2.05-2.34L13 4.3V3c0-.55-.45-1-1-1s-1 .45-1 1v1.3l-3.95.49C5.86 4.94 5 5.95 5 7.13V9H2v12h20V9h-3zm1 10h-5v-3c0-1.66-1.34-3-3-3s-3 1.34-3 3v3H4V11h4V7.16l4-.5 4 .5V11h4v8z"/><\/svg><\/div><div class="ad-title">Peterborough<br>Cathedral<\/div><div class="ad-tagline">900 years of history<\/div><div class="ad-cta">Visit â<\/div><\/a>' 49 }, 50 51 sidebar3: { 52 // 300x250 â Got a tip? (navy, envelope icon) 53 code: '<a class="ad-house ad-300 ad-navy" href="mailto:[email protected]"><div class="ad-eyebrow">Editorial · Confidential<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/><\/svg><\/div><div class="ad-title">Got a tip?<\/div><div class="ad-tagline">If you know something we should be reporting, tell us â in confidence.<\/div><div class="ad-cta">[email protected]<\/div><\/a>', 54 fallback: '<a class="ad-house ad-300 ad-navy ad-fallback" href="mailto:[email protected]"><div class="ad-eyebrow">Editorial<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/><\/svg><\/div><div class="ad-title">Got a tip?<\/div><div class="ad-tagline">Email us in confidence<\/div><div class="ad-cta">[email protected]<\/div><\/a>' 55 } 56 57 // When you want to use real AdSense, replace the above with: 58 // after_hero: { 59 // code: '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-XXXX" data-ad-slot="1234567890" data-ad-format="auto"><\/ins><script>(adsbygoogle = window.adsbygoogle || []).push({});<\/script>', 60 // fallback: '<a href="..."><img src="..." style="max-width:100%;height:auto"><\/a>' 61 // } 62 }, 63 ADS_DEBUG: true, // set false to silence the console diagnostics 64 65 /* --- Local direct-sold ads (CambsNews-Local-Ads mu-plugin) --- */ 66 LOCAL_ADS_ENDPOINT: '/wp-json/cn/v1/ads', // returns active ads for a slot 67 LOCAL_ADS_TRACK: '/wp-json/cn/v1/track', // /click/<id> and /impression 68 69 /* --- 360 map (CambsNews map API mu-plugin) --- 70 * GET returns posts pinned on the /360/ map: 71 * [ {"id":123, "slug":"story-slug", "url":"/360/index.html#p123"}, ... ] 72 * `url` optional - front end defaults to /360/index.html?post=<id>.
73 * Set to '' to disable the whole map-links feature. */ 74 MAP_POSTS_ENDPOINT: '/wp-json/cn/v1/map-posts', 75 76 /* --- Newsletter (CambsNews-Newsletter mu-plugin) --- */ 77 NEWSLETTER_ENDPOINT: '/wp-json/cn/v1/subscribe', 78 79 /* --- Google AdSense --- 80 * SETUP (per slot you want to fill with AdSense instead of house ads): 81 * 1. AdSense Dashboard â Ads â By ad unit â Display ads â Create new ad unit 82 * 2. Name it (e.g. "CN After Hero"), choose "Responsive" size 83 * 3. Save â copy the 10-digit number from the data-ad-slot in the snippet 84 * 4. Paste it below as a STRING (keep the quotes) â uncomment the line 85 * 86 * Priority order at render time, per slot: 87 * 1. Local direct-sold ad (WP Admin â CambsNews Local Ads, if active for that slot) 88 * 2. AdSense slot ID below (if set) 89 * 3. House ad from CONFIG.QUADS_SLOTS[slot].code 90 * 4. House ad fallback from CONFIG.QUADS_SLOTS[slot].fallback (when ad-blocker detected) 91 * 92 * Leave all SLOTS commented out until your AdSense account is approved + has slot IDs. 93 * House ads will fill the slots in the meantime. 94 */ 95 ADSENSE_PUB_ID: 'ca-pub-1369754178697614', 96 ADSENSE_AUTO_ADS: true, // also let Google auto-place anywhere it sees fit 97 ADSENSE_SLOTS: { 98 after_hero: '7829301393', // CN After Hero â responsive leaderboard under the hero 99 sidebar: '9493736854', // CN Sidebar â responsive first sidebar 100 sidebar2: '1427848490', // CN Sidebar 2 â responsive second sidebar 101 sidebar3: '6628204283', // CN Sidebar 3 â responsive third sidebar 102 /* in_article: '0000000000' â CN Article Rail â responsive rectangle inside posts. 103 If empty, the post sidebar reuses the homepage `sidebar` slot ID above. */ 104 in_article: '' // â Create "CN Article Rail" in AdSense and paste the slot ID here 105 }, 106 107 /* --- Local sponsor rail (rotating in post sidebar) --- 108 * Each entry shows once-per-article-load (deterministic per post.id so the 109 * same sponsor doesn't keep flickering between renders). 110 * 111 * Image-based (preferred): set `img` to a 600x500-ish creative URL. 112 * Text-based: omit `img` â name + tagline render instead. 113 * 114 * `display` controls device targeting: 115 * 'mobile' â only shown on viewports < 768px 116 * 'desktop' â only shown on viewports >= 768px 117 * 'both' (or omitted) â shown everywhere 118 */ 119 SPONSORS_POOL: [ 120 { 121 name:'Peterborough Cathedral â Titanosaur', 122 url:'https://peterborough-cathedral.org.uk/', 123 // Square 1:1 creative â sized for mobile. Upload the Titanosaur image to 124 // WP â Media â Add New, then paste the full URL below. 125 img:'/wp-content/uploads/2026/05/Titanosaur-Peterborough-Cathedral-01-350x350.jpg', 126 tagline:'Experience the worldâs biggest known dinosaur â 13 April â 31 May', 127 cta:'Book tickets â', 128 display:'mobile' 129 }, 130 { 131 name:'Canopy UK â Is your restaurant festival ready?', 132 url:'https://canopyuk.com/', 133 // Wide 8:1 leaderboard creative â sized for desktop rail. 134 img:'', // TODO: upload a Canopy creative to WP Media and paste its URL here 135 tagline:'01733 700 468 â local installer, trusted by Cambridgeshire', 136 cta:'Get a quote â', 137 display:'desktop' 138 }, 139 { 140 // "Both" fallback so the rail never goes empty if the device-specific creatives miss 141 name:'Discover Peterborough', 142 url:'https://discoverpeterborough.co.uk/', 143 img:'', 144 tagline:'Restaurants, events, things to do â all in one place', 145 cta:'Explore â', 146 display:'both' 147 } 148 ], 149 150 /* --- Weather --- */ 151 WEATHER_API_KEY: 'b13fdd5d1f463bff84bef3690017efb6', 152 WEATHER_LOCATION: 'Peterborough,GB', 153 154 /* --- Google Analytics 4 --- 155 * Site Kit normally injects this on WP pages, but the SPA bypasses WP's 156 * render, so we add gtag.js manually here. 157 * 158 * Get your Measurement ID from: WP Admin â Site Kit â Analytics â 159 * Settings â "Measurement ID" (looks like G-XXXXXXXXXX). 160 * Paste it below to enable client-side page-view tracking on the SPA. */ 161 GA4_MEASUREMENT_ID: 'G-WE1QYKPS88', // CambsNews GA4 via Site Kit 162
163 /* --- Plausible self-hosted analytics --- 164 * Self-hosted at analytics.cambsnews.co.uk (Docker on lucy-ai â Plesk 165 * nginx reverse-proxy â port 8022). Editors/authors can log in directly 166 * to view stats without WordPress permission tangles. 167 * 168 * Uses the `script.manual.js` variant so the SPA controls when each 169 * page-view fires (route changes call plausible('pageview') from 170 * gaPageView() below). Set PLAUSIBLE_DOMAIN to '' to disable. */ 171 PLAUSIBLE_DOMAIN: 'cambsnews.co.uk', 172 PLAUSIBLE_HOST: 'https://analytics.cambsnews.co.uk', 173 174 /* --- Internal analytics endpoint (Independent Analytics + Most Read) --- 175 * POSTs to a tiny mu-plugin (cn-spa-helpers.php) so the SPA's article opens 176 * still increment view counts. Throttled server-side per IP per minute. */ 177 VIEW_TRACK_ENDPOINT: '/wp-json/cn/v1/view', 178 179 /* --- WP user info endpoint â for "Edit Post" buttons when an editor is logged in --- */ 180 ME_ENDPOINT: '/wp-json/cn/v1/me' 181}; 182 183/* ============================================================ 184 * Google Analytics 4 (Site Kit measurement ID). 185 * Loaded only if a real-looking GA4 ID is set in CONFIG â the placeholder 186 * "G-XXXXXXXXXX" is treated as unset so we don't blast fake traffic. 187 * ============================================================ */ 188(function loadGA4(){ 189 var id = (CONFIG.GA4_MEASUREMENT_ID || '').trim(); 190 if (!id || /XXXXX/i.test(id)) return; 191 var s = document.createElement('script'); 192 s.async = true; 193 s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id); 194 document.head.appendChild(s); 195 window.dataLayer = window.dataLayer || []; 196 window.gtag = function(){ dataLayer.push(arguments); }; 197 gtag('js', new Date()); 198 gtag('config', id, { send_page_view: false }); // SPA fires page_view manually on route changes 199})(); 200 201/* ============================================================ 202 * Plausible self-hosted analytics â loads the MANUAL variant 203 * so route changes fire pageviews via plausible('pageview'). 204 * 205 * The tiny shim (window.plausible = â¦) queues calls made BEFORE 206 * the script.manual.js bundle finishes loading; once loaded it 207 * processes the queue. Means we can call plausible('pageview') 208 * from gaPageView() even on the first paint without races. 209 * ============================================================ */ 210(function loadPlausible(){ 211 var dom = (CONFIG.PLAUSIBLE_DOMAIN || '').trim(); 212 var host = (CONFIG.PLAUSIBLE_HOST || '').trim().replace(/\/+$/,''); 213 if (!dom || !host) return; 214 var s = document.createElement('script'); 215 s.defer = true; 216 s.setAttribute('data-domain', dom); 217 s.src = host + '/js/script.manual.js'; 218 document.head.appendChild(s); 219 // Shim so plausible('pageview') calls queue up until the script loads 220 window.plausible = window.plausible || function(){ 221 (window.plausible.q = window.plausible.q || []).push(arguments); 222 }; 223})(); 224 225/* Fire a GA4 page_view AND Plausible pageview â call on every SPA navigation. 226 * Both run in parallel: GA4 for cross-site reporting + Search Console, Plausible 227 * for editors/authors to see real numbers without WP permission tangles. */ 228function gaPageView(){ 229 // GA4 230 if (typeof window.gtag === 'function'){ 231 try{ 232 gtag('event', 'page_view', { 233 page_path: location.pathname + location.search, 234 page_title: document.title, 235 page_location: location.href 236 }); 237 }catch(e){} 238 } 239 // Plausible â manual mode: explicit pageview per route. Includes the 240 // current URL so SPA route changes are tracked under the right path 241 // (without this it'd report the URL the script first loaded at). 242 if (typeof window.plausible === 'function'){ 243 try{ 244 window.plausible('pageview', { u: location.href }); 245 }catch(e){} 246 } 247} 248 249/* ============================================================ 250 * Internal view tracking â POST to cn-spa-helpers mu-plugin so 251 * Independent Analytics + Most Read get updated when a post opens 252 * in the SPA. Fires once per article view; the server rate-limits 253 * per IP per minute so refresh-spammers can't inflate counts. 254 * ============================================================ */ 255var _seenViews = {}; 256function recordPostView(postId){ 257 if (!postId || !CONFIG.VIEW_TRACK_ENDPOINT) return; 258 if (_seenViews[postId]) return; // already tracked in this session 259 _seenViews[postId] = true; 260 try{ 261 var body = JSON.stringify({ post_id: parseInt(postId, 10) }); 262 var url = CONFIG.CN_HOST + CONFIG.VIEW_TRACK_ENDPOINT; 263 if (navigator.sendBeacon) { 264 navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); 265 } else { 266 fetch(url, { method:'POST', body:body, headers:{'Content-Type':'application/json'}, keepalive:true }); 267 } 268 }catch(e){} 269} 270 271/* ============================================================ 272 * Auth detection â call /wp-json/cn/v1/me to find out if the visitor 273 * is a logged-in WP user. If so, show Edit Post links throughout the SPA. 274 * Cached for the session. 275 * ============================================================ */ 276var _currentUser = null; 277function detectLoggedInUser(){ 278 if (!CONFIG.ME_ENDPOINT) return Promise.resolve(null); 279 // Append a cache-buster so neither browser nor Cloudflare can serve a stale anon response 280 var url = CONFIG.CN_HOST + CONFIG.ME_ENDPOINT + '?_=' + Date.now(); 281 console.log('[CN] checking auth at', url); 282 return fetch(url, { 283 credentials: 'include', // send WP login cookies 284 cache: 'no-store', // browser cache bypass 285 headers: { 'Cache-Control': 'no-cache' } 286 }) 287 .then(function(r){ 288 console.log('[CN] /me HTTP', r.status, 'cf-cache:', r.headers.get('cf-cache-status')); 289 return r.json(); 290 }) 291 .then(function(data){ 292 console.log('[CN] /me response:', data); 293 if (data && data.logged_in) { 294 _currentUser = data; 295 document.body.classList.add('cn-editor'); // CSS hook for showing Edit buttons 296 console.log('[CN] â admin mode on â body.cn-editor added, can_edit=' + data.can_edit); 297 } else { 298 console.log('[CN] not logged in (or response missing logged_in flag)'); 299 } 300 return _currentUser; 301 }) 302 .catch(function(err){ 303 console.warn('[CN] /me fetch failed:', err && err.message); 304 return null; 305 }); 306} 307/* Build an "Edit Post" link HTML if the current visitor can edit. */ 308function editLinkHtml(post){ 309 if (!_currentUser || !_currentUser.can_edit || !post || !post.id) return ''; 310 var href = (_currentUser.admin_url || '/wp-admin/') + 'post.php?post=' + post.id + '&action=edit'; 311 return '<a class="edit-link" href="' + esc(href) + '" target="_blank" rel="noopener" title="Edit in WordPress">â Edit</a>'; 312} 313
314/* ============================================================ 315 * Tiny helpers 316 * ============================================================ */ 317var $ = function(s){ return document.querySelector(s); }; 318var esc = function(s){ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }; 319var stripHtml = function(s){ var d=document.createElement('div'); d.innerHTML=s||''; return d.textContent||''; }; 320var fmtDate = function(s){ var d=new Date(s); return isNaN(d)?'':d.toLocaleDateString('en-GB',{year:'numeric',month:'short',day:'numeric'}); }; 321var fmtRel = function(s){ 322 var d = new Date(s), now = new Date(); 323 if(isNaN(d)) return ''; 324 var diff = Math.floor((now-d)/1000); 325 if(diff < 60) return 'just now'; 326 if(diff < 3600) return Math.floor(diff/60)+' min ago'; 327 if(diff < 86400) return Math.floor(diff/3600)+' hours ago'; 328 if(diff < 604800) return Math.floor(diff/86400)+' days ago'; 329 return fmtDate(s); 330}; 331var toast = function(msg){ 332 var t = document.createElement('div'); 333 t.className='toast'; t.textContent=msg; 334 document.body.appendChild(t); 335 setTimeout(function(){ t.remove(); }, 4000); 336}; 337 338/* ============================================================ 339 * Smart fetch â tries direct, falls back to CORS proxy 340 * ============================================================ */ 341function smartFetch(url){ 342 return fetch(url, {mode:'cors', credentials:'omit'}) 343 .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }) 344 .catch(function(){ 345 return fetch(CONFIG.CORS_PROXY + encodeURIComponent(url)) 346 .then(function(r){ if(!r.ok) throw new Error('proxy HTTP '+r.status); return r.json(); }); 347 }); 348} 349 350/* ============================================================ 351 * Cache helper 352 * ============================================================ */ 353function cacheGet(key, ttlMs){ 354 try{ 355 var raw = localStorage.getItem(key); if(!raw) return null; 356 var o = JSON.parse(raw); 357 if(Date.now() - o.t > ttlMs) return null; 358 return o.v; 359 }catch(e){ return null; } 360} 361/* Return cached value regardless of age â used for stale-while-revalidate first paint. */ 362function cacheGetStale(key){ 363 try{ 364 var raw = localStorage.getItem(key); if(!raw) return null; 365 return JSON.parse(raw).v; 366 }catch(e){ return null; } 367} 368function cacheSet(key, v){ 369 try{ localStorage.setItem(key, JSON.stringify({t:Date.now(), v:v})); } 370 catch(e){ 371 // Likely QuotaExceededError â proactively trim and retry once. 372 pruneCache(true); 373 try{ localStorage.setItem(key, JSON.stringify({t:Date.now(), v:v})); }catch(_){} 374 } 375} 376 377/* ============================================================ 378 * localStorage LRU eviction â prevents the cache growing unbounded 379 * across a long browsing session. Without this the tab gets slower 380 * after a few hours because: 381 * - Hover-prefetch + viewed-article caching adds an entry per story 382 * - Archive/tag views add their own per-section keys 383 * - Reading + parsing JSON gets slower as the cache grows 384 * 385 * Strategy: cap each bucket (cn-post-*, cn-arch-*) at MAX_ENTRIES, 386 * evicting the oldest by timestamp. Runs on boot + every 5 min. 387 * ============================================================ */ 388var CACHE_LIMITS = { 389 'cn-post-': 60, // ~60 most-recently-viewed/prefetched articles 390 'cn-arch-': 20, // ~20 most-recently-visited archives/tags/categories 391 'cn-page-': 20, // static pages 392 'cn-tagid-': 100, // slug â id mappings are tiny but multiply fast 393 'cn-catid-': 100 394}; 395function pruneCache(force){ 396 try{ 397 Object.keys(CACHE_LIMITS).forEach(function(prefix){ 398 var limit = CACHE_LIMITS[prefix]; 399 var entries = []; 400 for(var i = 0; i < localStorage.length; i++){ 401 var k = localStorage.key(i); 402 if(!k || k.indexOf(prefix) !== 0) continue; 403 var t = 0; 404 try{ t = (JSON.parse(localStorage.getItem(k)) || {}).t || 0; }catch(_){} 405 entries.push({ k: k, t: t }); 406 } 407 if(entries.length <= limit && !force) return; 408 // Sort oldest-first, drop everything beyond the limit. 409 entries.sort(function(a, b){ return a.t - b.t; }); 410 var toRemove = entries.length - limit; 411 if(force) toRemove = Math.max(toRemove, Math.ceil(entries.length * 0.3)); // forcefully drop 30% 412 for(var j = 0; j < toRemove; j++){ 413 localStorage.removeItem(entries[j].k); 414 } 415 }); 416 }catch(e){ /* localStorage might be disabled; do nothing */ } 417} 418// Boot prune + periodic prune (every 5 minutes while tab is open) 419pruneCache(); 420setInterval(pruneCache, 5 * 60 * 1000); 421 422/* ============================================================ 423 * Social meta â updates Open Graph + Twitter Card tags on 424 * client-side route changes so modern crawlers see the right 425 * image/title/description per article. 426 * 427 * For crawlers that DON'T execute JS (older Facebook, some Slack), 428 * the .htaccess bot-detection rule routes them to WordPress where 429 * Yoast / RankMath emit the same tags server-side. 430 * ============================================================ */ 431function updateSocialMeta(m){ 432 function ensureMeta(attr, key){ 433 var sel = 'meta['+attr+'="'+key+'"]'; 434 var el = document.head.querySelector(sel); 435 if(!el){ 436 el = document.createElement('meta'); 437 el.setAttribute(attr, key); 438 document.head.appendChild(el); 439 } 440 return el; 441 } 442 function set(attr, key, val){ if(val) ensureMeta(attr, key).setAttribute('content', val); } 443 444 var title = m.title ? (m.title + ' â CambsNews') : 'CambsNews'; 445 var desc = m.description || 'Independent local news for Peterborough and Cambridgeshire.'; 446 var img = m.image || (location.origin + '/favicon.svg'); 447 var url = m.url || location.href; 448 var type = m.type || 'website'; 449 450 // Open Graph (Facebook, LinkedIn, WhatsApp, Discord, Slack) 451 set('property', 'og:type', type); 452 set('property', 'og:site_name', 'CambsNews'); 453 set('property', 'og:locale', 'en_GB'); 454 set('property', 'og:title', title); 455 set('property', 'og:description', desc); 456 set('property', 'og:image', img); 457 set('property', 'og:url', url); 458 459 // Twitter / X 460 set('name', 'twitter:card', 'summary_large_image'); 461 set('name', 'twitter:site', '@cambsnewsonline'); 462 set('name', 'twitter:title', title); 463 set('name', 'twitter:description', desc); 464 set('name', 'twitter:image', img); 465 466 // Canonical link helps Google index the right URL even for SPA routes 467 var canonical = document.head.querySelector('link[rel="canonical"]'); 468 if(!canonical){ 469 canonical = document.createElement('link'); 470 canonical.rel = 'canonical'; 471 document.head.appendChild(canonical); 472 } 473 canonical.href = url; 474} 475
476/* ============================================================ 477 * Extract featured image from a WP REST post (with _embed) 478 * ============================================================ */ 479function featuredImg(p, prefer){ 480 try{ 481 // 1) Native WordPress featured image (the proper way) 482 var m = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0]; 483 if(m){ 484 var sizes = m.media_details && m.media_details.sizes; 485 if(sizes){ 486 var order = prefer==='large' ? ['large','medium_large','full','medium'] : ['medium_large','medium','large','full']; 487 for(var i=0;i<order.length;i++){ if(sizes[order[i]]) return sizes[order[i]].source_url; } 488 } 489 if(m.source_url) return m.source_url; 490 } 491 // 2) Fallback: first <img> in the post content. A lot of older posts have no 492 // featured-image set but include their hero image inline in the body. 493 var html = (p.content && p.content.rendered) || (p.excerpt && p.excerpt.rendered) || ''; 494 if(html){ 495 var match = html.match(/<img[^>]+(?:data-src|src)=["']([^"']+)["']/i); 496 if(match && match[1] && /^https?:\/\//.test(match[1])) return match[1]; 497 } 498 return ''; 499 }catch(e){ return ''; } 500} 501 502/* Build a responsive srcset for the hero image so the browser picks the right 503 * size for the viewport instead of downloading the biggest version. Massive 504 * mobile LCP win on slow connections. Returns { src, srcset, sizes } or null. */ 505function featuredImgResponsive(p){ 506 try{ 507 var m = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0]; 508 var sizes = m && m.media_details && m.media_details.sizes; 509 if(!sizes) return null; 510 var candidates = []; 511 ['medium','medium_large','large','full'].forEach(function(name){ 512 var s = sizes[name]; 513 if(s && s.source_url && s.width) candidates.push({url: s.source_url, w: s.width}); 514 }); 515 if(!candidates.length) return null; 516 // De-duplicate by URL (some sizes share the same file) 517 var seen = {}; 518 candidates = candidates.filter(function(c){ if(seen[c.url]) return false; seen[c.url]=1; return true; }); 519 candidates.sort(function(a,b){ return a.w - b.w; }); 520 var srcset = candidates.map(function(c){ return c.url + ' ' + c.w + 'w'; }).join(', '); 521 // Default src = the medium one for slow connections; srcset will override
522 var def = candidates[1] || candidates[0]; 523 return { src: def.url, srcset: srcset, sizes: '(max-width: 1000px) 100vw, 820px' }; 524 }catch(e){ return null; } 525} 526function postCategory(p){ 527 try{ 528 var terms = p._embedded && p._embedded['wp:term']; 529 if(!terms) return 'News'; 530 var fallback = null; 531 // Prefer any non-"Exclusive" category, since "Exclusive" is conveyed by the badge 532 for(var i = 0; i < terms.length; i++){ 533 for(var j = 0; j < terms[i].length; j++){ 534 var t = terms[i][j]; 535 if(!t || t.taxonomy !== 'category') continue; 536 if(t.name && t.name.toLowerCase() === 'exclusive'){ 537 if(!fallback) fallback = t.name; // remember as last resort 538 continue; 539 } 540 return t.name; 541 } 542 } 543 return fallback || 'News'; 544 }catch(e){} 545 return 'News'; 546} 547/* Renders the category pill (<span class="cat">...</span>) but returns '' 548 * if the chosen category is "Exclusive" â avoids visually duplicating the 549 * EXCLUSIVE badge that's shown next to the headline. */ 550function catHtml(p, tag){ 551 tag = tag || 'span'; 552 var c = postCategory(p); 553 if(!c || c.toLowerCase() === 'exclusive') return ''; 554 var slug = c.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,''); 555 return '<' + tag + ' class="cat" data-c="'+slug+'">' + esc(c) + '</' + tag + '>'; 556} 557function postAuthor(p){ 558 try{ 559 var a = p._embedded && p._embedded.author && p._embedded.author[0]; 560 return a ? a.name : ''; 561 }catch(e){ return ''; } 562} 563/* Prefer slug for nicer URLs; fall back to numeric ID if a post somehow has no slug */ 564function postRef(p){ return (p && (p.slug || p.id)) || ''; } 565 566/* True if the post has a tag or category with the given name (case-insensitive) */ 567function postHasTerm(p, name){ 568 if(!p || !name) return false; 569 try{ 570 var terms = p._embedded && p._embedded['wp:term']; 571 if(!terms) return false; 572 var target = String(name).toLowerCase(); 573 for(var i = 0; i < terms.length; i++){ 574 for(var j = 0; j < terms[i].length; j++){ 575 var t = terms[i][j]; 576 if(t && t.name && String(t.name).toLowerCase() === target) return true; 577 if(t && t.slug && String(t.slug).toLowerCase() === target) return true; 578 } 579 } 580 }catch(e){} 581 return false; 582} 583/* Build the EXCLUSIVE badge HTML if the post qualifies, else empty string */ 584function exclusiveBadge(p, big){ 585 return postHasTerm(p, 'Exclusive') 586 ? '<span class="excl'+(big?' lg':'')+'">Exclusive</span>' 587 : ''; 588} 589/* Build the CN+ club badge if the post has the configured premium tag */ 590function cnPlusBadge(p, big){ 591 if(!CONFIG.PLUS_TAG || !postHasTerm(p, CONFIG.PLUS_TAG)) return ''; 592 return '<span class="cnplus'+(big?' lg':'')+'">'+esc(CONFIG.PLUS_BRAND || 'CN+')+'</span>'; 593} 594/* Combined badges block â order matters: CN+ first (premium), then EXCLUSIVE */ 595function postBadges(p, big){ 596 return cnPlusBadge(p, big) + exclusiveBadge(p, big); 597} 598/* Hot-score ranking â combines recency boost with popularity. 599 * - Brand-new posts (<6h old) get a "fresh news" boost so they land high 600 * immediately, regardless of views. 601 * - As they age, the boost fades and the post needs view-count to stay up. 602 * - Posts much older than HOT_HALF_LIFE_H decay fast unless they're viral. 603 * 604 * Tunable: HOT_HALF_LIFE_H controls how aggressively old posts drop. 605 */ 606var HOT_HALF_LIFE_H = 48; // half-life in hours for the view-score decay 607function hotScore(p){ 608 var views = (p.meta && typeof p.meta.iawp_total_views === 'number') ? p.meta.iawp_total_views : 0; 609 var ts = p.date ? new Date(p.date).getTime() : Date.now(); 610 var ageH = Math.max(0.5, (Date.now() - ts) / (1000*60*60)); 611 var freshBoost = ageH < 6 ? (6 - ageH) * 60 : 0; // first 6 hours: big boost 612 var decay = Math.pow(0.5, ageH / HOT_HALF_LIFE_H); // exponential decay over time 613 return (views * decay) + freshBoost; 614} 615function sortByHot(posts){ 616 return posts.slice().sort(function(a,b){ return hotScore(b) - hotScore(a); }); 617} 618 619/* Author name, linked to their author archive when available. 620 * Guest authors (via cn-spa-helpers' _cn_byline custom field) take precedence 621 * over the WP user â that's the editor's intent when they set a guest byline. */ 622function authorHtml(p){ 623 try{ 624 // 1. Guest byline (custom field via cn-spa-helpers mu-plugin) 625 if (p._cn_byline && p._cn_byline.name) { 626 var g = p._cn_byline; 627 var inner = (g.image ? '<img class="author-pic" loading="lazy" src="'+esc(g.image)+'" alt="">' : '') 628 + esc(g.name); 629 return '<span class="author-link guest-author">' + inner + '</span>'; 630 } 631 // 2. WP user 632 var a = p._embedded && p._embedded.author && p._embedded.author[0]; 633 if(!a || !a.name) return ''; 634 var pic = ''; 635 if (a.avatar_urls && (a.avatar_urls['48'] || a.avatar_urls['96'])) { 636 pic = '<img class="author-pic" loading="lazy" src="'+esc(a.avatar_urls['48'] || a.avatar_urls['96'])+'" alt="">'; 637 } 638 if(a.link){ 639 return '<a class="author-link" href="'+esc(a.link)+'" target="_blank" rel="noopener">' + pic + esc(a.name) + '</a>'; 640 } 641 return '<span class="author-link">' + pic + esc(a.name) + '</span>'; 642 }catch(e){ return ''; } 643} 644 645/* WordPress lazy-load plugins (LiteSpeed Cache, WP Rocket, A3 Lazy Load, 646 * Jetpack Lazy Images, etc.) rewrite <img src> in different ways. This walks 647 * the rendered article and tries to restore the real image URL from whatever 648 * data-* attribute the plugin used. 649 * 650 * Strategies (run in order): 651 * 1. Promote any <noscript><img...></noscript> fallback into a real <img>. 652 * 2. For each <img>, if src is empty / a tiny placeholder / a data: SVG, 653 * scan a wide list of data-* attrs for a real URL and copy it to src. 654 * 3. Same for srcset.
655 * 4. Strip "lazy" class names that re-trigger lazy behaviour. 656 * 657 * Set CONFIG.ADS_DEBUG = true to log unresolved images to the console. 658 */ 659function activateLazyImages(scope){ 660 if(!scope) scope = document; 661 662 // 1) Promote noscript image fallbacks 663 var noscripts = scope.querySelectorAll('noscript'); 664 for(var n = 0; n < noscripts.length; n++){ 665 var ns = noscripts[n]; 666 var txt = ns.textContent || ns.innerHTML; 667 if(/<img[\s\S]*?>/i.test(txt)){ 668 var wrap = document.createElement('span'); 669 wrap.innerHTML = txt; 670 if(ns.parentNode) ns.parentNode.replaceChild(wrap, ns); 671 } 672 } 673 674 // Candidate data-* attribute names plugins use for the real URL 675 var SRC_ATTRS = [ 676 'data-src', 'data-lazy-src', 'data-original', 'data-lazysrc', 677 'data-lazy', 'data-actual-src', 'data-orig-src', 'data-cfsrc', 678 'data-large-file', 'data-medium-file', 'data-full-src' 679 ]; 680 var SRCSET_ATTRS = ['data-srcset', 'data-lazy-srcset', 'data-orig-srcset']; 681 682 function looksLikePlaceholder(src){ 683 if(!src) return true; 684 if(src.indexOf('data:image/svg') === 0) return true; 685 if(src.indexOf('data:image/gif;base64,R0lG') === 0) return true; // 1x1 gif 686 if(/\b(blank|placeholder|loading)\.(gif|png|svg)/i.test(src)) return true; 687 return false; 688 } 689 690 var imgs = scope.querySelectorAll('img'); 691 for(var i = 0; i < imgs.length; i++){ 692 var img = imgs[i]; 693 var currentSrc = img.getAttribute('src') || ''; 694 var isPlaceholder = looksLikePlaceholder(currentSrc); 695 696 // src 697 for(var a = 0; a < SRC_ATTRS.length; a++){ 698 var v = img.getAttribute(SRC_ATTRS[a]); 699 if(v && /^(https?:\/\/|\/)/.test(v)){ 700 if(isPlaceholder || !currentSrc) img.setAttribute('src', v); 701 img.removeAttribute(SRC_ATTRS[a]); 702 isPlaceholder = false; 703 break; 704 } 705 } 706 // srcset 707 for(var b = 0; b < SRCSET_ATTRS.length; b++){ 708 var ss = img.getAttribute(SRCSET_ATTRS[b]); 709 if(ss){ 710 img.setAttribute('srcset', ss); 711 img.removeAttribute(SRCSET_ATTRS[b]); 712 break; 713 } 714 } 715 // Strip lazyload triggers 716 ['lazyload','lazy','lazyloading','b-lazy','jetpack-lazy-image'].forEach(function(cls){ 717 img.classList.remove(cls); 718 }); 719 720 // Native loading="lazy" can fail to trigger reliably when innerHTML is 721 // injected into a SPA â remove it so the browser fetches immediately. 722 img.removeAttribute('loading'); 723 724 if(!img.hasAttribute('decoding')) img.setAttribute('decoding','async'); 725 726 // Diagnostic â surface error vs. success per image 727 if(CONFIG && CONFIG.ADS_DEBUG){ 728 var finalSrc = img.getAttribute('src'); 729 if(looksLikePlaceholder(finalSrc)){ 730 console.log('[img] still placeholder after activation:', img.outerHTML.slice(0,260)); 731 } else { 732 img.addEventListener('error', function(){ 733 console.warn('[img] failed to load:', img.src, '(status unknown â check Network tab)'); 734 }, {once:true}); 735 } 736 } 737 738 // Force the browser to (re-)issue the network request by replacing the 739 // element with a clone. Browsers may not refetch if the src attribute 740 // is unchanged textually, even though the element is freshly inserted. 741 if(img.parentNode && img.getAttribute('src')){ 742 var fresh = img.cloneNode(false); 743 img.parentNode.replaceChild(fresh, img); 744 } 745 } 746} 747 748/* ============================================================ 749 * HOME â render 750 * ============================================================ */ 751/* Global set of post IDs already rendered anywhere on the home page â 752 * prevents duplicates when infinite scroll loads more pages. */ 753var renderedPostIds = {}; 754 755function renderHome(posts){ 756 if(!posts || !posts.length){ 757 $('#news-grid').innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories available right now.</div>'; 758 return; 759 } 760 761 // Pin the hero (posts[0]) and side stack (posts[1..3]) â these reflect 762 // editorial decisions (homepage-tagged hero + latest few). Everything from 763 // index 4 onwards gets reranked by hot-score so fresh + popular wins. 764 var pinned = posts.slice(0, 4); 765 var pool = posts.slice(4); 766 var ranked = sortByHot(pool); 767 posts = pinned.concat(ranked); 768 769 // Record every ID we're about to render so infinite scroll can skip them 770 renderedPostIds = {};
771 posts.forEach(function(p){ if(p && p.id) renderedPostIds[p.id] = true; }); 772 773 // LEAD 774 var p0 = posts[0]; 775 // Build a responsive srcset for the hero so mobile downloads a 480w/768w 776 // variant instead of the full 1024w "large" â slashes mobile LCP on Slow 4G. 777 var heroResp = featuredImgResponsive(p0); 778 var leadImg = (heroResp && heroResp.src) 779 || featuredImg(p0, 'large') 780 || 'https://images.unsplash.com/photo-1543872084-c7bd3822856f?w=1200&q=70'; 781 var heroSrcSetAttr = heroResp 782 ? ' srcset="' + esc(heroResp.srcset) + '" sizes="' + esc(heroResp.sizes) + '"' 783 : ''; 784 $('#lead').classList.remove('sk','sk-hero'); 785 786 $('#lead').innerHTML = 787 '<div class="img"><img src="'+leadImg+'"'+heroSrcSetAttr+' alt="" fetchpriority="high" decoding="async"></div>' 788 + '<div class="grad"></div>' 789 + '<div class="copy">' 790 + catHtml(p0) 791 + '<h1>'+postBadges(p0, true)+esc(stripHtml(p0.title.rendered))+'</h1>' 792 + '<div class="meta">'+(authorHtml(p0)?'by '+authorHtml(p0)+' · ':'')+esc(fmtRel(p0.date))+'</div>' 793 + editLinkHtml(p0) 794 + '</div>'; 795 $('#lead').onclick = function(e){ 796 if (e.target.closest('.edit-link')) return; // don't navigate when editor clicks Edit 797 go('#/post/'+postRef(p0)); 798 }; 799 800 // SIDE (next 3) 801 $('#side').innerHTML = posts.slice(1,4).map(function(p){ 802 var img = featuredImg(p) || ''; 803 return '<div class="card" data-id="'+postRef(p)+'">' 804 + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>' 805 + '<div>' 806 + catHtml(p, 'div') 807 + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>' 808 + '<time>'+esc(fmtRel(p.date))+'</time>' 809 + '</div></div>'; 810 }).join(''); 811 $('#side').onclick = function(e){ 812 var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id); 813 }; 814 815 // NEWS GRID (next 9 â 1 full-width featured + 8 in 2-col grid) 816 $('#news-grid').innerHTML = posts.slice(4,13).map(function(p){ 817 var img = featuredImg(p) || ''; 818 var excerpt = stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0,160); 819 return '<article class="story" data-id="'+postRef(p)+'">' 820 + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>' 821 + catHtml(p) 822 + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>' 823 + '<div class="meta">'+esc(fmtDate(p.date))+(authorHtml(p)?' · '+authorHtml(p):'')+'</div>' 824 + (excerpt?'<p>'+esc(excerpt)+'â¦</p>':'') 825 + editLinkHtml(p) 826 + '</article>'; 827 }).join(''); 828 $('#news-grid').onclick = function(e){ 829 if (e.target.closest('.edit-link')) return; // Edit link opens in new tab, don't navigate SPA 830 var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id); 831 }; 832 833 // EDITOR'S CHOICE (next 4 â 2 rows of 2) 834 $('#editors-grid').innerHTML = posts.slice(13,17).map(function(p){ 835 var img = featuredImg(p) || ''; 836 return '<article class="story" data-id="'+postRef(p)+'">' 837 + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>' 838 + catHtml(p) 839 + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>' 840 + '<div class="meta">'+esc(fmtDate(p.date))+'</div>' 841 + '</article>'; 842 }).join(''); 843 $('#editors-grid').onclick = function(e){ 844 var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id); 845 }; 846 847 // Most Read is populated by loadMostRead() â real IAWP view counts. 848} 849 850/* ============================================================ 851 * MOST READ â uses Independent Analytics (iawp_total_views) when available 852 * Falls back to comment_count, then to recency. 853 * ============================================================ */ 854function renderMostRead(posts){ 855 if(!posts || !posts.length) return; 856 var withViews = posts.filter(function(p){ 857 return p.meta && typeof p.meta.iawp_total_views === 'number' && p.meta.iawp_total_views > 0; 858 }); 859 var sorted, useViews = false; 860 if(withViews.length >= 3){ 861 sorted = withViews.sort(function(a,b){ 862 return (b.meta.iawp_total_views||0) - (a.meta.iawp_total_views||0); 863 }).slice(0,5); 864 useViews = true; 865 } else { 866 sorted = posts.slice().sort(function(a,b){ 867 return (b.comment_count||0) - (a.comment_count||0); 868 }).slice(0,5); 869 if(sorted.every(function(p){ return !p.comment_count; })) sorted = posts.slice(0,5); 870 } 871 $('#most-read').innerHTML = sorted.map(function(p){ 872 // Ranking (1, 2, 3â¦) is still shown via the .most-read::before counter in CSS. 873 // We only render the publish DATE underneath the headline â never the raw view count, 874 // so the public never sees internal traffic numbers. 875 var sub = esc(fmtDate(p.date)); 876 var img = featuredImg(p) || ''; 877 return '<li data-id="'+postRef(p)+'">' 878 + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">
878':'')+'</div>' 879 + '<div><h4>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h4><time>'+sub+'</time></div></li>'; 880 }).join(''); 881 $('#most-read').onclick = function(e){ 882 var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id); 883 }; 884} 885 886function loadMostRead(){ 887 // 30 most recent posts with full embed (need wp:term so the EXCLUSIVE badge can detect the tag) 888 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=30&_embed=1'; 889 var cached = cacheGetStale('cn-mostread-v3'); 890 if(cached) renderMostRead(cached); 891 smartFetch(url).then(function(posts){ 892 renderMostRead(posts); 893 cacheSet('cn-mostread-v3', posts); 894 }).catch(function(){ /* silent fallback â main grid will still load */ }); 895} 896 897/* ============================================================ 898 * QUADS ADS â fetch ad codes from WP REST endpoint, swap to 899 * QUADS fallback ad if an ad blocker is detected. 900 * Requires the mu-plugin shown in the docs to be installed on 901 * the WordPress origin. Slots/IDs are configured in CONFIG. 902 * ============================================================ */ 903function detectAdBlock(){ 904 return new Promise(function(resolve){ 905 var done = false; 906 var settle = function(blocked){ 907 if(done) return; done = true; 908 try{ bait.remove(); }catch(e){} 909 resolve(!!blocked); 910 }; 911 912 // Method 1 â bait class (catches in-browser blockers: uBlock, AdBlock Plus, Brave shields) 913 var bait = document.createElement('div'); 914 bait.className = 'ads adsbox doubleclick ad-placement carbon-ads pub_300x250 text-ads'; 915 bait.style.cssText = 'position:absolute;left:-9999px;top:-9999px;height:1px;width:1px'; 916 bait.innerHTML = ' '; 917 document.body.appendChild(bait); 918 919 setTimeout(function(){ 920 var cs = getComputedStyle(bait); 921 var baitBlocked = bait.offsetHeight === 0 || bait.offsetParent === null 922 || cs.display === 'none' || cs.visibility === 'hidden'; 923 if(baitBlocked){ settle(true); return; } 924 925 // Method 2 â network test (catches DNS-level blockers: Pi-hole, NextDNS, AdGuard DNS). 926 // Using fetch w/ mode:'no-cors' â resolves on ANY successful TCP response, 927 // rejects ONLY on network/DNS failure (= blocked). 928 fetch('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?_=' + Date.now(), { 929 method: 'GET', 930 mode: 'no-cors', 931 cache: 'no-store', 932 credentials: 'omit', 933 redirect: 'follow' 934 }).then(function(){ settle(false); }) 935 .catch(function(){ settle(true); }); 936 937 // Safety timeout â default to NOT blocked. False negative (showing an ad 938 // that ends up getting blocked anyway) is harmless; false positive 939 // (showing fallback when ads would have shown) loses revenue. 940 setTimeout(function(){ settle(false); }, 2500); 941 }, 100); 942 }); 943} 944 945function injectAdHtml(slot, html, isFallback){ 946 if(!html){ slot.innerHTML = ''; slot.style.display = 'none'; return; } 947 slot.classList.add('loaded'); 948 if(isFallback) slot.classList.add('ad-fallback-active'); 949 slot.innerHTML = isFallback 950 ? '<div class="ad-fallback">'+html+'</div>' 951 : html; 952 // re-execute any inline scripts so AdSense actually runs 953 var scripts = slot.querySelectorAll('script'); 954 for(var i=0;i<scripts.length;i++){ 955 var oldS = scripts[i]; 956 var newS = document.createElement('script'); 957 for(var j=0;j<oldS.attributes.length;j++){ 958 newS.setAttribute(oldS.attributes[j].name, oldS.attributes[j].value); 959 } 960 newS.text = oldS.textContent; 961 oldS.parentNode.replaceChild(newS, oldS); 962 } 963} 964 965/** 966 * Build the AdSense <ins> tag HTML for a given slot, when a specific slot ID 967 * is configured in CONFIG.ADSENSE_SLOTS. Responsive display ad unit format â 968 * works for both leaderboard (728x90) and rectangle (300x250) placements. 969 * Returns '' if AdSense isn't configured for this slot. 970 */ 971function adsenseInsHtml(slotName){ 972 if(!CONFIG.ADSENSE_PUB_ID || !CONFIG.ADSENSE_SLOTS) return ''; 973 var slotId = CONFIG.ADSENSE_SLOTS[slotName]; 974 // Fallback map â each in-article placement reuses a DIFFERENT homepage slot ID 975 // so AdSense serves a different creative to each (rather than the same Temu spray-gun 976 // ad in every slot, which happens when 3 placements share one slot ID). 977 if(!slotId){ 978 var fallbacks = { 979 in_article: CONFIG.ADSENSE_SLOTS.sidebar, // sticky rail 980 in_article_top: CONFIG.ADSENSE_SLOTS.sidebar2, // first inline ad 981 in_article_mid: CONFIG.ADSENSE_SLOTS.sidebar3 // second inline ad 982 }; 983 slotId = fallbacks[slotName]; 984 } 985 if(!slotId) return ''; 986 // IMPORTANT: even inside a JS comment, a literal end-script tag will close the 987 // host script tag, because the HTML parser doesn't understand JS comments. 988 // Both the string below AND this comment must use the escaped form '<\/script>'. 989 return '<ins class="adsbygoogle" ' 990 + 'style="display:block" ' 991 + 'data-ad-client="' + esc(CONFIG.ADSENSE_PUB_ID) + '" ' 992 + 'data-ad-slot="' + esc(String(slotId)) + '" ' 993 + 'data-ad-format="auto" ' 994 + 'data-full-width-responsive="true"></ins>' 995 + '<script>(adsbygoogle = window.adsbygoogle || []).push({});<\/script>'; 996} 997 998function loadAds(){ 999 var slots = document.querySelectorAll('.ad-slot'); 1000 var dbg = function(){ if(CONFIG.ADS_DEBUG && window.console) console.log.apply(console, ['[ads]'].concat([].slice.call(arguments))); }; 1001 1002 if(!slots.length){ dbg('no .ad-slot elements found on page'); return; } 1003 if(!CONFIG.QUADS_SLOTS){ dbg('CONFIG.QUADS_SLOTS missing'); return; } 1004 1005 // Decide whether we need the endpoint at all (only if any slot value is a numeric ID) 1006 var needsEndpoint = false;
1007 Object.keys(CONFIG.QUADS_SLOTS).forEach(function(k){ 1008 var v = CONFIG.QUADS_SLOTS[k]; 1009 if(typeof v === 'number' || typeof v === 'string') needsEndpoint = true; 1010 }); 1011 1012 // Snapshot what each slot wants â and inject the primary code IMMEDIATELY 1013 // for any inline-configured slot, so the user never sees an empty placeholder 1014 // while waiting for ad-block detection / endpoint fetch. 1015 var slotRenderState = []; // {slot, slotName, cfgKind: 'inline'|'id', codeRendered: 'code'|'fallback'|'none'} 1016 1017 slots.forEach(function(slot){ 1018 var slotName = slot.dataset.slot; 1019 var cfg = CONFIG.QUADS_SLOTS[slotName]; 1020 var entry = { slot: slot, slotName: slotName, kind: null, code: null, fallback: null, current: 'none' }; 1021 1022 if(cfg == null){ 1023 dbg('slot "'+slotName+'" not configured in CONFIG.QUADS_SLOTS'); 1024 slotRenderState.push(entry); 1025 return; 1026 } 1027 1028 if(typeof cfg === 'object'){ 1029 entry.kind = 'inline'; 1030 // Priority for the "code" slot, picked LAZILY (so AdSense slot IDs added 1031 // to CONFIG after page load can still be picked up on the next render): 1032 // 1. AdSense <ins> tag â if CONFIG.ADSENSE_SLOTS has an ID for this slot 1033 // 2. The inline cfg.code (house ad) â fallback when AdSense isn't configured 1034 // Either way, the cfg.fallback (house ad) is used when an ad-blocker is detected. 1035 var adsenseCode = adsenseInsHtml(slotName); 1036 entry.code = adsenseCode || cfg.code; 1037 entry.usingAdsense = !!adsenseCode; 1038 entry.fallback = cfg.fallback; 1039 1040 // PAINT THE HOUSE AD IMMEDIATELY so the user never sees an empty placeholder 1041 // even if /wp-json/cn/v1/ads is slow or 404s. 1042 if(entry.code){ 1043 injectAdHtml(slot, entry.code, false); 1044 entry.current = 'code'; 1045 dbg('slot "'+slotName+'" â injected ' + (entry.usingAdsense ? 'AdSense unit (' + CONFIG.ADSENSE_SLOTS[slotName] + ')' : 'house ad') + ' (immediate)'); 1046 } 1047 1048 // Then ASYNC try to upgrade to a local direct-sold ad if one exists. 1049 // The callback is only invoked when no local ad rendered â and at that 1050 // point we've already injected the house ad, so it's a no-op. 1051 loadLocalAdsForSlot(slot, function(){ /* already painted */ }); 1052 } else { 1053 entry.kind = 'id'; 1054 entry.adId = String(cfg); 1055 } 1056 slotRenderState.push(entry); 1057 }); 1058 1059 // Fetch endpoint ads + detect ad-block in parallel â but don't block first paint 1060 var fetchAds = needsEndpoint && CONFIG.QUADS_ENDPOINT 1061 ? smartFetch(CONFIG.CN_HOST + CONFIG.QUADS_ENDPOINT).catch(function(err){ dbg('endpoint fetch failed:', err && err.message); return null; }) 1062 : Promise.resolve([]); 1063 1064 Promise.all([fetchAds, detectAdBlock()]).then(function(res){ 1065 var ads = res[0]; 1066 var blocked = res[1]; 1067 var cached = cacheGet('quads-ads-v2', 60*60*1000); 1068 if(!ads && cached){ dbg('endpoint failed, using cached ads'); ads = cached; } 1069 if(ads && ads.length) cacheSet('quads-ads-v2', ads); 1070 dbg('blocker detected:', blocked, '· ads from endpoint:', (ads||[]).length); 1071 1072 var byId = {}; 1073 (ads||[]).forEach(function(a){ byId[String(a.id)] = a; }); 1074 1075 slotRenderState.forEach(function(entry){ 1076 var slot = entry.slot; 1077 var code, fallback; 1078 1079 if(entry.kind === 'inline'){ 1080 code = entry.code; fallback = entry.fallback; 1081 } else if(entry.kind === 'id'){ 1082 var ad = byId[entry.adId]; 1083 if(!ad){ dbg('slot "'+entry.slotName+'" -> ad id '+entry.adId+' not in endpoint response. Available IDs:', Object.keys(byId)); return; } 1084 code = ad.code; fallback = ad.fallback; 1085 } else { 1086 return; // unconfigured slot 1087 } 1088 1089 // If blocked and we have a fallback â swap to it (or render if we hadn't yet) 1090 if(blocked && fallback){ 1091 if(entry.current !== 'fallback'){ 1092 dbg('slot "'+entry.slotName+'" â blocker detected, swapping to fallback'); 1093 injectAdHtml(slot, fallback, true); 1094 } 1095 } else if(code){ 1096 // For ID-based slots that we couldn't render synchronously, render now 1097 if(entry.current !== 'code'){ 1098 dbg('slot "'+entry.slotName+'" â injecting primary code'); 1099 injectAdHtml(slot, code, false); 1100 } 1101 } else if(!code && !fallback){ 1102 dbg('slot "'+entry.slotName+'" â no code available, hiding'); 1103 slot.style.display = 'none'; 1104 } 1105 }); 1106 }); 1107} 1108 1109/* Resolve a tag slug -> tag ID. Cached for a month (tags rarely move). */ 1110function getTagId(slug){ 1111 if(!slug) return Promise.resolve(null); 1112 var key = 'cn-tagid-' + slug;
1113 var cached = cacheGet(key, 30*24*3600*1000); 1114 if(cached) return Promise.resolve(cached); 1115 return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/tags?slug=' + encodeURIComponent(slug)) 1116 .then(function(tags){ 1117 var id = tags && tags[0] && tags[0].id; 1118 if(id) cacheSet(key, id); 1119 return id || null; 1120 }).catch(function(){ return null; }); 1121} 1122/* Resolve a category slug -> category ID. Cached for a month. */ 1123function getCategoryId(slug){ 1124 if(!slug) return Promise.resolve(null); 1125 var key = 'cn-catid-' + slug; 1126 var cached = cacheGet(key, 30*24*3600*1000); 1127 if(cached) return Promise.resolve(cached); 1128 return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/categories?slug=' + encodeURIComponent(slug)) 1129 .then(function(cats){ 1130 var id = cats && cats[0] && cats[0].id; 1131 if(id) cacheSet(key, id); 1132 return id || null; 1133 }).catch(function(){ return null; }); 1134} 1135 1136/* ============================================================ 1137 * ARCHIVE â renders posts for a category or tag inside the SPA 1138 * ============================================================ */ 1139function loadArchive(kind, slug){ 1140 $('#home-view').classList.add('hidden'); 1141 $('#post-view').classList.remove('hidden'); 1142 $('#post-view').classList.add('is-archive'); 1143 $('#post-view').classList.remove('has-rail'); 1144 var view = $('#post-view'); 1145 1146 // Pretty label from slug 1147 var label = decodeURIComponent(slug).replace(/[-_]/g,' ') 1148 .replace(/\b\w/g, function(c){ return c.toUpperCase(); }); 1149 1150 view.innerHTML = '<a class="back" href="/">â Back to home</a>' 1151 + '<h1 style="font-family:var(--serif);font-size:28px;margin:8px 0 4px">' + esc(label) + '</h1>' 1152 + '<p style="color:var(--muted);margin-bottom:18px">Latest stories ' + (kind==='tag'?'tagged ':'in ') 1153 + '<strong>' + (kind==='tag'?'#':'') + esc(label) + '</strong></p>' 1154 + '<div class="news-grid" id="archive-grid">' 1155 + '<div class="sk sk-card"></div><div class="sk sk-card"></div>' 1156 + '<div class="sk sk-card"></div><div class="sk sk-card"></div>' 1157 + '</div>' 1158 + '<div class="load-more" id="archive-more"></div>'; 1159 document.title = label + ' â CambsNews'; 1160 window.scrollTo({top:0, behavior:'instant'}); 1161 1162 var resolver = kind==='tag' ? getTagId : getCategoryId; 1163 var queryKey = kind==='tag' ? 'tags' : 'categories'; 1164 var cacheKey = 'cn-arch-' + kind + '-' + slug; 1165 var cachedList = cacheGetStale(cacheKey); 1166 if(cachedList && cachedList.length){ 1167 $('#archive-grid').innerHTML = cachedList.map(renderResultCard).join(''); 1168 } 1169 1170 resolver(slug).then(function(id){ 1171 if(!id){ 1172 var grid = $('#archive-grid'); 1173 if(grid) grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">Section <strong>' + esc(label) + '</strong> not found on the site.</div>'; 1174 return; 1175 } 1176 var page = 1, totalPages = 1, totalCount = 0; 1177 function loadPage(){ 1178 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?' + queryKey + '=' + id + '&per_page=12&page=' + page + '&_embed=1'; 1179 return fetchWithTotals(url).then(function(res){ 1180 var posts = res.data || []; 1181 var grid = $('#archive-grid'); 1182 if(!grid) return; 1183 if(page === 1){ 1184 if(!posts.length){ 1185 grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories found in <strong>' + esc(label) + '</strong>.</div>'; 1186 $('#archive-more').innerHTML = ''; 1187 return; 1188 } 1189 grid.innerHTML = ''; 1190 cacheSet(cacheKey, posts); 1191 } 1192 grid.insertAdjacentHTML('beforeend', posts.map(renderResultCard).join('')); 1193 totalPages = res.pages || totalPages; 1194 totalCount = res.total || (page * posts.length); 1195 renderMoreButton('archive-more', page, totalPages, totalCount, function(){ page++; loadPage(); }); 1196 }); 1197 } 1198 loadPage(); 1199 }).catch(function(err){ 1200 if(!cachedList){ 1201 var grid = $('#archive-grid'); 1202 if(grid) grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">Could not load section: ' + esc(err.message) + '</div>'; 1203 } 1204 }); 1205 1206 $('#archive-grid').onclick = function(e){ 1207 var c = e.target.closest('.story[data-id]'); 1208 if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); } 1209 }; 1210} 1211 1212function loadCambsNews(){ 1213 var listUrl = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=22&_embed=1'; 1214 // Stale-while-revalidate: paint any cached version instantly, then refresh
1215 var cached = cacheGetStale('cn-posts-v3'); 1216 if(cached) renderHome(cached); 1217 1218 // Resolve the hero tag, then fetch list + latest hero-tagged post in parallel. 1219 var heroPromise = CONFIG.HERO_TAG 1220 ? getTagId(CONFIG.HERO_TAG).then(function(tid){ 1221 if(!tid) return null; 1222 return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?tags=' + tid + '&per_page=1&_embed=1') 1223 .then(function(arr){ return (arr && arr[0]) || null; }) 1224 .catch(function(){ return null; }); 1225 }) 1226 : Promise.resolve(null); 1227 1228 Promise.all([ 1229 smartFetch(listUrl).catch(function(){ return null; }), 1230 heroPromise 1231 ]).then(function(results){ 1232 var posts = results[0]; 1233 var heroPost = results[1]; 1234 1235 if(!posts && !heroPost){ 1236 if(!cached){ 1237 $('#news-grid').innerHTML = '<div class="error-banner" style="grid-column:1/-1">Sorry, we couldnât load the latest stories â please refresh in a moment.</div>'; 1238 $('#lead').classList.remove('sk','sk-hero'); 1239 $('#lead').innerHTML = '<div class="copy" style="position:static;padding:40px;color:#fff;background:#222;border-radius:4px"><span class="cat">Connection issue</span><h1>Latest stories will be back shortly</h1><div class="meta">Try refreshing the page in a moment.</div></div>'; 1240 } 1241 return; 1242 } 1243 1244 // Pin the hero-tagged post to the front and dedupe 1245 if(heroPost){ 1246 posts = posts || []; 1247 posts = [heroPost].concat(posts.filter(function(p){ return p.id !== heroPost.id; })); 1248 } 1249 1250 renderHome(posts); 1251 cacheSet('cn-posts-v3', posts); 1252 }); 1253 1254 loadMostRead(); 1255} 1256 1257/* ============================================================ 1258 * ARTICLE â single post view 1259 * ============================================================ */ 1260/* SVG icons (inline so the file is fully self-contained) */ 1261var SHARE_ICONS = { 1262 fb: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22.675 0H1.325C.593 0 0 .593 0 1.325v21.351C0 23.407.593 24 1.325 24H12.82v-9.294H9.692v-3.622h3.128V8.413c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.464.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.313h3.587l-.467 3.622h-3.12V24h6.116c.73 0 1.323-.593 1.323-1.325V1.325C24 .593 23.407 0 22.675 0z"/></svg>', 1263 tw: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>', 1264 wa: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.59 5.392 1.591 5.448 0 9.887-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.652 3.891 1.746 5.634l-.999 3.648 3.742-.98zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.471-.148-.669.15-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.371-.025-.52-.075-.149-.669-1.611-.916-2.206-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.71.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413z"/></svg>', 1265 share: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg>' 1266}; 1267 1268function shareBarHtml(title, url, position){ 1269 var t = encodeURIComponent(title); 1270 var u = encodeURIComponent(url); 1271 var tEsc = esc(title), uEsc = esc(url); 1272 return '<div class="share-bar' + (position==='bottom'?' bottom':'') + '">' 1273 + '<a class="sb-fb" href="https://www.facebook.com/sharer/sharer.php?u='+u+'" target="_blank" rel="noopener" aria-label="Share on Facebook">'+SHARE_ICONS.fb+'<span>Share on Facebook</span></a>' 1274 + '<a class="sb-tw" href="https://twitter.com/intent/tweet?url='+u+'&text='+t+'" target="_blank" rel="noopener" aria-label="Share on X">'+SHARE_ICONS.tw+'<span>Share on Twitter</span></a>'
1275 + '<a class="sb-wa" href="https://api.whatsapp.com/send?text='+t+'%20'+u+'" target="_blank" rel="noopener" aria-label="Share on WhatsApp">'+SHARE_ICONS.wa+'</a>' 1276 + '<button class="sb-more" data-share-url="'+uEsc+'" data-share-title="'+tEsc+'" aria-label="More share options">'+SHARE_ICONS.share+'</button>' 1277 + '</div>'; 1278} 1279 1280/* ============================================================ 1281 * Article rail â sticky right column shown inside each post. 1282 * Contents: AdSense unit + "Coming up" events card + rotating sponsor. 1283 * Falls back gracefully if the events cache is empty or no sponsors exist. 1284 * ============================================================ */ 1285function cachedEventsList(){ 1286 // Two caches store the same events under different keys, written by different 1287 // views (the /whats-on page writes 'cn-whats-on-v2', the homepage DP widget 1288 // writes 'dp-cache-v3'). Read both â whichever has data wins â so the article 1289 // rail isn't empty just because the visitor landed on the article directly 1290 // without hitting the homepage first. 1291 function pullFrom(key, path){ 1292 try{ 1293 var raw = localStorage.getItem(key); if(!raw) return []; 1294 var parsed = JSON.parse(raw); 1295 // Walk the path (e.g. ['v','events'] for cn-whats-on-v2, ['v','events'] for dp-cache-v3) 1296 var cur = parsed; 1297 for(var i=0;i<path.length;i++){ cur = cur && cur[path[i]]; } 1298 return Array.isArray(cur) ? cur : []; 1299 }catch(e){ return []; } 1300 } 1301 // cn-whats-on-v2 shape: {t, v:{events:[...]}} 1302 // dp-cache-v3 shape: {t, v:{posts:[...], events:[...]}} 1303 var events = pullFrom('cn-whats-on-v2', ['v','events']); 1304 if(!events.length) events = pullFrom('dp-cache-v3', ['v','events']); 1305 // localStorage can be full (setItem fails silently) - fall back to the in-memory 1306 // copy kept by prefetchEventsForRail so the rail still works without storage. 1307 if(!events.length) events = window.__cnRailEvents || []; 1308 if(!events.length) return []; 1309 // Only future events (including today), sorted ascending by date 1310 var now = Date.now(); 1311 return events.filter(function(ev){ 1312 var d = new Date((ev.start_date||'').replace(' ','T')); 1313 return !isNaN(d) && d.getTime() >= now - 86400000; 1314 }).sort(function(a,b){ 1315 return new Date(a.start_date) - new Date(b.start_date); 1316 }); 1317} 1318/* Background-load DP events if there's no cached copy yet (so the rail isn't empty on a fresh visit). 1319 * Uses smartFetch so CORS errors on Discover Peterborough fall back through the CORS proxy. */ 1320function prefetchEventsForRail(){ 1321 if(cachedEventsList().length) return; // already cached 1322 if(!CONFIG.DP_HOST) return; 1323 var url = CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=50'; 1324 smartFetch(url).then(function(data){ 1325 var events = (data && data.events) || []; 1326 if(!events.length) return; 1327 window.__cnRailEvents = events; 1328 try{ localStorage.setItem('cn-whats-on-v2', JSON.stringify({t:Date.now(), v:{events:events}})); }catch(_){} 1329 // If we're already on a post view with an empty rail, fill it in now 1330 var slot = document.querySelector('.article-rail .rail-events-slot'); 1331 if(slot){ 1332 var newBlock = railEventsBlockHtml(); 1333 var temp = document.createElement('div'); 1334 temp.innerHTML = newBlock; 1335 if(temp.firstElementChild){ slot.replaceWith(temp.firstElementChild); } 1336 } 1337 }).catch(function(){ 1338 // If even the proxy failed, leave the placeholder as a simple link to the full What's On page 1339 }); 1340} 1341function railEventsBlockHtml(){ 1342 var events = cachedEventsList().slice(0, 4); 1343 // No events yet - branded CTA card (green DP panel with calendar icon) 1344 if(!events.length){ 1345 return '<div class="rail-block rail-events rail-events-slot">' 1346 + '<div class="rail-eyebrow">Coming up · Peterborough</div>' 1347 + '<a class="rail-ev-fb" href="/whats-on">' 1348 + '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/></svg>' 1349 + '<div class="fb-title">What's on this week</div>' 1350 + '<div class="fb-tag">Concerts, festivals and family days out across Peterborough</div>' 1351 + '<div class="fb-cta">Browse all events →</div>' 1352 + '</a>' 1353 + '</div>'; 1354 } 1355 var MONTHS_S = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 1356 var DOW_S = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; 1357 var rows = events.map(function(ev){ 1358 var d = new Date((ev.start_date||'').replace(' ','T')); 1359 var venue = (ev.venue && (ev.venue.venue || ev.venue.address)) || ''; 1360 var url = ev.url || ev.website || ('https://discoverpeterborough.co.uk' + (ev.slug ? '/event/'+ev.slug+'/' : '/events/')); 1361 var title = stripHtml(ev.title || ''); 1362 var thumb = ''; 1363 try{ 1364 thumb = (ev.image && ((ev.image.sizes && ((ev.image.sizes.thumbnail && ev.image.sizes.thumbnail.url) || (ev.image.sizes.medium && ev.image.sizes.medium.url))) || ev.image.url)) || ''; 1365 }catch(_){} 1366 return '<a class="rail-event" href="'+esc(url)+'" target="_blank" rel="noopener">' 1367 + '<div class="rd">' 1368 + '<div class="rd-mo">'+MONTHS_S[d.getMonth()]+'</div>' 1369 + '<div class="rd-d">
1369'+d.getDate()+'</div>' 1370 + '<div class="rd-dow">'+DOW_S[d.getDay()]+'</div>' 1371 + '</div>' 1372 + '<div class="rt">' 1373 + '<div class="rt-title">'+esc(title)+'</div>' 1374 + (venue ? '<div class="rt-venue">'+esc(venue)+'</div>' : '') 1375 + '</div>' 1376 + (thumb ? '<div class="re-th"><img loading="lazy" src="'+esc(thumb)+'" alt=""></div>' : '') 1377 + '</a>'; 1378 }).join(''); 1379 return '<div class="rail-block rail-events">' 1380 + '<div class="rail-eyebrow">Coming up · Peterborough</div>' 1381 + rows 1382 + '<a class="rail-more" href="/whats-on">See all events â</a>' 1383 + '</div>'; 1384} 1385function railSponsorBlockHtml(postId){ 1386 var pool = (CONFIG.SPONSORS_POOL || []).filter(function(s){ return s && s.name && s.url; }); 1387 if(!pool.length) return ''; 1388 // Deterministic pick from post.id â same sponsor for the same article every visit (better tracking, no flicker) 1389 var idx = Math.abs(parseInt(postId, 10) || 0) % pool.length; 1390 var s = pool[idx]; 1391 // Use a real <img> element (not CSS background) so we can detect failure with onerror 1392 // and hide the broken-image grey box if the URL 404s. The IMG falls inside .rs-img-wrap 1393 // so the layout matches the original CSS placeholder size. 1394 var img = s.img 1395 ? '<div class="rs-img-wrap"><img class="rs-img-tag" src="'+esc(s.img)+'" alt="" ' 1396 + 'onload="this.parentNode&&this.parentNode.classList.add(\'loaded\')" ' 1397 + 'onerror="this.parentNode&&(this.parentNode.style.display=\'none\')" /></div>' 1398 : ''; 1399 return '<div class="rail-block rail-sponsor">' 1400 + '<div class="rail-eyebrow" style="color:#666;border-bottom-color:#999">Sponsor</div>' 1401 + '<a href="'+esc(s.url)+'" target="_blank" rel="noopener sponsored">' 1402 + img 1403 + '<div class="rs-name">'+esc(s.name)+'</div>' 1404 + (s.tagline ? '<div class="rs-tag">'+esc(s.tagline)+'</div>' : '') 1405 + (s.cta ? '<div class="rs-cta">'+esc(s.cta)+'</div>' : '') 1406 + '</a>' 1407 + '</div>'; 1408} 1409/* Inject up to 2 inline ad slots into article HTML. 1410 * 1411 * Strategy: walk the paragraphs counting WORDS (not paragraph count), insert 1412 * an ad after the paragraph that crosses each threshold. This keeps spacing 1413 * sensible regardless of paragraph length â short news flashes won't have ads 1414 * stacked on top of each other, and long features get one early + one mid-way. 1415 * 1416 * Thresholds: 1417 * - First ad after ~200 words read (catches engaged readers early) 1418 * - Second ad after ~600 words read (catches readers in for the long haul) 1419 * 1420 * Each ad uses a DIFFERENT data-slot key (in_article_top / in_article_mid) so 1421 * AdSense's fallback map serves a different creative to each â fixes the 1422 * "same Temu ad twice" bug. 1423 * 1424 * Skipped entirely on stories with fewer than 4 substantive paragraphs so we 1425 * don't bury short flashes in ads. 1426 */ 1427function injectInArticleAds(htmlString){ 1428 if(!htmlString) return htmlString; 1429 var temp = document.createElement('div'); 1430 temp.innerHTML = htmlString; 1431 var paragraphs = Array.prototype.slice.call(temp.children).filter(function(el){ 1432 return el.tagName === 'P' && el.textContent.trim().length > 40; // ignore tiny <p> wrappers around images 1433 }); 1434 if(paragraphs.length < 4) return htmlString; 1435 1436 var THRESHOLDS = [200, 600]; // word counts at which to inject 1437 var SLOT_KEYS = ['in_article_top', 'in_article_mid']; // unique keys -> different AdSense slot IDs 1438 var MAX_ADS = 2; 1439 1440 var wordsSeen = 0; 1441 var inserted = 0; 1442 var nextIdx = 0; 1443 1444 for(var i = 0; i < paragraphs.length && inserted < MAX_ADS; i++){ 1445 var pEl = paragraphs[i]; 1446 wordsSeen += (pEl.textContent.trim().match(/\S+/g) || []).length; 1447 if(nextIdx < THRESHOLDS.length && wordsSeen >= THRESHOLDS[nextIdx]){ 1448 var slot = document.createElement('div'); 1449 slot.className = 'ad-slot ad-inline'; 1450 slot.setAttribute('data-slot', SLOT_KEYS[inserted]); 1451 slot.innerHTML = 'Advertisement'; 1452 if(pEl.parentNode){ 1453 pEl.parentNode.insertBefore(slot, pEl.nextSibling); 1454 } 1455 inserted++; 1456 nextIdx++; 1457 } 1458 } 1459 return temp.innerHTML; 1460} 1461 1462function articleRailHtml(post){ 1463 // 1) AdSense placeholder â populated by activateAdSlot in the post-render pass. 1464 // Using a plain placeholder (instead of pre-embedding the <ins>) makes the activation 1465 // path identical for rail + inline ads, and gives the rail a visible "Advertisement" 1466 // eyebrow if AdSense fails to fill rather than collapsing silently. 1467 var adBlock = '<div class="ad-slot ad-inarticle ad-sidebar" data-slot="in_article">Advertisement</div>'; 1468 // 2) Events card from cached DP events (always renders SOMETHING â see railEventsBlockHtml) 1469 var eventsBlock = railEventsBlockHtml(); 1470 // 3) Rotating sponsor card 1471 var sponsorBlock = railSponsorBlockHtml(post.id); 1472 return '<aside class="article-rail">' 1473 + adBlock 1474 + eventsBlock 1475 + sponsorBlock 1476 + '</aside>'; 1477} 1478 1479function renderPost(p, related){ 1480 var img = featuredImg(p,'large'); 1481 var cat = postCategory(p); 1482 var author = postAuthor(p); 1483 var title = stripHtml(p.title.rendered); 1484 // Share URL = bare slug (NOT WP's long /category/subcategory/slug/ID/ permalink). 1485 // Bots fetching /slug/ get rewritten by .htaccess to /index.php?name=slug so WP 1486 // can still serve them the post with proper og:image meta tags. 1487 var shareUrl = p.slug 1488 ? CONFIG.CN_HOST + '/' + p.slug + '/' 1489 : p.link; // fall back to WP permalink if for some reason slug is missing 1490 var tags = []; 1491 try{ 1492 var terms = p._embedded && p._embedded['wp:term']; 1493 if(terms){ 1494 for(var i=0;i<terms.length;i++){ 1495 for(var j=0;j<terms[i].length;j++){ 1496 if(terms[i][j].taxonomy==='post_tag') tags.push(terms[i][j].name); 1497 } 1498 } 1499 } 1500 }catch(e){} 1501 1502 // If the category itself is "Exclusive", suppress the category pill so we 1503 // don't show the same red EXCLUSIVE label twice (the H1 badge handles it). 1504 var showCat = cat && cat.toLowerCase() !== 'exclusive'; 1505 // Inner = the actual article body. Wrapped in .article-main so the rail can sit beside it. 1506 var inner = '<a class="back" href="#/">â Back to home</a>' 1507 + (showCat ? '<span class="cat">'+esc(cat)+'</span>' : '') 1508 + '<h1>'+postBadges(p, true)+esc(title)+'</h1>' 1509 + '<div class="byline">' 1510 + (authorHtml(p)?'<span class="author">'+authorHtml(p)+'</span> · ':'') 1511 + '<span>'+esc(fmtDate(p.date))+'</span>'
1512 + (p.comment_count?' · <span>'+p.comment_count+' comments</span>':'') 1513 + editLinkHtml(p) 1514 + '</div>'; 1515 if(img) { 1516 // Read caption + alt text from the embedded featured-media object. 1517 // WordPress stores image captions on the media library item itself; they 1518 // arrive via the REST API's _embed=1 query parameter as wp:featuredmedia. 1519 var media = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0]; 1520 var capRaw = (media && media.caption && media.caption.rendered) || ''; 1521 var caption = stripHtml(capRaw).trim(); 1522 // Some sites use the media "description" field for credit lines instead of caption 1523 if(!caption && media && media.description && media.description.rendered){ 1524 caption = stripHtml(media.description.rendered).trim(); 1525 } 1526 var altText = (media && media.alt_text) || title || ''; 1527 // Header video (cn-story-video plugin): play it in place of the hero image. 1528 // Muted looping autoplay with the featured image as poster - captions are 1529 // burned into the video so sound-off is the intended experience. 1530 var heroVideo = (p.meta && p.meta._cn_header_video) ? p.meta._cn_header_video : ''; 1531 inner += '<figure class="hero-img">' 1532 + (heroVideo 1533 ? '<video src="'+esc(heroVideo)+'" poster="'+img+'" muted autoplay loop playsinline preload="metadata" style="width:100%;display:block;background:#000"></video>' 1534 : '<img src="'+img+'" alt="'+esc(altText)+'">') 1535 + (caption ? '<figcaption>'+esc(caption)+'</figcaption>' : '') 1536 + '</figure>'; 1537 } 1538 inner += shareBarHtml(title, shareUrl, 'top'); 1539 // Inject up to 2 inline ad slots into the article body â every 2 paragraphs, max 2 ads. 1540 // Slots are <div class="ad-slot ad-inline" data-slot="in_article_N"> placeholders; 1541 // they're swapped for the real AdSense <ins> tag in the post-render pass below. 1542 inner += '<div class="prose">'+ injectInArticleAds(p.content && p.content.rendered || '') +'</div>'; 1543 inner += shareBarHtml(title, shareUrl, 'bottom'); 1544 if(tags.length){ 1545 inner += '<div class="tags">'+tags.map(function(t){ return '<a href="#/tag/'+encodeURIComponent(t.toLowerCase().replace(/\s+/g,'-'))+'">#'+esc(t)+'</a>'; }).join('')+'</div>'; 1546 } 1547 1548 if(related && related.length){ 1549 inner += '<h3 class="related-head">Related Stories</h3>' 1550 + '<div class="news-grid">' 1551 + related.slice(0,4).map(function(rp){ 1552 var ri = featuredImg(rp) || ''; 1553 return '<article class="story" data-id="'+postRef(rp)+'">' 1554 + '<div class="img">'+(ri?'<img loading="lazy" src="'+ri+'" alt="">':'')+'</div>' 1555 + catHtml(rp) 1556 + '<h3>'+postBadges(rp)+esc(stripHtml(rp.title.rendered))+'</h3>' 1557 + '<div class="meta">'+esc(fmtDate(rp.date))+'</div>' 1558 + '</article>'; 1559 }).join('') 1560 + '</div>'; 1561 } 1562 1563 // Compose final HTML with main column + sticky right rail 1564 var html = '<div class="article-main">' + inner + '</div>' + articleRailHtml(p); 1565 1566 var view = $('#post-view'); 1567 view.classList.add('has-rail'); // enables the 2-col grid layout 1568 view.innerHTML = html; 1569 1570 // Reusable helper: take an ad-slot div, drop the AdSense <ins> tag into it, 1571 // and re-execute any nested <script> so the push() actually fires 1572 // (innerHTML doesn't execute inline <script>). 1573 function activateAdSlot(slot, slotKey){ 1574 if(!slot) return; 1575 var code = adsenseInsHtml(slotKey); 1576 if(!code){ 1577 slot.style.display = 'none'; 1578 return; 1579 } 1580 // AdSense crashes with "No slot size for availableWidth=0" if we push the 1581 // <ins> tag before the parent has actually laid out. Wait for the slot to 1582 // have a real width â poll every 80ms up to 2s, give up after that. 1583 var tries = 0; 1584 function tryActivate(){ 1585 if (slot.offsetWidth >= 80) { 1586 slot.innerHTML = code; 1587 Array.prototype.forEach.call(slot.querySelectorAll('script'), function(oldS){ 1588 var newS = document.createElement('script'); 1589 for(var i=0;i<oldS.attributes.length;i++) newS.setAttribute(oldS.attributes[i].name, oldS.attributes[i].value); 1590 newS.text = oldS.textContent; 1591 oldS.parentNode.replaceChild(newS, oldS); 1592 }); 1593 slot.classList.add('loaded'); 1594 return; 1595 } 1596 if (++tries < 25) { // up to ~2s of waiting 1597 setTimeout(tryActivate, 80); 1598 } 1599 } 1600 tryActivate(); 1601 } 1602 1603 // DEFER all AdSense activation + event prefetch to AFTER the article paints. 1604 // Each AdSense push triggers Google's ad script (sync DOM + layout work) and 1605 // collectively they were adding ~200-400ms of perceived delay between the
1606 // article skeleton swap and the article being visible. Running them via 1607 // requestAnimationFrame means the browser paints the article first, then 1608 // does the ad work on the next frame â the article feels instant again, 1609 // and ads still fill within a beat. 1610 var deferAdSetup = function(){ 1611 // 1) Sticky rail AdSense slot 1612 var railSlot = view.querySelector('.ad-slot[data-slot="in_article"]'); 1613 if(railSlot) activateAdSlot(railSlot, 'in_article'); 1614 1615 // 2) Inline in-article ad slots â each has its own data-slot key so AdSense 1616 // serves a different creative to each (fixes the "same Temu ad twice" bug). 1617 Array.prototype.forEach.call(view.querySelectorAll('.ad-slot.ad-inline[data-slot^="in_article_"]'), function(slot){ 1618 activateAdSlot(slot, slot.getAttribute('data-slot')); 1619 }); 1620 }; 1621 if(window.requestAnimationFrame){ 1622 requestAnimationFrame(function(){ requestAnimationFrame(deferAdSetup); }); 1623 } else { 1624 setTimeout(deferAdSetup, 0); 1625 } 1626 // Defer DP events fetch to idle time â don't compete with the first-paint critical path. 1627 if(window.requestIdleCallback){ 1628 requestIdleCallback(function(){ prefetchEventsForRail(); }, {timeout: 2000}); 1629 } else { 1630 setTimeout(prefetchEventsForRail, 200); 1631 } 1632 // Swap any WP lazy-load placeholders back to real image URLs 1633 activateLazyImages(view); 1634 view.onclick = function(e){ 1635 // Share-bar "more" button: native share sheet on mobile, copy link on desktop 1636 var moreBtn = e.target.closest('.sb-more'); 1637 if(moreBtn){ 1638 e.preventDefault(); 1639 var sUrl = moreBtn.getAttribute('data-share-url'); 1640 var sTitle = moreBtn.getAttribute('data-share-title'); 1641 if(navigator.share){ 1642 navigator.share({title: sTitle, url: sUrl}).catch(function(){}); 1643 } else if(navigator.clipboard && navigator.clipboard.writeText){ 1644 navigator.clipboard.writeText(sUrl).then(function(){ toast('Link copied to clipboard'); }); 1645 } else { 1646 try{ window.prompt('Copy this link:', sUrl); }catch(_){} 1647 } 1648 return; 1649 } 1650 var c = e.target.closest('.story[data-id]'); 1651 if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); } 1652 }; 1653 document.title = stripHtml(p.title.rendered) + ' â CambsNews'; 1654 // Record the view for Independent Analytics + Most Read, and ping GA4 1655 recordPostView(p.id); 1656 gaPageView(); 1657 // Update Open Graph + Twitter Card meta tags for modern social crawlers. 1658 // (Most crawlers prefer pre-rendered tags but Twitter/X, LinkedIn, Discord etc. 1659 // do execute JS and re-scrape. WordPress serves the same tags pre-rendered for 1660 // crawlers that don't â see the .htaccess bot-detection rule.) 1661 updateSocialMeta({ 1662 title: stripHtml(p.title.rendered), 1663 description: stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0, 220), 1664 image: featuredImg(p, 'large') || '', 1665 url: location.href, 1666 type: 'article' 1667 }); 1668 window.scrollTo({top:0, behavior:'instant'}); 1669} 1670 1671function loadPost(idOrSlug){ 1672 $('#home-view').classList.add('hidden'); 1673 $('#post-view').classList.remove('hidden'); 1674 $('#post-view').classList.remove('is-archive'); 1675 $('#post-view').classList.remove('is-page'); 1676 $('#post-view').classList.remove('has-rail'); // cleared until renderPost re-adds it 1677 $('#post-view').innerHTML = '<a class="back" href="/">â Back to home</a>' 1678 + '<div class="sk" style="height:30px;width:120px;margin-bottom:10px"></div>' 1679 + '<div class="sk" style="height:48px;margin-bottom:12px"></div>' 1680 + '<div class="sk" style="height:18px;width:50%;margin-bottom:20px"></div>' 1681 + '<div class="sk" style="height:340px;margin-bottom:20px"></div>' 1682 + '<div class="sk" style="height:20px;margin-bottom:8px"></div>' 1683 + '<div class="sk" style="height:20px;margin-bottom:8px"></div>' 1684 + '<div class="sk" style="height:20px;margin-bottom:8px;width:80%"></div>'; 1685 1686 var cacheKey = 'cn-post-'+idOrSlug; 1687 var cached = cacheGetStale(cacheKey); 1688 1689 // If it's all digits it's an old numeric ID URL â keep working for backward compat. 1690 // Otherwise treat as a slug and use ?slug= which returns an array.
1691 var isNumeric = /^\d+$/.test(idOrSlug); 1692 var postUrl = isNumeric 1693 ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts/' + encodeURIComponent(idOrSlug) + '?_embed=1' 1694 : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?slug=' + encodeURIComponent(idOrSlug) + '&_embed=1'; 1695 1696 function loadRelatedAndRender(post){ 1697 var catId = null; 1698 try{ catId = post.categories && post.categories[0]; }catch(e){} 1699 var rUrl = catId 1700 ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts?categories='+catId+'&per_page=5&exclude='+post.id+'&_embed=1' 1701 : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=5&exclude='+post.id+'&_embed=1'; 1702 smartFetch(rUrl).then(function(rel){ renderPost(post, rel); }) 1703 .catch(function(){ renderPost(post, []); }); 1704 } 1705 1706 if(cached){ loadRelatedAndRender(cached); return; } 1707 1708 smartFetch(postUrl).then(function(result){ 1709 // ?slug= returns an array; /posts/:id returns a single object 1710 var post = Array.isArray(result) ? result[0] : result; 1711 if(post && post.id){ 1712 cacheSet(cacheKey, post); 1713 // Also cache under the slug for next time someone hits it that way 1714 if(post.slug && post.slug !== idOrSlug) cacheSet('cn-post-'+post.slug, post); 1715 loadRelatedAndRender(post); 1716 return; 1717 } 1718 // Posts API returned nothing â slug is probably a Page (Contact, Support Us, About, 1719 // Privacy, etc.), not an Article. Delegate to loadPage which queries /wp/v2/pages. 1720 if(!isNumeric){ 1721 loadPage(idOrSlug); 1722 return; 1723 } 1724 throw new Error('Story not found'); 1725 }).catch(function(err){ 1726 // Last-resort fallback: try pages even after a fetch error (in case smartFetch threw on 404). 1727 if(!isNumeric){
1728 try { loadPage(idOrSlug); return; } catch(_){} 1729 } 1730 $('#post-view').innerHTML = '<a class="back" href="/">â Back to home</a>' 1731 + '<div class="error-banner">Could not load "'+esc(idOrSlug)+'": '+esc(err.message)+'</div>'; 1732 }); 1733} 1734 1735/* ============================================================ 1736 * Router â PATHNAME-BASED (History API) 1737 * 1738 * WordPress-style URLs handled natively: 1739 * / â home 1740 * /SLUG/ â tries WP posts API first, falls back to pages API 1741 * /category/SLUG/ â category archive 1742 * /tag/SLUG/ â tag archive 1743 * /author/SLUG/ â author archive 1744 * /?s=QUERY â search 1745 * 1746 * Backward compat: any old #/post/slug, #/cat/slug etc. style URL (from 1747 * Google's index or bookmarks) is converted to its pathname equivalent 1748 * via history.replaceState on initial load. 1749 * ============================================================ */ 1750 1751/** Map a URL pathname (e.g. "/category/news/") to a route descriptor. */ 1752function pathToRoute(pathname){ 1753 // Strip leading/trailing slashes; treat empty path as home 1754 var p = (pathname || '/').replace(/^\/+|\/+$/g, ''); 1755 if(!p) return { type:'home' }; 1756 1757 // SPA file itself â if someone hits /cnh.html (or index.html / index.php) directly, 1758 // render the home view, not a 404 attempt to look up a story called "cnh.html". 1759 if(p === 'cnh.html' || p === 'index.html' || p === 'index.php') return { type:'home' }; 1760 1761 // Any other .html/.php file path is also not a content slug. 1762 if(/\.(html?|php)$/i.test(p)) return { type:'home' }; 1763 1764 var parts = p.split('/'); 1765 1766 if(parts[0] === 'category' && parts[1]) return { type:'cat', slug: parts[1] }; 1767 if(parts[0] === 'tag' && parts[1]) return { type:'tag', slug: parts[1] }; 1768 if(parts[0] === 'author' && parts[1]) return { type:'author', slug: parts[1] }; 1769 if(parts[0] === 'page' && parts[1]) return { type:'home', page: parseInt(parts[1],10) }; // /page/2/ paginated home 1770 if(parts[0] === 'whats-on' || parts[0] === 'events') return { type:'whats-on' }; 1771 if(parts[0] === 'send-a-tip' || parts[0] === 'tip' || parts[0] === 'contact') return { type:'send-tip' }; 1772 1773 // WordPress permalink structure: /news/%postname%/ â the canonical URL for every 1774 // CambsNews story Google has ever indexed. The slug is the last non-numeric part. 1775 // (Preview URLs look like /news/POST_ID/POST_ID/ â those won't reach here because 1776 // the .htaccess preview-skip routes them to WordPress for auth.) 1777 if(parts[0] === 'news' && parts.length >= 2){ 1778 // Walk backwards from the last segment, skip any purely-numeric ID segments, 1779 // and take the first textual segment as the slug. Falls back to last segment. 1780 for(var k = parts.length - 1; k >= 1; k--){ 1781 if(parts[k] && !/^\d+$/.test(parts[k])) return { type:'post-or-page', slug: parts[k] }; 1782 } 1783 return { type:'post-or-page', slug: parts[parts.length - 1] }; 1784 } 1785 1786 // Ignore feed/atom/etc â let WP handle those (won't reach this code anyway, .htaccess routes them around) 1787 // Anything else: treat the first segment as a post-or-page slug. 1788 return { type:'post-or-page', slug: parts[0] }; 1789} 1790 1791/** Convert a legacy hash route (#/post/slug) to a clean pathname (/slug/). */ 1792function hashToPath(hash){ 1793 var m; 1794 if((m = hash.match(/^#\/post\/([^/?#]+)/))) return '/' + decodeURIComponent(m[1]) + '/'; 1795 if((m = hash.match(/^#\/page\/([^/?#]+)/))) return '/' + decodeURIComponent(m[1]) + '/'; 1796 if((m = hash.match(/^#\/cat\/([^/?#]+)/))) return '/category/' + decodeURIComponent(m[1]) + '/'; 1797 if((m = hash.match(/^#\/tag\/([^/?#]+)/))) return '/tag/' + decodeURIComponent(m[1]) + '/'; 1798 if((m = hash.match(/^#\/search\/(.+)/))) return '/?s=' + encodeURIComponent(decodeURIComponent(m[1])); 1799 if(hash === '#/' || hash === '#') return '/'; 1800 return null; 1801} 1802 1803/** SPA-style navigation: pushState + run router. */ 1804function navigate(path){ 1805 var here = location.pathname + location.search; 1806 if(here !== path){ 1807 history.pushState({}, '', path); 1808 } 1809 router(); 1810} 1811 1812/** Backward-compat helper kept under the old name so any legacy callers still work. */ 1813function go(hash){ 1814 var path = (hash && hash.charAt(0) === '#') ? hashToPath(hash) : hash; 1815 if(path) navigate(path); 1816 else router(); 1817} 1818 1819/* Logo click â always return to the top of the home page (even if already home). */ 1820document.addEventListener('click', function(e){ 1821 var brand = e.target.closest && e.target.closest('.brand'); 1822 if(!brand) return; 1823 e.preventDefault(); 1824 e.stopPropagation(); // beat the generic SPA link handler 1825 if(location.pathname !== '/' || location.search) navigate('/'); 1826 window.scrollTo({top:0, behavior:'smooth'}); 1827}, true); // capture phase, so this runs first 1828 1829function router(){ 1830 // Search via ?s=query 1831 var qs = new URLSearchParams(location.search); 1832 var s = qs.get('s'); 1833 if(s){ loadSearch(s); return; } 1834 1835 var route = pathToRoute(location.pathname); 1836 1837 if(route.type === 'cat') { loadArchive('category', route.
1837slug); return; } 1838 if(route.type === 'tag') { loadArchive('tag', route.slug); return; } 1839 if(route.type === 'author') { loadAuthor(route.slug); return; } 1840 if(route.type === 'whats-on') { loadWhatsOn(); return; } 1841 if(route.type === 'send-tip') { loadSendTip(); return; } 1842 if(route.type === 'post-or-page') { loadPostOrPage(route.slug); return; } 1843 1844 // Default: home 1845 $('#post-view').classList.add('hidden'); 1846 $('#home-view').classList.remove('hidden'); 1847 document.title = 'CambsNews â News for Peterborough and Cambridgeshire'; 1848} 1849 1850/** 1851 * Single-slug URLs like /article-slug/ could be a post OR a page in WordPress. 1852 * Try posts API first (newest article fast-path), fall back to pages. 1853 */ 1854function loadPostOrPage(slug){ 1855 // loadPost already handles both API attempts under the hood â reuse it. 1856 // (It tries posts?slug= first; if that returns nothing it tries pages?slug=.) 1857 loadPost(slug); 1858} 1859 1860/** What's On â pulls upcoming events from Discover Peterborough's Events Calendar API. */ 1861function loadWhatsOn(){ 1862 $('#home-view').classList.add('hidden'); 1863 $('#post-view').classList.remove('hidden'); 1864 $('#post-view').classList.add('is-archive'); 1865 $('#post-view').classList.remove('has-rail'); 1866 var view = $('#post-view'); 1867 1868 // Inject scoped styles for the events layout. Defined inline so they ship 1869 // with this view only and don't pollute the global stylesheet. 1870 var styles = '' 1871 + '<style>' 1872 + '.wo-intro{font-size:15px;color:#444;line-height:1.6;margin:8px 0 22px;max-width:780px}' 1873 + '.wo-filters{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 22px;align-items:center}' 1874 + '.wo-chip{background:#fff;border:1px solid #d0d0d0;padding:6px 14px;border-radius:999px;font-size:13px;font-weight:600;color:#1a3a40;cursor:pointer;transition:all .15s ease;font-family:Helvetica,Arial,sans-serif}' 1875 + '.wo-chip:hover{border-color:var(--discover);color:var(--discover)}' 1876 + '.wo-chip.active{background:var(--discover);color:#fff;border-color:var(--discover)}' 1877 + '.wo-reset{background:transparent;border:0;padding:6px 8px;cursor:pointer;font-size:13px;font-weight:600;color:var(--discover);display:inline-flex;align-items:center;gap:4px}' 1878 + '.wo-month{font-family:Helvetica,Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#1a3a40;padding:18px 0 8px;border-bottom:2px solid #eee;margin-top:18px}' 1879 + '.wo-month:first-of-type{margin-top:0}' 1880 + '.wo-event{display:grid;grid-template-columns:100px 260px 1fr;gap:22px;padding:22px 0;border-bottom:1px solid #eee;align-items:start}' 1881 + '.wo-date{background:var(--discover);color:#fff;border-radius:6px;padding:14px 8px;text-align:center;font-family:Helvetica,Arial,sans-serif}' 1882 + '.wo-date .day{display:block;font-size:30px;font-weight:800;line-height:1}' 1883 + '.wo-date .mon{display:block;font-size:13px;font-weight:600;margin-top:4px;letter-spacing:.04em}' 1884 + '.wo-img{width:260px;height:170px;border-radius:6px;overflow:hidden;background:#f0f0f0;display:flex;align-items:center;justify-content:center}' 1885 + '.wo-img img{width:100%;height:100%;object-fit:cover}' 1886 + '.wo-img.no-image{background:linear-gradient(135deg,#1f6f4a,#0d4a26);color:rgba(255,255,255,.5);font-family:var(--serif);font-size:42px;font-weight:900}' 1887 + '.wo-info h3{font-family:var(--serif);font-size:22px;color:#1a3a40;margin:0 0 12px;line-height:1.25}' 1888 + '.wo-meta{font-family:Helvetica,Arial,sans-serif;font-size:13px;color:#333;line-height:1.7}' 1889 + '.wo-meta .row{display:flex;gap:6px;align-items:baseline;margin-bottom:3px}' 1890 + '.wo-meta .ico{color:var(--discover);min-width:18px;font-size:14px}' 1891 + '.wo-meta strong{font-weight:700;color:#1a3a40}' 1892 + '.wo-cta{display:inline-block;background:var(--red);color:#fff;padding:9px 22px;border-radius:5px;text-decoration:none;font-weight:700;font-size:13px;margin-top:14px;font-family:Helvetica,Arial,sans-serif;transition:background .15s}' 1893 + '.wo-cta:hover{background:#8f1212;color:#fff}' 1894 + '@media(max-width:760px){' 1895 + '.wo-event{grid-template-columns:80px 1fr;gap:14px}' 1896 + '.wo-img{display:none}' /* image hidden on mobile to keep rows scannable; tap title for full event */ 1897 + '.wo-date .day{font-size:24px}' 1898 + '.wo-date .mon{font-size:11px}' 1899 + '.wo-info h3{font-size:17px;margin-bottom:8px}' 1900 + '.wo-meta{font-size:12px}' 1901 + '}' 1902 + '</style>'; 1903 1904 view.innerHTML = styles 1905 + '<a class="back" href="/">â Back to home</a>' 1906 + '<h1 style="font-family:var(--serif);font-size:38px;margin:14px 0 4px;
1906color:#1a3a40">Peterborough Events</h1>' 1907 + '<p class="wo-intro">Peterborough\'s events calendar is packed with experiences throughout the year â from major festivals and live performances to exhibitions, family activities, heritage tours, markets and community celebrations. Listings updated live from <a href="https://discoverpeterborough.co.uk/events/" target="_blank" rel="noopener" style="color:var(--discover);font-weight:600">Discover Peterborough</a>.</p>' 1908 + '<div class="wo-filters" id="wo-filters"></div>' 1909 + '<div id="whats-on-list">' 1910 + '<div class="sk" style="height:170px;margin-bottom:14px"></div>' 1911 + '<div class="sk" style="height:170px;margin-bottom:14px"></div>' 1912 + '<div class="sk" style="height:170px"></div>' 1913 + '</div>'; 1914 document.title = "Peterborough Events â CambsNews"; 1915 window.scrollTo({top:0, behavior:'instant'}); 1916 1917 // ---------- Helpers ---------- 1918 function eventImg(ev){ 1919 if(!ev.image) return ''; 1920 if(typeof ev.image === 'string') return ev.image; 1921 if(ev.image.sizes){ 1922 var s = ev.image.sizes; 1923 var pick = s.large || s.medium_large || s.medium || s.full; 1924 if(pick) return (typeof pick === 'string') ? pick : (pick.url || ''); 1925 } 1926 return ev.image.url || ''; 1927 } 1928 var MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; 1929 function parseDate(s){ if(!s) return null; var d = new Date(s.replace(' ','T')); return isNaN(d) ? null : d; } 1930 function fmtShort(d){ return d ? (MONTHS[d.getMonth()].slice(0,3) + ' ' + d.getDate() + ', ' + d.getFullYear()) : ''; } 1931 function fmtTime(d){ 1932 if(!d) return ''; 1933 var h = d.getHours(), m = d.getMinutes(); 1934 var ampm = h >= 12 ? 'pm' : 'am'; 1935 var hh = h % 12; if(hh === 0) hh = 12; 1936 return hh + (m ? ':' + (m < 10 ? '0' : '') + m : '') + ampm; 1937 } 1938 function dateRange(ev){ 1939 var s = parseDate(ev.start_date), e = parseDate(ev.end_date); 1940 if(!s) return '';
1941 if(!e || e.toDateString() === s.toDateString()) return fmtShort(s); 1942 return fmtShort(s) + ' â ' + fmtShort(e); 1943 } 1944 function timeText(ev){ 1945 if(ev.all_day) return 'All Day Event'; 1946 var s = parseDate(ev.start_date), e = parseDate(ev.end_date); 1947 if(!s) return ''; 1948 if(!e || e.getTime() === s.getTime()) return fmtTime(s); 1949 return fmtTime(s) + ' â ' + fmtTime(e); 1950 } 1951 function categoryNames(ev){ 1952 if(!ev.categories || !ev.categories.length) return []; 1953 return ev.categories.map(function(c){ return c.name || ''; }).filter(Boolean); 1954 } 1955 function organiserName(ev){ 1956 if(ev.organizer && ev.organizer.length) return ev.organizer.map(function(o){ return o.organizer || ''; }).filter(Boolean).join(', '); 1957 if(ev.organiser && ev.organiser.length) return ev.organiser.map(function(o){ return o.organiser || o.organizer || ''; }).filter(Boolean).join(', '); 1958 return ''; 1959 } 1960 1961 // ---------- Cache + fetch ---------- 1962 var cacheKey = 'cn-whats-on-v2'; 1963 var cached = (typeof cacheGetStale === 'function') ? cacheGetStale(cacheKey) : null; 1964 var activeCategory = null; 1965 var lastEvents = []; 1966 1967 if(cached && cached.events){ lastEvents = cached.events; render(); } 1968 1969 fetch(CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=100') 1970 .then(function(r){ return r.json(); }) 1971 .then(function(data){ 1972 lastEvents = (data && data.events) || [];
1973 try { localStorage.setItem(cacheKey, JSON.stringify({t:Date.now(), v:{events:lastEvents}})); } catch(_){} 1974 render(); 1975 }) 1976 .catch(function(){ 1977 if(!lastEvents.length) $('#whats-on-list').innerHTML = '<div class="empty">Could not reach Discover Peterborough\'s events feed. Try refreshing in a moment.</div>'; 1978 }); 1979 1980 // ---------- Render ---------- 1981 function render(){ 1982 var box = $('#whats-on-list'); 1983 var filtersBox = $('#wo-filters'); 1984 if(!box) return; 1985 if(!lastEvents.length){ 1986 box.innerHTML = '<div class="empty">No upcoming events listed at the moment.</div>'; 1987 return; 1988 } 1989 1990 // Build category filter chips from the actual data 1991 var allCats = {}; 1992 lastEvents.forEach(function(ev){ 1993 categoryNames(ev).forEach(function(c){ allCats[c] = (allCats[c] || 0) + 1; }); 1994 }); 1995 var catList = Object.keys(allCats).sort(); 1996 filtersBox.innerHTML = catList.map(function(c){ 1997 var on = c === activeCategory ? ' active' : ''; 1998 return '<button type="button" class="wo-chip'+on+'" data-cat="'+esc(c)+'">'+esc(c)+'</button>'; 1999 }).join('') + (activeCategory ? '<button type="button" class="wo-reset" id="wo-reset">
1999â» Reset</button>' : ''); 2000 2001 // Wire chip clicks (once â relying on innerHTML re-render to refresh) 2002 Array.prototype.forEach.call(filtersBox.querySelectorAll('.wo-chip'), function(btn){ 2003 btn.addEventListener('click', function(){ 2004 var c = btn.getAttribute('data-cat'); 2005 activeCategory = (activeCategory === c) ? null : c; 2006 render(); 2007 }); 2008 }); 2009 var resetBtn = filtersBox.querySelector('#wo-reset'); 2010 if(resetBtn) resetBtn.addEventListener('click', function(){ activeCategory = null; render(); }); 2011 2012 // Apply filter 2013 var events = activeCategory 2014 ? lastEvents.filter(function(ev){ return categoryNames(ev).indexOf(activeCategory) !== -1; }) 2015 : lastEvents.slice(); 2016 2017 // Sort by start date ascending 2018 events.sort(function(a,b){ 2019 var da = parseDate(a.start_date), db = parseDate(b.start_date); 2020 return (da ? da.getTime() : 0) - (db ? db.getTime() : 0); 2021 }); 2022 2023 if(!events.length){ 2024 box.innerHTML = '<div class="empty">No events found in this category.</div>'; 2025 return; 2026 } 2027 2028 // Group by month-year of start date 2029 var groups = []; 2030 var currentMonthKey = null; 2031 events.forEach(function(ev){ 2032 var d = parseDate(ev.start_date) || new Date(); 2033 var key = d.getFullYear() + '-' + d.getMonth();
2034 if(key !== currentMonthKey){ 2035 groups.push({ label: MONTHS[d.getMonth()] + ' ' + d.getFullYear(), items: [] }); 2036 currentMonthKey = key; 2037 } 2038 groups[groups.length - 1].items.push(ev); 2039 }); 2040 2041 box.innerHTML = groups.map(function(g){ 2042 return '<div class="wo-month">' + esc(g.label) + '</div>' + 2043 g.items.map(renderEvent).join(''); 2044 }).join('') 2045 + '<p style="text-align:center;margin:36px 0 20px;color:#666;font-size:13px">For the full events listing visit <a href="https://discoverpeterborough.co.uk/events/" target="_blank" rel="noopener" style="color:var(--discover);font-weight:600">Discover Peterborough â</a></p>'; 2046 } 2047 2048 function renderEvent(ev){ 2049 var sd = parseDate(ev.start_date); 2050 var day = sd ? sd.getDate() : ''; 2051 var mon = sd ? MONTHS[sd.getMonth()] : ''; 2052 var img = eventImg(ev); 2053 var url = ev.url || ev.website || '#'; 2054 var venue = (ev.venue && ev.venue.venue) ? ev.venue.venue : ''; 2055 var org = organiserName(ev); 2056 var cats = categoryNames(ev).join(', '); 2057 var dRange = dateRange(ev); 2058 var tText = timeText(ev); 2059 2060 var imgEl = img 2061 ? '<div class="wo-img"><img loading="lazy" src="'+esc(img)+'" alt=""></div>' 2062 : '<div class="wo-img no-image">DP</div>'; 2063 2064 return '' 2065 + '<article class="wo-event">' 2066 + '<div class="wo-date"><span class="day">'+esc(String(day))+'</span><span class="mon">'+esc(mon)+'</span></div>' 2067 + imgEl 2068 + '<div class="wo-info">' 2069 + '<h3>'+esc(stripHtml(ev.title || ''))+'</h3>' 2070 + '<div class="wo-meta">' 2071 + (dRange ? '<div class="row"><span class="ico">ð </span><span><strong>Date</strong> '+esc(dRange)+'</span></div>' : '') 2072 + (tText ? '<div class="row"><span class="ico">ð</span><span><strong>Time</strong> '+esc(tText)+'</span></div>' : '') 2073 + (venue ? '<div class="row"><span class="ico">ð</span><span><strong>Venue</strong> '+esc(venue)+'</span></div>' : '') 2074 + (org ? '<div class="row"><span class="ico">ð¤</span><span><strong>Organiser</strong> '+esc(org)+'</span></div>' : '') 2075 + (cats ? '<div class="row"><span class="ico">ð·ï¸</span><span><strong>Category</strong> '+esc(cats)+'</span></div>' : '') 2076 + '</div>' 2077 + '<a class="wo-cta" href="'+esc(url)+'" target="_blank" rel="noopener">More Info</a>' 2078 + '</div>' 2079 + '</article>'; 2080 } 2081} 2082 2083/** Send a Tip â in-design contact form. Replaces the broken /contact/ page. 2084 * POSTs to /wp-json/cn/v1/tip (provided by cn-tip.php mu-plugin) which emails 2085 * [email protected] and archives the tip as a CPT for admin reference. */ 2086function loadSendTip(){ 2087 $('#home-view').classList.add('hidden'); 2088 $('#post-view').classList.remove('hidden'); 2089 $('#post-view').classList.add('is-page'); 2090 $('#post-view').classList.remove('is-archive'); 2091 $('#post-view').classList.remove('has-rail'); 2092 document.title = "Send us a story â CambsNews"; 2093 window.scrollTo({top:0, behavior:'instant'}); 2094 2095 var view = $('#post-view'); 2096 view.innerHTML = '' 2097 + '<style>' 2098 + '.tip-wrap{max-width:680px;margin:0 auto}' 2099 + '.tip-intro{font-size:16px;line-height:1.6;color:#333;margin:10px 0 26px}' 2100 + '.tip-form{background:#fff;border:1px solid #e6e6e6;border-radius:8px;padding:26px;box-shadow:0 1px 4px rgba(0,0,0,.03)}' 2101 + '.tip-row{display:block;margin-bottom:18px}' 2102 + '.tip-row label{display:block;font-weight:700;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:#1a1a1a;margin-bottom:6px}' 2103 + '.tip-row label .opt{font-weight:400;text-transform:none;letter-spacing:0;color:#888;font-size:12px;margin-left:6px}' 2104 + '.tip-row input,.tip-row textarea{width:100%;padding:11px 14px;border:1px solid #d0d0d0;border-radius:4px;font-size:15px;font-family:inherit;color:#1a1a1a;background:#fafafa;box-sizing:border-box;transition:border-color .15s,background .15s}' 2105 + '.tip-row input:focus,.tip-row textarea:focus{outline:0;border-color:var(--red);background:#fff}' 2106 + '.tip-row textarea{min-height:140px;resize:vertical;line-height:1.5}' 2107 + '.tip-row .hint{font-size:12px;color:#888;margin-top:4px}' 2108 + '.tip-honeypot{position:absolute;
2108left:-10000px;width:1px;height:1px;overflow:hidden}' /* hidden honey-pot */ 2109 + '.tip-submit{display:inline-flex;align-items:center;gap:8px;background:var(--red);color:#fff;border:0;padding:13px 28px;border-radius:5px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit;letter-spacing:.02em;transition:background .15s}' 2110 + '.tip-submit:hover{background:#8f1212}' 2111 + '.tip-submit:disabled{background:#888;cursor:not-allowed}' 2112 + '.tip-msg{margin-top:16px;padding:12px 14px;border-radius:4px;font-size:14px;line-height:1.5}' 2113 + '.tip-msg.ok{background:#e8f5ee;color:#0d4a26;border:1px solid #1f6f4a}' 2114 + '.tip-msg.err{background:#fdecec;color:#c62828;border:1px solid #c62828}' 2115 + '.tip-side{background:#f7f7f7;border-left:4px solid var(--red);padding:14px 18px;border-radius:4px;margin-top:30px;font-size:14px;line-height:1.55;color:#333}' 2116 + '.tip-side strong{display:block;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--red);margin-bottom:4px}' 2117 + '</style>' 2118 + '<a class="back" href="/">â Back to home</a>' 2119 + '<div class="tip-wrap">' 2120 + '<h1 class="page-title" style="margin:14px 0 8px">Send us a story</h1>' 2121 + '<p class="tip-intro">Got something we should know about? A news tip, a community story, a council document, a photo from the scene? Send it here. We read every message. We treat sources in confidence by default â if you want to talk off the record, just say so.</p>' 2122 + '<form class="tip-form" id="cn-tip-form" autocomplete="on" novalidate>' 2123 + '<div class="tip-row"><label for="tip-name">Your name</label>' 2124 + '<input id="tip-name" name="name" type="text" required autocomplete="name" placeholder="So we know who we\'re talking to"></div>' 2125 + '<div class="tip-row"><label for="tip-email">Email address</label>' 2126 + '<input id="tip-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]">' 2127 + '<div class="hint">We\'ll reply to this address. Never shared.</div></div>' 2128 + '<div class="tip-row"><label for="tip-phone">Phone <span class="opt">(optional)</span></label>' 2129 + '<input id="tip-phone" name="phone" type="tel" autocomplete="tel" placeholder="If we can call you to check details"></div>' 2130 + '<div class="tip-row"><label for="tip-story">Story / subject <span class="opt">(optional)</span></label>' 2131 + '<input id="tip-story" name="story" type="text" maxlength="120" placeholder="e.g. \'Roadworks on Bourges Boulevard\'"></div>' 2132 + '<div class="tip-row"><label for="tip-message">Your message</label>' 2133 + '<textarea id="tip-message" name="message" required minlength="20" maxlength="8000" placeholder="Tell us what happened, when, where â and how you know about it. Anything you\'d like to keep on background, just mention it."></textarea>' 2134 + '<div class="hint" id="tip-charcount">0 characters</div></div>' 2135 + '<div class="tip-honeypot" aria-hidden="true"><label>Don\'t fill this in <input name="website" tabindex="-1" autocomplete="off"></label></div>' 2136 + '<button class="tip-submit" type="submit" id="tip-submit-btn">ð¨ Send tip</button>' 2137 + '<div id="tip-msg-out"></div>' 2138 + '</form>' 2139 + '<div class="tip-side">' 2140 + '<strong>ð Confidential</strong>' 2141 + 'We protect our sources. If you want to talk strictly off-record, write that in your message and we\'ll honour it. For the most sensitive material, email us directly at <a href="mailto:[email protected]" style="color:var(--red);font-weight:700">[email protected]</a> from a personal address.' 2142 + '</div>' 2143 + '</div>'; 2144 2145 var form = $('#cn-tip-form'); 2146 var msgOut = $('#tip-msg-out'); 2147 var btn = $('#tip-submit-btn'); 2148 var msgArea = $('#tip-message'); 2149 var counter = $('#tip-charcount'); 2150 2151 // Character counter 2152 msgArea.addEventListener('input', function(){ 2153 var n = msgArea.value.length; 2154 counter.textContent = n + ' character' + (n === 1 ? '' : 's'); 2155 if(n > 7800) counter.style.color = '#c62828'; 2156 else if(n < 20) counter.style.color = '#888'; 2157 else counter.style.color = '#0d4a26'; 2158 }); 2159 2160 form.addEventListener('submit', function(e){ 2161 e.preventDefault(); 2162 msgOut.innerHTML = ''; 2163 btn.disabled = true; 2164 btn.innerHTML = 'â³ Sendingâ¦'; 2165 2166 var payload = {
2167 name: $('#tip-name').value.trim(), 2168 email: $('#tip-email').value.trim(), 2169 phone: $('#tip-phone').value.trim(), 2170 story: $('#tip-story').value.trim(), 2171 message: msgArea.value.trim(), 2172 website: form.querySelector('input[name="website"]').value // honey-pot 2173 }; 2174 2175 fetch(CONFIG.CN_HOST + '/wp-json/cn/v1/tip', { 2176 method: 'POST', 2177 headers: { 'Content-Type': 'application/json' }, 2178 body: JSON.stringify(payload) 2179 }).then(function(r){ 2180 return r.text().then(function(txt){ 2181 var j = null; 2182 try { j = JSON.parse(txt); } catch(_) { /* response wasn't JSON */ } 2183 return { status: r.status, ok: r.ok, json: j, raw: txt }; 2184 }); 2185 }).then(function(res){ 2186 btn.disabled = false; 2187 btn.innerHTML = 'ð¨ Send tip'; 2188 2189 // Happy path 2190 if(res.json && res.json.ok){ 2191 msgOut.innerHTML = '<div class="tip-msg ok">â Thanks â your message has been sent to the newsroom. We\'ll be in touch.</div>'; 2192 form.reset(); 2193 counter.textContent = '0 characters'; 2194 counter.style.color = '#888'; 2195 return; 2196 } 2197 2198 // Server responded with structured error â surface it verbatim. 2199 // Look at common WP REST error shapes: {error:'â¦'} (our plugin), 2200 // {message:'â¦'} (WP rest_no_route etc.), {code:'â¦',message:'â¦'}. 2201 var serverMsg = res.json && (res.json.error || res.json.message); 2202 2203 // Endpoint missing entirely â tell the editor exactly what to do 2204 if(res.status === 404 || (res.json && res.json.code === 'rest_no_route')){ 2205 msgOut.innerHTML = '<div class="tip-msg err">' 2206 + '<strong>â The tip endpoint isn\'t installed on WordPress.</strong><br>' 2207 + 'Upload <code>cn-tip.php</code> to <code>wp-content/mu-plugins/</code> on the server, ' 2208 + 'then try again. (Must-use plugins don\'t need activation â the route comes alive as ' 2209 + 'soon as the file exists.)' 2210 + '</div>'; 2211 return; 2212 } 2213 2214 // Generic: show what the server said + HTTP status so we can diagnose 2215 msgOut.innerHTML = '<div class="tip-msg err">â ' 2216 + esc(serverMsg || ('Server returned HTTP ' + res.status + (res.raw ? ' â ' + res.raw.slice(0,200) : ''))) 2217 + '</div>'; 2218 }).catch(function(err){ 2219 btn.disabled = false; 2220 btn.innerHTML = 'ð¨ Send tip'; 2221 msgOut.innerHTML = '<div class="tip-msg err">â Could not reach the server: ' 2222 + esc(err && err.message ? err.message : String(err)) 2223 + '. Please try again, or email <a href="mailto:[email protected]">[email protected]</a> directly.</div>'; 2224 }); 2225 }); 2226 2227 updateSocialMeta({ 2228 title: 'Send us a story', 2229 description: 'Got a news tip for CambsNews? Send it here, in confidence.', 2230 type: 'website' 2231 }); 2232} 2233 2234/** Author archive â fetch user by slug, then their posts. */ 2235function loadAuthor(slug){ 2236 $('#home-view').classList.add('hidden'); 2237 $('#post-view').classList.remove('hidden'); 2238 $('#post-view').classList.add('is-archive'); 2239 $('#post-view').classList.remove('has-rail'); 2240 var view = $('#post-view'); 2241 view.innerHTML = '<a class="back" href="/">â Back to home</a><div class="sk" style="height:42px;margin-bottom:20px;width:60%"></div>'; 2242 document.title = 'Author â CambsNews'; 2243 fetch(CONFIG.CN_HOST + '/wp-json/wp/v2/users?slug=' + encodeURIComponent(slug)) 2244 .then(function(r){ return r.json(); }) 2245 .then(function(users){ 2246 if(!users || !users.length){ view.innerHTML = '<a class="back" href="/">â Back to home</a><h1>Author not found</h1>'; return; } 2247 var u = users[0]; 2248 document.title = u.name + ' â CambsNews'; 2249 return fetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?author=' + u.id + '&per_page=20&_embed=1') 2250 .then(function(r){ return r.json(); }) 2251 .then(function(posts){ 2252 var html = '<a class="back" href="/">â Back to home</a>' 2253 + '<h1 style="font-size:32px;margin:14px 0 4px">' + esc(u.name) + '</h1>' 2254 + '<p style="color:#666;margin:0 0 24px">All stories by this author</p>'; 2255 if(!posts || !posts.length){ html += '<p>No stories found.</p>'; } 2256 else { html += '<div class="grid">' + posts.map(renderResultCard).join('') + '</div>'; } 2257 view.innerHTML = html; 2258 }); 2259 }) 2260 .catch(function(){ view.innerHTML = '<a class="back" href="/">â Back to home</a><h1>Could not load author</h1>'; }); 2261} 2262 2263/* ============================================================ 2264 * Click interceptor â handles BOTH legacy #/-style hash links 2265 * AND clean pathname links in one place, so we don't have to 2266 * rewrite every link template across the page. 2267 * ============================================================ */ 2268document.addEventListener('click', function(e){ 2269 // Respect modifier-click (open in new tab, etc.) 2270 if(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; 2271 var link = e.target.closest && e.target.closest('a'); 2272 if(!link) return; 2273 var href = link.getAttribute('href'); 2274 if(!href) return; 2275 // Explicit opt-outs 2276 if(link.target === '_blank' || link.hasAttribute('download') || link.getAttribute('rel') === 'external') return; 2277 // Pure on-page anchors (e.g. href="#footnote-1") â leave the browser to scroll 2278 if(href.charAt(0) === '#' && href.charAt(1) !== '/') return; 2279 2280 // Legacy hash route â convert to pathname and navigate 2281 if(href.indexOf('#/') === 0){ 2282 var p = hashToPath(href); 2283 if(p){ e.preventDefault(); navigate(p); } 2284 return; 2285 } 2286 2287 // Otherwise interpret as a URL relative to this document 2288 var url; 2289 try { url = new URL(href, location.origin); } catch(_) { return; } 2290 // External â let the browser handle 2291 if(url.origin !== location.origin) return; 2292 // WordPress admin / REST / uploads / login â let the browser load WP normally 2293 if(/^\/(wp-admin|wp-includes|wp-json|wp-content|wp-login\.php|xmlrpc\.php)(\/|$)/.test(url.pathname)) return; 2294 // Direct file assets (anything with an extension other than html) â let browser handle 2295 if(/\.[a-z0-9]+$/i.test(url.pathname) && !/\.html?$/i.test(url.pathname)) return; 2296 // Intercept â SPA navigation 2297 e.preventDefault(); 2298 navigate(url.pathname + url.search); 2299}); 2300 2301/* ============================================================ 2302 * STATIC PAGES â fetch WordPress pages (not posts) into the SPA 2303 * Used for Support Us, About, Contact, Advertise, Privacy etc. 2304 * ============================================================ */ 2305function loadPage(slug){ 2306 $('#home-view').classList.add('hidden'); 2307 $('#post-view').classList.remove('hidden'); 2308 var view = $('#post-view'); 2309 view.innerHTML = '<a class="back" href="#/">â Back to home</a>' 2310 + '<div class="sk" style="height:42px;margin-bottom:20px;width:60%"></div>' 2311 + '<div class="sk" style="height:20px;margin-bottom:8px"></div>' 2312 + '<div class="sk" style="height:20px;margin-bottom:8px"></div>' 2313 + '<div class="sk" style="height:20px;margin-bottom:8px;width:80%"></div>'; 2314 document.title = 'Loading⦠â CambsNews'; 2315 window.scrollTo({top:0, behavior:'instant'}); 2316 2317 var cacheKey = 'cn-page-'+slug;
2318 var cached = cacheGetStale(cacheKey); 2319 if(cached) renderPage(cached); 2320 2321 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/pages?slug=' + encodeURIComponent(slug) + '&_embed=1'; 2322 smartFetch(url).then(function(arr){ 2323 var page = Array.isArray(arr) ? arr[0] : arr; 2324 if(!page || !page.id){ throw new Error('Page not found'); } 2325 cacheSet(cacheKey, page); 2326 renderPage(page); 2327 }).catch(function(err){ 2328 if(!cached){ 2329 view.innerHTML = '<a class="back" href="#/">â Back to home</a>' 2330 + '<div class="error-banner">Couldn\'t load this page: '+esc(err.message)+'</div>'; 2331 } 2332 }); 2333} 2334/* Re-execute scripts that arrived via innerHTML. 2335 * - External (with src): re-create, then wait for onload 2336 * - Inline: run AFTER all externals have loaded (so SDK is defined first) 2337 * Critical for embedded PayPal donate buttons, Twitter embeds, oEmbeds etc. */ 2338function executeScripts(scope){ 2339 if(!scope) return Promise.resolve(); 2340 var oldScripts = Array.prototype.slice.call(scope.querySelectorAll('script')); 2341 var inlineScripts = []; 2342 var loadPromises = []; 2343 2344 oldScripts.forEach(function(oldS){ 2345 if(oldS.src){ 2346 // External â re-create with same attrs and track its load 2347 var newS = document.createElement('script'); 2348 for(var i = 0; i < oldS.attributes.length; i++){ 2349 newS.setAttribute(oldS.attributes[i].name, oldS.attributes[i].value); 2350 } 2351 var p = new Promise(function(resolve){ 2352 newS.onload = resolve; 2353 newS.onerror = resolve; // don't hang the chain on failed externals 2354 }); 2355 loadPromises.push(p); 2356 if(oldS.parentNode) oldS.parentNode.replaceChild(newS, oldS); 2357 } else if((oldS.textContent || '').trim()){ 2358 // Keep inline scripts aside to run last 2359 inlineScripts.push(oldS); 2360 } 2361 }); 2362 2363 return Promise.all(loadPromises).then(function(){ 2364 inlineScripts.forEach(function(oldS){ 2365 if(!oldS.parentNode) return; 2366 var newS = document.createElement('script'); 2367 // copy attributes if any (e.g. type="application/ld+json" â but we wouldn't execute those) 2368 var typeAttr = oldS.getAttribute('type'); 2369 if(typeAttr && typeAttr.indexOf('javascript') === -1 && typeAttr !== '') return; 2370 newS.text = oldS.textContent; 2371 oldS.parentNode.replaceChild(newS, oldS); 2372 }); 2373 }); 2374} 2375 2376function renderPage(p){ 2377 var view = $('#post-view'); 2378 view.classList.add('is-page'); 2379 view.classList.remove('has-rail'); 2380 var title = stripHtml(p.title && p.title.rendered || ''); 2381 var img = featuredImg(p, 'large'); 2382 var content = (p.content && p.content.rendered) || ''; 2383 var html = '<a class="back" href="#/">â Back to home</a>' 2384 + '<h1 class="page-title">'+esc(title)+'</h1>'; 2385 if(img) { 2386 // Read caption + alt text from the embedded featured-media object. 2387 // WordPress stores image captions on the media library item itself; they 2388 // arrive via the REST API's _embed=1 query parameter as wp:featuredmedia. 2389 var media = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0]; 2390 var capRaw = (media && media.caption && media.caption.rendered) || ''; 2391 var caption = stripHtml(capRaw).trim(); 2392 // Some sites use the media "description" field for credit lines instead of caption 2393 if(!caption && media && media.description && media.description.rendered){ 2394 caption = stripHtml(media.description.rendered).trim(); 2395 } 2396 var altText = (media && media.alt_text) || title || ''; 2397 html += '<figure class="hero-img">' 2398 + '<img src="'+img+'" alt="'+esc(altText)+'">' 2399 + (caption ? '<figcaption>'+esc(caption)+'</figcaption>' : '') 2400 + '</figure>'; 2401 } 2402 html += '<div class="prose page-prose">'+content+'</div>'; 2403 2404 // Safety-net donate button on the Support page IF the WP page content doesn't already include payment markup. 2405 // CambsNews PayPal hosted_button_id: C5FFWUAUUMACA (live, verified working) 2406 if(/support/i.test(p.slug || '') && !/paypal|stripe|donate|hosted_button/i.test(content)){ 2407 html += '<div id="donate-fallback" style="margin:30px 0;text-align:center">' 2408 + '<div id="donate-button"></div>' 2409 + '<p style="margin-top:10px;color:var(--muted);font-size:13px">Secure donation via PayPal â no PayPal account required.</p>' 2410 + '</div>'; 2411 } 2412 2413 view.innerHTML = html;
2414 activateLazyImages(view); 2415 document.title = title + ' â CambsNews'; 2416 2417 // Run any embedded scripts (PayPal SDK, Twitter, etc) in the WP page content 2418 executeScripts(view).then(function(){ 2419 // If the WP page didn't render the PayPal button itself but our fallback container exists, 2420 // render the official PayPal button into it using the SDK we know is now loaded 2421 var fallback = document.getElementById('donate-fallback'); 2422 if(fallback && window.PayPal && window.PayPal.Donation){ 2423 try{ 2424 PayPal.Donation.Button({ 2425 env: 'production', 2426 hosted_button_id: 'C5FFWUAUUMACA', 2427 image: { 2428 src: 'https://www.cambsnews.co.uk/wp-content/uploads/2023/11/donate-button.png', 2429 alt: 'Donate with PayPal button', 2430 title: 'PayPal â The safer, easier way to pay online' 2431 } 2432 }).render('#donate-button'); 2433 }catch(_){} 2434 } else if(fallback){ 2435 // SDK didn't load yet â pull it in explicitly then render 2436 var s = document.createElement('script'); 2437 s.src = 'https://www.paypalobjects.com/donate/sdk/donate-sdk.js'; 2438 s.onload = function(){ 2439 try{ 2440 PayPal.Donation.Button({ 2441 env: 'production', 2442 hosted_button_id: 'T5RBXDB99ZRFE', 2443 image: { 2444 src: 'https://www.cambsnews.co.uk/wp-content/uploads/2023/11/donate-button.png', 2445 alt: 'Donate with PayPal button', 2446 title: 'PayPal â The safer, easier way to pay online' 2447 } 2448 }).render('#donate-button'); 2449 }catch(_){} 2450 }; 2451 document.head.appendChild(s); 2452 } 2453 }); 2454} 2455// Reset .is-page and .is-archive classes when navigating between view types 2456// (post â archive â post should swap layouts properly each time). 2457window.addEventListener('popstate', function(){ 2458 var view = $('#post-view'); 2459 if(view){ view.classList.remove('is-page'); view.classList.remove('is-archive'); } 2460}); 2461window.addEventListener('popstate', router); 2462 2463// Initial-load hash migration: if someone arrived via an old #/post/slug bookmark 2464// or Google-indexed hash URL, rewrite the URL to the clean pathname equivalent 2465// BEFORE the router runs. This is a single-shot history.replaceState â no extra 2466// network request, no flicker, and the browser bar shows the proper URL forever. 2467(function(){ 2468 if(!location.hash || location.hash.indexOf('#/') !== 0) return; 2469 var newPath = hashToPath(location.hash); 2470 if(!newPath) return; 2471 try { history.replaceState({}, '', newPath); } catch(_) {} 2472})(); 2473 2474/* ============================================================ 2475 * SEARCH â renders WP REST search results inside the design 2476 * ============================================================ */ 2477function renderResultCard(p){ 2478 var img = featuredImg(p) || ''; 2479 var excerpt = stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0,160); 2480 return '<article class="story" data-id="'+postRef(p)+'">' 2481 + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>' 2482 + catHtml(p) 2483 + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>' 2484 + '<div class="meta">'+esc(fmtDate(p.date))+(authorHtml(p)?' · '+authorHtml(p):'')+'</div>' 2485 + (excerpt?'<p>'+esc(excerpt)+'â¦</p>':'') 2486 + '</article>'; 2487} 2488/* smartFetch variant that also returns total + page count from X-WP-Total headers */ 2489function fetchWithTotals(url){ 2490 return fetch(url, {mode:'cors', credentials:'omit'}).then(function(r){ 2491 var total = parseInt(r.headers.get('X-WP-Total') || '0', 10); 2492 var pages = parseInt(r.headers.get('X-WP-TotalPages') || '1', 10); 2493 return r.json().then(function(json){ return {data: json, total: total, pages: pages}; }); 2494 }).catch(function(){ 2495 // CORS proxy fallback (won't expose headers; we just return data and unknown totals) 2496 return fetch(CONFIG.CORS_PROXY + encodeURIComponent(url)) 2497 .then(function(r){ return r.json(); }) 2498 .then(function(json){ return {data: json, total: 0, pages: 1}; }); 2499 }); 2500} 2501 2502function loadSearch(q){ 2503 $('#home-view').classList.add('hidden'); 2504 $('#post-view').classList.remove('hidden'); 2505 $('#post-view').classList.add('is-archive'); 2506 $('#post-view').classList.remove('has-rail'); 2507 var view = $('#post-view'); 2508 view.innerHTML = '<a class="back" href="/">â Back to home</a>' 2509 + '<h1 style="font-family:var(--serif);font-size:28px;margin:8px 0 4px">Search results</h1>' 2510 + '<p style="color:var(--muted);margin-bottom:18px">Showing results for <strong>"'+esc(q)+'"</strong></p>' 2511 + '<div class="news-grid" id="search-grid">' 2512 + '<div class="sk sk-card"></div><div class="sk sk-card"></div>' 2513 + '<div class="sk sk-card"></div><div class="sk sk-card"></div>' 2514 + '</div>' 2515 + '<div class="load-more" id="search-more"></div>'; 2516 document.title = 'Search: ' + q + ' â CambsNews'; 2517 window.scrollTo({top:0, behavior:'instant'}); 2518 2519 var page = 1, totalPages = 1, totalCount = 0; 2520 function load(){ 2521 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?search=' + encodeURIComponent(q) + '&per_page=12&page=' + page + '&_embed=1'; 2522 return fetchWithTotals(url).then(function(res){ 2523 var posts = res.data || []; 2524 var grid = $('#search-grid'); 2525 if(!grid) return; 2526 if(page === 1 && !posts.length){ 2527 grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories found for "'+esc(q)+'". Try a different keyword.</div>'; 2528 $('#search-more').innerHTML = ''; 2529 return; 2530 } 2531 if(page === 1) grid.innerHTML = ''; 2532 grid.insertAdjacentHTML('beforeend', posts.map(renderResultCard).join('')); 2533 totalPages = res.pages || totalPages; 2534 totalCount = res.total || (page * posts.length); 2535 renderMoreButton('search-more', page, totalPages, totalCount, function(){ page++; load(); }); 2536 }); 2537 } 2538 load(); 2539 $('#search-grid').onclick = function(e){ 2540 var c = e.target.closest('.story[data-id]'); 2541 if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); } 2542 }; 2543} 2544 2545/* Render or update the Load More button + count */ 2546function renderMoreButton(containerId, page, totalPages, totalCount, onClick){ 2547 var c = document.getElementById(containerId); 2548 if(!c) return; 2549 if(page >= totalPages){ 2550 c.innerHTML = totalCount ? '<div class="count">Showing all '+totalCount+' stories</div>' : ''; 2551 return; 2552 } 2553 c.innerHTML = '<div><button type="button">Load more stories</button>' 2554 + (totalCount ? '<div class="count">Page '+page+' of '+totalPages+' · '+totalCount+' stories</div>' : '') 2555 + '</div>'; 2556 var btn = c.querySelector('button'); 2557 btn.onclick = function(){ 2558 btn.disabled = true; 2559 btn.textContent = 'Loadingâ¦'; 2560 onClick(); 2561 }; 2562} 2563 2564/* Date */ 2565document.getElementById('today').textContent = 2566 new Date().toLocaleDateString('en-GB',{weekday:'long',year:'numeric',month:'long',day:'numeric'}); 2567 2568/* ============================================================ 2569 * YouTube Data API 2570 * ============================================================ */ 2571function renderVideos(videos){ 2572 if(!videos || !videos.length){ 2573 // No real videos available â show a clean "watch on YouTube" CTA instead of empty/fake content 2574 var featured = $('#yt-featured'); 2575 var list = $('#yt-list'); 2576 if(featured){ 2577 featured.classList.remove('sk'); 2578 featured.style.background = 'linear-gradient(135deg,#000,#1a1a1a)'; 2579 featured.style.display = 'flex'; 2580 featured.style.alignItems = 'center'; 2581 featured.style.justifyContent = 'center'; 2582 featured.innerHTML = '<a href="https://www.youtube.com/@CambsNewsLive" target="_blank" rel="noopener" style="color:#fff;text-decoration:none;text-align:center;padding:30px">' 2583 + '<svg viewBox="0 0 24 24" width="48" height="48" fill="#ff0000" style="margin-bottom:12px"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.546 15.568V8.432L15.818 12l-6.272 3.568z"/></svg>' 2584 + '<div style="font-family:Georgia,serif;font-size:22px;font-weight:700">Watch on YouTube</div>' 2585 + '<div style="font-size:13px;opacity:.8;margin-top:4px">@CambsNewsLive â local reporting on video</div>' 2586 + '</a>'; 2587 } 2588 if(list) list.innerHTML = ''; 2589 var sidebar = document.getElementById('sidebar-videos'); 2590 if(sidebar){ 2591 sidebar.innerHTML = '<li style="text-align:center;padding:14px 0"><a href="https://www.youtube.com/@CambsNewsLive" target="_blank" rel="noopener" style="display:inline-block;background:#ff0000;color:#fff;padding:8px 16px;border-radius:4px;font-weight:700;font-size:13px;text-decoration:none">Visit our YouTube channel â</a></li>'; 2592 } 2593 return; 2594 } 2595 var feat = videos[0]; 2596 $('#yt-featured').classList.remove('sk'); 2597 $('#yt-featured').style.background = '#000'; 2598 $('#yt-featured').setAttribute('data-yt', feat.id); 2599 $('#yt-featured').innerHTML = 2600 '<img loading="lazy" src="https://i.ytimg.com/vi/'+feat.id+'/hqdefault.jpg" alt="">' 2601 + '<div class="play" aria-hidden="true"></div>' 2602 + '<div class="title">
2602'+esc(feat.title)+'</div>'; 2603 $('#yt-list').innerHTML = videos.slice(1,4).map(function(v){ 2604 return '<div class="vrow" data-yt="'+v.id+'">' 2605 + '<div class="thumb"><img loading="lazy" src="https://i.ytimg.com/vi/'+v.id+'/mqdefault.jpg" alt=""></div>' 2606 + '<div><h4>'+esc(v.title)+'</h4><small>'+esc(v.date||'YouTube')+'</small></div>' 2607 + '</div>'; 2608 }).join(''); 2609 // Also populate the sidebar Latest Videos widget with the same data 2610 var sidebar = document.getElementById('sidebar-videos'); 2611 if(sidebar){ 2612 sidebar.innerHTML = videos.slice(0,4).map(function(v){ 2613 return '<li data-yt="'+v.id+'">' 2614 + '<div class="thumb"><img loading="lazy" src="https://i.ytimg.com/vi/'+v.id+'/mqdefault.jpg" alt=""></div>' 2615 + '<div><h4>'+esc(v.title)+'</h4><time>'+esc(v.date||'YouTube')+'</time></div>' 2616 + '</li>'; 2617 }).join(''); 2618 } 2619} 2620 2621function loadYouTube(){ 2622 if(!CONFIG.YT_API_KEY){ renderVideos(CONFIG.YT_FALLBACK_VIDEOS); return; } 2623 2624 var cached = cacheGetStale('yt-videos-v3'); 2625 if(cached) renderVideos(cached); 2626 2627 // ----- helpers only used if no YT_PLAYLIST_ID is configured ----- 2628 function fetchUploads(channelId){ 2629 var cacheKey = 'yt-uploads-'+channelId; 2630 var pl = cacheGet(cacheKey, 7*24*3600*1000); 2631 if(pl) return Promise.resolve(pl); 2632 var u = 'https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id='+channelId+'&key='+CONFIG.YT_API_KEY; 2633 return fetch(u).then(function(r){ return r.json(); }).then(function(j){ 2634 var p = j.items && j.items[0] && j.items[0].contentDetails && j.items[0].contentDetails.relatedPlaylists && j.items[0].contentDetails.relatedPlaylists.uploads; 2635 if(p) cacheSet(cacheKey, p); 2636 return p; 2637 }); 2638 } 2639 function resolveChannelId(){ 2640 if(CONFIG.YT_CHANNEL_ID) return Promise.resolve(CONFIG.YT_CHANNEL_ID); 2641 var cKey = 'yt-cid-'+CONFIG.YT_HANDLE; 2642 var cid = cacheGet(cKey, 30*24*3600*1000); 2643 if(cid) return Promise.resolve(cid); 2644 var handle = CONFIG.YT_HANDLE.replace(/^@/,''); 2645 var u = 'https://www.googleapis.com/youtube/v3/channels?part=id&forHandle='+encodeURIComponent('@'+handle)+'&key='+CONFIG.YT_API_KEY; 2646 return fetch(u).then(function(r){ return r.json(); }).then(function(j){ 2647 var id = j.items && j.items[0] && j.items[0].id; 2648 if(id) cacheSet(cKey, id); 2649 return id; 2650 }); 2651 } 2652 function resolvePlaylist(){ 2653 if(CONFIG.YT_PLAYLIST_ID) return Promise.resolve(CONFIG.YT_PLAYLIST_ID); 2654 return resolveChannelId().then(function(cid){ 2655 if(!cid) throw new Error('Channel not found'); 2656 return fetchUploads(cid); 2657 }); 2658 } 2659 2660 resolvePlaylist().then(function(playlist){ 2661 if(!playlist) throw new Error('No playlist available'); 2662 // Fetch up to 20 so we have headroom before sorting by real publish date 2663 var u = 'https://www.googleapis.com/youtube/v3/playlistItems' 2664 + '?part=snippet,contentDetails' 2665 + '&playlistId=' + encodeURIComponent(playlist) 2666 + '&maxResults=20' 2667 + '&key=' + CONFIG.YT_API_KEY; 2668 return fetch(u).then(function(r){ return r.json(); }); 2669 }).then(function(j){ 2670 var items = j.items || []; 2671 var vids = items.map(function(it){ 2672 var s = it.snippet || {}; 2673 // Prefer the real video publish date over the date added to the playlist 2674 var pub = (it.contentDetails && it.contentDetails.videoPublishedAt) || s.publishedAt; 2675 return { 2676 id: (s.resourceId && s.resourceId.videoId) || (it.contentDetails && it.contentDetails.videoId), 2677 title: s.title, 2678 publishedAt: pub, 2679 date: fmtRel(pub) 2680 }; 2681 }).filter(function(v){ return v.id && v.title; }); 2682 2683 // Dedupe: a playlist can contain the same video more than once â keep newest occurrence only 2684 var seen = {}; 2685 vids = vids 2686 .sort(function(a,b){ return new Date(b.publishedAt) - new Date(a.publishedAt); }) 2687 .filter(function(v){ if(seen[v.id]) return false; seen[v.id] = true; return true; }) 2688 .slice(0,4); 2689 2690 if(vids.length){ renderVideos(vids); cacheSet('yt-videos-v3', vids); } 2691 else if(!cached) renderVideos(CONFIG.YT_FALLBACK_VIDEOS); 2692 }).catch(function(){ 2693 if(!cached) renderVideos(CONFIG.YT_FALLBACK_VIDEOS); 2694 }); 2695} 2696 2697/* YouTube facade -> real iframe on click */ 2698document.addEventListener('click', function(e){ 2699 var el = e.target.closest('[data-yt]'); 2700 if(!el) return; 2701 var id = el.getAttribute('data-yt'); 2702 if(!id) return; 2703 var iframe = document.createElement('iframe'); 2704 iframe.src = 'https://www.youtube.com/embed/' + id + '?autoplay=1&rel=0'; 2705 iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; 2706 iframe.allowFullscreen = true; 2707 iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;background:#000'; 2708 if(el.classList.contains('yt-facade')){ 2709 el.innerHTML = ''; el.style.cursor='default'; el.appendChild(iframe); 2710 } else { 2711 var modal = document.createElement('div'); 2712 modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:1000;display:grid;place-items:center;padding:20px'; 2713 var frame = document.createElement('div'); 2714 frame.style.cssText='position:relative;width:min(960px,100%);aspect-ratio:16/9;background:#000;border-radius:6px;overflow:hidden'; 2715 frame.appendChild(iframe); 2716 modal.appendChild(frame); 2717 modal.addEventListener('click', function(){ document.body.removeChild(modal); }); 2718 document.body.appendChild(modal); 2719 } 2720}); 2721 2722/* ============================================================ 2723 * Discover Peterborough loader 2724 * Filters posts to actual news (excludes the "Peterborough People" 2725 * biographical category) and pulls What's On events from the 2726 * Events Calendar (tribe/events/v1) API. 2727 * ============================================================ */ 2728(function(){ 2729 /* Resolve a DP category slug â ID, cached for a month */ 2730 function getDPCategoryId(slug){ 2731 var key = 'dp-catid-' + slug;
2732 var cached = cacheGet(key, 30*24*3600*1000); 2733 if(cached) return Promise.resolve(cached); 2734 return smartFetch(CONFIG.DP_HOST + '/wp-json/wp/v2/categories?slug=' + encodeURIComponent(slug)) 2735 .then(function(cats){ 2736 var id = cats && cats[0] && cats[0].id; 2737 if(id) cacheSet(key, id); 2738 return id || null; 2739 }).catch(function(){ return null; }); 2740 } 2741 /* Build the right DP posts URL: prefer a "news" category, else exclude "peterborough-people". 2742 * Fetch extra (16) so we have headroom after filtering out posts that have no featured image. */ 2743 function getDPPostsUrl(){ 2744 return getDPCategoryId('news').then(function(newsId){ 2745 if(newsId) return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&categories=' + newsId + '&_embed=1'; 2746 return getDPCategoryId('peterborough-people').then(function(peopleId){ 2747 if(peopleId) return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&categories_exclude=' + peopleId + '&_embed=1'; 2748 return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&_embed=1'; 2749 }); 2750 }).catch(function(){ 2751 return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&_embed=1'; 2752 }); 2753 } 2754 var DP_EVENTS = CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=5'; 2755 var rail = $('#dp-rail'); 2756 var eventsBox = $('#dp-events'); 2757 var statusBox = $('#dp-status'); 2758 2759 function renderPosts(posts){ 2760 // Drop posts without a featured image â leaving an empty image slot looks broken in the rail 2761 var withImages = (posts || []).filter(function(p){ return !!featuredImg(p); }); 2762 if(!withImages.length){ 2763 rail.innerHTML = '<p style="color:var(--muted);padding:14px;grid-column:1/-1">No stories available right now.</p>'; return; 2764 } 2765 rail.innerHTML = withImages.slice(0,8).map(function(p){ 2766 var img = featuredImg(p); 2767 var cat = 'Discover'; 2768 try{ var terms = p._embedded && p._embedded['wp:term']; if(terms && terms[0] && terms[0][0]) cat = terms[0][0].name; }catch(e){} 2769 return '<a class="dp-card" href="'+p.link+'" target="_blank" rel="noopener">' 2770 + '<div class="img"><img loading="lazy" decoding="async" src="'+img+'" alt=""></div>' 2771 + '<div class="body"><div class="cat">'+esc(cat)+'</div><h4>'+esc(stripHtml(p.title.rendered))+'</h4><time>'+esc(fmtDate(p.date))+'</time></div></a>'; 2772 }).join(''); 2773 } 2774 function eventImage(ev){ 2775 if(!ev.image) return ''; 2776 if(typeof ev.image === 'string') return ev.image; 2777 if(ev.image.sizes){ 2778 var s = ev.image.sizes; 2779 var pick = s.medium || s.thumbnail || s.small || s.full; 2780 if(pick) return (typeof pick === 'string') ? pick : (pick.url || ''); 2781 } 2782 return ev.image.url || ''; 2783 } 2784 function renderEvents(events){ 2785 if(!events || !events.length){ eventsBox.innerHTML = '<p style="color:var(--muted);font-size:13px">No upcoming events listed.</p>'; return; } 2786 eventsBox.innerHTML = '<div class="events-list">' + events.slice(0,4).map(function(ev){ 2787 var when = ev.start_date ? fmtDate(ev.start_date) : ''; 2788 var venue = (ev.venue && ev.venue.venue) ? ev.venue.venue : ''; 2789 var img = eventImage(ev); 2790 var thumb = img 2791 ? '<div class="thumb"><img loading="lazy" src="'+img+'" alt=""></div>' 2792 : '<div class="thumb">EVT</div>'; 2793 return '<a class="ev" href="'+(ev.url||ev.website||'#')+'" target="_blank" rel="noopener">' 2794 + thumb 2795 + '<div><h5>'+esc(stripHtml(ev.title))+'</h5>' 2796 + '<span class="when">'+esc(when)+'</span>' 2797 + (venue?' <span class="venue">· '+esc(venue)+'</span>':'') 2798 + '</div></a>'; 2799 }).join('') + '</div>'; 2800 } 2801 2802 var c = cacheGetStale('dp-cache-v3'); 2803 if(c){ if(c.posts) renderPosts(c.posts); if(c.events) renderEvents(c.events); } 2804 2805 // First resolve the correct news URL, then fetch posts + events in parallel 2806 getDPPostsUrl().then(function(postsUrl){ 2807 return Promise.allSettled([smartFetch(postsUrl), smartFetch(DP_EVENTS)]); 2808 }).then(function(results){ 2809 var posts = results[0].status==='fulfilled' ? results[0].value : null; 2810 var events = null; 2811 if(results[1].status==='fulfilled'){ 2812 events = results[1].value && results[1].value.events ? results[1].value.events : null; 2813 } 2814 if(posts && posts.length) renderPosts(posts); 2815 else if(!c){ 2816 statusBox.className = 'error-banner'; statusBox.classList.remove('hidden'); 2817 statusBox.textContent = 'Live Discover Peterborough feed unavailable from this origin.'; 2818 rail.innerHTML = '<p style="color:var(--muted);padding:14px;grid-column:1/-1">Discover Peterborough stories will appear here once their API is reachable.</p>'; 2819 } 2820 if(events) renderEvents(events); 2821 else if(!c) renderEvents([]); 2822 if(posts || events) cacheSet('dp-cache-v3', {posts:posts, events:events}); 2823 }); 2824})(); 2825 2826/* Search -> stays inside the SPA */ 2827$('#search-input').addEventListener('keydown', function(e){ 2828 if(e.key === 'Enter'){ 2829 var q = e.target.value.trim(); 2830 if(q){ go('#/search/' + encodeURIComponent(q)); document.querySelector('.search').classList.remove('expanded'); } 2831 } 2832}); 2833/* Mobile search toggle â slides the existing input into a floating bar */ 2834var searchToggleBtn = document.getElementById('search-toggle'); 2835if(searchToggleBtn){ 2836 searchToggleBtn.addEventListener('click', function(){ 2837 var s = document.querySelector('.search'); 2838 s.classList.toggle('expanded'); 2839 if(s.classList.contains('expanded')){ 2840 setTimeout(function(){ $('#search-input').focus(); }, 50); 2841 } 2842 }); 2843 // Close on outside click 2844 document.addEventListener('click', function(e){ 2845 var s = document.querySelector('.search'); 2846 if(!s.classList.contains('expanded')) return; 2847 if(s.contains(e.target) || searchToggleBtn.contains(e.target)) return; 2848 s.classList.remove('expanded'); 2849 }); 2850} 2851 2852/* ---------- Hover-prefetch: fetch post into cache when user hovers a card ---------- */ 2853(function(){ 2854 var prefetching = {}; 2855 function prefetch(idOrSlug){ 2856 if(!idOrSlug || prefetching[idOrSlug]) return; 2857 var key = 'cn-post-'+idOrSlug; 2858 if(cacheGet(key, 60*60*1000)) return; // already cached 2859 prefetching[idOrSlug] = true; 2860 var isNumeric = /^\d+$/.test(idOrSlug); 2861 var url = isNumeric 2862 ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts/' + encodeURIComponent(idOrSlug) + '?_embed=1' 2863 : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?slug=' + encodeURIComponent(idOrSlug) + '&_embed=1'; 2864 var run = function(){ 2865 smartFetch(url).then(function(result){ 2866 var p = Array.isArray(result) ? result[0] : result;
2867 if(p && p.id){ 2868 cacheSet(key, p); 2869 if(p.slug) cacheSet('cn-post-'+p.slug, p); 2870 } 2871 }).catch(function(){}); 2872 }; 2873 if(window.requestIdleCallback) requestIdleCallback(run, {timeout: 1500}); 2874 else setTimeout(run, 80); 2875 } 2876 document.addEventListener('mouseover', function(e){ 2877 var card = e.target.closest('[data-id]'); 2878 if(!card) return; 2879 var id = card.getAttribute('data-id'); 2880 if(id) prefetch(id); 2881 }); 2882 document.addEventListener('touchstart', function(e){ 2883 var card = e.target.closest('[data-id]'); 2884 if(!card) return; 2885 var id = card.getAttribute('data-id'); 2886 if(id) prefetch(id); 2887 }, {passive:true}); 2888})(); 2889 2890/* ---------- Hamburger menu toggle ---------- */ 2891(function(){ 2892 var btn = document.getElementById('ham-btn'); 2893 var menu = document.getElementById('mobile-menu'); 2894 if(!btn || !menu) return; 2895 function setOpen(open){ 2896 btn.classList.toggle('open', open); 2897 menu.classList.toggle('open', open); 2898 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 2899 menu.setAttribute('aria-hidden', open ? 'false' : 'true'); 2900 document.body.classList.toggle('menu-open', open); 2901 } 2902 btn.addEventListener('click', function(){ setOpen(!menu.classList.contains('open')); }); 2903 menu.addEventListener('click', function(e){ 2904 if(e.target.tagName === 'A') setOpen(false); 2905 }); 2906 // close on route change too 2907 window.addEventListener('popstate', function(){ setOpen(false); }); 2908 // Also catch SPA navigations (which use pushState, no native event) â listen on link clicks 2909 document.addEventListener('click', function(e){ 2910 if(e.target && e.target.closest && e.target.closest('a')) setOpen(false); 2911 }, true); 2912 // close on Escape 2913 document.addEventListener('keydown', function(e){ 2914 if(e.key === 'Escape' && menu.classList.contains('open')) setOpen(false); 2915 }); 2916})(); 2917 2918/* ---------- Sidebar section widgets (Crime, Politics, Business, Sport, Peterborough People) ---------- */ 2919var SECTION_STRIPES = [ 2920 {label:'Crime', slug:'crime'}, 2921 {label:'Politics', slug:'politics'}, 2922 {label:'Business', slug:'business'}, 2923 {label:'Your Views', slug:'your_views'}, 2924 {label:'Local Council', slug:'local-council'} 2925]; 2926
2927function renderSideStoryItem(p){ 2928 var img = featuredImg(p) || ''; 2929 return '<li data-id="'+postRef(p)+'">' 2930 + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>' 2931 + '<div><h4>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h4><time>'+esc(fmtDate(p.date))+'</time></div>' 2932 + '</li>'; 2933} 2934 2935function hideSectionWidget(slug){ 2936 var widget = document.querySelector('[data-section-widget="'+slug+'"]'); 2937 if(widget) widget.style.display = 'none'; 2938} 2939function loadSectionStripe(s){ 2940 var list = document.querySelector('.side-stories[data-stripe="'+s.slug+'"]'); 2941 if(!list) return; 2942 var cacheKey = 'cn-stripe-v2-' + s.slug; 2943 var cached = cacheGetStale(cacheKey); 2944 if(cached && cached.length){ 2945 list.innerHTML = cached.slice(0,4).map(renderSideStoryItem).join(''); 2946 } 2947 getCategoryId(s.slug).then(function(id){ 2948 // Try as a TAG if it's not a category â covers CN's taxonomy quirks 2949 if(!id) return getTagId(s.slug).then(function(tid){ 2950 return tid ? smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?tags=' + tid + '&per_page=4&_embed=1') : null; 2951 }); 2952 return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?categories=' + id + '&per_page=4&_embed=1'); 2953 }).then(function(posts){ 2954 if(posts && posts.length){ 2955 cacheSet(cacheKey, posts); 2956 list.innerHTML = posts.slice(0,4).map(renderSideStoryItem).join(''); 2957 } else if(!cached || !cached.length){ 2958 // Nothing now and nothing cached â hide the whole widget rather than show "No posts" 2959 hideSectionWidget(s.slug); 2960 } 2961 }).catch(function(){ 2962 if(!cached || !cached.length) hideSectionWidget(s.slug); 2963 }); 2964} 2965 2966function loadSectionStripes(){ 2967 // Click delegation â single listener catches every widget's items 2968 document.addEventListener('click', function(e){ 2969 var item = e.target.closest('.side-stories li[data-id]'); 2970 if(item){ e.preventDefault(); go('#/post/'+item.dataset.id); } 2971 }); 2972 SECTION_STRIPES.forEach(loadSectionStripe); 2973} 2974 2975/* ---------- Trending Tags widget ---------- */ 2976function loadTrendingTags(){ 2977 var box = $('#trending-tags'); 2978 if(!box) return; 2979 var cached = cacheGetStale('cn-trending-tags-v1'); 2980 if(cached) renderTrendingTags(cached); 2981 // Most-used tags from WP â sorted by post count desc 2982 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/tags?per_page=18&orderby=count&order=desc&hide_empty=true&_fields=name,slug,count'; 2983 smartFetch(url).then(function(tags){ 2984 if(tags && tags.length){ 2985 renderTrendingTags(tags); 2986 cacheSet('cn-trending-tags-v1', tags); 2987 } 2988 }).catch(function(){}); 2989} 2990function renderTrendingTags(tags){ 2991 var box = $('#trending-tags'); 2992 if(!box) return; 2993 box.innerHTML = tags.map(function(t){ 2994 return '<a href="#/tag/'+esc(t.slug)+'">'+esc(t.name)+'<span class="count">'+t.count+'</span></a>'; 2995 }).join(''); 2996} 2997 2998/* ---------- Newsletter form â POSTs to CambsNews mu-plugin ---------- */ 2999(function(){ 3000 var f = document.getElementById('newsletter-form'); 3001 if(!f) return; 3002 f.addEventListener('submit', function(e){ 3003 e.preventDefault(); 3004 var emailInput = document.getElementById('newsletter-email'); 3005 var email = emailInput.value.trim(); 3006 var msg = document.getElementById('newsletter-msg'); 3007 var honeypot = f.querySelector('input[name="website"]').value; 3008 if(!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ 3009 if(msg){ msg.textContent = 'Please enter a valid email address.'; msg.style.color = '#c62828'; } 3010 return; 3011 } 3012 var btn = f.querySelector('button[type="submit"]'); 3013 btn.disabled = true; 3014 var originalLabel = btn.textContent; 3015 btn.textContent = 'Signing upâ¦'; 3016 if(msg){ msg.style.color = '#999'; msg.textContent = 'Submittingâ¦'; } 3017 3018 fetch(CONFIG.CN_HOST + (CONFIG.NEWSLETTER_ENDPOINT || '/wp-json/cn/v1/subscribe'), { 3019 method: 'POST', 3020 mode: 'cors', 3021 credentials: 'omit', 3022 headers: {'Content-Type': 'application/json'}, 3023 body: JSON.stringify({email: email, website: honeypot}) 3024 }).then(function(r){ 3025 return r.json().then(function(j){ return {ok: r.ok, body: j}; }); 3026 }).then(function(res){ 3027 if(res.ok && res.body && res.body.ok){ 3028 var already = res.body.already; 3029 f.innerHTML = '<div style="padding:14px 0;color:var(--discover);font-weight:700;font-size:14px;text-align:center">' 3030 + (already ? 'â You\'re already on the list.' : 'â Thanks â check your inbox to confirm.') 3031 + '</div>'; 3032 } else { 3033 var err = (res.body && res.body.error) || 'Sorry â sign-up failed. Please try again.'; 3034 if(msg){ msg.textContent = err; msg.style.color = '#c62828'; } 3035 btn.disabled = false; btn.textContent = originalLabel; 3036 } 3037 }).catch(function(){ 3038 if(msg){ msg.textContent = 'Network error â please try again in a moment.'; msg.style.color = '#c62828'; } 3039 btn.disabled = false; btn.textContent = originalLabel; 3040 }); 3041 }); 3042})(); 3043 3044/* ============================================================ 3045 * WEATHER WIDGET (OpenWeatherMap)
3046 * Caches 30 minutes â Peterborough weather doesn't change second to second 3047 * ============================================================ */ 3048function loadWeather(){ 3049 var box = $('#weather-body'); 3050 if(!box || !CONFIG.WEATHER_API_KEY) return; 3051 var loc = encodeURIComponent(CONFIG.WEATHER_LOCATION || 'Peterborough,GB'); 3052 var cached = cacheGetStale('cn-weather-v2'); 3053 if(cached) renderWeather(cached); 3054 Promise.all([ 3055 fetch('https://api.openweathermap.org/data/2.5/weather?q='+loc+'&units=metric&appid='+CONFIG.WEATHER_API_KEY).then(function(r){return r.json();}), 3056 fetch('https://api.openweathermap.org/data/2.5/forecast?q='+loc+'&units=metric&appid='+CONFIG.WEATHER_API_KEY).then(function(r){return r.json();}) 3057 ]).then(function(res){ 3058 var now = res[0], fc = res[1]; 3059 if(!now || !now.main) return; 3060 // Aggregate forecast intervals by day to derive hi/lo + a midday icon/desc 3061 var byDay = {}; 3062 (fc.list || []).forEach(function(it){ 3063 var d = new Date(it.dt * 1000); 3064 var key = d.toISOString().slice(0,10); 3065 if(!byDay[key]){ 3066 byDay[key] = {day: d, items: [], midday: null, middayScore: 99, hi: -Infinity, lo: Infinity}; 3067 } 3068 byDay[key].items.push(it); 3069 if(typeof it.main.temp_max === 'number') byDay[key].hi = Math.max(byDay[key].hi, it.main.temp_max); 3070 if(typeof it.main.temp_min === 'number') byDay[key].lo = Math.min(byDay[key].lo, it.main.temp_min); 3071 // pick the interval closest to 1pm for the icon/desc 3072 var hourScore = Math.abs(d.getHours() - 13); 3073 if(hourScore < byDay[key].middayScore){ 3074 byDay[key].midday = it; 3075 byDay[key].middayScore = hourScore; 3076 } 3077 }); 3078 var todayKey = new Date().toISOString().slice(0,10); 3079 var days = Object.keys(byDay) 3080 .map(function(k){ return byDay[k]; }) 3081 .sort(function(a,b){ return a.day - b.day; }) 3082 .filter(function(d){ return d.day.toISOString().slice(0,10) !== todayKey; }) 3083 .slice(0, 3) 3084 .map(function(d){ 3085 var m = d.midday || d.items[0]; 3086 return { 3087 label: d.day.toLocaleDateString('en-GB',{weekday:'short'}), 3088 icon: m.weather && m.weather[0] && m.weather[0].icon, 3089 desc: (m.weather && m.weather[0] && m.weather[0].main) || '', 3090 hi: Math.round(d.hi), 3091 lo: Math.round(d.lo) 3092 }; 3093 }); 3094 var payload = { 3095 now: { 3096 temp: Math.round(now.main.temp), 3097 feels: Math.round(now.main.feels_like), 3098 desc: (now.weather && now.weather[0] && now.weather[0].description) || '', 3099 main: (now.weather && now.weather[0] && now.weather[0].main) || '', 3100 icon: (now.weather && now.weather[0] && now.weather[0].icon) || '01d', 3101 wind: Math.round((now.wind && now.wind.speed || 0) * 2.237), // m/s â mph 3102 humidity: now.main.humidity || 0, 3103 city: now.name || 'Peterborough' 3104 }, 3105 days: days 3106 }; 3107 cacheSet('cn-weather-v2', payload); 3108 renderWeather(payload); 3109 }).catch(function(){ /* silent â keep cached or skeleton */ }); 3110} 3111/* Map OpenWeatherMap icon code â our themed class */ 3112function weatherTheme(iconCode){ 3113 if(!iconCode) return 'cond-default'; 3114 var n = iconCode.endsWith('n'); 3115 var c = iconCode.slice(0,2); 3116 if(c === '01') return n ? 'cond-clear-night' : 'cond-clear-day'; 3117 if(c === '02' || c === '03' || c === '04') return 'cond-cloud'; 3118 if(c === '09' || c === '10') return 'cond-rain'; 3119 if(c === '11') return 'cond-thunder'; 3120 if(c === '13') return 'cond-snow'; 3121 if(c === '50') return 'cond-mist'; 3122 return 'cond-default'; 3123} 3124/* Map OpenWeatherMap icon code â an emoji that renders natively in colour */ 3125function weatherEmoji(iconCode){ 3126 if(!iconCode) return 'ð¡ï¸'; 3127 var n = iconCode.endsWith('n'); 3128 var c = iconCode.slice(0,2); 3129 if(c === '01') return n ? 'ð' : 'âï¸'; 3130 if(c === '02') return n ? 'âï¸' : 'â '; 3131 if(c === '03') return 'âï¸'; 3132 if(c === '04') return 'âï¸'; 3133 if(c === '09') return 'ð§ï¸'; 3134 if(c === '10') return n ? 'ð§ï¸' : 'ð¦ï¸'; 3135 if(c === '11') return 'âï¸'; 3136 if(c === '13') return 'âï¸'; 3137 if(c === '50') return 'ð«ï¸'; 3138 return 'ð¡ï¸'; 3139} 3140function renderWeather(p){ 3141 var box = $('#weather-body'); 3142 if(!box || !p || !p.now) return; 3143 var theme = weatherTheme(p.now.icon); 3144 var nowHtml = '' 3145 + '<div class="weather-now '+theme+'">' 3146 + '<div class="icon" aria-label="'+esc(p.now.desc)+'">'+weatherEmoji(p.now.icon)+'</div>' 3147 + '<div class="temp-block">' 3148 + '<div class="temp">'+p.now.temp+'<sup>°C</sup></div>' 3149 + '<div class="desc">'+esc(p.now.desc)+'</div>' 3150 + '<div class="meta">Feels like '+p.now.feels+'° · '+p.now.wind+' mph · '+p.now.humidity+'% humidity</div>' 3151 + '</div>' 3152 + '</div>'; 3153 var fcHtml = ''; 3154 if(p.days && p.days.length){ 3155 fcHtml = '<div class="weather-forecast">' 3156 + p.days.map(function(d){ 3157 return '<div>' 3158 + '<div class="day">'+esc(d.label)+'</div>' 3159 + '<div class="ico" aria-label="'+esc(d.desc||'')+'">'+weatherEmoji(d.icon)+'</div>' 3160 + '<div class="hi-lo">'+d.hi+'°<span class="lo">'+d.lo+'°</span></div>' 3161 + '<div class="desc">'+esc(d.desc||'')+'</div>' 3162 + '</div>'; 3163 }).join('') 3164 + '</div>'; 3165 } 3166 box.innerHTML = nowHtml + fcHtml; 3167} 3168 3169/* ============================================================ 3170 * LOCAL ADS â fetch from cambsnews WP plugin, rotate per pageview 3171 * Falls back to inline CONFIG ads if no active local ads for the slot. 3172 * ============================================================ */ 3173function pickWeightedAd(ads){ 3174 if(!ads.length) return null; 3175 var total = 0; 3176 ads.forEach(function(a){ total += Math.max(1, a.weight||5); }); 3177 var r = Math.random() * total; 3178 for(var i = 0; i < ads.length; i++){ 3179 r -= Math.max(1, ads[i].weight||5); 3180 if(r <= 0) return ads[i]; 3181 } 3182 return ads[0]; 3183} 3184function renderLocalAd(slot, ad){ 3185 if(!ad || !ad.image) return false;
3186 var sponsorLine = ad.sponsor ? '<div class="local-ad-sponsor">Sponsored · '+esc(ad.sponsor)+'</div>' : '<div class="local-ad-sponsor">Sponsored</div>'; 3187 var click = CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/click/' + ad.id; 3188 slot.classList.add('loaded'); 3189 slot.innerHTML = '<div class="local-ad">' 3190 + '<a href="'+esc(click)+'" target="_blank" rel="noopener nofollow sponsored" data-ad-id="'+ad.id+'">' 3191 + '<img src="'+esc(ad.image)+'" alt="'+esc(ad.name||'Advertisement')+'" loading="lazy">' 3192 + '</a>' 3193 + sponsorLine 3194 + '</div>'; 3195 return true; 3196} 3197function loadLocalAdsForSlot(slot, fallbackToInline){ 3198 var slotName = slot.dataset.slot; 3199 if(!CONFIG.LOCAL_ADS_ENDPOINT){ fallbackToInline(); return; } 3200 var url = CONFIG.CN_HOST + CONFIG.LOCAL_ADS_ENDPOINT + '?slot=' + encodeURIComponent(slotName); 3201 smartFetch(url).then(function(ads){ 3202 if(ads && ads.length){ 3203 var chosen = pickWeightedAd(ads); 3204 if(renderLocalAd(slot, chosen)){ 3205 // Fire-and-forget impression tracking 3206 try{ 3207 navigator.sendBeacon 3208 ? navigator.sendBeacon(CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/impression', JSON.stringify({ids:[chosen.id]})) 3209 : fetch(CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/impression', {method:'POST', body:JSON.stringify({ids:[chosen.id]}), keepalive:true}); 3210 }catch(_){} 3211 return; 3212 } 3213 } 3214 // No local ads for this slot â fall back to the inline CONFIG ad 3215 fallbackToInline(); 3216 }).catch(function(){ fallbackToInline(); }); 3217} 3218 3219/* ============================================================ 3220 * INFINITE SCROLL on home page 3221 * Loads next batch of posts when user scrolls near the bottom sentinel. 3222 * Starts from page 2 (page 1 = the 22 posts already loaded in loadCambsNews). 3223 * Caps at MAX pages so we don't loop forever on small content sets. 3224 * ============================================================ */ 3225function initInfiniteScroll(){ 3226 var sentinel = document.getElementById('home-sentinel'); 3227 var container = document.getElementById('home-infinite'); 3228 if(!sentinel || !container || !('IntersectionObserver' in window)) return; 3229 3230 var page = 1; // loadCambsNews already covered page 1 with per_page=22 3231 var loading = false; 3232 var done = false; 3233 var MAX_INFINITE_PAGES = 8; // hard cap â 22 + (8 à 12) = up to 118 stories on home 3234 3235 function showLoader(on){ document.getElementById('home-infinite-loading').style.display = on ? 'flex' : 'none'; } 3236 function showEnd(){ document.getElementById('home-infinite-end').style.display = 'flex'; } 3237 3238 function loadNext(){ 3239 if(loading || done) return; 3240 loading = true; 3241 page++; 3242 showLoader(true); 3243 // Use offset so we never overlap with the 22 posts loadCambsNews already rendered. 3244 // Offset = 22 (initial home posts) + 12 à (pages already added to infinite scroll) 3245 var offset = 22 + Math.max(0, (page - 2)) * 12; 3246 var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=12&offset=' + offset + '&_embed=1'; 3247 fetchWithTotals(url).then(function(res){ 3248 var posts = res.data || []; 3249 // Filter out anything already on the page (hero/side/grid/editor's/previous batches) 3250 var fresh = posts.filter(function(p){ return p && p.id && !renderedPostIds[p.id]; }); 3251 fresh.forEach(function(p){ renderedPostIds[p.id] = true; }); 3252 3253 // No posts returned at all â real end of feed 3254 if(!posts.length || page >= MAX_INFINITE_PAGES){ 3255 done = true; 3256 showLoader(false); 3257 if(container.children.length) showEnd(); 3258 obs.disconnect(); 3259 loading = false; 3260 return; 3261 } 3262 3263 if(fresh.length){ 3264 var batchId = 'home-batch-' + page; 3265 var html = '<section data-batch="'+page+'" style="margin-top:18px">' 3266 + '<div class="section-head"><h2>More <span>Stories</span></h2><span style="font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">Page '+page+'</span></div>' 3267 + '<div class="news-grid" id="'+batchId+'">' 3268 + fresh.map(renderResultCard).join('') 3269 + '</div>' 3270 + '</section>'; 3271 container.insertAdjacentHTML('beforeend', html); 3272 document.getElementById(batchId).onclick = function(e){ 3273 var c = e.target.closest('.story[data-id]'); 3274 if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); } 3275 }; 3276 showLoader(false); 3277 loading = false;
3278 } else { 3279 // Whole batch was duplicates â skip straight to the next page without showing loader flicker 3280 loading = false; 3281 showLoader(false); 3282 loadNext(); 3283 } 3284 }).catch(function(){ 3285 loading = false; 3286 showLoader(false); 3287 }); 3288 } 3289 3290 var obs = new IntersectionObserver(function(entries){ 3291 entries.forEach(function(en){ if(en.isIntersecting) loadNext(); }); 3292 }, { rootMargin: '600px 0px 600px 0px' }); // start loading 600px before sentinel comes into view 3293 obs.observe(sentinel); 3294} 3295 3296/* Boot */ 3297loadCambsNews(); 3298loadYouTube(); 3299loadAds(); 3300loadTrendingTags(); 3301loadSectionStripes(); 3302loadWeather(); 3303initInfiniteScroll(); 3304router(); 3305// Detect if visitor is a logged-in WP editor â adds "â Edit" links on stories. 3306// Once resolved, re-render the homepage so the new links appear without a refresh. 3307detectLoggedInUser().then(function(user){ 3308 if (user) loadCambsNews(); 3309}); 3310// First-page GA4 hit (article opens fire their own gaPageView from renderPost) 3311gaPageView(); 3312// Fire GA4 page_view on every SPA navigation 3313window.addEventListener('popstate', gaPageView); 3314window.addEventListener('pushstate', gaPageView); // some browsers fire this on history.pushState 3315(function patchPushState(){ 3316 var orig = history.pushState; 3317 history.pushState = function(){ 3318 var ret = orig.apply(this, arguments); 3319 try{ window.dispatchEvent(new Event('pushstate')); }catch(e){} 3320 return ret; 3321 }; 3322})(); 3323 3324/* ============================================================ 3325 * Refresh-on-focus â when an editor returns to the tab after 3326 * publishing a story, silently re-fetch the homepage list so the 3327 * new story appears without a manual refresh. Throttled to one 3328 * call per 30s to avoid hammering the API on quick tab swaps. 3329 * ============================================================ */ 3330(function(){ 3331 var lastRefresh = Date.now(); 3332 var REFRESH_INTERVAL_MS = 30 * 1000; 3333 function maybeRefresh(){ 3334 if(document.hidden) return; // tab is in background, do nothing 3335 var now = Date.now(); 3336 if(now - lastRefresh < REFRESH_INTERVAL_MS) return; // throttle 3337 lastRefresh = now; 3338 // Only refresh when we're actually on the home view 3339 var onHome = !$('#home-view').classList.contains('hidden'); 3340 if(onHome && typeof loadCambsNews === 'function'){ 3341 loadCambsNews(); 3342 if(typeof loadMostRead === 'function') loadMostRead(); 3343 } 3344 } 3345 document.addEventListener('visibilitychange', maybeRefresh); 3346 window.addEventListener('focus', maybeRefresh); 3347})(); 3348 3349/* ---------- Service Worker registration (HTML + API caching) ---------- */ 3350if('serviceWorker' in navigator && location.protocol === 'https:'){ 3351 window.addEventListener('load', function(){ 3352 navigator.serviceWorker.register('sw.js').catch(function(){ /* ignored â SW is enhancement, not required */ }); 3353 }); 3354} 3355 3356/* ============================================================ 3357 * 360 MAP LINKS 3358 * Stories pinned on the interactive /360/ map get a pin badge on 3359 * their cards and a banner link on the article page. 3360 * Data comes from CONFIG.MAP_POSTS_ENDPOINT (10-min cache). 3361 * If the endpoint 404s (API not built yet) the feature stays 3362 * dormant - no errors, no visual change. 3363 * ============================================================ */ 3364(function(){ 3365 if(!CONFIG.MAP_POSTS_ENDPOINT) return; 3366 var LOOKUP = null; 3367 var PIN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg>'; 3368 3369 function buildLookup(list){ 3370 var m = {}; 3371 (list || []).forEach(function(e){ 3372 if(!e || e.id == null) return; 3373 var url = e.url || ('/360/index.html?post=' + e.id); 3374 m[String(e.id)] = url; 3375 if(e.slug) m[e.slug] = url; 3376 }); 3377 return m; 3378 } 3379 3380 function makeBadge(url){ 3381 var a = document.createElement('a'); 3382 a.className = 'map-badge'; 3383 a.href = url; 3384 a.target = '_blank'; 3385 a.rel = 'noopener'; 3386 a.title = 'See where this story happened - view on the 360 map'; 3387 a.innerHTML = '<span>360°</span>'; 3388 a.addEventListener('click', function(ev){ ev.stopPropagation(); }); 3389 return a; 3390 } 3391 3392 function badgeArticle(){ 3393 var pv = document.getElementById('post-view'); 3394 if(!pv || pv.classList.contains('hidden')) return; 3395 var fig = pv.querySelector('.hero-img'); 3396 if(!fig || fig.querySelector('.map-badge')) return; 3397 var slug = (location.hash.match(/^#\/post\/([^\/?]+)/) || [])[1] || location.pathname.replace(/\//g, ''); 3398 var url = LOOKUP[slug]; 3399 if(!url) return; 3400 var b = makeBadge(url); 3401 b.className = 'map-badge lg'; 3402 fig.appendChild(b); 3403 } 3404 3405 function decorate(){ 3406 if(!LOOKUP) return; 3407 var nodes = document.querySelectorAll('.card[data-id], article.story[data-id]'); 3408 for(var i = 0; i < nodes.length; i++){ 3409 var n = nodes[i]; 3410 var url = LOOKUP[n.getAttribute('data-id')]; 3411 if(!url || n.querySelector('.map-badge')) continue; 3412 n.classList.add('on-map'); 3413 n.appendChild(makeBadge(url)); 3414 } 3415 badgeArticle(); 3416 } 3417 3418 var scheduled = false;
3419 function scheduleDecorate(){ 3420 if(scheduled) return; 3421 scheduled = true; 3422 setTimeout(function(){ scheduled = false; decorate(); }, 150); 3423 } 3424 3425 var started = false; 3426 function start(){ 3427 if(started) return; 3428 started = true; 3429 decorate(); 3430 setTimeout(decorate, 1000); 3431 setTimeout(decorate, 4000); 3432 new MutationObserver(scheduleDecorate).observe(document.body, {childList: true, subtree: true}); 3433 if(CONFIG.ADS_DEBUG && window.console) console.log('[cn-map] active'); 3434 } 3435 3436 var KEY = 'cn-map-posts-v1'; 3437 var cached = cacheGet(KEY, 10 * 60 * 1000) || cacheGetStale(KEY); 3438 if(cached){ 3439 LOOKUP = buildLookup(cached); 3440 start(); 3441 } 3442 smartFetch(CONFIG.CN_HOST + CONFIG.MAP_POSTS_ENDPOINT + '?t=' + Math.floor(Date.now() / 300000)).then(function(list){ 3443 if(Array.isArray(list) && list.length){ 3444 cacheSet(KEY, list); 3445 LOOKUP = buildLookup(list); 3446 start(); 3447 decorate(); 3448 } 3449 }).catch(function(){ 3450 if(CONFIG.ADS_DEBUG && window.console) console.log('[cn-map] endpoint unavailable - dormant'); 3451 }); 3452})(); 3453 3454/* ---------- discreet build number in the footer ---------- 3455 * Reads the ?v= off the cnh.js script tag actually running, so it 3456 * always shows the truth (handy when chasing stale caches). */ 3457(function(){ 3458 var el = document.getElementById('cn-build'); 3459 if(!el) return; 3460 var s = document.currentScript || document.querySelector('script[src*="cnh.js"]'); 3461 var m = s && s.src && s.src.match(/[?&]v=([A-Za-z0-9._-]+)/); 3462 if(m) el.textContent = ' · v' + m[1]; 3463})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.