diff --git a/.github/workflows/intergrate.yml b/.github/workflows/intergrate.yml index 592faa7c..7f8a3e15 100644 --- a/.github/workflows/intergrate.yml +++ b/.github/workflows/intergrate.yml @@ -9,21 +9,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: persist-credentials: false - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ github.workspace }}/.next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }} - name: Install and Build - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18.x - run: npm install - run: npm run build env: - CI: true + CI: true diff --git a/.gitignore b/.gitignore index d0bb6ff8..588abf34 100644 --- a/.gitignore +++ b/.gitignore @@ -37,9 +37,6 @@ yarn-error.log* /.idea/ /.vscode/ -# yarn -yarn.lock - # docker /.devcontainer diff --git a/components/colormode-toggler.jsx b/components/colormode-toggler.jsx index 4cad69e0..df05f748 100644 --- a/components/colormode-toggler.jsx +++ b/components/colormode-toggler.jsx @@ -18,7 +18,7 @@ function ColorModeToggler() { ? _('Toggle light mode') : _('Toggle dark mode') } - className='shadow bg-gray-100 text-gray-600 dark:text-gray-400 dark:bg-gray-700 rounded p-1 mr-1 md:mr-7 focus:outline-none w-8 h-8' + className='hover:bg-gray-100 text-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 rounded p-1 mr-1 md:mr-7 focus:outline-none w-8 h-8' > {themeCtx.theme === 'dark' ? : } diff --git a/components/component-mapper.jsx b/components/component-mapper.jsx index 42cbf1bf..fd198ac1 100644 --- a/components/component-mapper.jsx +++ b/components/component-mapper.jsx @@ -1,53 +1,119 @@ -import Counter from './example-counter' +import { AirtableBase, AirtableForm } from "mdx-embed2/dist/components/airtable"; +import { Buzzsprout } from "mdx-embed2/dist/components/buzzsprout"; +import { Cinnamon } from "mdx-embed2/dist/components/cinnamon"; +import { CodePen } from "mdx-embed2/dist/components/codepen"; +import { CodeSandbox } from "mdx-embed2/dist/components/codesandbox"; +import { EggheadLesson } from "mdx-embed2/dist/components/egghead"; +import { Figma } from "mdx-embed2/dist/components/figma"; +import { Flickr } from "mdx-embed2/dist/components/flickr"; +import { Gist } from "mdx-embed2/dist/components/gist"; +import { Instagram } from "mdx-embed2/dist/components/instagram"; +import { Lbry } from "mdx-embed2/dist/components/lbry"; +import { LinkedInBadge } from "mdx-embed2/dist/components/linkedin"; +import { Pin, PinterestBoard, PinterestFollowButton } from "mdx-embed2/dist/components/pinterest"; +import { Replit } from "mdx-embed2/dist/components/replit"; +import { SimplecastEpisode } from "mdx-embed2/dist/components/simplecast"; +import { Snack } from "mdx-embed2/dist/components/snack"; +import { SoundCloud } from "mdx-embed2/dist/components/soundcloud"; +import { Spotify } from "mdx-embed2/dist/components/spotify"; +import { TikTok } from "mdx-embed2/dist/components/tiktok"; +import { Twitch } from "mdx-embed2/dist/components/twitch"; +import { Tweet, TwitterFollowButton, TwitterHashtagButton, TwitterList, TwitterMentionButton, TwitterTimeline } from "mdx-embed2/dist/components/twitter"; +import { Vimeo } from "mdx-embed2/dist/components/vimeo"; +import { Whimsical } from "mdx-embed2/dist/components/whimsical"; +import { Wikipedia } from "mdx-embed2/dist/components/wikipedia"; +import { Wistia } from "mdx-embed2/dist/components/wistia"; +import { YouTube } from "mdx-embed2/dist/components/youtube"; + +import Counter from './example-counter'; import { - Accordion, - Blockquote, - Code, - CustomImage, - CustomLink, - H1, - H2, - H3, - H4, - H5, - H6, - Hr, - Ol, - P, - Tab, - Table, - Tabs, - Tbody, - Td, - Th, - Thead, - Tr, - Ul -} from './mdx-components' + Accordion, + Blockquote, + Code, + CustomImage, + CustomLink, + H1, + H2, + H3, + H4, + H5, + H6, + Hr, + Ol, + P, + Tab, + Table, + Tabs, + Tbody, + Td, + Th, + Thead, + Tr, + Ul +} from './mdx-components'; export const componentMap = { - Counter, - Accordion, - Tab, - Tabs, - table: Table, - thead: Thead, - tbody: Tbody, - tr: Tr, - td: Td, - th: Th, - a: CustomLink, - img: CustomImage, - blockquote: Blockquote, - code: Code, - h1: H1, - h2: H2, - h3: H3, - h4: H4, - h5: H5, - h6: H6, - ol: Ol, - ul: Ul, - hr: Hr, - p: P + // Example Counter Component + Counter, + + // NextBook Components + Accordion, + Tab, + Tabs, + table: Table, + thead: Thead, + tbody: Tbody, + tr: Tr, + td: Td, + th: Th, + a: CustomLink, + img: CustomImage, + blockquote: Blockquote, + code: Code, + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, + ol: Ol, + ul: Ul, + hr: Hr, + p: P, + + // mdx-embed2 + AirtableBase, + AirtableForm, + Buzzsprout, + Cinnamon, + CodePen, + CodeSandbox, + EggheadLesson, + Figma, + Flickr, + Gist, + Instagram, + Lbry, + LinkedInBadge, + Pin, + PinterestBoard, + PinterestFollowButton, + Replit, + SimplecastEpisode, + Snack, + SoundCloud, + Spotify, + TikTok, + Twitch, + Tweet, + TwitterFollowButton, + TwitterHashtagButton, + TwitterList, + TwitterMentionButton, + TwitterTimeline, + Vimeo, + Whimsical, + Wikipedia, + Wistia, + YouTube, } diff --git a/components/in-page-toc.jsx b/components/in-page-toc.jsx index c15fd04e..3db4f9c4 100644 --- a/components/in-page-toc.jsx +++ b/components/in-page-toc.jsx @@ -1,44 +1,76 @@ +import { useEffect, useState } from 'react'; +import router from 'next/router'; import Scrollspy from 'components/scrollspy' import Text from './text' +import { Link } from './svg-icons'; function InPageTocElement(props) { - // remove 1 from first heading to prevent exe left padding and add standard left padding - const style = { paddingLeft: `${props.levels[props.children] - 1 + 0.2}em` } - return ( -
  • - {props.children} -
  • - ) + // Indent headings based on their level + const level = props.levels[props.slug] >= 1 ? props.levels[props.slug] : 1 + const style = { paddingLeft: `${level / 2}em` } + return ( +
  • + {props.children} +
  • + ) } function InPageToc({ tocRaw }) { - let tocIds = [] - let levels = {} - tocRaw.forEach((row) => { - // populate dictionary of heading slugs - tocIds.push(row.slug) - // populate dictionary of headings and their levels - levels[row.content] = row.lvl - }) - - if (tocIds.length) { - return ( -
    -
    - -
    - } - itemContainerClassName='tracking-wide mt-4 text-gray-600 dark:text-gray-400 text-sm 2xl:text-base border-0 border-l border-gray-300 dark:border-gray-600 leading-6 cursor-pointer' - activeItemClassName='text-gray-900 dark:text-gray-200 border-l border-gray-900 dark:border-gray-200' - includeParentClasses={false} - /> -
    - ) - } else { - return null - } + + let tocIds = [] + let levels = {} + tocRaw.forEach((row) => { + // populate dictionary of heading slugs + tocIds.push(row.slug) + // populate dictionary of headings and their levels + levels[row.slug] = row.lvl + }) + + // Update current "copy link" button when the user scrolls + const [url, setUrl] = useState('') + + const onUpdateHash = (hash) => { + setUrl(window.location.origin + window.location.pathname + hash) + } + + // on mount, set the initial hash + useEffect(() => { + setUrl(router.asPath) + }, []); + + if (tocIds.length) { + return ( +
    + { + e.preventDefault(); + navigator.clipboard.writeText(url); + }} + > + + + + +
    + +
    + } + itemContainerClassName='tracking-wide mt-4 text-gray-600 dark:text-gray-400 text-xs 2xl:text-sm border-0 border-l border-gray-200 dark:border-gray-600 leading-6 cursor-pointer' + itemClassName="text-gray-500 truncate py-1 hover:text-gray-700 dark:hover:text-gray-300" + activeItemClassName='active text-gray-700 dark:text-gray-200 border-l-2 border-gray-900 dark:border-gray-200' + includeParentClasses={false} + onUpdateHash={onUpdateHash} + /> + +
    + ) + } else { + return null + } } export default InPageToc diff --git a/components/navbar.jsx b/components/navbar.jsx index b4735156..b19eb5e3 100644 --- a/components/navbar.jsx +++ b/components/navbar.jsx @@ -5,27 +5,63 @@ import Link from 'next/link' import { useRouter } from 'next/router' function NavBar() { - const { navbarItems } = config + const { branding, navbarItems } = config const router = useRouter() return ( -
    +
    -
    diff --git a/components/page-nav.jsx b/components/page-nav.jsx index 2c68d4b3..28d11b4a 100644 --- a/components/page-nav.jsx +++ b/components/page-nav.jsx @@ -2,46 +2,54 @@ import { ArrowLeft, ArrowRight } from 'components/svg-icons' import config from 'config/config.json' import Link from 'next/link' import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' import { useShortcuts } from 'react-shortcuts-hook' import { _ } from './text' export default function PageNav() { - const router = useRouter() const { toc } = config - const { asPath } = router + const router = useRouter() + const [prevChapter, setPrevChapter] = useState('') + const [nextChapter, setNextChapter] = useState('') - // isolate current part array - const currentPart = toc.find((part) => - part.chapters.some((chapter) => chapter.path === asPath) - ) - const currentPartIndex = toc.indexOf(currentPart) + useEffect(() => { + + // isolate current part array + const currentPart = toc.find((part) => + part.chapters.some((chapter) => chapter.path === router.asPath) + ) + const currentPartIndex = toc.indexOf(currentPart) - // find previous and next parts even if they do not exist - const prevPart = toc[currentPartIndex - 1] - const nextPart = toc[currentPartIndex + 1] + // find previous and next parts even if they do not exist + const prevPart = toc[currentPartIndex - 1] + const nextPart = toc[currentPartIndex + 1] - // find index of current title - const currentChapterIndex = currentPart?.chapters.findIndex( - (chapter) => chapter.path === asPath - ) + // find index of current title + const currentChapterIndex = currentPart?.chapters.findIndex( + (chapter) => chapter.path === router.asPath + ) + - // find previous page, iff not, use last page of previous part - let prevChapter = - currentPart?.chapters[currentChapterIndex - 1] || - prevPart?.chapters[prevPart?.chapters.length - 1] - // find next page, if not, use first page of next part - let nextChapter = - currentPart?.chapters[currentChapterIndex + 1] || nextPart?.chapters[0] + // find previous page, if not, use last page of previous part + setPrevChapter( + currentPart?.chapters[currentChapterIndex - 1] || + prevPart?.chapters[prevPart?.chapters.length - 1] + ) + // find next page, if not, use first page of next part + setNextChapter( + currentPart?.chapters[currentChapterIndex + 1] || nextPart?.chapters[0] + ) + }, [router.asPath, toc]) useShortcuts( ['ArrowRight'], () => nextChapter && router.push(nextChapter.path), - [asPath] + [router.asPath] ) useShortcuts( ['ArrowLeft'], () => prevChapter && router.push(prevChapter.path), - [asPath] + [router.asPath] ) return ( @@ -54,13 +62,15 @@ export default function PageNav() { {prevChapter && ( - -
    {prevChapter.title}
    +
    + +
    +
    {prevChapter.title}
    )} @@ -68,13 +78,15 @@ export default function PageNav() { {nextChapter && ( -
    {nextChapter.title}
    - +
    {nextChapter.title}
    +
    + +
    )} diff --git a/components/scrollspy.jsx b/components/scrollspy.jsx index 495de64a..3f86e7a7 100644 --- a/components/scrollspy.jsx +++ b/components/scrollspy.jsx @@ -2,112 +2,159 @@ * @class Scrollspy * Taken verbatim from https://github.com/telega/react-scrollspy-ez * and stripped of TS interfaces. + * + * Lacy - November 2022: Added nextJS router support */ -import React from 'react' import classnames from 'classnames' - -const SPY_INTERVAL = 100 +import React from 'react' +import debounce from 'utils/debounce' +import throttle from 'utils/throttle' +const THROTTLE_DELAY = 20 +const DEBOUNCE_HASH_DELAY = 100 export default class Scrollspy extends React.Component { - constructor(props) { - super(props) - this.state = { - items: [] - } - } - - timer = 0 - - spy() { - const items = this.props.ids - .map((id) => { - const element = document.getElementById(id) - if (element) { - return { - inView: this.isInView(element), - element - } - } else { - return - } - }) - .filter((item) => item) - - const firstTrueItem = items.find((item) => !!item && item.inView) - - if (!firstTrueItem) { - return // dont update state - } else { - const update = items.map((item) => { - return { ...item, inView: item === firstTrueItem } - }) - - this.setState({ items: update }) - } - } - - componentDidMount() { - this.timer = window.setInterval(() => this.spy(), SPY_INTERVAL) - } - - componentWillUnmount() { - window.clearInterval(this.timer) - } - - isInView = (element) => { - if (!element) { - return false - } - const { offset } = this.props - const rect = element.getBoundingClientRect() - - return rect.top >= 0 - offset && rect.bottom <= window.innerHeight + offset - } - - scrollTo(element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'start', - inline: 'nearest' - }) - } - - renderItems() { - const { itemElement, activeItemClassName, itemClassName } = this.props - return this.state.items.map((item, k) => { - return itemElement - ? React.cloneElement(itemElement, { - key: k, - className: classnames( - this.props.includeParentClasses && item.element.className - ? item.element.className - : null, - itemClassName, - item.inView ? activeItemClassName : null - ), - onClick: () => this.scrollTo(item.element), - children: item.element.innerText - }) - : null - }) - } - - render() { - const { itemContainerClassName, containerElement } = this.props - return containerElement - ? React.cloneElement(containerElement, { - className: classnames(itemContainerClassName), - children: this.renderItems() - }) - : null - } + constructor(props) { + super(props) + this.elements = [] + this.state = { + items: [], + current: '' + } + } + + updateHash = (hash) => { + // enforce hash # prefix + if (('' + hash).charAt(0) !== '#') hash = '#' + hash; + + // router.replace causes a scroll + history.replaceState({}, '', hash); + + this.props.onUpdateHash(hash) + } + + dUpdateHash = debounce(this.updateHash, DEBOUNCE_HASH_DELAY) + + spy() { + // I don't understand why this was implemented this way, but I optimized it + if (this.elements) { + const items = this.elements + .map((element) => { + if (element) { + return { + id: element.id, + inView: this.isInView(element), + element + } + } else { + return + } + }) + + let itemInView = items.find((item) => !!item && item.inView) + + if (itemInView && this.state.current !== itemInView?.id) { + // item updated + + const update = items.map((item) => { + return { ...item, inView: item === itemInView } + }) + + this.setState({ items: update, current: itemInView?.id }) + + // update page hash + itemInView?.id && this.dUpdateHash(itemInView.id) + + } + } + } + + tSpy = throttle(this.spy, THROTTLE_DELAY) + + componentDidMount() { + this.elements = this.props.ids.map((id) => document.getElementById(id)) + window.addEventListener('scroll', () => this.tSpy(), false); + window.addEventListener('resize', () => this.tSpy(), false); + // fire on router change + // router.events.on('routeChangeComplete', () => this.tSpy()) + + // run on mount + this.spy() + } + + componentDidUpdate() { + this.elements = this.props.ids.map((id) => document.getElementById(id)) + this.spy() + } + + componentWillUnmount() { + window.removeEventListener('scroll', () => this.tSpy(), false); + window.removeEventListener('resize', () => this.tSpy(), false); + // router.events.off('routeChangeComplete', () => this.tSpy()) + + } + + isInView = (element) => { + if (!element) { + return false + } + const { offset } = this.props + const rect = element.getBoundingClientRect() + return rect.top >= 0 - offset && rect.bottom <= window.innerHeight + offset + } + + scrollTo(element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }) + } + + renderItems() { + const { itemElement, activeItemClassName, itemClassName } = this.props + return this.state.items.map((item, k) => { + return itemElement + ? React.cloneElement(itemElement, { + key: k, + className: classnames( + this.props.includeParentClasses && item.element.className + ? item.element.className + : null, + itemClassName, + item.inView ? activeItemClassName : null + ), + onClick: () => { + // use next router to update url hash + this.dUpdateHash(item.id) + + // scroll to the element + this.scrollTo(item.element) + }, + slug: item.id, + children: item?.element?.innerText, + // Truncate the text to n characters + // children: item.element.innerText.replace(/(.{20})..+/, "$1โ€ฆ") + }) + : null + }) + } + + render() { + const { itemContainerClassName, containerElement } = this.props + return containerElement && this.state.items + ? React.cloneElement(containerElement, { + className: classnames(itemContainerClassName), + children: this.renderItems() + }) + : null + } } Scrollspy.defaultProps = { - offset: 2, - ids: [], - containerElement:
      , - itemElement:
    • , - includeParentClasses: false + offset: 2, + ids: [], + containerElement:
        , + itemElement:
      • , + includeParentClasses: false } diff --git a/components/sidebar-item.jsx b/components/sidebar-item.jsx index c27bcb95..4cec445e 100644 --- a/components/sidebar-item.jsx +++ b/components/sidebar-item.jsx @@ -4,30 +4,57 @@ import { useContext, useEffect, useState } from 'react' import HistoryContext from './store/history-context' import { Check, Dot } from './svg-icons' -const SideBarItem = ({ item }) => { +const sidebarItemStyle = "flex w-full items-center py-2 px-3 rounded-md text-base hover:bg-gray-100 dark:hover:bg-gray-900 active:opacity-50 transition-opacity duration-200" + +const SideBarItem = ({ active, item, onClick, icon }) => { const { asPath: path } = useRouter() const historyCtx = useContext(HistoryContext) - const [icon, setIcon] = useState() + const [visited, setVisited] = useState(false) + const [currentPath, setCurrentPath] = useState(false) + useEffect(() => { if (historyCtx.history.includes(item.path)) { - setIcon() + setVisited(true) } + + setCurrentPath(item.path === path) }, [path, historyCtx, item.path]) return ( -
        - {icon} - - - {item.title} - - +
        + {item.path ? ( + + +
        + {item.title} +
        + {icon ? ( + {icon} + ) : visited && } +
        + + ) : ( +
        +
        + {item.title} +
        + {icon && {icon}} +
        + )}
        ) } diff --git a/components/sidebar-section.jsx b/components/sidebar-section.jsx index 6510a688..7511f62f 100644 --- a/components/sidebar-section.jsx +++ b/components/sidebar-section.jsx @@ -1,10 +1,12 @@ +import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import SideBarItem from './sidebar-item' import { AngleDown, AngleRight } from './svg-icons' const SideBarSection = ({ toc }) => { - const [menuVisible, setMenuVisible] = useState(false) + const [menuVisible, setMenuVisible] = useState(toc.openByDefault || false) + const [menuActive, setMenuActive] = useState(false) const { asPath: path } = useRouter() const chapterItems = ( <> @@ -14,39 +16,36 @@ const SideBarSection = ({ toc }) => { ) - const toggleMenu = () => { + const toggleMenu = (e) => { + e.preventDefault() setMenuVisible((current) => !current) } // Open part containing current page useEffect(() => { + setMenuActive(false) const currentPart = toc.chapters.some((chapter) => chapter.path === path) if (currentPart) { setMenuVisible(true) } + setMenuActive(currentPart) }, [path, toc.chapters]) - return ( -
        - {/* display toggleable titlebar only when we have a part */} - {toc.part && ( -
        -
        {menuVisible ? : }
        -
        - {toc.part} -
        + if(toc.part) { + return ( +
        + {/* display toggleable titlebar only when we have a part */} +
        + : } />
        - )} - - {toc.part ? ( -
        +
        {chapterItems}
        - ) : ( -
        {chapterItems}
        - )} -
        - ) +
        + ) + } + + return (
        {chapterItems}
        ) } export default SideBarSection diff --git a/components/sidebar.jsx b/components/sidebar.jsx index 2510cb6e..74b668d3 100644 --- a/components/sidebar.jsx +++ b/components/sidebar.jsx @@ -6,7 +6,7 @@ import { useClickAway, useMedia } from 'react-use' import SideBarSection from './sidebar-section' function SideBar() { - const { toc, projectTitle } = config + const { branding, toc, projectTitle } = config const sideBarCtx = useContext(SideBarContext) const isWide = useMedia('(min-width: 770px)', false) const ref = useRef(null) @@ -24,33 +24,36 @@ function SideBar() { }) const sideBarStyle = sideBarCtx.sideBar - ? 'sidebar w-2/3 z-50 h-screen bg-gray-100 dark:bg-gray-800 border-r border-gray-300 dark:border-gray-800 fixed pl-4 text-lg top-10 md:hidden' - : 'sidebar z-50 flex-none md:w-56 xl:w-64 h-screen fixed top-10 md:top-14 hidden md:block' + ? 'sidebar w-2/3 z-50 h-screen bg-[#ffffff] dark:bg-gray-800 border-t border-r border-gray-300 dark:border-gray-800 fixed pl-4 text-lg top-10 md:hidden' + : 'sidebar z-50 shrink h-screen fixed top-10 md:top-14 hidden md:block pl-4 pr-4 border-r dark:border-gray-600 h-full overflow-y-auto max-w-72 bg-[#ffffff] dark:bg-gray-800' return (