diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index df50f6b..2190194 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -12,8 +12,9 @@ jobs: - uses: actions/checkout@v2 - name: Build Link Index - uses: jackyzha0/hugo-obsidian@v2.1 + uses: jackyzha0/hugo-obsidian@v2.3 with: + index: true input: content output: data diff --git a/.gitignore b/.gitignore index c216e49..1eaad60 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ public resources .idea content/.obsidian -data/linkIndex.yaml \ No newline at end of file +data/linkIndex.yaml +data/contentIndex.yaml \ No newline at end of file diff --git a/assets/base.scss b/assets/base.scss index 025c203..a1b5c7b 100644 --- a/assets/base.scss +++ b/assets/base.scss @@ -235,4 +235,120 @@ a[href^="/"] { .centered { margin-top: 30vh; +} + +header { + display: flex; + flex-direction: row; + align-items: center; + + & > nav { + @media all and (max-width: 600px) { + display: none; + } + + & > a { + margin-left: 2em; + } + } + + & > .spacer { + flex: 1 1 auto; + } + + & > svg { + cursor: pointer; + width: 18px; + min-width: 18px; + margin: 0 1em; + + &:hover .search-path { + stroke: var(--tertiary); + } + + .search-path { + stroke: var(--gray); + stroke-width: 2px; + transition: stroke 0.5s ease; + } + } +} + +#search-container { + position: fixed; + z-index: 9999; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + display: none; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + + & > div { + width: 50%; + margin-top: 15vh; + margin-left: auto; + margin-right: auto; + + @media all and (max-width: 1200px) { + width: 90%; + } + + & > * { + width: 100%; + border-radius: 4px; + background: var(--light); + box-shadow: 0 14px 50px rgba(27, 33, 48, 0.12), 0 10px 30px rgba(27, 33, 48, 0.16); + margin-bottom: 2em; + } + + & > input { + box-sizing: border-box; + padding: 0.5em 1em; + font-family: Inter, sans-serif; + color: var(--dark); + font-size: 1.1em; + border: 1px solid var(--outlinegray); + + &:focus { + outline: none; + } + } + + & > #results-container { + & > .result-card { + padding: 1em; + cursor: pointer; + transition: background 0.2s ease; + border: 1px solid var(--outlinegray); + border-bottom: none; + + &:hover { + background: rgba(180, 180, 180, 0.15); + } + + &:first-of-type { + border-top-left-radius: 5px; + border-top-right-radius: 5px; + } + + &:last-of-type { + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-bottom: 1px solid var(--outlinegray); + } + + & > h3, & > p { + margin: 0; + } + + & .search-highlight { + background-color: #afbfc966; + padding: 0.05em 0.2em; + border-radius: 3px; + } + } + } + } } \ No newline at end of file diff --git a/assets/darkmode.scss b/assets/darkmode.scss index dde5be6..61967d7 100644 --- a/assets/darkmode.scss +++ b/assets/darkmode.scss @@ -1,67 +1,44 @@ - .darkmode { - text-align: right; + float: right; + padding: 1em; + min-width: 30px; + position: relative; + + @media all and (max-width: 450px) { + padding: 1em; + } & > .toggle { display: none; box-sizing: border-box; - - &:checked + .toggle-button:after { - left: 50%; - } - - & + .toggle-button { - box-sizing: border-box; - outline: 0; - display: inline-block; - width: 3em; - height: 1.5em; - position: relative; - cursor: pointer; - border: 2px solid var(--gray); - user-select: none; - padding: 2px; - transition: all 0.2s ease; - border-radius: 2em; - - &:after, &:before { - position: relative; - display: block; - box-sizing: border-box; - content: ""; - width: 50%; - height: 100%; - } - - &:before { - display: none; - } - - &:after { - left: 0; - transition: all 0.2s ease; - background: var(--gray); - content: ""; - border-radius: 1em; - } - } } - & #dayIcon { - position: relative; + & svg { + opacity: 0; + position: absolute; width: 20px; height: 20px; - top: -1.5px; + top: calc(50% - 10px); margin: 0 7px; fill: var(--gray); + transition: opacity 0.1s ease; } +} - & #nightIcon { - position: relative; - width: 18px; - height: 18px; - top: -2px; - margin: 0 7px; - fill: var(--gray); +.toggle:checked ~ label { + & > #dayIcon { + opacity: 0; + } + & > #nightIcon { + opacity: 1; + } +} + +.toggle:not(:checked) ~ label { + & > #dayIcon { + opacity: 1; + } + & > #nightIcon { + opacity: 0; } } \ No newline at end of file diff --git a/content/_index.md b/content/_index.md index 3d19704..b665a3d 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,5 +1,5 @@ # 🌱 Quartz -## v1.1 +## v2.0 Simple second brain and [digital garden](https://jzhao.xyz/posts/digital-gardening). diff --git a/content/notes/preview-changes.md b/content/notes/preview-changes.md index 21e106a..dac619e 100644 --- a/content/notes/preview-changes.md +++ b/content/notes/preview-changes.md @@ -15,7 +15,7 @@ $ go install github.com/jackyzha0/hugo-obsidian $ cd <location-of-your-local-quartz> # Scrape all links in your Quartz folder and generate info for Quartz -$ hugo-obsidian -input=content -output=data +$ hugo-obsidian -input=content -output=data -index=true ``` Afterwards, start the Hugo server as shown above and your local backlinks and interactive graph should be populated! diff --git a/layouts/_default/single.html b/layouts/_default/single.html index 14a3b05..1922083 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -3,11 +3,16 @@ {{ partial "head.html" . }} <body> +{{partial "search.html" .}} <div class="singlePage"> <!-- Begin actual content --> - {{partial "darkmode.html" .}} - <article> + <header> {{if .Title}}<h1>{{ .Title }}</h1>{{end}} + <svg tabindex="0" id="search-icon" aria-labelledby="title desc" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7"><title id="title">Search Icon</title><desc id="desc">Icon to open search</desc><g class="search-path" fill="none"><path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4"/><circle cx="8" cy="8" r="7"/></g></svg> + <div class="spacer"></div> + {{partial "darkmode.html" .}} + </header> + <article> {{if $.Site.Data.config.enableToc}} <aside class="mainTOC"> <h3>Table of Contents</h3> diff --git a/layouts/partials/darkmode.html b/layouts/partials/darkmode.html index 3d36d9a..d7540c2 100644 --- a/layouts/partials/darkmode.html +++ b/layouts/partials/darkmode.html @@ -1,12 +1,11 @@ <div class='darkmode'> + <input class='toggle' id='darkmode-toggle' type='checkbox' tabindex="-1"> <label id="toggle-label-light" for='darkmode-toggle' tabindex="-1"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="dayIcon" x="0px" y="0px" viewBox="0 0 35 35" style="enable-background:new 0 0 35 35;" xml:space="preserve"> <title>Light Mode</title> <path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z" /> </svg> </label> - <input class='toggle' id='darkmode-toggle' type='checkbox' tabindex="-1"> - <label class='toggle-button' for='darkmode-toggle'></label> <label id="toggle-label-dark" for='darkmode-toggle' tabindex="-1"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="nightIcon" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background='new 0 0 100 100'" xml:space="preserve"> <title>Dark Mode</title> diff --git a/layouts/partials/header.html b/layouts/partials/header.html deleted file mode 100644 index e69de29..0000000 diff --git a/layouts/partials/search.html b/layouts/partials/search.html new file mode 100644 index 0000000..e2285ea --- /dev/null +++ b/layouts/partials/search.html @@ -0,0 +1,208 @@ +<div id="search-container"> + <div> + <input autocomplete="off" id="search-bar" name="search" type="text" aria-label="Search" placeholder="Search for something..."> + <div id="results-container"> + </div> + </div> +</div> +<script src="https://cdn.jsdelivr.net/gh/nextapps-de/flexsearch@0.7.2/dist/flexsearch.bundle.js"></script> +<script> + // code from https://github.com/danestves/markdown-to-text + const removeMarkdown = ( + markdown, + options = { + listUnicodeChar: false, + stripListLeaders: true, + gfm: true, + useImgAltText: false, + preserveLinks: false, + } + ) => { + let output = markdown || ""; + output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, ""); + + try { + if (options.stripListLeaders) { + if (options.listUnicodeChar) + output = output.replace( + /^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, + options.listUnicodeChar + " $1" + ); + else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, "$1"); + } + if (options.gfm) { + output = output + .replace(/\n={2,}/g, "\n") + .replace(/~{3}.*\n/g, "") + .replace(/~~/g, "") + .replace(/`{3}.*\n/g, ""); + } + if(options.preserveLinks) { + output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, "$1 ($2)") + } + output = output + .replace(/<[^>]*>/g, "") + .replace(/^[=\-]{2,}\s*$/g, "") + .replace(/\[\^.+?\](\: .*?$)?/g, "") + .replace(/\s{0,2}\[.*?\]: .*?$/g, "") + .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? "$1" : "") + .replace(/\[(.*?)\][\[\(].*?[\]\)]/g, "$1") + .replace(/^\s{0,3}>\s?/g, "") + .replace(/(^|\n)\s{0,3}>\s?/g, "\n\n") + .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "") + .replace( + /^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} {0,}(\n)?\s{0,}$/gm, + "$1$2$3" + ) + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") + .replace(/(`{3,})(.*?)\1/gm, "$2") + .replace(/`(.+?)`/g, "$1") + .replace(/\n{2,}/g, "\n\n"); + } catch (e) { + console.error(e); + return markdown; + } + return output; + }; +</script> +<script> + const contentIndex = new FlexSearch.Worker({ + tokenize: "strict", + charset: "latin:advanced", + context: true, + depth: 3, + cache: 10, + suggest: true, + }) + + const scrapedContent = {{$.Site.Data.contentIndex}} + for (const [key, value] of Object.entries(scrapedContent)) { + contentIndex.add(key, value.content) + } + + const stopwords = ['i','me','my','myself','we','our','ours','ourselves','you','your','yours','yourself','yourselves','he','him','his','himself','she','her','hers','herself','it','its','itself','they','them','their','theirs','themselves','what','which','who','whom','this','that','these','those','am','is','are','was','were','be','been','being','have','has','had','having','do','does','did','doing','a','an','the','and','but','if','or','because','as','until','while','of','at','by','for','with','about','against','between','into','through','during','before','after','above','below','to','from','up','down','in','out','on','off','over','under','again','further','then','once','here','there','when','where','why','how','all','any','both','each','few','more','most','other','some','such','no','nor','not','only','own','same','so','than','too','very','s','t','can','will','just','don','should','now'] + const highlight = (content, term) => { + const highlightWindow = 15 + const tokenizedTerm = term.split(/\s+/).filter(t => t !== "") + const splitText = content.split(/\s+/).filter(t => t !== "") + const includesCheck = (token) => tokenizedTerm.some(term => token.toLowerCase().includes(term.toLowerCase())) + + const occurrencesIndices = splitText + .map(includesCheck) + + // calculate best index + let bestSum = 0 + let bestIndex = 0 + for (let i = 0; i < Math.max(occurrencesIndices.length - highlightWindow, 0); i++) { + const window = occurrencesIndices.slice(i, i + highlightWindow) + const windowSum = window.reduce((total, cur) => total + cur, 0) + if (windowSum > bestSum) { + bestSum = windowSum + bestIndex = i + } + } + + const startIndex = Math.max(bestIndex - highlightWindow, 0) + const endIndex = Math.min(startIndex + 2 * highlightWindow, splitText.length) + const mappedText = splitText + .slice(startIndex, endIndex) + .map(token => { + if (includesCheck(token)) { + return `<span class="search-highlight">${token}</span>` + } + return token + }) + .join(" ") + .replaceAll('</span> <span class="search-highlight">', " ") + return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}` + } + + const resultToHTML = ({url, title, content, term}) => { + const md = content.split("---")[2] + const text = removeMarkdown(md) + const resultTitle = highlight(title, term) + const resultText = highlight(text, term) + return `<div class="result-card" id="${url}"> + <h3>${resultTitle}</h3> + <p>${resultText}</p> + </div>` + } + + const source = document.getElementById('search-bar') + const results = document.getElementById("results-container") + source.addEventListener('input', (e) => { + const term = e.target.value + contentIndex.search(term, { + limit: 5, + depth: 3, + suggest: true, + }).then(searchResults => { + const resultIds = [...new Set(searchResults)] + const finalResults = resultIds.map(id => ({ + url: id, + title: scrapedContent[id].title, + content: scrapedContent[id].content + })) + + // display + if (finalResults.length === 0) { + results.innerHTML = `<div class="result-card"> + <p>No results.</p> + </div>` + } else { + results.innerHTML = finalResults + .map(result => resultToHTML({ + ...result, + term, + })) + .join("\n") + const anchors = document.getElementsByClassName("result-card"); + [...anchors].forEach(anchor => { + anchor.onclick = () => { + window.location.href = `${anchor.id}#:~:text=${encodeURIComponent(term)}` + } + }) + } + }) + }) + + + const searchContainer = document.getElementById("search-container") + function openSearch() { + if (searchContainer.style.display === "none" || searchContainer.style.display === "") { + source.value = "" + results.innerHTML = "" + searchContainer.style.display = "block" + source.focus() + } else { + searchContainer.style.display = "none" + } + } + + function closeSearch() { + searchContainer.style.display = "none" + } + + document.addEventListener('keydown', (event) => { + if (event.key === "/") { + event.preventDefault() + openSearch() + } + if (event.key === "Escape") { + event.preventDefault() + closeSearch() + } + }) + + window.addEventListener('DOMContentLoaded', () => { + const searchButton = document.getElementById("search-icon") + searchButton.addEventListener('click', (evt) => { + openSearch() + }) + searchButton.addEventListener('keydown', (evt) => { + openSearch() + }) + }) + +</script> \ No newline at end of file