PageSourceSearch

https://thelondonerpub.com/spillover/proto/thelondonerpub2/js/blog.js

js thelondonerpub.com collected 2026-10-06 19:13:21 UTC 20,895 bytes, 616 lines download raw bytes

1const proto = (await import("./protoDeterminer.js")).determineProto();
2
3const debounce = (fn, wait) => {
4  let t;
5  return (...args) => {
6    clearTimeout(t);
7    t = setTimeout(() => fn(...args), wait);
8  };
9};
10
11const initializeTooltips = () => {
12  document
13    .querySelectorAll('[data-bs-toggle="tooltip"]')
14    .forEach((el) => new bootstrap.Tooltip(el));
15};
16
17const blogManager = {
18  config: {
19    pageNameAlt: "Blog",
20    postsPerPage: 12,
21    blogPostsEndpoint: `https://rails-admin.spillover.com/esites-api/v1/${proto}/site_posts?number_of_posts=1000`,
22    fetchedPostsLog: "Fetched blog posts:",
23    errorLoadingLog: "Error loading blog posts:",
24    postNotFoundLog: "Post not found.",
25    urlCopiedLog: "URL copied to clipboard:",
26    blogLinkNotification: "Blog Link Copied to Clipboard!",
27    shareHover: "Share blog to Facebook",
28    copyHover: "Copy blog link to clipboard",
29  },
30
31  blogPosts: [],
32  selectedTags: [],
33
34  getBlogPostsContainer() {
35    return document.getElementById("blogPostsContainer");
36  },
37
38  isReferralMode() {
39    const container = this.getBlogPostsContainer();
40    if (!container) return false;
41
42    return (
43      container.hasAttribute("data-use-referral-links") ||
44      container.hasAttribute("data-referral-links") ||
45      container.hasAttribute("use-referral-links")
46    );
47  },
48
49  normalizeExternalUrl(url) {
50    const raw = (url ?? "").toString().trim();
51    if (!raw) return "";
52
53    if (/^https?:\/\//i.test(raw)) return raw;
54    if (/^\/\//.test(raw)) return `https:${raw}`;
55
56    return `https://${raw}`;
57  },
58
59  getExcludedTags() {
60    const container = this.getBlogPostsContainer();
61    if (!container) return [];
62
63    const raw =
64      container.getAttribute("data-exclude-tags") ||
65      container.getAttribute("data-hide-tags") ||
66      container.getAttribute("exclude-tags") ||
67      "";
68
69    return raw
70      .split(",")
71      .map((t) => t.trim().toLowerCase())
72      .filter(Boolean);
73  },
74
75  postHasExcludedTag(post) {
76    const excluded = this.getExcludedTags();
77    if (!excluded.length) return false;
78
79    const tags = Array.isArray(post?.tags) ? post.tags : [];
80    const normalized = tags.map((t) => (t ?? "").toString().trim().toLowerCase()).filter(Boolean);
81
82    return normalized.some((t) => excluded.includes(t));
83  },
84
85  updateTagDropdownVisibility(tagCount) {
86    const blogTags = document.getElementById("blogTags");
87    if (!blogTags) return;
88
89    const dropdownButton = document.getElementById("tagDropdownButton");
90    if (!dropdownButton) return;
91
92    if (tagCount <= 1) {
93      dropdownButton.style.display = "none";
94      blogTags.style.display = "none";
95    } else {
96      dropdownButton.style.display = "";
97      blogTags.style.display = "";
98    }
99  },
100
101  getPostById(id) {
102    const postId = parseInt(id, 10);
103    return this.blogPosts.find((p) => p.id === postId);
104  },
105
106  getInternalPostUrl(postId) {
107    return `?post=${postId}`;
108  },
109
110  async fetchBlogPosts() {
111    try {
112      const res = await fetch(this.config.blogPostsEndpoint);
113      if (!res.ok) throw new Error("Network response was not ok");
114
115      const allPosts = await res.json();
116      this.blogPosts = Array.isArray(allPosts)
117        ? allPosts.filter((p) => !this.postHasExcludedTag(p))
118        : [];
119    } catch (e) {
120      console.error(this.config.errorLoadingLog, e);
121    }
122  },
123
124  getAllTags() {
125    return Array.from(new Set(this.blogPosts.flatMap((p) => p.tags)));
126  },
127
128  getTagsFromURL: () =>
129    new URLSearchParams(window.location.search).get("tags")?.split(",") || [],
130
131  updateCheckboxesBasedOnTags(tags) {
132    tags.forEach((tag) => {
133      const cb = document.getElementById(`tag${tag}Checkbox`);
134      if (cb) cb.checked = true;
135    });
136  },
137
138  renderBlogPosts(posts) {
139    const container = this.getBlogPostsContainer();
140    if (!container) return;
141
142    const referralMode = this.isReferralMode();
143
144    container.innerHTML = posts
145      .map((p) => {
146        const normalizedReferral = this.normalizeExternalUrl(p.referral_link);
147        const hasReferral = Boolean(normalizedReferral);
148        const useReferral = referralMode && hasReferral;
149
150        const titleHref = useReferral ? normalizedReferral : this.getInternalPostUrl(p.id);
151        const readMoreHref = useReferral ? normalizedReferral : this.getInternalPostUrl(p.id);
152
153        const titleTarget = useReferral ? ` target="_blank" rel="noopener noreferrer"` : "";
154        const readMoreTarget = useReferral ? ` target="_blank" rel="noopener noreferrer"` : "";
155
156        const externalA11y = useReferral
157          ? ` aria-label="Open ${String(p.headline).replace(/"/g, "&quot;")} in a new tab"`
158          : "";
159
160        const referralAttr = hasReferral
161          ? ` data-referral-link="${String(normalizedReferral).replace(/"/g, "&quot;")}"`
162          : "";
163
164        return `
165	<article class="col-lg-3 col-md-6 col-12" tabindex="0" aria-labelledby="post-title-${p.id}" role="region">
166		<div class="uk-panel blog-post-card pointer" data-post-id="${p.id}"${referralAttr}>
167			<img src="${p.post_image}" class="blog-img" width="100%" height="100%" alt="${String(p.headline).replace(/"/g, "&quot;")}">
168			<div class="blog-top">
169				<p class="blog-tags pointer">
170					${p.tags
171            .map(
172              (tag) =>
173                `<span class="blog-tag" data-tag="${String(tag).replace(/"/g, "&quot;")}" aria-label="Tag: ${String(tag).replace(
174                  /"/g,
175                  "&quot;"
176                )}">${tag}</span>`
177            )
178            .join(", ")}
179				</p>
180			</div>
181			<div class="blog-bottom">
182				<a href="${titleHref}" class="title-wrapper" aria-describedby="post-title-${p.id}"${titleTarget}${externalA11y}>
183					<p id="post-title-${p.id}" class="blog-title pointer truncate">${p.headline}</p>
184				</a>
185				<a href="${readMoreHref}" class="badge badge-template blog-button"${readMoreTarget}${externalA11y}>Read More</a>
186				<i class="fas fa-share ms-2 text-white pointer share-button" role="button" tabindex="0" aria-label="Share ${String(p.headline).replace(
187          /"/g,
188          "&quot;"
189        )}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="${this.config.shareHover}"></i>
190				<i class="fas fa-copy ms-2 text-white pointer copy-button" role="button" tabindex="0" aria-label="Copy link to ${String(p.headline).replace(
191          /"/g,
192          "&quot;"
193        )}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="${this.config.copyHover}"></i>
194			</div>
195		</div>
196	</article>
197`;
198      })
199      .join("");
200
201    this.addDynamicEventListeners();
202    initializeTooltips();
203  },
204
205  generateTagChecklist() {
206    const list = document.getElementById("tagChecklist");
207    const tags = this.getAllTags().sort();
208
209    this.updateTagDropdownVisibility(tags.length);
210
211    if (!tags.length) {
212      const tagsWrap = document.getElementById("blogTags");
213      if (tagsWrap) tagsWrap.style.display = "none";
214      return;
215    }
216
217    if (!list) return;
218
219    list.innerHTML = tags
220      .map(
221        (t) => `
222	<div class="form-check">
223		<input type="checkbox" class="form-check-input tag-checkbox" value="${String(t).replace(/"/g, "&quot;")}" id="tag${String(t).replace(
224          /"/g,
225          "&quot;"
226        )}Checkbox" aria-label="Filter posts by ${String(t).replace(/"/g, "&quot;")} tag">
227		<label class="form-check-label" for="tag${String(t).replace(/"/g, "&quot;")}Checkbox">${t}</label>
228	</div>
229`
230      )
231      .join("");
232
233    this.addDynamicEventListeners();
234  },
235
236  createSharePostLink(postId) {
237    const post = this.getPostById(postId);
238    if (!post) return console.error(this.config.postNotFoundLog);
239
240    const referralMode = this.isReferralMode();
241    const referralLink = this.normalizeExternalUrl(post.referral_link);
242    const hasReferral = Boolean(referralLink);
243
244    const urlToShare =
245      referralMode && hasReferral
246        ? referralLink
247        : `${window.location.href.split("?")[0]}?post=${post.id}`;
248
249    const shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(urlToShare)}`;
250    window.open(shareUrl, "_blank", "noopener,noreferrer");
251  },
252
253  copyBlogLinkToClipboard(postId) {
254    const post = this.getPostById(postId);
255    if (!post) return console.error(this.config.postNotFoundLog);
256
257    const referralMode = this.isReferralMode();
258    const referralLink = this.normalizeExternalUrl(post.referral_link);
259    const hasReferral = Boolean(referralLink);
260
261    const urlToCopy =
262      referralMode && hasReferral
263        ? referralLink
264        : `${window.location.href.split("?")[0]}?post=${post.id}`;
265
266    navigator.clipboard
267      .writeText(urlToCopy)
268      .then(() => this.showNotification(this.config.blogLinkNotification))
269      .catch((e) => console.error("Error copying URL to clipboard:", e));
270  },
271
272  showNotification(message) {
273    const html = `
274	<div class="alert alert-success alert-dismissible fade show" role="alert">
275		${message}
276		<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
277	</div>`;
278
279    ["notificationContainer", "modalNotificationContainer"].forEach((id) => {
280      const container = document.getElementById(id);
281      if (!container) return;
282
283      const wrap = document.createElement("div");
284      wrap.innerHTML = html;
285      container.appendChild(wrap);
286
287      setTimeout(() => {
288        if (wrap.parentNode === container) container.removeChild(wrap);
289      }, 3000);
290    });
291  },
292
293  showFullArticle(postId, pageTitle) {
294    const post = this.getPostById(postId);
295    if (!post) return console.error(this.config.postNotFoundLog);
296
297    document.title = `${
298      document.querySelector('meta[property="og:site_name"]')?.content
299        ? `${document.querySelector('meta[property="og:site_name"]')?.content} | `
300        : ""
301    }${post.headline}`;
302
303    const pageTitleEl = document.querySelector('[data-type="page-title"]');
304    if (pageTitleEl) pageTitleEl.textContent = post.headline;
305
306    const published = new Date(post.published_at).toLocaleDateString();
307
308    document.getElementById("blog").classList.add("showing-post");
309    document.getElementById("blog").innerHTML = `
310	<div class="container mt-5">
311		<div id="notificationContainer" class="notification-container my-3"></div>
312		<div class="article-header" data-post-id="${post.id}">
313			<div class="article-top">
314				<nav aria-label="breadcrumb">
315					<ol class="breadcrumb">
316						<li class="breadcrumb-item"><a href="#" id="backToBlogs">${pageTitle}</a></li>
317						<li class="breadcrumb-item active">${post.headline}</li>
318					</ol>
319				</nav>
320				<div class="article-actions">
321					<i class="fas fa-share ms-2 pointer share-button" role="button" tabindex="0" aria-label="Share ${String(post.headline).replace(
322            /"/g,
323            "&quot;"
324          )}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="${this.config.shareHover}"></i>
325					<i class="fas fa-copy ms-2 pointer copy-button" role="button" tabindex="0" aria-label="Copy link to ${String(post.headline).replace(
326            /"/g,
327            "&quot;"
328          )}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="${this.config.copyHover}"></i>
329				</div>
330			</div>
331			<div class="article-details">
332				<p class="article-date"><span class="fw-bolder">Published:</span> ${published}</p>
333				<div class="blog-tags">
334					${post.tags
335            .map((t) => `<a href="?tags=${encodeURIComponent(t)}" class="badge badge-template me-1">${t}</a>`)
336            .join(" ")}
337				</div>
338			</div>
339		</div>
340		<div class="row justify-content-center">
341			<div class="col-lg-10 d-flex justify-content-center">
342				<div>
343					<img src="${post.post_image}" class="img-fluid mb-4" alt="${String(post.headline).replace(/"/g, "&quot;")}">
344					<div>${post.caption}</div>
345					${
346            post.referral_link
347              ? `<div class="mt-4">
347<a href="${this.normalizeExternalUrl(post.referral_link)}" target="_blank" rel="noopener noreferrer" class="btn btn-template" aria-label="Open referral link in a new tab">See More</a></div>`
348              : ""
349          }
350				</div>
351			</div>
352		</div>
353	</div>
354`;
355
356    const back = document.getElementById("backToBlogs");
357    if (back) {
358      back.addEventListener("click", (e) => {
359        e.preventDefault();
360        this.backToBlogListing();
361      });
362    }
363
364    this.addDynamicEventListeners();
365    initializeTooltips();
366  },
367
368  backToBlogListing() {
369    const url = new URL(window.location);
370    url.searchParams.delete("post");
371    window.history.replaceState({}, document.title, url.pathname);
372    window.location.reload();
373  },
374
375  calculateTotalPages(posts) {
376    return Math.ceil(posts.length / this.config.postsPerPage);
377  },
378
379  initFilterAndRenderBlogs() {
380    this.filterAndRenderBlogs = debounce(
381      function (page = 1) {
382        this.selectedTags = Array.from(document.querySelectorAll(".tag-checkbox:checked")).map((cb) => cb.value);
383
384        const url = new URL(window.location);
385        if (this.selectedTags.length) url.searchParams.set("tags", this.selectedTags.join(","));
386        else url.searchParams.delete("tags");
387        window.history.replaceState({}, document.title, url);
388
389        const filtered = this.selectedTags.length
390          ? this.blogPosts.filter((p) => this.selectedTags.some((tag) => p.tags.includes(tag)))
391          : this.blogPosts;
392
393        requestAnimationFrame(() => {
394          this.renderPaginatedBlogPosts(page, filtered);
395          this.renderPagination(this.calculateTotalPages(filtered), page);
396          this.updateRemoveFiltersButtonVisibility();
397          this.setupReadFullArticleButtons();
398          this.addDynamicEventListeners();
399
400          const container = this.getBlogPostsContainer();
401          if (container) {
402            window.scrollTo({
403              top: container.getBoundingClientRect().top + window.scrollY - 250,
404              behavior: "smooth",
405            });
406          }
407        });
408      }.bind(this),
409      200
410    );
411  },
412
413  filterByTag(tag) {
414    const cb = document.getElementById(`tag${tag}Checkbox`);
415    if (cb) cb.checked = !cb.checked;
416    this.filterAndRenderBlogs();
417  },
418
419  updateRemoveFiltersButtonVisibility() {
420    const btn = document.getElementById("removeFiltersButton");
421    if (!btn) return;
422
423    if (this.selectedTags.length) {
424      btn.style.display = "inline-block";
425      setTimeout(() => btn.classList.add("visible"), 50);
426    } else {
427      btn.classList.remove("visible");
428      setTimeout(() => (btn.style.display = "none"), 300);
429    }
430  },
431
432  removeFilters() {
433    this.selectedTags = [];
434    document.querySelectorAll(".tag-checkbox").forEach((cb) => (cb.checked = false));
435
436    requestAnimationFrame(() => {
437      const url = new URL(window.location);
438      url.searchParams.delete("tags");
439      window.history.replaceState({}, document.title, url.pathname);
440      this.filterAndRenderBlogs();
441      this.updateRemoveFiltersButtonVisibility();
442    });
443  },
444
445  renderPaginatedBlogPosts(page, posts) {
446    const slice = posts.slice((page - 1) * this.config.postsPerPage, page * this.config.postsPerPage);
447    this.renderBlogPosts(slice);
448  },
449
450  renderPagination(totalPages, currentPage) {
451    const pagination = document.getElementById("pagination");
452    if (!pagination) return;
453
454    if (totalPages <= 1) {
455      pagination.innerHTML = "";
456      return;
457    }
458
459    pagination.innerHTML = `
460	<ul class="pagination justify-content-center">
461		<li class="page-item ${currentPage === 1 ? "disabled" : ""}">
462			<button class="page-link page-button" data-page="${currentPage - 1}" ${currentPage === 1 ? "disabled" : ""}>Previous</button>
463		</li>
464		${Array.from({ length: totalPages }, (_, i) => {
465      const page = i + 1;
466      return `<li class="page-item ${currentPage === page ? "active" : ""}">
467					<button class="page-link page-button" data-page="${page}">${page}</button>
468				</li>`;
469    }).join("")}
470		<li class="page-item ${currentPage === totalPages ? "disabled" : ""}">
471			<button class="page-link page-button" data-page="${currentPage + 1}" ${currentPage === totalPages ? "disabled" : ""}>Next</button>
472		</li>
473	</ul>
474`;
475
476    document.querySelectorAll(".page-button").forEach((btn) => {
477      btn.addEventListener("click", (e) => {
478        const page = parseInt(e.target.dataset.page, 10);
479        this.filterAndRenderBlogs(page);
480      });
481    });
482  },
483
484  setupReadFullArticleButtons() {
485    const title = document.querySelector('[data-type="page-title"]')?.textContent || this.config.pageNameAlt;
486
487    document.querySelectorAll(".read-full-article").forEach((btn) => {
488      btn.addEventListener("click", (e) => {
489        this.showFullArticle(e.target.dataset.index, title);
490      });
491    });
492  },
493
494  handleAction(e) {
495    e.preventDefault();
496    e.stopPropagation();
497
498    const card = e.currentTarget.closest(".blog-post-card, .article-header");
499    if (!card) return;
500
501    const postId = card.dataset.postId;
502    const isShare = e.currentTarget.classList.contains("share-button");
503
504    if (isShare) this.createSharePostLink(postId);
505    else this.copyBlogLinkToClipboard(postId);
506  },
507
508  handleTagChange() {
509    this.filterAndRenderBlogs();
510  },
511
512  handlePageClick(e) {
513    e.preventDefault();
514    const page = parseInt(e.currentTarget.dataset.page, 10);
515    this.filterAndRenderBlogs(page);
516  },
517
518  handleTagClick(e) {
519    e.stopPropagation();
520    this.filterByTag(e.currentTarget.textContent.trim());
521  },
522
523  handleTagKeydown(e) {
524    if (e.key === "Enter" || e.key === " ") {
525      e.preventDefault();
526      this.filterByTag(e.currentTarget.textContent.trim());
527    }
528  },
529
530  handleCardClick(e) {
531    if (e.target.closest(".blog-tag, .share-button, .copy-button, #removeFiltersButton, .page-button, .pagination")) return;
532
533    const card = e.currentTarget;
534    const referralMode = this.isReferralMode();
535    const referralLink = this.normalizeExternalUrl(card.dataset.referralLink);
536
537    if (referralMode && referralLink) {
538      window.open(referralLink, "_blank", "noopener,noreferrer");
539      return;
540    }
541
542    const postId = card.dataset.postId;
543    window.location.href = this.getInternalPostUrl(postId);
544  },
545
546  addDynamicEventListeners() {
547    const bindOnce = (selector, event, handler) => {
548      document.querySelectorAll(`${selector}:not([data-bound])`).forEach((el) => {
549        el.addEventListener(event, handler.bind(this));
550        el.dataset.bound = "true";
551      });
552    };
553
554    bindOnce(".tag-checkbox", "change", this.handleTagChange);
555    bindOnce(".blog-post-card", "click", this.handleCardClick);
556    bindOnce(".page-button", "click", this.handlePageClick);
557    bindOnce(".blog-tag", "click", this.handleTagClick);
558    bindOnce(".blog-tag", "keydown", this.handleTagKeydown);
559
560    const removeBtn = document.getElementById("removeFiltersButton");
561    if (removeBtn && !removeBtn.dataset.bound) {
562      removeBtn.addEventListener("click", (e) => {
563        e.stopPropagation();
564        this.removeFilters();
565      });
566      removeBtn.dataset.bound = "true";
567    }
568
569    document.querySelectorAll(".share-button, .copy-button").forEach((el) => {
570      if (el.dataset.shareCopyBound) return;
571
572      el.addEventListener("click", this.handleAction.bind(this));
573      el.addEventListener("keydown", (e) => {
574        if (e.key === "Enter" || e.key === " ") this.handleAction(e);
575      });
576
577      el.dataset.shareCopyBound = "true";
578    });
579  },
580
581  initializeBlog() {
582    this.initFilterAndRenderBlogs();
583
584    const pageTitle = document.querySelector('[data-type="page-title"]')?.textContent || this.config.pageNameAlt;
585
586    this.fetchBlogPosts().then(() => {
587      const totalPages = this.calculateTotalPages(this.blogPosts);
588
589      this.generateTagChecklist();
590      this.renderPaginatedBlogPosts(1, this.blogPosts);
591      this.renderPagination(totalPages, 1);
592      this.setupReadFullArticleButtons();
593
594      const params = new URLSearchParams(window.location.search);
595      const post = params.get("post");
596      const tags = params.get("tags");
597
598      if (tags) {
599        this.selectedTags = tags.split(",");
600        this.updateCheckboxesBasedOnTags(this.selectedTags);
601      }
602
603      this.filterAndRenderBlogs(1);
604
605      if (post) this.showFullArticle(post, pageTitle);
606
607      this.addDynamicEventListeners();
608    });
609  },
610};
611
612window.blogManager = blogManager;
613
614if (document.getElementById("blogPostsContainer")) {
615  blogManager.initializeBlog();
616}

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.