summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBigCoke233 <hi@guhub.cn>2026-01-17 01:08:30 +0800
committerBigCoke233 <hi@guhub.cn>2026-01-17 01:08:30 +0800
commitae0c23f3f95c762819a67840e73928a38bdfe5b3 (patch)
treeefd09ff0034a6a7f05ff0c51c8535f6498021c74
parent7ed6af4c26a43d7e69e774df8b66bfd7e4b63110 (diff)
perf: remove inline scripts and bundle JS files
-rw-r--r--assets/js/back2top.js57
-rw-r--r--assets/js/giscus-count.js30
-rw-r--r--assets/js/like.js120
-rw-r--r--assets/js/toc.js40
-rw-r--r--layouts/_partials/components/back-to-top.html57
-rw-r--r--layouts/_partials/posts/action-menu/comment-button.html41
-rw-r--r--layouts/_partials/posts/action-menu/like-button.html115
-rw-r--r--layouts/_partials/scripts.html85
8 files changed, 321 insertions, 224 deletions
diff --git a/assets/js/back2top.js b/assets/js/back2top.js
new file mode 100644
index 00000000..86f8d11d
--- /dev/null
+++ b/assets/js/back2top.js
@@ -0,0 +1,57 @@
+// Back2top, the Batrick
+// author: Eltrac
+
+const backToTop = document.getElementById("back2top")
+const backToTopButton = document.getElementById("back2top-button")
+const backToTopInstruction = document.getElementById("back2top-instruction")
+const seenAndUsedBat = localStorage.getItem("geedeapro.ui.seenAndUsedBat")
+
+function hideInstruction() {
+ backToTopInstruction.remove()
+}
+if (seenAndUsedBat) hideInstruction()
+
+// listen to button click and scroll to top
+backToTopButton.addEventListener("click", () => {
+ window.scrollTo({ top: 0, behavior: "smooth" })
+ if (!seenAndUsedBat) {
+ localStorage.setItem("geedeapro.ui.seenAndUsedBat", true);
+ hideInstruction();
+ }
+})
+
+// listen to scroll and toggle button visibility
+let lastOffset = window.scrollY
+
+window.addEventListener("scroll", () => {
+ const offset = window.scrollY
+ const batIsHidden = backToTop.classList.contains("-top-25")
+ const enoughHeight = offset >= 2000
+ const isScrollingUp = offset < lastOffset
+ const isScrollingDown = offset > lastOffset
+
+ function hide() {
+ backToTop.classList.remove("top-0")
+ backToTop.classList.add("-top-25")
+ backToTop.classList.remove("animate-head-shake")
+ }
+
+ function show() {
+ backToTop.classList.remove("-top-25")
+ backToTop.classList.add("top-0")
+ backToTop.classList.add("animate-head-shake")
+ }
+
+ if (enoughHeight && isScrollingUp && batIsHidden) {
+ // show only when scrolling up
+ show()
+ } else if (isScrollingDown && !batIsHidden) {
+ // hide as soon as user scrolls down again
+ hide()
+ }
+
+ // hide no matter what if too close to top
+ if (!enoughHeight) hide()
+
+ lastOffset = offset
+})
diff --git a/assets/js/giscus-count.js b/assets/js/giscus-count.js
new file mode 100644
index 00000000..b6b01e61
--- /dev/null
+++ b/assets/js/giscus-count.js
@@ -0,0 +1,30 @@
+// count giscus comments and update DOM
+// author: Eltrac
+document.addEventListener('DOMContentLoaded', () => {
+ const commentCountEl = document.getElementById('comment-count');
+ if (!commentCountEl) return;
+
+ const observer = new MutationObserver(() => {
+ // check if giscus is loaded
+ const giscusIframe = document.querySelector('iframe.giscus-frame');
+ if (!giscusIframe) return;
+
+ giscusIframe.addEventListener('load', () => {
+ // receive message from giscus iframe
+ window.addEventListener('message', function(event) {
+ if (event.origin !== 'https://giscus.app') return;
+ const giscusData = event.data;
+ if (giscusData.giscus?.discussion) {
+ const discussion = giscusData.giscus.discussion;
+ const totalComments = (discussion.totalCommentCount || 0) + (discussion.totalReplyCount || 0);
+ // set comment count
+ commentCountEl.textContent = totalComments.toString();
+ // if no discussion found, set to 0
+ } else commentCountEl.textContent = '0';
+ });
+ });
+
+ observer.disconnect();
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+});
diff --git a/assets/js/like.js b/assets/js/like.js
new file mode 100644
index 00000000..684bc25a
--- /dev/null
+++ b/assets/js/like.js
@@ -0,0 +1,120 @@
+// implement features of like buttons in action menu
+// 1. fetch likes
+// 2. send like to server
+// 3. visualize like data (orgam farm)
+// 4. update like states
+
+document.addEventListener('DOMContentLoaded', () => {
+ // get page ID to fetch and update like data
+ const pageId = "{{ $pageID }}";
+
+ // get required elements
+ const elements = {
+ count: document.getElementById("like-count-" + pageId),
+ btn: document.getElementById("like-btn-" + pageId),
+ container: document.getElementById("like-container-" + pageId),
+ icon: document.getElementById("like-icon-" + pageId)
+ };
+
+ // return if no page ID found or not in production or no elements
+ if (
+ !pageId || window.location.host !== 'www.geedea.pro' ||
+ !elements.count || !elements.btn || !elements.container
+ ) return;
+
+ function setLikedState() {
+ // update classes
+ elements.icon?.classList.add('liked-heart');
+ elements.icon?.classList.add('animate-heart-beat');
+ elements.count?.classList.add('liked-count');
+ elements.btn.disabled = true;
+ elements.container.classList.add('liked');
+ // update tooltip
+ elements.btn.setAttribute('title',
+ elements.container.classList.contains('liked')
+ ? elements.btn.getAttribute('data-liked-title')
+ : '为这篇文章献上心脏'
+ );
+ }
+
+ async function fetchLikes() {
+ try {
+ const response = await fetch("https://like.guhub.cn/like/" + pageId);
+ const data = await response.json();
+ elements.count.innerText = data.likes ?? 0;
+ if (localStorage.getItem("liked_" + pageId)) {
+ setLikedState();
+ updateOrganFarm(data.likes);
+ }
+ } catch(e) {
+ console.log(e);
+ elements.count.innerText = "❌";
+ }
+ }
+
+ window.sendLike = async function(pageId) {
+ if (localStorage.getItem("liked_" + pageId)) return;
+
+ try {
+ const response = await fetch("https://like.guhub.cn/like/" + pageId, { method: "POST" });
+ const data = await response.json();
+ localStorage.setItem("liked_" + pageId, "1");
+ elements.count.innerText = data.likes ?? 1;
+ updateOrganFarm(data.likes);
+ setLikedState();
+ } catch(e) {
+ console.log(e);
+ alert("点赞失败,请稍后再试");
+ }
+ };
+
+ function updateOrganFarm(count) {
+ const organFarm = document.getElementById("organ-farm");
+ const organCount = count || parseInt(elements.count.innerText);
+ const organImages = [
+ "https://image.guhub.cn/picgo2025/oragn-3.png",
+ "https://image.guhub.cn/picgo2025/organ-1.png",
+ "https://image.guhub.cn/picgo2025/organ-2.png"
+ ];
+
+ if (!organFarm) return;
+ if (organFarm.classList.contains("hidden")) {
+ organFarm.classList.remove("hidden");
+ organFarm.classList.add("animate-slide-in-down");
+ }
+
+ organFarm.innerHTML = "";
+ for (let i = 0; i < organCount; i++) {
+ const img = document.createElement("img");
+ img.className = "transition hover:scale-150 dark:invert";
+
+ const sizeRange = [6, 10];
+ const size = Math.floor(Math.random() * (sizeRange[1] - sizeRange[0] + 1)) + sizeRange[0];
+ img.classList.add(`w-${size}`, `h-${size}`);
+
+ img.src = organImages[Math.floor(Math.random() * organImages.length)];
+ // randomly offset and rotate
+ img.classList.add("rotate-" + Math.floor(Math.random() * 5));
+ img.classList.add("translate-x-" + Math.floor(Math.random() * 5));
+ img.classList.add("translate-y-" + Math.floor(Math.random() * 5));
+ img.classList.add("opacity" + (100 - Math.floor(Math.random() * 3)*10));
+
+ organFarm.appendChild(img);
+ }
+ }
+
+ // fetch likes when action menu is visible
+ const observer = new IntersectionObserver((entries, obs) => {
+ if (entries[0].isIntersecting) {
+ fetchLikes();
+ obs.disconnect();
+ }
+ });
+ observer.observe(document.getElementById("action-menu"));
+
+ // add click event listener to like button
+ const likeBtn = document.getElementById("like-btn");
+ likeBtn.addEventListener("click", _ => {
+ sendLike(likeBtn.dataset.pageID)
+ })
+});
diff --git a/assets/js/toc.js b/assets/js/toc.js
new file mode 100644
index 00000000..ec0028db
--- /dev/null
+++ b/assets/js/toc.js
@@ -0,0 +1,40 @@
+// Animate Hugo-built TOC
+// author: Eltrac
+
+document.addEventListener('DOMContentLoaded', () => {
+ const toc = document.getElementById('toc');
+ if (!toc) return;
+
+ const headings = document.querySelectorAll('h2, h3');
+ const observer = new IntersectionObserver(entries => {
+ entries.forEach(entry => {
+ if (!entry.isIntersecting) return;
+
+ const id = entry.target.id;
+ const tocLink = toc.querySelector(`a[href="#${id}"]`);
+ if (!tocLink) return;
+
+ const listItem = tocLink.parentElement;
+ const isH2 = entry.target.tagName === 'H2';
+
+ // Clear all active states when a new H2 is encountered
+ if (isH2) {
+ toc.querySelectorAll('li.active').forEach(el => el.classList.remove('active'));
+ }
+
+ // Set current item as active
+ listItem.classList.add('active');
+
+ // If this is an H3, find and activate its parent H2
+ if (!isH2) {
+ const parentH2Item = listItem.closest('ul').previousElementSibling?.closest('li');
+ if (parentH2Item) parentH2Item.classList.add('active');
+ }
+ });
+ }, {
+ rootMargin: '0px 0px -50% 0px',
+ threshold: 0.5
+ });
+
+ headings.forEach(heading => observer.observe(heading));
+});
diff --git a/layouts/_partials/components/back-to-top.html b/layouts/_partials/components/back-to-top.html
index 6ef367b6..84be73d6 100644
--- a/layouts/_partials/components/back-to-top.html
+++ b/layouts/_partials/components/back-to-top.html
@@ -13,60 +13,3 @@
</button>
</div>
-<script>
-// Batrick functionality
-const backToTop = document.getElementById("back2top")
-const backToTopButton = document.getElementById("back2top-button")
-const backToTopInstruction = document.getElementById("back2top-instruction")
-const seenAndUsedBat = localStorage.getItem("geedeapro.ui.seenAndUsedBat")
-
-function hideInstruction() {
- backToTopInstruction.remove()
-}
-if (seenAndUsedBat) hideInstruction()
-
-// listen to button click and scroll to top
-backToTopButton.addEventListener("click", () => {
- window.scrollTo({ top: 0, behavior: "smooth" })
- if (!seenAndUsedBat) {
- localStorage.setItem("geedeapro.ui.seenAndUsedBat", true);
- hideInstruction();
- }
-})
-
-// listen to scroll and toggle button visibility
-let lastOffset = window.scrollY
-
-window.addEventListener("scroll", () => {
- const offset = window.scrollY
- const batIsHidden = backToTop.classList.contains("-top-25")
- const enoughHeight = offset >= 2000
- const isScrollingUp = offset < lastOffset
- const isScrollingDown = offset > lastOffset
-
- function hide() {
- backToTop.classList.remove("top-0")
- backToTop.classList.add("-top-25")
- backToTop.classList.remove("animate-head-shake")
- }
-
- function show() {
- backToTop.classList.remove("-top-25")
- backToTop.classList.add("top-0")
- backToTop.classList.add("animate-head-shake")
- }
-
- if (enoughHeight && isScrollingUp && batIsHidden) {
- // show only when scrolling up
- show()
- } else if (isScrollingDown && !batIsHidden) {
- // hide as soon as user scrolls down again
- hide()
- }
-
- // hide no matter what if too close to top
- if (!enoughHeight) hide()
-
- lastOffset = offset
-})
-</script>
diff --git a/layouts/_partials/posts/action-menu/comment-button.html b/layouts/_partials/posts/action-menu/comment-button.html
index 7dcea107..8ad02ae0 100644
--- a/layouts/_partials/posts/action-menu/comment-button.html
+++ b/layouts/_partials/posts/action-menu/comment-button.html
@@ -1,44 +1,7 @@
-{{ partial "components/toggle-button.html" (dict
+{{ partial "components/toggle-button.html" (dict
"id" "comments"
"title" (T "actionMenu.comment")
"icon" "/img/comments.svg"
- "countId" "comment-count"
+ "countId" "comment-count"
"group" "action-menu-section"
) }}
-
-<script>
-// initialize button features and comment count
-document.addEventListener('DOMContentLoaded', () => {
-
- function initCommentCount() {
- const commentCountEl = document.getElementById('comment-count');
- if (!commentCountEl) return;
-
- const observer = new MutationObserver(() => {
- // check if giscus is loaded
- const giscusIframe = document.querySelector('iframe.giscus-frame');
- if (!giscusIframe) return;
-
- giscusIframe.addEventListener('load', () => {
- // receive message from giscus iframe
- window.addEventListener('message', function(event) {
- if (event.origin !== 'https://giscus.app') return;
- const giscusData = event.data;
- if (giscusData.giscus?.discussion) {
- const discussion = giscusData.giscus.discussion;
- const totalComments = (discussion.totalCommentCount || 0) + (discussion.totalReplyCount || 0);
- // set comment count
- commentCountEl.textContent = totalComments.toString();
- // if no discussion found, set to 0
- } else commentCountEl.textContent = '0';
- });
- });
-
- observer.disconnect();
- });
- observer.observe(document.body, { childList: true, subtree: true });
- }
-
- initCommentCount();
-});
-</script>
diff --git a/layouts/_partials/posts/action-menu/like-button.html b/layouts/_partials/posts/action-menu/like-button.html
index c9e793b8..485660e3 100644
--- a/layouts/_partials/posts/action-menu/like-button.html
+++ b/layouts/_partials/posts/action-menu/like-button.html
@@ -4,8 +4,8 @@
<button class="dark:invert flex items-center justify-center gap-1.5 min-w-12 h-12 px-2
border-none bg-transparent text-gray-600 cursor-pointer relative transition-all duration-200
hover:bg-black/5 hover:rounded-lg group animate-count-infinite"
- onclick="sendLike('{{ $pageID }}')"
- id="like-btn-{{ $pageID }}"
+ id="like-btn"
+ data-pageID="{{ $pageID }}"
title="{{ T "actionMenu.like" }}"
data-liked-title="{{ T "actionMenu.liked" }}"
data-tooltip>
@@ -25,114 +25,3 @@
color: #8B0000 !important;
}
</style>
-
-<script>
-document.addEventListener('DOMContentLoaded', () => {
- // get page ID to fetch and update like data
- const pageId = "{{ $pageID }}";
-
- // get required elements
- const elements = {
- count: document.getElementById("like-count-" + pageId),
- btn: document.getElementById("like-btn-" + pageId),
- container: document.getElementById("like-container-" + pageId),
- icon: document.getElementById("like-icon-" + pageId)
- };
-
- // return if no page ID found or not in production or no elements
- if (
- !pageId || window.location.host !== 'www.geedea.pro' ||
- !elements.count || !elements.btn || !elements.container
- ) return;
-
- function setLikedState() {
- // update classes
- elements.icon?.classList.add('liked-heart');
- elements.icon?.classList.add('animate-heart-beat');
- elements.count?.classList.add('liked-count');
- elements.btn.disabled = true;
- elements.container.classList.add('liked');
- // update tooltip
- elements.btn.setAttribute('title',
- elements.container.classList.contains('liked')
- ? elements.btn.getAttribute('data-liked-title')
- : '为这篇文章献上心脏'
- );
- }
-
- async function fetchLikes() {
- try {
- const response = await fetch("https://like.guhub.cn/like/" + pageId);
- const data = await response.json();
- elements.count.innerText = data.likes ?? 0;
- if (localStorage.getItem("liked_" + pageId)) {
- setLikedState();
- updateOrganFarm(data.likes);
- }
- } catch(e) {
- console.log(e);
- elements.count.innerText = "❌";
- }
- }
-
- window.sendLike = async function(pageId) {
- if (localStorage.getItem("liked_" + pageId)) return;
-
- try {
- const response = await fetch("https://like.guhub.cn/like/" + pageId, { method: "POST" });
- const data = await response.json();
- localStorage.setItem("liked_" + pageId, "1");
- elements.count.innerText = data.likes ?? 1;
- updateOrganFarm(data.likes);
- setLikedState();
- } catch(e) {
- console.log(e);
- alert("点赞失败,请稍后再试");
- }
- };
-
- function updateOrganFarm(count) {
- const organFarm = document.getElementById("organ-farm");
- const organCount = count || parseInt(elements.count.innerText);
- const organImages = [
- "https://image.guhub.cn/picgo2025/oragn-3.png",
- "https://image.guhub.cn/picgo2025/organ-1.png",
- "https://image.guhub.cn/picgo2025/organ-2.png"
- ];
-
- if (!organFarm) return;
- if (organFarm.classList.contains("hidden")) {
- organFarm.classList.remove("hidden");
- organFarm.classList.add("animate-slide-in-down");
- }
-
- organFarm.innerHTML = "";
- for (let i = 0; i < organCount; i++) {
- const img = document.createElement("img");
- img.className = "transition hover:scale-150 dark:invert";
-
- const sizeRange = [6, 10];
- const size = Math.floor(Math.random() * (sizeRange[1] - sizeRange[0] + 1)) + sizeRange[0];
- img.classList.add(`w-${size}`, `h-${size}`);
-
- img.src = organImages[Math.floor(Math.random() * organImages.length)];
- // randomly offset and rotate
- img.classList.add("rotate-" + Math.floor(Math.random() * 5));
- img.classList.add("translate-x-" + Math.floor(Math.random() * 5));
- img.classList.add("translate-y-" + Math.floor(Math.random() * 5));
- img.classList.add("opacity" + (100 - Math.floor(Math.random() * 3)*10));
-
- organFarm.appendChild(img);
- }
- }
-
- // fetch likes when action menu is visible
- const observer = new IntersectionObserver((entries, obs) => {
- if (entries[0].isIntersecting) {
- fetchLikes();
- obs.disconnect();
- }
- });
- observer.observe(document.getElementById("action-menu"));
-});
-</script>
diff --git a/layouts/_partials/scripts.html b/layouts/_partials/scripts.html
index 878ac01e..9da17ba8 100644
--- a/layouts/_partials/scripts.html
+++ b/layouts/_partials/scripts.html
@@ -1,21 +1,76 @@
-{{ with resources.Get "js/tooltip.js" }}
-<!-- tooltip by Eltrac -->
-<script>{{ .Content | safeJS }}</script>
-{{ end }}
+<!-- global JS -->
+{{ $commonJS := slice
+ (resources.Get "js/tooltip.js")
+ (resources.Get "js/instantpage.js")
+}}
+{{ $commonBundle := $commonJS | resources.Concat "js/common-bundle.js" | minify | fingerprint }}
+<script src="{{ $commonBundle.RelPermalink }}" integrity="{{ $commonBundle.Data.Integrity }}" defer></script>
+<!-- content page only JS -->
{{ if not .IsHome }}
-{{ with resources.Get "js/inject-footnotes.js" }}
-<!-- inject footnotes by Eltrac -->
-<script>{{ .Content | safeJS }}</script>
-{{ end }}
+ {{ $articleJS := slice
+ (resources.Get "js/toc.js")
+ (resources.Get "js/inject-footnotes.js")
+ (resources.Get "js/back2top.js")
+ (resources.Get "js/giscus-count.js")
+ (resources.Get "js/like.js")
+ }}
+ {{ $articleBundle := $articleJS | resources.Concat "js/article-bundle.js" | minify | fingerprint }}
+ <script src="{{ $articleBundle.RelPermalink }}" integrity="{{ $articleBundle.Data.Integrity }}" defer></script>
{{ end }}
-{{ with resources.Get "js/instantpage.js" }}
-<!-- instantpage.js -->
-<script>{{ .Content | safeJS }}</script>
-{{ end }}
+<!-- libra.js -->
+<script type="module">
+document.addEventListener('DOMContentLoaded', async () => {
+ const { default: LightBox } = await import('/js/libra.min.js');
+ const lightbox = new LightBox({ offset: { x: -8, y: -80 } });
+});
+</script>
+
+{{ if and (not .IsHome) .Params.hasAplayer }}
+<script>
+document.addEventListener("DOMContentLoaded", function () {
+ const lazyPlayers = document.querySelectorAll(".lazy-aplayer");
+ if (!lazyPlayers.length) return;
-{{ partial "scripts/toc-js" . }}
-{{ partial "scripts/libra-js" . }}
-{{ partial "scripts/aplayer-js" . }}
+ const loadAPlayer = () => {
+ if (window.APlayer) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = "https://unpkg.com/aplayer@1.10.1/dist/APlayer.min.js";
+ script.onload = resolve;
+ script.onerror = reject;
+ document.body.appendChild(script);
+ });
+ };
+ const observer = new IntersectionObserver((entries, obs) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ loadAPlayer().then(() => {
+ const el = entry.target;
+ const id = el.dataset.id ? `-` + el.dataset.id : ``;
+ const container = document.createElement('div');
+ container.id = `aplayer${id}`;
+ el.appendChild(container);
+
+ const ap = new APlayer({
+ container: container,
+ audio: [{
+ name: el.dataset.name,
+ artist: el.dataset.artist,
+ url: el.dataset.url,
+ cover: el.dataset.cover
+ }]
+ });
+
+ obs.unobserve(el);
+ });
+ }
+ });
+ });
+
+ lazyPlayers.forEach(player => observer.observe(player));
+});
+</script>
+{{ end }}