web/front.tsx
import Head from 'next/head'
import Link from 'next/link'
import Router from 'next/router'
import Script from 'next/script'
import React from 'react'
import { mutate } from 'swr'
import ourbigbook, {
Macro,
INCOMING_LINKS_MARKER,
TAGS_MARKER,
URL_SEP,
htmlCreatedUpdatedPills,
} from 'ourbigbook'
import { webApi } from 'front/api'
import {
appName,
appUrl,
docsUrl,
logoPath,
sureLeaveMessage,
useCaptcha
} from 'front/config'
import { AUTH_COOKIE_NAME } from 'front/js'
import CustomLink from 'front/CustomLink'
import routes from 'front/routes'
import { ArticleType } from 'front/types/ArticleType'
import { UserLinkWithImageInner } from 'front/UserLinkWithImage'
export const AUTH_LOCAL_STORAGE_NAME = 'user'
export const SHOW_PINNED_LOCAL_STORAGE_NAME = 'showPinned'
export const LOGIN_ACTION = 'Sign in'
export const REGISTER_ACTION = 'Sign up'
export const NON_NEGATIVE_INPUT_RE = /^([0-9]+|)$/
export function AlphabeticalOrderTabTitle() {
return <>A-Z</>
}
/** https://stackoverflow.com/questions/2901102/how-to-format-a-number-with-commas-as-thousands-separators */
export function addCommasToInteger(i: number): string {
return i.toLocaleString('en-US', { maximumFractionDigits: 0 })
}
export function capitalize(s) {
return s[0].toUpperCase() + s.slice(1)
}
export function CreateMyOwnVersionOfThisTopic({ titleSource, toplevel } : { titleSource: string, toplevel: boolean}) {
return <Link href={routes.articleNew({ title: titleSource })} className="btn new" title="Create my own version of this topic">
{' '}<NewArticleIcon title={null}/>{toplevel ? ' Create my own version' : ''}{' '}
</Link>
}
export function SeeMyOwnVersionOfThisTopic({ slug, toplevel } : { slug: string, toplevel: boolean}) {
return <Link href={routes.article(slug)} className="btn see" title="See my version of this topic">
{' '}<SeeIcon title={null}/>{toplevel ? ' See my version' : ''}{' '}
</Link>
}
export function decapitalize(s) {
return s[0].toLowerCase() + s.slice(1)
}
export function ArticleBy(
{
article,
newTab=false,
showArticleIcon=false,
showAuthor=true,
showTopicId=false,
}: {
article?: ArticleType,
newTab?: boolean,
showArticleIcon?: boolean,
showAuthor?: boolean,
showTopicId?: boolean,
}
) {
const isIndex = article.topicId === ''
const quote = (!isIndex && showAuthor) ? '"' : ''
const inner = <>
{showArticleIcon && <><ArticleIcon /> </>}
{quote}<span
className="ourbigbook-title"
dangerouslySetInnerHTML={{ __html: article.titleRender }}
/>{quote}{showAuthor && <>
{' '}by <UserLinkWithImageInner {...{
user: article.author,
showUsername: true,
}} />
</>}
{showTopicId && <> <span className="meta">({article.topicId})</span></>}
</>
const href = routes.article(article.slug)
if (newTab) {
return <a href={href} target="_blank">{inner} <LinkOpensInNewTabIcon /></a>
} else {
return <CustomLink href={href}>{inner}</CustomLink>
}
}
export function IssueBy(
{ article }:
{ article?: ArticleType }
) {
return <CustomLink href={routes.article(article.slug)}>
"<span
className="ourbigbook-title"
dangerouslySetInnerHTML={{ __html: article.titleRender }}
/>" by { article.author.displayName }
</CustomLink>
}
export function DiscussionAbout(
{ article, children, span }:
{ article?: ArticleType; children?: React.ReactNode, span?: boolean }
) {
const inner = <>
<DiscussionIcon />{' '}
Discussion on{' '}
<ArticleBy {...{article}} />
{children}
</>
let inner2
if (span) {
inner2 = <span className="h2">{ inner }</span>
} else {
inner2 = <h1 className="h2">{ inner }</h1>
}
return <div className="discussion-about">{inner2}</div>
}
// Icons.
export function Icon(cls, title, opts) {
const showTitle = opts.title === undefined ? true : opts.title
const extraClasses = opts.extraClasses === undefined ? [] : opts.extraClasses
return <i className={extraClasses.concat([cls, 'icon']).join(' ')} title={showTitle ? title : undefined } />
}
type FontAwesomeIconOptsType = {
title?: string|null
}
// Icon IDs can be found by searching under: https://fontawesome.com/search
export function FontAwesomeIcon(
code,
opts: {
cls?: string,
clsExtra?: string[],
title?: string,
opts?: FontAwesomeIconOptsType,
} ={}
) {
let { cls, clsExtra, opts: _opts, title } = opts
const classes = ['icon']
if (cls === undefined) {
cls = 'fa-solid-900'
}
if (_opts === undefined) {
_opts = {}
}
let _title = _opts.title
if (_title === null) {
title = undefined
} else if (_title !== undefined) {
title = _title
}
classes.push(cls)
if (clsExtra === undefined) {
clsExtra = []
}
classes.push(...clsExtra)
return <span
title={title}
className={classes.join(' ')}
>
{code && String.fromCharCode(code)}
</span>
}
export function AnnounceIcon(opts) {
return FontAwesomeIcon(0xf0a1, { opts, title: "Announce" })
}
export function ArticleIcon(opts) {
return FontAwesomeIcon(0xf518, { opts, title: "Article" })
}
export function ArrowLeftIcon(opts) {
return FontAwesomeIcon(0xf060, { opts })
}
export function ArrowRightIcon(opts) {
return FontAwesomeIcon(0xf061, { opts })
}
export function ArrowUpIcon(opts) {
return FontAwesomeIcon(0xf062, { opts })
}
export function CancelIcon(opts) {
return FontAwesomeIcon(0xf00d, { clsExtra: ['icon-cancel'], opts, title: "Cancel" })
}
export function ChildrenIcon(opts) {
return FontAwesomeIcon(0xf063, { opts, title: "Children" })
}
export function CommentIcon(opts) {
return FontAwesomeIcon(0xf086, { opts, title: "Comments" })
}
export function DeleteIcon(opts) {
return FontAwesomeIcon(0xf2ed, { clsExtra: ['icon-delete'], opts, title: "Delete" })
}
export function DirectoryIcon(opts) {
// https://fontawesome.com/icons/folder-open?f=classic&s=regular
return FontAwesomeIcon(undefined, { opts, clsExtra: ['directory-icon'], title: "Directory" })
}
export function DiscussionIcon(opts) {
return FontAwesomeIcon(0xf075, { opts, title: "Discussion" })
}
export function EditArticleIcon(opts) {
return FontAwesomeIcon(undefined, { opts, clsExtra: ['edit-icon'], title: "Edit" })
}
export function ErrorIcon(opts) {
return FontAwesomeIcon(0xf00d, { clsExtra: ['icon-error'], opts, title: "Error" })
}
export function LinkOpensInNewTabIcon(opts) {
return FontAwesomeIcon(0xf08e, { opts, title: "Link opens in a new tab" })
}
export function FollowIcon(opts) {
return FontAwesomeIcon(0xf06e, { opts, title: "Follow" })
}
export function FileIcon(opts) {
return FontAwesomeIcon(0xf15b, { opts, title: "File" })
}
export function HelpIcon(opts) {
return FontAwesomeIcon(0xf05a, { opts, title: "Help" })
}
export function HideIcon(opts) {
// https://fontawesome.com/icons/eye-slash
return FontAwesomeIcon(0xf070, { opts, title: "Hide" })
}
export function IdIcon(opts) {
return FontAwesomeIcon(0xf2c1, { opts, title: "Discussion" })
}
export function IncomingIcon() {
return <span dangerouslySetInnerHTML={{ __html: INCOMING_LINKS_MARKER }} />
}
export function ImageIcon(opts) {
return FontAwesomeIcon(0xf03e, { opts, title: "Image" })
}
export function InformationIcon(opts) {
return FontAwesomeIcon(0xf05a, { opts, title: "Note" })
}
export function ListIcon(opts) {
return FontAwesomeIcon(0xf03a, { opts, title: "List" })
}
export function LargestIcon(opts) {
return FontAwesomeIcon(0xf160, { opts, title: "Largest" })
}
export function LockIcon(opts) {
// https://fontawesome.com/icons/lock?s=solid
return FontAwesomeIcon(0xf023, { opts })
}
export function LikeIcon(opts) {
return FontAwesomeIcon(0xf004, { clsExtra: ['icon-heart'], opts, title: "Like" })
}
export function LogoutIcon(opts) {
return FontAwesomeIcon(0xf2f5, { opts, title: "Logout" })
}
export function MoreIcon(opts) {
return FontAwesomeIcon(0xf05a, { opts, title: "More" })
}
export function NewArticleIcon(opts) {
return FontAwesomeIcon(undefined, { opts, clsExtra: ['new-article-icon'], title: "New" })
}
export function OkIcon(opts) {
return FontAwesomeIcon(0xf00c, { clsExtra: ['icon-ok'], opts, title: "Submit" })
}
export function PinnedArticleIcon(opts) {
return FontAwesomeIcon(0xf08d, { opts, title: "Pin" })
}
export function SeeIcon(opts) {
// https://fontawesome.com/icons/eye?f=classic&s=regular
return FontAwesomeIcon(0xf06e, { opts, title: "View" })
}
export function SettingsIcon(opts) {
return FontAwesomeIcon(0xf013, { opts, title: "Settings" })
}
export function SourceIcon(opts) {
return FontAwesomeIcon(0xf15c, { opts, title: "Source" })
}
export function StarIcon(opts) {
return FontAwesomeIcon(0xf005, { clsExtra: ['icon-star'], opts, title: "Admin" })
}
export function TagIcon() {
return <span dangerouslySetInnerHTML={{ __html: TAGS_MARKER }} />
}
export function TimeIcon(opts) {
return FontAwesomeIcon(undefined, { cls: 'time-icon'})
}
export function TopicIcon(opts) {
return FontAwesomeIcon(0xf500, { opts, title: "Topic" })
}
export function TreeIcon(opts) {
return FontAwesomeIcon(0xf0e8, { opts, title: "Tree" })
}
export function UserIcon(opts) {
return FontAwesomeIcon(0xf007, { opts, title: "User" })
}
export function UnfollowIcon(opts) {
return FontAwesomeIcon(0xf070, { opts, title: "Unfollow" })
}
export function UnlistedIcon(opts) {
// https://fontawesome.com/icons/eye-slash
return FontAwesomeIcon(0xf070, { opts, title: "Unlisted" })
}
export function UnlockIcon(opts) {
// https://fontawesome.com/icons/lock-open?f=classic&s=solid
return FontAwesomeIcon(0xf3c1, { opts })
}
export function WarningIcon(opts) {
return FontAwesomeIcon(0xf071, { clsExtra: ['icon-warning'], opts })
}
export function SignupOrLogin(
{ to }:
{ to: string }
) {
return <>
<UserIcon />{' '}
<CustomLink href={routes.userNew()}>
{REGISTER_ACTION}
</CustomLink>
{' '}or{' '}
<CustomLink href={routes.userLogin()}>
{decapitalize(LOGIN_ACTION)}
</CustomLink>
{' '}to {to}.
</>
}
export function TopicsHelp({ className=undefined }) {
return <span className={className ? className : ''}>
<HelpIcon /> New to <b>topics</b>?
{' '}
<a href={`${docsUrl}/ourbigbook-web-topics`}>Read the docs here!</a>
</span>
}
export function disableButton(btn, msg='Cannot submit due to errors') {
btn.setAttribute('disabled', '')
btn.setAttribute('title', msg)
}
export function enableButton(btn) {
btn.removeAttribute('disabled')
btn.removeAttribute('title')
}
/// Logout the current user on web UI.
export function logout() {
window.localStorage.removeItem(AUTH_LOCAL_STORAGE_NAME);
deleteCookie(AUTH_COOKIE_NAME)
mutate('user', null);
}
export function slugFromArray(arr, { username }: { username?: boolean } = {}) {
if (username === undefined) {
username = true
}
const start = username ? 0 : 1
return arr.slice(start).join(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR)
}
export function slugFromRouter(router, opts={}) {
let arr = router.query.slug
if (!arr) {
return router.query.uid
}
return slugFromArray(arr, opts)
}
export const AppContext = React.createContext<{
prevPageNoSignup: string
setLoggedInUserEffectiveImage: (effectiveImage: string) => void | undefined,
updatePrevPageNoSignup: (newCur: string) => void | undefined,
}>({
prevPageNoSignup: '',
setLoggedInUserEffectiveImage: undefined,
updatePrevPageNoSignup: undefined
});
// Global state.
export const AppContextProvider = ({ children, vals }) => {
return <AppContext.Provider value={vals} >
{children}
</AppContext.Provider>
};
export function useCtrlEnterSubmit(handleSubmit) {
React.useEffect(() => {
function ctrlEnterListener(e) {
if (e.code === 'Enter' && e.ctrlKey) {
handleSubmit(e)
}
}
document.addEventListener('keydown', ctrlEnterListener);
return () => {
document.removeEventListener('keydown', ctrlEnterListener);
};
}, [handleSubmit]);
}
export function useEEdit(
canEdit: boolean,
slug: string,
) {
React.useEffect(() => {
function listener(e) {
if (
e.code === 'KeyE' &&
document.activeElement === document.body
) {
if (canEdit) {
Router.push(routes.articleEdit(slug))
}
}
}
if (slug !== undefined) {
document.addEventListener('keydown', listener);
return () => {
document.removeEventListener('keydown', listener);
}
}
}, [canEdit, slug]);
}
export function useEEditIssue(
canEdit: boolean,
slug: string,
number: number,
) {
React.useEffect(() => {
function listener(e) {
if (
e.code === 'KeyE' &&
document.activeElement === document.body
) {
if (canEdit) {
Router.push(routes.issueEdit(slug, number))
}
}
}
document.addEventListener('keydown', listener);
return () => {
document.removeEventListener('keydown', listener);
}
}, [canEdit, slug, number]);
}
/** Add a window event listener but only for the current page.
* Remove the event when next.js router moves away. */
export function useWindowEventListener(event, callback) {
React.useEffect(() => {
window.addEventListener(event, callback)
// Cleanup listener after leaving the page.
return () => {
window.removeEventListener(event, callback)
}
}, [callback, event])
}
/** Ask if user really wants to save page that may have unsaved changes.
* https://stackoverflow.com/questions/63064778/next-js-warn-user-for-unsaved-form-before-route-change
*/
export function useConfirmExitPage(okToLeave: boolean) {
React.useEffect(() => {
// If closing tab or browser history or input another domain on URL bar.
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
if (!okToLeave) {
(e || window.event).returnValue = sureLeaveMessage
return sureLeaveMessage
}
}
// If exiting by clicking a Next.js link.
const routeChangeStartHandler = (url: string) => {
if (!okToLeave && Router.pathname !== url && !confirm(sureLeaveMessage)) {
// to inform NProgress or something ...
Router.events.emit('routeChangeError')
// tslint:disable-next-line: no-string-throw
throw `Route change to "${url}" was aborted (this error can be safely ignored). See https://github.com/vercel/next.js/discussions/32231`
}
}
Router.events.on('routeChangeStart', routeChangeStartHandler)
window.addEventListener('beforeunload', beforeUnloadHandler)
return () => {
Router.events.off('routeChangeStart', routeChangeStartHandler)
window.removeEventListener('beforeunload', beforeUnloadHandler)
}
}, [okToLeave])
}
// https://stackoverflow.com/questions/4825683/how-do-i-create-and-read-a-value-from-cookie/38699214#38699214
export function setCookie(name, value, days?: number, path = '/') {
let delta
if (days === undefined) {
delta = Number.MAX_SAFE_INTEGER
} else {
delta = days * 864e5
}
const expires = new Date(Date.now() + delta).toUTCString()
document.cookie = `${name}=${encodeURIComponent(
value
)};expires=${expires};path=${path}`
}
export function setCookies(cookieDict, days, path = '/') {
for (let key in cookieDict) {
setCookie(key, cookieDict[key], days, path)
}
}
export function getCookie(name) {
return getCookieFromString(document.cookie, name)
}
export function getCookieFromReq(req, name) {
const cookie = req.headers.cookie
if (cookie) {
return getCookieFromString(cookie, name)
} else {
return null
}
}
export function getCookieFromString(s, name) {
return getCookiesFromString(s)[name]
}
// https://stackoverflow.com/questions/5047346/converting-strings-like-document-cookie-to-objects
export function getCookiesFromString(s) {
return s.split('; ').reduce((prev, current) => {
const [name, ...value] = current.split('=')
prev[name] = value.join('=')
return prev
}, {})
}
export function deleteCookie(name, path = '/') {
setCookie(name, '', -1, path)
}
export async function setupUserLocalStorage(user, setErrors?) {
// We fetch from /profiles/:username again because the return from /users/login above
// does not contain the image placeholder.
const { data: userData, status: userStatus } = await webApi.user(
user.username
)
user.effectiveImage = userData.effectiveImage
window.localStorage.setItem(
AUTH_LOCAL_STORAGE_NAME,
JSON.stringify(user)
);
setCookie(AUTH_COOKIE_NAME, user.token)
mutate(AUTH_COOKIE_NAME, user.token)
mutate(AUTH_LOCAL_STORAGE_NAME, user);
}
// URL Fragment handling
/**
* Based on the given URL path, decide the short version of a given long fragment:
* on /user: user/mathematics -> mathematics
* on /user: -/toc/user/mathematics -> -/toc/mathematics
* on /user/mathematics: user/algebra -> algebra
* on /user/has-scope: user/has-scope/no-scope -> no-scope
*/
export function getShortFragFromLongForPath(fragNoHash, pathNoSlash) {
// e.g. mathematics/
const path = pathNoSlash + '/'
let prefix
if (fragNoHash.startsWith(Macro.TOC_PREFIX)) {
prefix = Macro.TOC_PREFIX
fragNoHash = fragNoHash.replace(prefix, '')
} else {
prefix = ''
}
let removePrefix
if (fragNoHash === pathNoSlash) {
// Toplevel element '#mathematics' -> '#'
removePrefix = pathNoSlash
} else if (fragNoHash.startsWith(path)) {
// Toplevel "mathematics" has scope, e.g. /username/mathematics.
// So we convert #username/mathematics/algebra to #algebra
removePrefix = path
} else {
removePrefix = pathNoSlash.split('/').slice(0, -1).join('/') + '/'
}
return prefix + fragNoHash.replace(removePrefix, '')
}
export function getShortFragFromLong(fragNoHash) {
return getShortFragFromLongForPath(fragNoHash, routes.decodeUrlPath(window.location.pathname.substring(1)))
}
/** Modify the current URL to have this hash. Do not add alter browser history. */
export function replaceFrag(fragNoHash) {
const newUrl = window.location.pathname + window.location.search + '#' + routes.encodeUrlFragment(fragNoHash)
// Using this internal-looking API works. Not amazing, bu we can't find a better way.
// replaceState first arg is an arbitrary object, and we just make it into what Next.js uses.
// https://github.com/vercel/next.js/discussions/18072
window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, '', newUrl)
// Makes user/mathematics -> user/mathematics#algebra -> user/linear-algebra -> browser back history button work
// However makes: user/mathematics -> user/mathematics#algebra -> user/mathematics#linear-algebra -> browser back history button work
// give "Error: Cancel rendering route"
//await Router.replace(shortFrag)
}
/** Input: we are in an url with long fragment such as #barack-obama/mathematics
* Outcome: replace the URL fragment with the corresponding short one without altering browser history. */
export function replaceShortFrag() {
replaceFrag(getShortFragFromLong(routes.decodeUrlPath(window.location.hash.substring(1))))
}
/** Use explicit .target class to overcome https://github.com/ourbigbook/ourbigbook/issues/302 */
export function fragSetTarget(elem: HTMLElement) {
const es = window.document.getElementsByClassName('target')
for (let i = 0; i < es.length; i++) {
es[i].classList.remove('target')
}
elem.classList.add('target')
}
/** Does everything we want to do on our short fragment redirection hacks, e.g. when you click
* a link to an element that is in the current page. Supposes that the element is for sure in page. */
export function shortFragGoTo(
handleShortFragmentSkipOnce: React.MutableRefObject<boolean>,
shortFrag: string,
longFrag: string,
targetElem: HTMLElement
) {
handleShortFragmentSkipOnce.current = true
window.location.hash = routes.encodeUrlFragment(longFrag)
replaceFrag(shortFrag)
fragSetTarget(targetElem)
}
export function orderToPageTitle(order: string): string {
switch(order) {
case 'announcedAt':
return 'Announced'
case 'createdAt':
return 'New'
case 'path':
case 'topicId':
return 'Alphabetically sorted'
case 'score':
return 'Top'
case 'updatedAt':
return 'Recently updated'
}
}
export function MyHead({
previewImage=undefined,
title,
}) {
let titleEffective = (title ? `${title} - ` : '') + appName
if (previewImage === undefined) {
// SVG here is not ideal as some sites like LinkedIn 2025 don't pick up SVG.
// The perfect solution would be to allow admins to select an upload from
// the upload database, and auto-generate the PNG there by default and use that
// as a default.
previewImage = logoPath
}
return <Head>
<title>{titleEffective}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
{/* Setting this because otherwise LinkedIn 2025 uses h1 instead of <head> which:
* - can contain more general HTML that shows as garbage. One notable
example where this was noticed was on home page where the home icon shows
- does not contain "by Username"
*/}
<meta property="og:title" content={titleEffective}></meta>
{/* Otherwise Twitter 2025 doesn't pick the image up
* https://stackoverflow.com/questions/44355062/twitter-cards-no-card-found-card-error */}
<meta property="og:type" content="website"></meta>
{/* Seems required so why not: https://ogp.me/
but it is insanely hard to get the current URL in Next.js so I'm lazy.
<meta property="og:url" content={`${appUrl}/${Router.pathname}`}></meta> */}
<meta
property="og:image"
content={`${previewImage[0] === '/' ? appUrl : ''}${previewImage}`}>
</meta>
</Head>
}
export function ArticleCreatedUpdatedPills({ article }) {
return <span dangerouslySetInnerHTML={{ __html:
htmlCreatedUpdatedPills({
createdAt: article.createdAt,
space: true,
updatedAt: article.updatedAt,
})
}}/>
}
export async function getRecaptchaToken() : Promise<string|undefined> {
if (useCaptcha) {
return new Promise((resolve, reject) => {
grecaptcha.ready(function() {
grecaptcha.execute(process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY, {action: 'submit'}).then(function(token) {
resolve(token)
})
})
})
}
}
export function RecaptchaScript() {
if (useCaptcha) {
return <Script src={`https://www.google.com/recaptcha/api.js?render=${process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}`} />
} else {
return <></>
}
}
export function uploadPathWithoutUser(path) {
return path.split(URL_SEP).slice(2).join(URL_SEP)
}
// URL operations.
/** /my/path?aaa=bbb -> /my/path?aaa=000&bbb=111 */
export function addParameterToUrlPath(path, key: string, value: string): string {
const url = new URL('http://a.com' + path)
const params = new URLSearchParams(url.search)
params.set(key, value)
return `${url.pathname}?${params.toString()}`
}
export function removeParameterFromUrlPath(path, key: string): string {
const url = new URL('http://a.com' + path)
const params = new URLSearchParams(url.search)
params.delete(key)
let paramsString = params.toString()
if (paramsString) {
paramsString = `?${paramsString}`
}
return `${url.pathname}${paramsString}`
}
export function booleanToStringForTable(tf: boolean): string {
return tf ? '1' : '0'
}