diff options
| author | BigCoke233 <hi@guhub.cn> | 2026-01-17 01:08:30 +0800 |
|---|---|---|
| committer | BigCoke233 <hi@guhub.cn> | 2026-01-17 01:08:30 +0800 |
| commit | ae0c23f3f95c762819a67840e73928a38bdfe5b3 (patch) | |
| tree | efd09ff0034a6a7f05ff0c51c8535f6498021c74 | |
| parent | 7ed6af4c26a43d7e69e774df8b66bfd7e4b63110 (diff) | |
perf: remove inline scripts and bundle JS files
| -rw-r--r-- | assets/js/back2top.js | 57 | ||||
| -rw-r--r-- | assets/js/giscus-count.js | 30 | ||||
| -rw-r--r-- | assets/js/like.js | 120 | ||||
| -rw-r--r-- | assets/js/toc.js | 40 | ||||
| -rw-r--r-- | layouts/_partials/components/back-to-top.html | 57 | ||||
| -rw-r--r-- | layouts/_partials/posts/action-menu/comment-button.html | 41 | ||||
| -rw-r--r-- | layouts/_partials/posts/action-menu/like-button.html | 115 | ||||
| -rw-r--r-- | layouts/_partials/scripts.html | 85 |
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 }} |
