diff --git a/assets/js/categories.js b/assets/js/categories.js new file mode 100644 index 0000000..ce87d67 --- /dev/null +++ b/assets/js/categories.js @@ -0,0 +1,7 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { categoryCollapse } from './modules/components'; + +basic(); +initSidebar(); +initTopbar(); +categoryCollapse(); diff --git a/assets/js/commons.js b/assets/js/commons.js new file mode 100644 index 0000000..6a17fb9 --- /dev/null +++ b/assets/js/commons.js @@ -0,0 +1,5 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; + +initSidebar(); +initTopbar(); +basic(); diff --git a/assets/js/home.js b/assets/js/home.js new file mode 100644 index 0000000..7f628a1 --- /dev/null +++ b/assets/js/home.js @@ -0,0 +1,8 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { initLocaleDatetime, loadImg } from './modules/components'; + +loadImg(); +initLocaleDatetime(); +initSidebar(); +initTopbar(); +basic(); diff --git a/assets/js/misc.js b/assets/js/misc.js new file mode 100644 index 0000000..37130da --- /dev/null +++ b/assets/js/misc.js @@ -0,0 +1,7 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { initLocaleDatetime } from './modules/components'; + +initSidebar(); +initTopbar(); +initLocaleDatetime(); +basic(); diff --git a/assets/js/modules/components.js b/assets/js/modules/components.js new file mode 100644 index 0000000..95791a6 --- /dev/null +++ b/assets/js/modules/components.js @@ -0,0 +1,10 @@ +export { categoryCollapse } from './components/category-collapse'; +export { initClipboard } from './components/clipboard'; +export { loadImg } from './components/img-loading'; +export { imgPopup } from './components/img-popup'; +export { initLocaleDatetime } from './components/locale-datetime'; +export { initToc } from './components/toc'; +export { loadMermaid } from './components/mermaid'; +export { modeWatcher } from './components/mode-toggle'; +export { back2top } from './components/back-to-top'; +export { loadTooptip } from './components/tooltip-loader'; diff --git a/assets/js/modules/components/back-to-top.js b/assets/js/modules/components/back-to-top.js new file mode 100644 index 0000000..40d9cd1 --- /dev/null +++ b/assets/js/modules/components/back-to-top.js @@ -0,0 +1,19 @@ +/** + * Reference: https://bootsnipp.com/snippets/featured/link-to-top-page + */ + +export function back2top() { + const btn = document.getElementById('back-to-top'); + + window.addEventListener('scroll', () => { + if (window.scrollY > 50) { + btn.classList.add('show'); + } else { + btn.classList.remove('show'); + } + }); + + btn.addEventListener('click', () => { + window.scrollTo({ top: 0 }); + }); +} diff --git a/assets/js/modules/components/category-collapse.js b/assets/js/modules/components/category-collapse.js new file mode 100644 index 0000000..0c53cb4 --- /dev/null +++ b/assets/js/modules/components/category-collapse.js @@ -0,0 +1,36 @@ +/** + * Tab 'Categories' expand/close effect. + */ + +import 'bootstrap/js/src/collapse.js'; + +const childPrefix = 'l_'; +const parentPrefix = 'h_'; +const children = document.getElementsByClassName('collapse'); + +export function categoryCollapse() { + [...children].forEach((elem) => { + const id = parentPrefix + elem.id.substring(childPrefix.length); + const parent = document.getElementById(id); + + // collapse sub-categories + elem.addEventListener('hide.bs.collapse', () => { + if (parent) { + parent.querySelector('.far.fa-folder-open').className = + 'far fa-folder fa-fw'; + parent.querySelector('.fas.fa-angle-down').classList.add('rotate'); + parent.classList.remove('hide-border-bottom'); + } + }); + + // expand sub-categories + elem.addEventListener('show.bs.collapse', () => { + if (parent) { + parent.querySelector('.far.fa-folder').className = + 'far fa-folder-open fa-fw'; + parent.querySelector('.fas.fa-angle-down').classList.remove('rotate'); + parent.classList.add('hide-border-bottom'); + } + }); + }); +} diff --git a/assets/js/modules/components/clipboard.js b/assets/js/modules/components/clipboard.js new file mode 100644 index 0000000..9566e9d --- /dev/null +++ b/assets/js/modules/components/clipboard.js @@ -0,0 +1,143 @@ +/** + * Clipboard functions + * + * Dependencies: + * clipboard.js (https://github.com/zenorocha/clipboard.js) + */ + +import Tooltip from 'bootstrap/js/src/tooltip'; + +const clipboardSelector = '.code-header>button'; + +const ICON_DEFAULT = 'far fa-clipboard'; +const ICON_SUCCESS = 'fas fa-check'; + +const ATTR_TIMEOUT = 'timeout'; +const ATTR_TITLE_SUCCEED = 'data-title-succeed'; +const ATTR_TITLE_ORIGIN = 'data-bs-original-title'; +const TIMEOUT = 2000; // in milliseconds + +function isLocked(node) { + if (node.hasAttribute(ATTR_TIMEOUT)) { + let timeout = node.getAttribute(ATTR_TIMEOUT); + if (Number(timeout) > Date.now()) { + return true; + } + } + + return false; +} + +function lock(node) { + node.setAttribute(ATTR_TIMEOUT, Date.now() + TIMEOUT); +} + +function unlock(node) { + node.removeAttribute(ATTR_TIMEOUT); +} + +function showTooltip(btn) { + const succeedTitle = btn.getAttribute(ATTR_TITLE_SUCCEED); + btn.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); + Tooltip.getInstance(btn).show(); +} + +function hideTooltip(btn) { + Tooltip.getInstance(btn).hide(); + btn.removeAttribute(ATTR_TITLE_ORIGIN); +} + +function setSuccessIcon(btn) { + const icon = btn.children[0]; + icon.setAttribute('class', ICON_SUCCESS); +} + +function resumeIcon(btn) { + const icon = btn.children[0]; + icon.setAttribute('class', ICON_DEFAULT); +} + +function setCodeClipboard() { + const clipboardList = document.querySelectorAll(clipboardSelector); + + if (clipboardList.length === 0) { + return; + } + + // Initial the clipboard.js object + const clipboard = new ClipboardJS(clipboardSelector, { + target: (trigger) => { + const codeBlock = trigger.parentNode.nextElementSibling; + return codeBlock.querySelector('code .rouge-code'); + } + }); + + [...clipboardList].map( + (elem) => + new Tooltip(elem, { + placement: 'left' + }) + ); + + clipboard.on('success', (e) => { + const trigger = e.trigger; + + e.clearSelection(); + + if (isLocked(trigger)) { + return; + } + + setSuccessIcon(trigger); + showTooltip(trigger); + lock(trigger); + + setTimeout(() => { + hideTooltip(trigger); + resumeIcon(trigger); + unlock(trigger); + }, TIMEOUT); + }); +} + +function setLinkClipboard() { + const btnCopyLink = document.getElementById('copy-link'); + + if (btnCopyLink === null) { + return; + } + + btnCopyLink.addEventListener('click', (e) => { + const target = e.target; + + if (isLocked(target)) { + return; + } + + // Copy URL to clipboard + navigator.clipboard.writeText(window.location.href).then(() => { + const defaultTitle = target.getAttribute(ATTR_TITLE_ORIGIN); + const succeedTitle = target.getAttribute(ATTR_TITLE_SUCCEED); + + // Switch tooltip title + target.setAttribute(ATTR_TITLE_ORIGIN, succeedTitle); + Tooltip.getInstance(target).show(); + + lock(target); + + setTimeout(() => { + target.setAttribute(ATTR_TITLE_ORIGIN, defaultTitle); + unlock(target); + }, TIMEOUT); + }); + }); + + btnCopyLink.addEventListener('mouseleave', (e) => { + Tooltip.getInstance(e.target).hide(); + }); +} + +export function initClipboard() { + setCodeClipboard(); + setLinkClipboard(); +} diff --git a/assets/js/modules/components/img-loading.js b/assets/js/modules/components/img-loading.js new file mode 100644 index 0000000..989d9e6 --- /dev/null +++ b/assets/js/modules/components/img-loading.js @@ -0,0 +1,67 @@ +/** + * Setting up image lazy loading and LQIP switching + */ + +const ATTR_DATA_SRC = 'data-src'; +const ATTR_DATA_LQIP = 'data-lqip'; + +const cover = { + SHIMMER: 'shimmer', + BLUR: 'blur' +}; + +function removeCover(clzss) { + this.parentElement.classList.remove(clzss); +} + +function handleImage() { + if (!this.complete) { + return; + } + + if (this.hasAttribute(ATTR_DATA_LQIP)) { + removeCover.call(this, cover.BLUR); + } else { + removeCover.call(this, cover.SHIMMER); + } +} + +/** + * Switches the LQIP with the real image URL. + */ +function switchLQIP() { + const src = this.getAttribute(ATTR_DATA_SRC); + this.setAttribute('src', encodeURI(src)); + this.removeAttribute(ATTR_DATA_SRC); +} + +export function loadImg() { + const images = document.querySelectorAll('article img'); + + if (images.length === 0) { + return; + } + + images.forEach((img) => { + img.addEventListener('load', handleImage); + }); + + // Images loaded from the browser cache do not trigger the 'load' event + document.querySelectorAll('article img[loading="lazy"]').forEach((img) => { + if (img.complete) { + removeCover.call(img, cover.SHIMMER); + } + }); + + // LQIPs set by the data URI or WebP will not trigger the 'load' event, + // so manually convert the URI to the URL of a high-resolution image. + const lqips = document.querySelectorAll( + `article img[${ATTR_DATA_LQIP}="true"]` + ); + + if (lqips.length) { + lqips.forEach((lqip) => { + switchLQIP.call(lqip); + }); + } +} diff --git a/assets/js/modules/components/img-popup.js b/assets/js/modules/components/img-popup.js new file mode 100644 index 0000000..420a226 --- /dev/null +++ b/assets/js/modules/components/img-popup.js @@ -0,0 +1,50 @@ +/** + * Set up image popup + * + * Dependencies: https://github.com/biati-digital/glightbox + */ + +const lightImages = '.popup:not(.dark)'; +const darkImages = '.popup:not(.light)'; +let selector = lightImages; + +function updateImages(current, reverse) { + if (selector === lightImages) { + selector = darkImages; + } else { + selector = lightImages; + } + + if (reverse === null) { + reverse = GLightbox({ selector: `${selector}` }); + } + + [current, reverse] = [reverse, current]; +} + +export function imgPopup() { + if (document.querySelector('.popup') === null) { + return; + } + + const hasDualImages = !( + document.querySelector('.popup.light') === null && + document.querySelector('.popup.dark') === null + ); + + if (Theme.visualState === Theme.DARK) { + selector = darkImages; + } + + let current = GLightbox({ selector: `${selector}` }); + + if (hasDualImages && Theme.switchable) { + let reverse = null; + + window.addEventListener('message', (event) => { + if (event.source === window && event.data && event.data.id === Theme.ID) { + updateImages(current, reverse); + } + }); + } +} diff --git a/assets/js/modules/components/locale-datetime.js b/assets/js/modules/components/locale-datetime.js new file mode 100644 index 0000000..eb75626 --- /dev/null +++ b/assets/js/modules/components/locale-datetime.js @@ -0,0 +1,53 @@ +/** + * Update month/day to locale datetime + * + * Requirement: + */ + +/* A tool for locale datetime */ +class LocaleHelper { + static get attrTimestamp() { + return 'data-ts'; + } + + static get attrDateFormat() { + return 'data-df'; + } + + static get locale() { + return document.documentElement.getAttribute('lang').substring(0, 2); + } + + static getTimestamp(elem) { + return Number(elem.getAttribute(this.attrTimestamp)); // unix timestamp + } + + static getDateFormat(elem) { + return elem.getAttribute(this.attrDateFormat); + } +} + +export function initLocaleDatetime() { + dayjs.locale(LocaleHelper.locale); + dayjs.extend(window.dayjs_plugin_localizedFormat); + + document + .querySelectorAll(`[${LocaleHelper.attrTimestamp}]`) + .forEach((elem) => { + const date = dayjs.unix(LocaleHelper.getTimestamp(elem)); + const text = date.format(LocaleHelper.getDateFormat(elem)); + elem.textContent = text; + elem.removeAttribute(LocaleHelper.attrTimestamp); + elem.removeAttribute(LocaleHelper.attrDateFormat); + + // setup tooltips + if ( + elem.hasAttribute('data-bs-toggle') && + elem.getAttribute('data-bs-toggle') === 'tooltip' + ) { + // see: https://day.js.org/docs/en/display/format#list-of-localized-formats + const tooltipText = date.format('llll'); + elem.setAttribute('data-bs-title', tooltipText); + } + }); +} diff --git a/assets/js/modules/components/mermaid.js b/assets/js/modules/components/mermaid.js new file mode 100644 index 0000000..91df4f2 --- /dev/null +++ b/assets/js/modules/components/mermaid.js @@ -0,0 +1,60 @@ +/** + * Mermaid-js loader + */ + +const MERMAID = 'mermaid'; +const themeMapper = Theme.getThemeMapper('default', 'dark'); + +function refreshTheme(event) { + if (event.source === window && event.data && event.data.id === Theme.ID) { + // Re-render the SVG › + const mermaidList = document.getElementsByClassName(MERMAID); + + [...mermaidList].forEach((elem) => { + const svgCode = elem.previousSibling.children.item(0).textContent; + elem.textContent = svgCode; + elem.removeAttribute('data-processed'); + }); + + const newTheme = themeMapper[Theme.visualState]; + + mermaid.initialize({ theme: newTheme }); + mermaid.init(null, `.${MERMAID}`); + } +} + +function setNode(elem) { + const svgCode = elem.textContent; + const backup = elem.parentElement; + backup.classList.add('d-none'); + // Create mermaid node + const mermaid = document.createElement('pre'); + mermaid.classList.add(MERMAID); + const text = document.createTextNode(svgCode); + mermaid.appendChild(text); + backup.after(mermaid); +} + +export function loadMermaid() { + if ( + typeof mermaid === 'undefined' || + typeof mermaid.initialize !== 'function' + ) { + return; + } + + const initTheme = themeMapper[Theme.visualState]; + + let mermaidConf = { + theme: initTheme + }; + + const basicList = document.getElementsByClassName('language-mermaid'); + [...basicList].forEach(setNode); + + mermaid.initialize(mermaidConf); + + if (Theme.switchable) { + window.addEventListener('message', refreshTheme); + } +} diff --git a/assets/js/modules/components/mode-toggle.js b/assets/js/modules/components/mode-toggle.js new file mode 100644 index 0000000..455ff0a --- /dev/null +++ b/assets/js/modules/components/mode-toggle.js @@ -0,0 +1,15 @@ +/** + * Add listener for theme mode toggle + */ + +const $toggle = document.getElementById('mode-toggle'); + +export function modeWatcher() { + if (!$toggle) { + return; + } + + $toggle.addEventListener('click', () => { + Theme.flip(); + }); +} diff --git a/assets/js/modules/components/search-display.js b/assets/js/modules/components/search-display.js new file mode 100644 index 0000000..40059ac --- /dev/null +++ b/assets/js/modules/components/search-display.js @@ -0,0 +1,110 @@ +/** + * This script make #search-result-wrapper switch to unload or shown automatically. + */ + +const btnSbTrigger = document.getElementById('sidebar-trigger'); +const btnSearchTrigger = document.getElementById('search-trigger'); +const btnCancel = document.getElementById('search-cancel'); +const content = document.querySelectorAll('#main-wrapper>.container>.row'); +const topbarTitle = document.getElementById('topbar-title'); +const search = document.getElementById('search'); +const resultWrapper = document.getElementById('search-result-wrapper'); +const results = document.getElementById('search-results'); +const input = document.getElementById('search-input'); +const hints = document.getElementById('search-hints'); + +// CSS class names +const LOADED = 'd-block'; +const UNLOADED = 'd-none'; +const FOCUS = 'input-focus'; +const FLEX = 'd-flex'; + +/* Actions in mobile screens (Sidebar hidden) */ +class MobileSearchBar { + static on() { + btnSbTrigger.classList.add(UNLOADED); + topbarTitle.classList.add(UNLOADED); + btnSearchTrigger.classList.add(UNLOADED); + search.classList.add(FLEX); + btnCancel.classList.add(LOADED); + } + + static off() { + btnCancel.classList.remove(LOADED); + search.classList.remove(FLEX); + btnSbTrigger.classList.remove(UNLOADED); + topbarTitle.classList.remove(UNLOADED); + btnSearchTrigger.classList.remove(UNLOADED); + } +} + +class ResultSwitch { + static resultVisible = false; + + static on() { + if (!this.resultVisible) { + resultWrapper.classList.remove(UNLOADED); + content.forEach((el) => { + el.classList.add(UNLOADED); + }); + this.resultVisible = true; + } + } + + static off() { + if (this.resultVisible) { + results.innerHTML = ''; + + if (hints.classList.contains(UNLOADED)) { + hints.classList.remove(UNLOADED); + } + + resultWrapper.classList.add(UNLOADED); + content.forEach((el) => { + el.classList.remove(UNLOADED); + }); + input.textContent = ''; + this.resultVisible = false; + } + } +} + +function isMobileView() { + return btnCancel.classList.contains(LOADED); +} + +export function displaySearch() { + btnSearchTrigger.addEventListener('click', () => { + MobileSearchBar.on(); + ResultSwitch.on(); + input.focus(); + }); + + btnCancel.addEventListener('click', () => { + MobileSearchBar.off(); + ResultSwitch.off(); + }); + + input.addEventListener('focus', () => { + search.classList.add(FOCUS); + }); + + input.addEventListener('focusout', () => { + search.classList.remove(FOCUS); + }); + + input.addEventListener('input', () => { + if (input.value === '') { + if (isMobileView()) { + hints.classList.remove(UNLOADED); + } else { + ResultSwitch.off(); + } + } else { + ResultSwitch.on(); + if (isMobileView()) { + hints.classList.add(UNLOADED); + } + } + }); +} diff --git a/assets/js/modules/components/toc.js b/assets/js/modules/components/toc.js new file mode 100644 index 0000000..dec814f --- /dev/null +++ b/assets/js/modules/components/toc.js @@ -0,0 +1,36 @@ +import { TocMobile as mobile } from './toc/toc-mobile'; +import { TocDesktop as desktop } from './toc/toc-desktop'; + +const desktopMode = matchMedia('(min-width: 1200px)'); + +function refresh(e) { + if (e.matches) { + if (mobile.popupOpened) { + mobile.hidePopup(); + } + + desktop.refresh(); + } else { + mobile.refresh(); + } +} + +function init() { + if (document.querySelector('main>article[data-toc="true"]') === null) { + return; + } + + // Avoid create multiple instances of Tocbot. Ref: + if (desktopMode.matches) { + desktop.init(); + } else { + mobile.init(); + } + + const $tocWrapper = document.getElementById('toc-wrapper'); + $tocWrapper.classList.remove('invisible'); + + desktopMode.onchange = refresh; +} + +export { init as initToc }; diff --git a/assets/js/modules/components/toc/toc-desktop.js b/assets/js/modules/components/toc/toc-desktop.js new file mode 100644 index 0000000..ea4986b --- /dev/null +++ b/assets/js/modules/components/toc/toc-desktop.js @@ -0,0 +1,20 @@ +export class TocDesktop { + /* Tocbot options Ref: https://github.com/tscanlin/tocbot#usage */ + static options = { + tocSelector: '#toc', + contentSelector: '.content', + ignoreSelector: '[data-toc-skip]', + headingSelector: 'h2, h3, h4', + orderedList: false, + scrollSmooth: false, + headingsOffset: 16 * 2 // 2rem + }; + + static refresh() { + tocbot.refresh(this.options); + } + + static init() { + tocbot.init(this.options); + } +} diff --git a/assets/js/modules/components/toc/toc-mobile.js b/assets/js/modules/components/toc/toc-mobile.js new file mode 100644 index 0000000..20e24a7 --- /dev/null +++ b/assets/js/modules/components/toc/toc-mobile.js @@ -0,0 +1,125 @@ +/** + * TOC button, topbar and popup for mobile devices + */ + +const $tocBar = document.getElementById('toc-bar'); +const $soloTrigger = document.getElementById('toc-solo-trigger'); +const $triggers = document.getElementsByClassName('toc-trigger'); +const $popup = document.getElementById('toc-popup'); +const $btnClose = document.getElementById('toc-popup-close'); + +const SCROLL_LOCK = 'overflow-hidden'; +const CLOSING = 'closing'; + +export class TocMobile { + static #invisible = true; + static #barHeight = 16 * 3; // 3rem + + static options = { + tocSelector: '#toc-popup-content', + contentSelector: '.content', + ignoreSelector: '[data-toc-skip]', + headingSelector: 'h2, h3, h4', + orderedList: false, + scrollSmooth: false, + collapseDepth: 4, + headingsOffset: this.#barHeight + }; + + static initBar() { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + $tocBar.classList.toggle('invisible', entry.isIntersecting); + }); + }, + { rootMargin: `-${this.#barHeight}px 0px 0px 0px` } + ); + + observer.observe($soloTrigger); + this.#invisible = false; + } + + static listenAnchors() { + const $anchors = document.getElementsByClassName('toc-link'); + [...$anchors].forEach((anchor) => { + anchor.onclick = () => this.hidePopup(); + }); + } + + static refresh() { + if (this.#invisible) { + this.initComponents(); + } + tocbot.refresh(this.options); + this.listenAnchors(); + } + + static get popupOpened() { + return $popup.open; + } + + static showPopup() { + this.lockScroll(true); + $popup.showModal(); + const activeItem = $popup.querySelector('li.is-active-li'); + activeItem.scrollIntoView({ block: 'center' }); + } + + static hidePopup() { + $popup.toggleAttribute(CLOSING); + + $popup.addEventListener( + 'animationend', + () => { + $popup.toggleAttribute(CLOSING); + $popup.close(); + }, + { once: true } + ); + + this.lockScroll(false); + } + + static lockScroll(enable) { + document.documentElement.classList.toggle(SCROLL_LOCK, enable); + document.body.classList.toggle(SCROLL_LOCK, enable); + } + + static clickBackdrop(event) { + if ($popup.hasAttribute(CLOSING)) { + return; + } + + const rect = event.target.getBoundingClientRect(); + if ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) { + this.hidePopup(); + } + } + + static initComponents() { + this.initBar(); + + [...$triggers].forEach((trigger) => { + trigger.onclick = () => this.showPopup(); + }); + + $popup.onclick = (e) => this.clickBackdrop(e); + $btnClose.onclick = () => this.hidePopup(); + $popup.oncancel = (e) => { + e.preventDefault(); + this.hidePopup(); + }; + } + + static init() { + tocbot.init(this.options); + this.listenAnchors(); + this.initComponents(); + } +} diff --git a/assets/js/modules/components/tooltip-loader.js b/assets/js/modules/components/tooltip-loader.js new file mode 100644 index 0000000..c36c879 --- /dev/null +++ b/assets/js/modules/components/tooltip-loader.js @@ -0,0 +1,11 @@ +import Tooltip from 'bootstrap/js/src/tooltip'; + +export function loadTooptip() { + const tooltipTriggerList = document.querySelectorAll( + '[data-bs-toggle="tooltip"]' + ); + + [...tooltipTriggerList].map( + (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl) + ); +} diff --git a/assets/js/modules/layouts.js b/assets/js/modules/layouts.js new file mode 100644 index 0000000..28f7962 --- /dev/null +++ b/assets/js/modules/layouts.js @@ -0,0 +1,3 @@ +export { basic } from './layouts/basic'; +export { initSidebar } from './layouts/sidebar'; +export { initTopbar } from './layouts/topbar'; diff --git a/assets/js/modules/layouts/basic.js b/assets/js/modules/layouts/basic.js new file mode 100644 index 0000000..b8eddf6 --- /dev/null +++ b/assets/js/modules/layouts/basic.js @@ -0,0 +1,7 @@ +import { back2top, loadTooptip, modeWatcher } from '../components'; + +export function basic() { + modeWatcher(); + back2top(); + loadTooptip(); +} diff --git a/assets/js/modules/layouts/sidebar.js b/assets/js/modules/layouts/sidebar.js new file mode 100644 index 0000000..bbf5e7d --- /dev/null +++ b/assets/js/modules/layouts/sidebar.js @@ -0,0 +1,19 @@ +const ATTR_DISPLAY = 'sidebar-display'; +const $sidebar = document.getElementById('sidebar'); +const $trigger = document.getElementById('sidebar-trigger'); +const $mask = document.getElementById('mask'); + +class SidebarUtil { + static #isExpanded = false; + + static toggle() { + this.#isExpanded = !this.#isExpanded; + document.body.toggleAttribute(ATTR_DISPLAY, this.#isExpanded); + $sidebar.classList.toggle('z-2', this.#isExpanded); + $mask.classList.toggle('d-none', !this.#isExpanded); + } +} + +export function initSidebar() { + $trigger.onclick = $mask.onclick = () => SidebarUtil.toggle(); +} diff --git a/assets/js/modules/layouts/topbar.js b/assets/js/modules/layouts/topbar.js new file mode 100644 index 0000000..cfcd0ed --- /dev/null +++ b/assets/js/modules/layouts/topbar.js @@ -0,0 +1,5 @@ +import { displaySearch } from '../components/search-display'; + +export function initTopbar() { + displaySearch(); +} diff --git a/assets/js/modules/theme.js b/assets/js/modules/theme.js new file mode 100644 index 0000000..49e4e89 --- /dev/null +++ b/assets/js/modules/theme.js @@ -0,0 +1,138 @@ +/** + * Theme management class + * + * To reduce flickering during page load, this script should be loaded synchronously. + */ +class Theme { + static #modeKey = 'mode'; + static #modeAttr = 'data-mode'; + static #darkMedia = window.matchMedia('(prefers-color-scheme: dark)'); + static switchable = !document.documentElement.hasAttribute(this.#modeAttr); + + static get DARK() { + return 'dark'; + } + + static get LIGHT() { + return 'light'; + } + + /** + * @returns {string} Theme mode identifier + */ + static get ID() { + return 'theme-mode'; + } + + /** + * Gets the current visual state of the theme. + * + * @returns {string} The current visual state, either the mode if it exists, + * or the system dark mode state ('dark' or 'light'). + */ + static get visualState() { + if (this.#hasMode) { + return this.#mode; + } else { + return this.#sysDark ? this.DARK : this.LIGHT; + } + } + + static get #mode() { + return ( + sessionStorage.getItem(this.#modeKey) || + document.documentElement.getAttribute(this.#modeAttr) + ); + } + + static get #isDarkMode() { + return this.#mode === this.DARK; + } + + static get #hasMode() { + return this.#mode !== null; + } + + static get #sysDark() { + return this.#darkMedia.matches; + } + + /** + * Maps theme modes to provided values + * @param {string} light Value for light mode + * @param {string} dark Value for dark mode + * @returns {Object} Mapped values + */ + static getThemeMapper(light, dark) { + return { + [this.LIGHT]: light, + [this.DARK]: dark + }; + } + + /** + * Initializes the theme based on system preferences or stored mode + */ + static init() { + if (!this.switchable) { + return; + } + + this.#darkMedia.addEventListener('change', () => { + const lastMode = this.#mode; + this.#clearMode(); + + if (lastMode !== this.visualState) { + this.#notify(); + } + }); + + if (!this.#hasMode) { + return; + } + + if (this.#isDarkMode) { + this.#setDark(); + } else { + this.#setLight(); + } + } + + /** + * Flips the current theme mode + */ + static flip() { + if (this.#hasMode) { + this.#clearMode(); + } else { + this.#sysDark ? this.#setLight() : this.#setDark(); + } + this.#notify(); + } + + static #setDark() { + document.documentElement.setAttribute(this.#modeAttr, this.DARK); + sessionStorage.setItem(this.#modeKey, this.DARK); + } + + static #setLight() { + document.documentElement.setAttribute(this.#modeAttr, this.LIGHT); + sessionStorage.setItem(this.#modeKey, this.LIGHT); + } + + static #clearMode() { + document.documentElement.removeAttribute(this.#modeAttr); + sessionStorage.removeItem(this.#modeKey); + } + + /** + * Notifies other plugins that the theme mode has changed + */ + static #notify() { + window.postMessage({ id: this.ID }, '*'); + } +} + +Theme.init(); + +export default Theme; diff --git a/assets/js/page.js b/assets/js/page.js new file mode 100644 index 0000000..4b03b79 --- /dev/null +++ b/assets/js/page.js @@ -0,0 +1,15 @@ +import { basic, initSidebar, initTopbar } from './modules/layouts'; +import { + loadImg, + imgPopup, + initClipboard, + loadMermaid +} from './modules/components'; + +loadImg(); +imgPopup(); +initSidebar(); +initTopbar(); +initClipboard(); +loadMermaid(); +basic(); diff --git a/assets/js/post.js b/assets/js/post.js new file mode 100644 index 0000000..dc472b4 --- /dev/null +++ b/assets/js/post.js @@ -0,0 +1,20 @@ +import { basic, initTopbar, initSidebar } from './modules/layouts'; + +import { + loadImg, + imgPopup, + initLocaleDatetime, + initClipboard, + initToc, + loadMermaid +} from './modules/components'; + +loadImg(); +initToc(); +imgPopup(); +initSidebar(); +initLocaleDatetime(); +initClipboard(); +initTopbar(); +loadMermaid(); +basic(); diff --git a/assets/js/pwa/app.js b/assets/js/pwa/app.js new file mode 100644 index 0000000..3c0ded2 --- /dev/null +++ b/assets/js/pwa/app.js @@ -0,0 +1,55 @@ +import Toast from 'bootstrap/js/src/toast'; + +if ('serviceWorker' in navigator) { + // Get Jekyll config from URL parameters + const src = new URL(document.currentScript.src); + const register = src.searchParams.get('register'); + const baseUrl = src.searchParams.get('baseurl'); + + if (register) { + const swUrl = `${baseUrl}/sw.min.js`; + const notification = document.getElementById('notification'); + const btnRefresh = notification.querySelector('.toast-body>button'); + const popupWindow = Toast.getOrCreateInstance(notification); + + navigator.serviceWorker.register(swUrl).then((registration) => { + // Restore the update window that was last manually closed by the user + if (registration.waiting) { + popupWindow.show(); + } + + registration.addEventListener('updatefound', () => { + registration.installing.addEventListener('statechange', () => { + if (registration.waiting) { + if (navigator.serviceWorker.controller) { + popupWindow.show(); + } + } + }); + }); + + btnRefresh.addEventListener('click', () => { + if (registration.waiting) { + registration.waiting.postMessage('SKIP_WAITING'); + } + popupWindow.hide(); + }); + }); + + let refreshing = false; + + // Detect controller change and refresh all the opened tabs + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + window.location.reload(); + refreshing = true; + } + }); + } else { + navigator.serviceWorker.getRegistrations().then(function (registrations) { + for (let registration of registrations) { + registration.unregister(); + } + }); + } +} diff --git a/assets/js/pwa/sw.js b/assets/js/pwa/sw.js new file mode 100644 index 0000000..ff9125d --- /dev/null +++ b/assets/js/pwa/sw.js @@ -0,0 +1,92 @@ +importScripts('./assets/js/data/swconf.js'); + +const purge = swconf.purge; +const interceptor = swconf.interceptor; + +function verifyUrl(url) { + const requestUrl = new URL(url); + const requestPath = requestUrl.pathname; + + if (!requestUrl.protocol.startsWith('http')) { + return false; + } + + for (const prefix of interceptor.urlPrefixes) { + if (requestUrl.href.startsWith(prefix)) { + return false; + } + } + + for (const path of interceptor.paths) { + if (requestPath.startsWith(path)) { + return false; + } + } + return true; +} + +self.addEventListener('install', (event) => { + if (purge) { + return; + } + + event.waitUntil( + caches.open(swconf.cacheName).then((cache) => { + return cache.addAll(swconf.resources); + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keyList) => { + return Promise.all( + keyList.map((key) => { + if (purge) { + return caches.delete(key); + } else { + if (key !== swconf.cacheName) { + return caches.delete(key); + } + } + }) + ); + }) + ); +}); + +self.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +self.addEventListener('fetch', (event) => { + if (event.request.headers.has('range')) { + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + if (response) { + return response; + } + + return fetch(event.request).then((response) => { + const url = event.request.url; + + if (purge || event.request.method !== 'GET' || !verifyUrl(url)) { + return response; + } + + // See: + let responseToCache = response.clone(); + + caches.open(swconf.cacheName).then((cache) => { + cache.put(event.request, responseToCache); + }); + return response; + }); + }) + ); +}); diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 20c1cc3..abe6d05 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -8,6 +8,9 @@ {{ $sass := resources.Get "scss/main.bundle.scss" }} {{ $style := $sass | toCSS $opts | minify | fingerprint }} + {{ $jsFiles := resources.Match "js/**/*.js" }} + {{ $jsBundle := $jsFiles | resources.Concat "js/bundle.js" | minify | fingerprint }} + diff --git a/layouts/partials/head.html b/layouts/partials/head.html new file mode 100644 index 0000000..d4e891e --- /dev/null +++ b/layouts/partials/head.html @@ -0,0 +1,104 @@ + + + + + + + + + {{ template "_internal/opengraph.html" }} + {{ template "_internal/twitter_cards.html" }} + {{ template "_internal/schema.html" }} + + + {{ if .Params.image }} + {{ $src := .Params.image }} + {{ $imgUrl := "" }} + + {{ if not (findRE "://" $src) }} + {{ $imgUrl = absURL $src }} + {{ $oldUrl := $src | absURL }} + {{ $newUrl := $imgUrl }} + + {{ end }} + {{ else if site.Params.social_preview_image }} + {{ $imgUrl := site.Params.social_preview_image | absURL }} + + + + {{ end }} + + + {{ if ne .Kind "home" }} + {{ .Title | safeHTML }} | + {{ end }} + {{ site.Title }} + + + {{ partial "favicons.html" . }} + + + {{ if not site.Params.assets.self_host.enabled }} + {{ range site.Data.origin.cors.resource_hints }} + {{ range .links }} + + {{ end }} + {{ end }} + {{ end }} + + + {{ if not hugo.IsProduction }} + + {{ end }} + + + + + + + + + + + + {{ if and site.Params.toc .Params.toc }} + + {{ end }} + + {{ if or (eq .Type "post") (eq .Type "page") (eq .Kind "home") }} + + {{ end }} + + {{ if or (eq .Type "page") (eq .Type "post") }} + + + {{ end }} + + + + + {{ partial "js-selector.html" . }} + + {{ if hugo.IsProduction }} + + {{ if site.Params.pwa.enabled }} + + {{ end }} + + + {{ range $platform, $config := site.Params.analytics }} + {{ if and $config.id (ne $config.id "") }} + {{ partial (printf "analytics/%s.html" $platform) . }} + {{ end }} + {{ end }} + {{ end }} + + {{ partial "metadata-hook.html" . }} + \ No newline at end of file