add table of contents

This commit is contained in:
keyan 2022-07-18 16:24:28 -05:00
parent beef34abfa
commit 70cbdd057a
9 changed files with 192 additions and 55 deletions

View File

@ -77,7 +77,6 @@
} }
.hunk { .hunk {
overflow: visible;
margin-bottom: 0; margin-bottom: 0;
margin-top: 0.15rem; margin-top: 0.15rem;
} }

View File

@ -59,7 +59,9 @@ export default function Comments ({ parentId, comments, ...props }) {
useEffect(() => { useEffect(() => {
const hash = window.location.hash const hash = window.location.hash
if (hash) { if (hash) {
try {
document.querySelector(hash).scrollIntoView({ behavior: 'smooth' }) document.querySelector(hash).scrollIntoView({ behavior: 'smooth' })
} catch {}
} }
}, []) }, [])
const [getComments, { loading }] = useLazyQuery(COMMENTS_QUERY, { const [getComments, { loading }] = useLazyQuery(COMMENTS_QUERY, {

View File

@ -83,7 +83,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
const ItemComponent = item.maxBid ? ItemJob : Item const ItemComponent = item.maxBid ? ItemJob : Item
return ( return (
<ItemComponent item={item} showFwdUser {...props}> <ItemComponent item={item} toc showFwdUser {...props}>
{item.text && <ItemText item={item} />} {item.text && <ItemText item={item} />}
{item.url && <ItemEmbed item={item} />} {item.url && <ItemEmbed item={item} />}
{!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />} {!noReply && <Reply parentId={item.id} meComments={item.meComments} replyOpen />}

View File

@ -10,6 +10,7 @@ import reactStringReplace from 'react-string-replace'
import { formatSats } from '../lib/format' import { formatSats } from '../lib/format'
import * as Yup from 'yup' import * as Yup from 'yup'
import Briefcase from '../svgs/briefcase-4-fill.svg' import Briefcase from '../svgs/briefcase-4-fill.svg'
import Toc from './table-of-contents'
function SearchTitle ({ title }) { function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@ -17,7 +18,7 @@ function SearchTitle ({ title }) {
}) })
} }
export function ItemJob ({ item, rank, children }) { export function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url) const isEmail = Yup.string().email().isValidSync(item.url)
return ( return (
@ -89,6 +90,7 @@ export function ItemJob ({ item, rank, children }) {
</>} </>}
</div> </div>
</div> </div>
{toc && <Toc text={item.text} />}
</div> </div>
{children && ( {children && (
<div className={`${styles.children}`}> <div className={`${styles.children}`}>
@ -110,7 +112,7 @@ function FwdUser ({ user }) {
) )
} }
export default function Item ({ item, rank, showFwdUser, children }) { export default function Item ({ item, rank, showFwdUser, toc, children }) {
const mine = item.mine const mine = item.mine
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const [canEdit, setCanEdit] = const [canEdit, setCanEdit] =
@ -202,6 +204,7 @@ export default function Item ({ item, rank, showFwdUser, children }) {
</div> </div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />} {showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div> </div>
{toc && <Toc text={item.text} />}
</div> </div>
{children && ( {children && (
<div className={styles.children}> <div className={styles.children}>

View File

@ -0,0 +1,91 @@
import React, { useState } from 'react'
import { Dropdown, FormControl } from 'react-bootstrap'
import TocIcon from '../svgs/list-unordered.svg'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import GithubSlugger from 'github-slugger'
export default function Toc ({ text }) {
if (!text || text.length === 0) {
return null
}
const tree = fromMarkdown(text)
const toc = []
const slugger = new GithubSlugger()
visit(tree, 'heading', (node, position, parent) => {
const str = toString(node)
toc.push({ heading: str, slug: slugger.slug(str.replace(/[^\w\-\s]+/gi, '')), depth: node.depth })
})
if (toc.length === 0) {
return null
}
return (
<Dropdown alignRight>
<Dropdown.Toggle as={CustomToggle} id='dropdown-custom-components'>
<TocIcon className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu as={CustomMenu}>
{toc.map(v => {
return (
<Dropdown.Item
className={v.depth === 1 ? 'font-weight-bold' : ''}
style={{
marginLeft: `${(v.depth - 1) * 5}px`
}}
key={v.slug} href={`#${v.slug}`}
>{v.heading}
</Dropdown.Item>
)
})}
</Dropdown.Menu>
</Dropdown>
)
}
const CustomToggle = React.forwardRef(({ children, onClick }, ref) => (
<a
href=''
ref={ref}
onClick={(e) => {
e.preventDefault()
onClick(e)
}}
>
{children}
</a>
))
// forwardRef again here!
// Dropdown needs access to the DOM of the Menu to measure it
const CustomMenu = React.forwardRef(
({ children, style, className, 'aria-labelledby': labeledBy }, ref) => {
const [value, setValue] = useState('')
return (
<div
ref={ref}
style={style}
className={className}
aria-labelledby={labeledBy}
>
<FormControl
className='mx-3 my-2 w-auto'
placeholder='filter'
onChange={(e) => setValue(e.target.value)}
value={value}
/>
<ul className='list-unstyled'>
{React.Children.toArray(children).filter(
(child) =>
!value || child.props.children.toLowerCase().includes(value)
)}
</ul>
</div>
)
}
)

View File

@ -11,6 +11,7 @@ import reactStringReplace from 'react-string-replace'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import Link from '../svgs/link.svg' import Link from '../svgs/link.svg'
import {toString} from 'mdast-util-to-string'
function myRemarkPlugin () { function myRemarkPlugin () {
return (tree) => { return (tree) => {
@ -29,16 +30,10 @@ function myRemarkPlugin () {
} }
} }
function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) { function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) {
const id = noFragments const id = noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, ''))
? undefined
: slugger.slug(children.reduce(
(acc, cur) => {
if (typeof cur !== 'string') {
return acc
}
return acc + cur.replace(/[^\w\-\s]+/gi, '')
}, ''))
return ( return (
<div className={styles.heading}> <div className={styles.heading}>

View File

@ -28,6 +28,8 @@
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"ln-service": "^52.8.0", "ln-service": "^52.8.0",
"mdast-util-find-and-replace": "^1.1.1", "mdast-util-find-and-replace": "^1.1.1",
"mdast-util-from-markdown": "^1.2.0",
"mdast-util-to-string": "^3.1.0",
"next": "^11.1.2", "next": "^11.1.2",
"next-auth": "^3.29.3", "next-auth": "^3.29.3",
"next-plausible": "^2.1.3", "next-plausible": "^2.1.3",

View File

@ -44,7 +44,7 @@ $container-max-widths: (
sm: 540px, sm: 540px,
md: 720px, md: 720px,
lg: 900px, lg: 900px,
) !default; ) !default;
$nav-link-padding-y: .1rem; $nav-link-padding-y: .1rem;
$nav-tabs-link-active-bg: #fff; $nav-tabs-link-active-bg: #fff;
$nav-tabs-link-hover-border-color: transparent; $nav-tabs-link-hover-border-color: transparent;
@ -54,7 +54,9 @@ $tooltip-bg: #5c8001;
@import "~bootstrap/scss/bootstrap"; @import "~bootstrap/scss/bootstrap";
@media screen and (min-width: 767px) { @media screen and (min-width: 767px) {
.table-sm th, .table-sm td {
.table-sm th,
.table-sm td {
padding: .3rem .75rem; padding: .3rem .75rem;
} }
} }
@ -74,7 +76,7 @@ $tooltip-bg: #5c8001;
opacity: 0.7; opacity: 0.7;
} }
.modal-close + .modal-body { .modal-close+.modal-body {
padding-top: 0.5rem; padding-top: 0.5rem;
} }
@ -82,16 +84,20 @@ $tooltip-bg: #5c8001;
color: var(--theme-grey) !important; color: var(--theme-grey) !important;
} }
ol, ul, dl { ol,
ul,
dl {
padding-inline-start: 2rem; padding-inline-start: 2rem;
} }
mark { mark {
background-color: var(--primary); background-color: var(--primary
padding: 0 0.2rem; );
padding: 0 0.2rem;
} }
.table-sm th, .table-sm td { .table-sm th,
.table-sm td {
line-height: 1.2rem; line-height: 1.2rem;
} }
@ -114,7 +120,9 @@ mark {
background-color: var(--theme-body); background-color: var(--theme-body);
} }
.table th, .table td, .table thead th { .table th,
.table td,
.table thead th {
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
} }
@ -138,7 +146,8 @@ a:hover {
color: var(--theme-linkHover); color: var(--theme-linkHover);
} }
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { .nav-tabs .nav-link.active,
.nav-tabs .nav-item.show .nav-link {
color: inherit; color: inherit;
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
@ -156,7 +165,8 @@ a:hover {
color: var(--theme-color); color: var(--theme-color);
} }
.form-control:disabled, .form-control[readonly] { .form-control:disabled,
.form-control[readonly] {
background-color: var(--theme-inputDisabledBg); background-color: var(--theme-inputDisabledBg);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
opacity: 1; opacity: 1;
@ -190,7 +200,8 @@ a:hover {
color: var(--theme-navLink) !important; color: var(--theme-navLink) !important;
} }
.nav-link:not(.text-success):hover, .nav-link:not(.text-success):focus { .nav-link:not(.text-success):hover,
.nav-link:not(.text-success):focus {
color: var(--theme-navLinkFocus) !important; color: var(--theme-navLinkFocus) !important;
} }
@ -205,6 +216,8 @@ a:hover {
.dropdown-menu { .dropdown-menu {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border: 1px solid var(--theme-borderColor); border: 1px solid var(--theme-borderColor);
max-width: 90vw;
overflow: scroll;
} }
.dropdown-item { .dropdown-item {
@ -267,7 +280,13 @@ footer {
} }
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
input, select, textarea, .form-control, .form-control:focus, .input-group-text {
input,
select,
textarea,
.form-control,
.form-control:focus,
.input-group-text {
font-size: 1rem !important; font-size: 1rem !important;
} }
} }
@ -420,17 +439,34 @@ textarea.form-control {
} }
@keyframes flash { @keyframes flash {
from { filter: brightness(1);} from {
2% { filter: brightness(2.3); } filter: brightness(1);
4% { filter: brightness(1.4); } }
8% { filter: brightness(3); }
16% { filter: brightness(1); } 2% {
to { filter: brightness(1);} filter: brightness(2.3);
}
4% {
filter: brightness(1.4);
}
8% {
filter: brightness(3);
}
16% {
filter: brightness(1);
}
to {
filter: brightness(1);
}
} }
.clouds { .clouds {
background-image: url('/clouds.jpeg') !important; background-image: url('/clouds.jpeg') !important;
background-color:var(--theme-grey); background-color: var(--theme-grey);
background-repeat: repeat; background-repeat: repeat;
background-origin: content-box; background-origin: content-box;
background-size: cover; background-size: cover;
@ -443,8 +479,13 @@ textarea.form-control {
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(359deg); } transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
} }
.spin { .spin {
@ -453,7 +494,7 @@ textarea.form-control {
.static { .static {
background: url('/giphy.gif'); background: url('/giphy.gif');
background-color:var(--theme-grey); background-color: var(--theme-grey);
background-repeat: repeat; background-repeat: repeat;
background-origin: content-box; background-origin: content-box;
background-size: cover; background-size: cover;
@ -461,10 +502,11 @@ textarea.form-control {
opacity: .1; opacity: .1;
} }
@keyframes flipX{ @keyframes flipX {
from { from {
transform: rotateX(180deg); transform: rotateX(180deg);
} }
to { to {
transform: rotateX(-180deg); transform: rotateX(-180deg);
} }
@ -474,10 +516,11 @@ textarea.form-control {
animation: flipX 2s linear infinite; animation: flipX 2s linear infinite;
} }
@keyframes flipY{ @keyframes flipY {
from { from {
transform: rotateY(0deg); transform: rotateY(0deg);
} }
to { to {
transform: rotateY(360deg); transform: rotateY(360deg);
} }
@ -487,7 +530,8 @@ textarea.form-control {
animation: flipY 4s linear infinite; animation: flipY 4s linear infinite;
} }
@media (hover:none), (hover:on-demand) { @media (hover:none),
(hover:on-demand) {
.tooltip { .tooltip {
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;

1
svgs/list-unordered.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 4h13v2H8V4zM4.5 6.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 6.9a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM8 11h13v2H8v-2zm0 7h13v2H8v-2z"/></svg>

After

Width:  |  Height:  |  Size: 312 B