mirror of
https://github.com/LukeHagar/plex-sdk-docs.git
synced 2025-12-11 04:20:57 +00:00
Initial Commit
This commit is contained in:
52
src/components/Buttons/CopyToClipboard/index.tsx
Normal file
52
src/components/Buttons/CopyToClipboard/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import CheckIcon from '@/src/icons/CheckIcon';
|
||||
import CopyIcon from '@/src/icons/CopyIcon';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface ICopyToClipboard {
|
||||
getValue(): string;
|
||||
}
|
||||
|
||||
const CopyToClipboard: FC<ICopyToClipboard> = ({ getValue }) => {
|
||||
const [isCopied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
setCopied(true);
|
||||
|
||||
if (!navigator?.clipboard) {
|
||||
console.error('Access to clipboard rejected!');
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(getValue());
|
||||
} catch {
|
||||
console.error('Failed to copy!');
|
||||
}
|
||||
}, [getValue]);
|
||||
|
||||
const IconToUse = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [isCopied]);
|
||||
|
||||
return (
|
||||
<button type='button' className={styles.button} onClick={handleClick}>
|
||||
<IconToUse />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyToClipboard;
|
||||
23
src/components/Buttons/CopyToClipboard/styles.module.scss
Normal file
23
src/components/Buttons/CopyToClipboard/styles.module.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.button {
|
||||
@include flex(row, center, center);
|
||||
@include generatePxToRem(padding, 8);
|
||||
@include generatePxToRem(border-radius, 8);
|
||||
background-color: var(--code-background) !important;
|
||||
border: 1px solid var(--code-block-border);
|
||||
cursor: pointer;
|
||||
svg path {
|
||||
stroke: var(--text-heading);
|
||||
stroke-opacity: var(--stroke-opacity);
|
||||
}
|
||||
&:hover {
|
||||
border-color: $select-hover;
|
||||
svg path:nth-of-type(1) {
|
||||
stroke: url(#paint0_linear_459_12127);
|
||||
}
|
||||
svg path:nth-of-type(2) {
|
||||
stroke: url(#paint1_linear_459_12127);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/components/Collapsible/index.tsx
Normal file
142
src/components/Collapsible/index.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import RightArrow from '@/src/icons/RightArrow';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export type Props = {
|
||||
openLabel: string;
|
||||
closeLabel: string;
|
||||
children: ReactNode[];
|
||||
defaultOpen?: boolean;
|
||||
content?: () => Promise<any>;
|
||||
};
|
||||
|
||||
const CollapsibleContext = createContext({
|
||||
isOpen: true,
|
||||
});
|
||||
|
||||
const Collapsible = (props: Props) => {
|
||||
const headerDefaultHeight = 36;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);
|
||||
const [height, setHeight] = useState(headerDefaultHeight);
|
||||
const [ContentComponent, setContentComponent] = useState<any>(null);
|
||||
const [shouldTransitionHeight, setShouldTransitionHeight] = useState(false);
|
||||
|
||||
const parentContext = useContext(CollapsibleContext);
|
||||
|
||||
const [headerRef, headerHeight] = useRefWithHeight();
|
||||
const [bodyRef, bodyHeight] = useRefWithHeight();
|
||||
|
||||
const heading = headingText(props.openLabel, props.closeLabel, isOpen);
|
||||
|
||||
const updateOpenHeight = (shouldTransition: boolean) => {
|
||||
setHeight((headerHeight || headerDefaultHeight) + (bodyHeight || 0));
|
||||
setShouldTransitionHeight(shouldTransition);
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!isOpen) {
|
||||
updateOpenHeight(true);
|
||||
} else {
|
||||
setShouldTransitionHeight(true);
|
||||
setHeight(headerHeight);
|
||||
}
|
||||
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updateOpenHeight(false);
|
||||
}
|
||||
}, [bodyHeight]);
|
||||
|
||||
/*
|
||||
* Pre-load dynamic content when the parent is opened
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (parentContext.isOpen && props.content && !ContentComponent) {
|
||||
props
|
||||
.content()
|
||||
.then((module) => {
|
||||
setContentComponent(() => module.default);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load the content component', error);
|
||||
});
|
||||
}
|
||||
}, [parentContext.isOpen, props.content, ContentComponent]);
|
||||
|
||||
const dynamicChildren = useMemo(
|
||||
() =>
|
||||
ContentComponent
|
||||
? [<ContentComponent key='dynamicContentComponent' />]
|
||||
: [],
|
||||
[ContentComponent],
|
||||
);
|
||||
|
||||
const existingChildren = props.children ? props.children : [];
|
||||
|
||||
const children =
|
||||
dynamicChildren.length > 0
|
||||
? [...existingChildren, ...dynamicChildren]
|
||||
: existingChildren;
|
||||
|
||||
return (
|
||||
<CollapsibleContext.Provider value={{ isOpen }}>
|
||||
<div
|
||||
className={styles.collapsible}
|
||||
style={{
|
||||
height,
|
||||
...(shouldTransitionHeight && { transition: 'height 0.5s ease' }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
onClick={open}
|
||||
className={styles.collapsible_heading}
|
||||
>
|
||||
<RightArrow activeClass={isOpen ? 'active' : ''} />
|
||||
<h5>{heading}</h5>
|
||||
</div>
|
||||
<div ref={bodyRef} className={styles.collapsible_body}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useRefWithHeight = (): [(ref: HTMLDivElement) => void, number] => {
|
||||
const [height, setHeight] = useState(0);
|
||||
const ref = useCallback((node: HTMLDivElement) => {
|
||||
if (node !== null) {
|
||||
setHeight(node.getBoundingClientRect().height);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setHeight(entry.target.getBoundingClientRect().height);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [ref, height];
|
||||
};
|
||||
|
||||
const headingText = (openLabel: string, closeLabel: string, isOpen: boolean) =>
|
||||
isOpen ? closeLabel : openLabel;
|
||||
|
||||
export default Collapsible;
|
||||
62
src/components/Collapsible/styles.module.scss
Normal file
62
src/components/Collapsible/styles.module.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
@import '../../styles/utils/mixins';
|
||||
|
||||
.collapsible {
|
||||
border-radius: var(--base-border-radius);
|
||||
border: 1px solid var(--hr-color);
|
||||
backdrop-filter: blur(10px);
|
||||
@include generatePxToRem('margin', 10 0);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Make the breaks extend edge-to-edge
|
||||
hr {
|
||||
width: 120%;
|
||||
@include generatePxToRem('margin', 12 -12 16 -12, !important);
|
||||
}
|
||||
|
||||
&_heading {
|
||||
@include flex(row, initial, center, nowrap);
|
||||
transition: all 0.5s ease;
|
||||
@include generatePxToRem('padding', 8);
|
||||
|
||||
> h5 {
|
||||
@include collapse-children-heading-text();
|
||||
@include generatePxToRem('margin', 0 0 0 10, !important);
|
||||
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: normal;
|
||||
@include generatePxToRem('font-size', 14);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
> h5 {
|
||||
color: var(--text-emphasis);
|
||||
}
|
||||
|
||||
path {
|
||||
stroke: var(--text-emphasis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&_body {
|
||||
@include generatePxToRem('padding', 12);
|
||||
border-top: 1px solid var(--hr-color);
|
||||
|
||||
table {
|
||||
@include generatePxToRem(margin, -12, !important);
|
||||
}
|
||||
|
||||
> p:not(:last-child) {
|
||||
@include generatePxToRem('margin', 10 0 10 0, !important);
|
||||
}
|
||||
|
||||
& > *:last-child {
|
||||
@include desktopMin1400 {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/components/Columns/index.tsx
Normal file
32
src/components/Columns/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { FC, ReactNode } from 'react';
|
||||
|
||||
import { splitByType } from '@/src/components/typeHelpers';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IColumns {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Columns: FC<IColumns> & { RHS: typeof RHS } = ({ children }) => {
|
||||
const [rhs = [], lhs = []] = splitByType(children, RHS);
|
||||
const mainContent = lhs.length || rhs.length ? lhs : children;
|
||||
|
||||
const columns = (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.columnContainer}>
|
||||
<div className={styles.mainContent}>{mainContent}</div>
|
||||
{/* Extra level of nesting needed for sticky to work */}
|
||||
<div>
|
||||
<div className={styles.rightSideContent}>{rhs}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div>{columns}</div>;
|
||||
};
|
||||
|
||||
export const RHS = (props: { children: ReactNode }) => <>{props.children}</>;
|
||||
|
||||
Columns.RHS = RHS;
|
||||
46
src/components/Columns/styles.module.scss
Normal file
46
src/components/Columns/styles.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.headings {
|
||||
@include tabletsMax768 {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.columnContainer {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 80px;
|
||||
|
||||
@include desktopMax1400 {
|
||||
grid-auto-flow: row;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
& > *:last-child {
|
||||
@include desktopMin1400 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightSideContent {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
|
||||
@include desktopMax1400 {
|
||||
@include generatePxToRem(max-width, 688);
|
||||
margin-left: 0;
|
||||
}
|
||||
@include tabletsMax768 {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/components/Footer/index.tsx
Normal file
27
src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { FC } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
import Logo from '@/src/components/Logo';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const Footer: FC = () => (
|
||||
<footer className={styles.footer}>
|
||||
<Logo />
|
||||
<div className={styles.footer_links}>
|
||||
<div className={styles.footer_links_social}>
|
||||
<p className={styles.footer_links_social_inc}>
|
||||
<NextLink
|
||||
href={'https://www.speakeasyapi.dev/'}
|
||||
key={'speakeasy'}
|
||||
target='_blank'
|
||||
>
|
||||
Built by Speakeasy
|
||||
</NextLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
72
src/components/Footer/styles.module.scss
Normal file
72
src/components/Footer/styles.module.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
@include flex(column, space-between, flex-start);
|
||||
gap: 40px;
|
||||
padding: 88px 128px;
|
||||
background-color: var(--color-footer-background);
|
||||
|
||||
@media (max-width: 1130px) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@include tabletsMax768 {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
&_links {
|
||||
width: 100%;
|
||||
@include flex(row, space-between, initial, wrap);
|
||||
gap: 20px;
|
||||
z-index: 2;
|
||||
|
||||
@media (max-width: 1130px) {
|
||||
gap: 80px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&_general {
|
||||
@include flex(row, flex-start, flex-end);
|
||||
gap: 32px;
|
||||
|
||||
@media (max-width: 1130px) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&_link {
|
||||
@include footer-links-text();
|
||||
}
|
||||
}
|
||||
|
||||
&_social {
|
||||
@include flex(column, flex-end, flex-end, nowrap);
|
||||
gap: 32px;
|
||||
|
||||
@media (max-width: 1130px) {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&_icons {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&_inc {
|
||||
@include footer-inc-text();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/components/Header/index.tsx
Normal file
24
src/components/Header/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
import Logo from '@/src/components/Logo';
|
||||
import ThemeToggle from '@/src/components/ThemeToggle';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const Header: FC = () => (
|
||||
<div className={styles.headerRoot}>
|
||||
<div className={styles.headerInner}>
|
||||
<div>
|
||||
<NextLink href='/'>
|
||||
<Logo />
|
||||
</NextLink>
|
||||
</div>
|
||||
<div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
18
src/components/Header/styles.module.scss
Normal file
18
src/components/Header/styles.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.headerRoot {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
@include generatePxToRem(padding, 0 32 0 32);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--color-page-background);
|
||||
.headerInner {
|
||||
@include flex(row, space-between, center);
|
||||
height: inherit;
|
||||
div {
|
||||
@include flex(row, center, center);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/components/LanguageSelector/index.tsx
Normal file
83
src/components/LanguageSelector/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import cn from 'classnames';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { languageData } from '@/src/lib/languageData';
|
||||
import { LanguageContext } from '@/src/utils/contexts/languageContext';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const LanguageSelector = ({ showIcon }: { showIcon?: boolean }) => {
|
||||
const {
|
||||
language: currentLanguage,
|
||||
setLanguage,
|
||||
languages,
|
||||
} = useContext(LanguageContext);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectLanguage = useCallback(
|
||||
(language: string) => {
|
||||
setLanguage(language);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[setLanguage],
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current != null &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [containerRef, isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.languageSelector, {
|
||||
[styles.open]: isOpen,
|
||||
[styles.hasIcon]: showIcon,
|
||||
})}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className={styles.button} onClick={() => setIsOpen(!isOpen)}>
|
||||
{showIcon ? (
|
||||
<div className={styles.icon}>
|
||||
{languageData[currentLanguage].Icon({ style: 'outline' })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.language}>
|
||||
{languageData[currentLanguage]?.title}
|
||||
</div>
|
||||
<div className={styles.popUpButton} />
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className={styles.options}>
|
||||
<ul>
|
||||
{languages.map((language) => (
|
||||
<li
|
||||
key={language}
|
||||
className={cn({
|
||||
[styles.active]: language === currentLanguage,
|
||||
})}
|
||||
onClick={() => selectLanguage(language)}
|
||||
>
|
||||
{languageData[language].title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
97
src/components/LanguageSelector/styles.module.scss
Normal file
97
src/components/LanguageSelector/styles.module.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.languageSelector {
|
||||
position: relative;
|
||||
|
||||
.button {
|
||||
height: 36px;
|
||||
@include flex(row, center, center);
|
||||
border-radius: var(--base-border-radius);
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
&.open .button, .button:hover {
|
||||
border: 1px solid var(--hr-color);
|
||||
|
||||
.language {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.popUpButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include generatePxToRem(padding, 8);
|
||||
}
|
||||
|
||||
.language {
|
||||
@include generatePxToRem(padding-right, 8);
|
||||
@include generatePxToRem(font-size, 14);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:not(.hasIcon) .language {
|
||||
@include generatePxToRem(padding-left, 8);
|
||||
}
|
||||
|
||||
.popUpButton {
|
||||
width: 12px;
|
||||
height: 13px;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 13' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.49451 1.30869L6 0.875L5.50548 1.30869L1.44053 4.87365L0.881031 5.36432H3.15598L6 2.87013L8.84402 5.36432H11.119L10.5595 4.87365L6.49451 1.30869Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M5.50549 12.4413L6 12.875L6.49452 12.4413L10.5595 8.87635L11.119 8.38568L8.84402 8.38568L6 10.8799L3.15598 8.38568L0.881034 8.38568L1.44053 8.87635L5.50549 12.4413Z' fill='white'/%3E%3C/svg%3E%0A");
|
||||
mask-repeat: no-repeat;
|
||||
background-color: var(--lang-selector-primary-color);
|
||||
opacity: 0.4;
|
||||
@include generatePxToRem(margin-right, 10);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
svg path {
|
||||
fill: var(--lang-selector-icon-color);
|
||||
}
|
||||
|
||||
.options {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
@include generatePxToRem(margin-top, 8);
|
||||
border-radius: var(--base-border-radius);
|
||||
border: 1px solid var(--hr-color);
|
||||
min-width: 8rem;
|
||||
z-index: 1000;
|
||||
background-color: var(--color-page-background);
|
||||
|
||||
li {
|
||||
@include generatePxToRem(padding, 8 12 8 16);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@include generatePxToRem(font-size, 14);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01rem;
|
||||
color: var(--lang-selector-item-color);
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--base-border-radius) var(--base-border-radius) 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--base-border-radius) var(--base-border-radius);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--lang-selector-item-hover-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/components/LinkWrapper/index.tsx
Normal file
36
src/components/LinkWrapper/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { FC, ReactNode, useContext } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import ExternalLink from '@/src/icons/ExternalLink';
|
||||
import { checkIsLinkInternal } from '@/src/utils/helpers';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
import { ScrollContext } from '../scrollManager';
|
||||
|
||||
interface ILinkWrapper {
|
||||
children: ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const LinkWrapper: FC<ILinkWrapper> = ({ children, href = '/' }) => {
|
||||
const { scrollTo } = useContext(ScrollContext);
|
||||
const isInternalLink = checkIsLinkInternal(href);
|
||||
const handleInternalClick = (e: any) => {
|
||||
e.preventDefault();
|
||||
scrollTo(href);
|
||||
};
|
||||
|
||||
return isInternalLink ? (
|
||||
<Link href={href} className={styles.link} onClick={handleInternalClick}>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<a className={styles.link} href={href} target={'_blank'}>
|
||||
{children}
|
||||
<ExternalLink />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkWrapper;
|
||||
30
src/components/LinkWrapper/styles.module.scss
Normal file
30
src/components/LinkWrapper/styles.module.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
line-height: 152%;
|
||||
border-bottom: 1px solid var(--text-link);
|
||||
border-radius: 0;
|
||||
margin-bottom: 1px;
|
||||
|
||||
column-gap: 4px;
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
padding-bottom: 2px;
|
||||
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-bottom: 2px solid var(--text-link);
|
||||
margin-bottom: 0;
|
||||
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
47
src/components/Logo/index.tsx
Normal file
47
src/components/Logo/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { createRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import docsTheme from '@/src/utils/theme';
|
||||
|
||||
const Logo = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const darkSourceRef = createRef<HTMLSourceElement>();
|
||||
|
||||
// This is necessary because of how Next handles images
|
||||
useEffect(() => {
|
||||
if (darkSourceRef.current) {
|
||||
darkSourceRef.current.media = resolvedTheme === 'dark' ? 'all' : 'none';
|
||||
}
|
||||
}, [resolvedTheme, darkSourceRef.current]);
|
||||
|
||||
const logo = (
|
||||
<picture>
|
||||
<source
|
||||
ref={darkSourceRef}
|
||||
srcSet={docsTheme.logo.dark}
|
||||
media='(prefers-color-scheme: dark)'
|
||||
/>
|
||||
<Image
|
||||
src={docsTheme.logo.light}
|
||||
alt='Logo'
|
||||
width={125}
|
||||
height={50}
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={docsTheme.logo.link}
|
||||
target={'_blank'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{logo}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
13
src/components/Logo/styles.module.scss
Normal file
13
src/components/Logo/styles.module.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.logo {
|
||||
@include mobile {
|
||||
@include generatePxToRem(width, 124);
|
||||
@include generatePxToRem(height, 24);
|
||||
}
|
||||
.path {
|
||||
fill: var(--text-heading);
|
||||
fill-opacity: var(--text-heading-opacity);
|
||||
}
|
||||
|
||||
}
|
||||
14
src/components/Message/index.tsx
Normal file
14
src/components/Message/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import InfoIcon from '@/src/icons/InfoIcon';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const Message: FC<{ message: string }> = ({ message }) => (
|
||||
<div className={styles.message}>
|
||||
<InfoIcon />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Message;
|
||||
17
src/components/Message/styles.module.scss
Normal file
17
src/components/Message/styles.module.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.message {
|
||||
width: 100%;
|
||||
@include flex(row, initial, center, nowrap);
|
||||
@include generatePxToRem(padding, 16 24);
|
||||
@include generatePxToRem(gap, 10);
|
||||
@include generatePxToRem(border-radius, 16);
|
||||
border: 1px solid var(--code-block-border);
|
||||
background: var(--message-bcg-color);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
@include message-text();
|
||||
}
|
||||
}
|
||||
9
src/components/MethodPill/index.tsx
Normal file
9
src/components/MethodPill/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const MethodPill: FC<{
|
||||
method: string;
|
||||
}> = ({ method }) => <span className={styles.methodPill}>{method}</span>;
|
||||
|
||||
export default MethodPill;
|
||||
15
src/components/MethodPill/styles.module.scss
Normal file
15
src/components/MethodPill/styles.module.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.methodPill {
|
||||
background-color: var(--method-pill-bg-color);
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.03rem;
|
||||
padding: 3px 9px 3px 8px;
|
||||
font-size: 85%;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
display: inline-block;
|
||||
@include generatePxToRem('height', 26);
|
||||
}
|
||||
27
src/components/NavItem/index.tsx
Normal file
27
src/components/NavItem/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { FC, useContext } from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './styles.module.scss';
|
||||
import { ScrollContext } from '../scrollManager';
|
||||
|
||||
export const NavItem: FC<Record<string, string>> = ({ route, title, type }) => {
|
||||
const { scrollTo } = useContext(ScrollContext);
|
||||
|
||||
const classForItem = {
|
||||
[styles['separator']]: type === 'separator',
|
||||
};
|
||||
|
||||
if (route === '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
scrollTo(route);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleClick} className={cn(styles.nav_item, classForItem)}>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
src/components/NavItem/styles.module.scss
Normal file
46
src/components/NavItem/styles.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.nav_item {
|
||||
width: 100%;
|
||||
@include generatePxToRem('padding', 8);
|
||||
@include generatePxToRem('border-radius', 8);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
@include generatePxToRem('border-radius', 8);
|
||||
border: 2px solid var(--heading-sidebar-bg-color);
|
||||
}
|
||||
|
||||
&.separator {
|
||||
@include generatePxToRem('padding', 14 16 0 4);
|
||||
|
||||
&:hover {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
> p {
|
||||
@include sidebar-heading-separator(var(--text-heading));
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
width: 100%;
|
||||
|
||||
// Check /src/styles/nextra.scss for the rest of the styling
|
||||
}
|
||||
|
||||
&.visible {
|
||||
background-color: #2A2A2A;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
@include heading-h5(var(--text-subheading));
|
||||
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/components/OperationHeader/index.tsx
Normal file
17
src/components/OperationHeader/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FC, useContext } from 'react';
|
||||
|
||||
import { LanguageContext } from '@/src/utils/contexts/languageContext';
|
||||
|
||||
const OperationHeader: FC<{
|
||||
sdkHeader: React.ReactNode;
|
||||
curlHeader: React.ReactNode;
|
||||
}> = ({ sdkHeader, curlHeader }) => {
|
||||
const { language } = useContext(LanguageContext);
|
||||
|
||||
if (language == 'curl') {
|
||||
return curlHeader;
|
||||
} else {
|
||||
return sdkHeader;
|
||||
}
|
||||
};
|
||||
export default OperationHeader;
|
||||
63
src/components/OperationInfo/index.tsx
Normal file
63
src/components/OperationInfo/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import cn from 'classnames';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
import MethodPill from '../MethodPill';
|
||||
|
||||
const OperationInfo = ({ method, path }: { method: string; path: string }) => (
|
||||
<div className={styles.operationInfo}>
|
||||
<MethodPill method={method} />
|
||||
<div className={styles.path}>
|
||||
{pathComponents(path).map((component, index) => {
|
||||
const { type } = component;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
type === 'placeholder' ? styles.placeholder : null,
|
||||
type === 'separator' ? styles.separator : null,
|
||||
)}
|
||||
>
|
||||
{text(component)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default OperationInfo;
|
||||
|
||||
type PathComponent =
|
||||
| { type: 'placeholder'; value: string }
|
||||
| { type: 'separator' }
|
||||
| { type: 'text'; value: string };
|
||||
|
||||
const pathComponents = (path: string): PathComponent[] => {
|
||||
const components = path.split(/(\/)/g);
|
||||
const pathComponents: PathComponent[] = [];
|
||||
|
||||
components.forEach((component) => {
|
||||
if (component === '/') {
|
||||
pathComponents.push({ type: 'separator' });
|
||||
} else if (component.startsWith('{') && component.endsWith('}')) {
|
||||
pathComponents.push({ type: 'placeholder', value: component });
|
||||
} else {
|
||||
pathComponents.push({ type: 'text', value: component });
|
||||
}
|
||||
});
|
||||
|
||||
return pathComponents;
|
||||
};
|
||||
|
||||
const text = (pathComponent: PathComponent) => {
|
||||
const { type } = pathComponent;
|
||||
|
||||
switch (type) {
|
||||
case 'placeholder':
|
||||
case 'text':
|
||||
return pathComponent.value;
|
||||
case 'separator':
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
29
src/components/OperationInfo/styles.module.scss
Normal file
29
src/components/OperationInfo/styles.module.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.operationInfo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: -1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.path {
|
||||
@include generatePxToRem(margin-left, 12);
|
||||
@include generatePxToRem(font-size, 16);
|
||||
line-height: 1.650;
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
.placeholder {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.separator {
|
||||
opacity: 0.3;
|
||||
font-weight: 600;
|
||||
@include generatePxToRem(padding, 0 2);
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/components/Parameters/index.tsx
Normal file
40
src/components/Parameters/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import LanguageSelector from '@/src/components/LanguageSelector';
|
||||
import { H3 } from '@/src/components/TextHeaderWrapper';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export const Authentication = (props: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={styles.parameterHeading}>
|
||||
<H3>Authentication</H3>
|
||||
{/* No language selector because this is only used for curl */}
|
||||
</div>
|
||||
<div className={styles.parameters}>{props.children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Parameters = (props: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={styles.parameterHeading}>
|
||||
<H3>Parameters</H3>
|
||||
<div className={styles.languageSelector}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.parameters}>{props.children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Response = (props: { children: ReactNode }) => (
|
||||
<>
|
||||
<div className={styles.parameterHeading}>
|
||||
<H3>Response</H3>
|
||||
<div className={styles.languageSelector}>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.parameters}>{props.children}</div>
|
||||
</>
|
||||
);
|
||||
47
src/components/Parameters/styles.module.scss
Normal file
47
src/components/Parameters/styles.module.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
@import '../../styles/utils/mixins';
|
||||
|
||||
.parameterHeading {
|
||||
@include flex(row, left, center);
|
||||
|
||||
h3 {
|
||||
@include generatePxToRem(margin-right, 6);
|
||||
}
|
||||
|
||||
.languageSelector {
|
||||
@include generatePxToRem(margin-top, 3);
|
||||
}
|
||||
}
|
||||
|
||||
.parameters {
|
||||
@include generatePxToRem('margin', 20 0);
|
||||
|
||||
h5 {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: normal;
|
||||
@include generatePxToRem('font-size', 15);
|
||||
@include generatePxToRem('margin', 0 0 8 0);
|
||||
|
||||
// Styling for parameter types
|
||||
em {
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
> em {
|
||||
color: var(--text-emphasis);
|
||||
}
|
||||
|
||||
> a > em {
|
||||
color: var(--text-emphasis);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
@include generatePxToRem('font-size', 14);
|
||||
@include generatePxToRem('line-height', 20);
|
||||
@include generatePxToRem('margin', 10 0);
|
||||
}
|
||||
|
||||
> hr {
|
||||
@include generatePxToRem('margin', 20 0, !important);
|
||||
}
|
||||
}
|
||||
46
src/components/SDKPicker/index.tsx
Normal file
46
src/components/SDKPicker/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import cn from 'classnames';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { languageData } from '@/src/lib/languageData';
|
||||
import { LanguageContext } from '@/src/utils/contexts/languageContext';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const SDKPicker = () => {
|
||||
const {
|
||||
language: currentLanguage,
|
||||
setLanguage,
|
||||
languages,
|
||||
} = useContext(LanguageContext);
|
||||
|
||||
return (
|
||||
<div className={styles.sdkPicker}>
|
||||
{languages
|
||||
.filter((language) => language !== 'curl')
|
||||
.map((language) => {
|
||||
const isActive = language === currentLanguage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={language}
|
||||
onClick={() => setLanguage(language)}
|
||||
className={cn(styles.item, {
|
||||
[styles.active]: isActive,
|
||||
})}
|
||||
>
|
||||
<div className={cn(styles.icon, styles[`${language}Icon`])}>
|
||||
{languageData[language].Icon({
|
||||
style: 'outline',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.label}>
|
||||
{languageData[language].title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SDKPicker;
|
||||
170
src/components/SDKPicker/styles.module.scss
Normal file
170
src/components/SDKPicker/styles.module.scss
Normal file
@@ -0,0 +1,170 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.sdkPicker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@include generatePxToRem(column-gap, 16);
|
||||
@include generatePxToRem(row-gap, 16);
|
||||
|
||||
@include tabletsMax1050 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@include desktopMin1400 {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.item {
|
||||
@include generatePxToRem(padding, 6 12);
|
||||
border: 1px solid var(--hr-color);
|
||||
border-radius: var(--base-border-radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@include generatePxToRem(font-size, 14);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01rem;
|
||||
|
||||
.icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@include generatePxToRem(margin-right, 8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.goIcon svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.pythonIcon svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.csharpIcon svg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.unityIcon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.javaIcon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.typescriptIcon svg {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sdkPicker {
|
||||
.item {
|
||||
&.active {
|
||||
.label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
.label {
|
||||
color: rgb(58, 57, 50);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: rgb(58, 57, 50);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.label {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: var(--primary-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) .sdkPicker {
|
||||
.item {
|
||||
&.active {
|
||||
.label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
.label {
|
||||
color: #fff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: #fff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.label {
|
||||
color: var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon svg path {
|
||||
fill: var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/components/Section/section.tsx
Normal file
44
src/components/Section/section.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { ReactElement, useContext } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
import { MultiPageContext } from '../scrollManager';
|
||||
|
||||
export const DocsSectionRouteContext = React.createContext('');
|
||||
|
||||
export const DocsSection = ({
|
||||
route = '',
|
||||
children,
|
||||
}: {
|
||||
route?: string;
|
||||
children?: ReactElement[];
|
||||
}) => {
|
||||
const parentRoute = useContext(DocsSectionRouteContext);
|
||||
const isMultiPage = useContext(MultiPageContext);
|
||||
|
||||
// if (parentRoute === '/') {
|
||||
// parentRoute = '';
|
||||
// }
|
||||
//
|
||||
// if (route.startsWith('/')) {
|
||||
// route = route.slice(1);
|
||||
// }
|
||||
|
||||
let homeOverride = '';
|
||||
|
||||
if (isMultiPage) {
|
||||
// Root page content needs a route to live in, so wrap it in /home
|
||||
if (parentRoute === '') {
|
||||
homeOverride = '/';
|
||||
}
|
||||
}
|
||||
|
||||
let fullRoute = `${parentRoute}/${route}${homeOverride}`;
|
||||
fullRoute = fullRoute.replaceAll('//', '/');
|
||||
|
||||
return (
|
||||
<DocsSectionRouteContext.Provider value={fullRoute}>
|
||||
<div className={styles.container}>{children}</div>
|
||||
</DocsSectionRouteContext.Provider>
|
||||
);
|
||||
};
|
||||
6
src/components/Section/styles.module.scss
Normal file
6
src/components/Section/styles.module.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.container {
|
||||
@include generatePxToRem(max-width, 1400);
|
||||
margin: auto;
|
||||
}
|
||||
34
src/components/StatusCode/index.tsx
Normal file
34
src/components/StatusCode/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const StatusCode: FC<{ code: string }> = ({ code }) => (
|
||||
<span className={[styles.statusCodes_code, codeTypeStyle(code)].join(' ')}>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
|
||||
const codeTypeStyle = (code: string) => {
|
||||
const range = parseInt(code[0]);
|
||||
|
||||
if (isNaN(range)) {
|
||||
return styles.statusCodes_codeInformative;
|
||||
}
|
||||
|
||||
switch (range) {
|
||||
// 2xx
|
||||
case 2:
|
||||
return styles.statusCodes_codeSuccess;
|
||||
// 3xx
|
||||
case 3:
|
||||
return styles.statusCodes_codeRedirect;
|
||||
// 4xx, 5xx
|
||||
case 4:
|
||||
case 5:
|
||||
return styles.statusCodes_codeError;
|
||||
default:
|
||||
return styles.statusCodes_codeInformative;
|
||||
}
|
||||
};
|
||||
|
||||
export default StatusCode;
|
||||
35
src/components/StatusCode/styles.module.scss
Normal file
35
src/components/StatusCode/styles.module.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@import '../../styles/utils/mixins';
|
||||
|
||||
.statusCodes {
|
||||
@include generatePxToRem('font-size', 14);
|
||||
|
||||
&_code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: normal;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
@include generatePxToRem('margin-right', 6);
|
||||
}
|
||||
}
|
||||
|
||||
&_codeInformative::before {
|
||||
background-color: var(--status-color-informative);
|
||||
}
|
||||
|
||||
&_codeSuccess::before {
|
||||
background-color: var(--status-color-success);
|
||||
}
|
||||
|
||||
&_codeRedirect::before {
|
||||
background-color: var(--status-color-redirect);
|
||||
}
|
||||
|
||||
&_codeError::before {
|
||||
background-color: var(--status-color-error);
|
||||
}
|
||||
}
|
||||
54
src/components/TabbedSection/index.tsx
Normal file
54
src/components/TabbedSection/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Children, FC, ReactNode, isValidElement, useState } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export const TabbedSection: FC<{ tabLabel: string; children: ReactNode }> = ({
|
||||
tabLabel,
|
||||
children,
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
|
||||
const tabs = Children.toArray(children)
|
||||
.map((child) => (isValidElement<TabProps>(child) ? [child] : []))
|
||||
.flat();
|
||||
|
||||
return (
|
||||
<div className={styles.tabbedSection}>
|
||||
<div className={styles.tabbedSection_heading}>
|
||||
{tabLabel ? (
|
||||
<span className={styles.tabbedSection_tabLabel}>{tabLabel}</span>
|
||||
) : null}
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
className={[
|
||||
styles.tabbedSection_tabButton,
|
||||
index == selectedTab
|
||||
? styles.tabbedSection_tabButton_selected
|
||||
: null,
|
||||
]
|
||||
.filter((c) => c)
|
||||
.join(' ')}
|
||||
key={index}
|
||||
onClick={() => setSelectedTab(index)}
|
||||
>
|
||||
{typeof tab.props.title === 'string' ? (
|
||||
<span className={styles.tabbedSection_tabButton_title}>
|
||||
tab.props.title
|
||||
</span>
|
||||
) : (
|
||||
tab.props.title
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className={styles.tabbedSection_headerFill} />
|
||||
</div>
|
||||
<div className={styles.tabbedSection_body}>{tabs[selectedTab]}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TabProps = {
|
||||
title: string | ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
export const Tab: FC<TabProps> = ({ children }) => <div>{children}</div>;
|
||||
78
src/components/TabbedSection/styles.module.scss
Normal file
78
src/components/TabbedSection/styles.module.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
@import '../../styles/utils/mixins';
|
||||
|
||||
.tabbedSection {
|
||||
border-radius: var(--base-border-radius);
|
||||
border: 1px solid var(--hr-color);
|
||||
backdrop-filter: blur(10px);
|
||||
@include generatePxToRem('margin', 10 0);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Make the breaks extend edge-to-edge
|
||||
hr {
|
||||
width: 120%;
|
||||
@include generatePxToRem('margin', 12 -12 16 -12, !important);
|
||||
}
|
||||
|
||||
&_heading {
|
||||
@include flex(row, initial, stretch, nowrap);
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
&_tabLabel {
|
||||
@include generatePxToRem('font-size', 14);
|
||||
@include generatePxToRem('padding-left', 16);
|
||||
@include generatePxToRem('padding-right', 12);
|
||||
@include generatePxToRem('padding-top', 8);
|
||||
@include generatePxToRem('padding-bottom', 8);
|
||||
border-bottom: 1px solid var(--hr-color);
|
||||
}
|
||||
|
||||
&_tabButton {
|
||||
@include generatePxToRem('font-size', 14);
|
||||
border-left: 1px solid var(--hr-color);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0);
|
||||
@include generatePxToRem('padding-left', 12);
|
||||
@include generatePxToRem('padding-right', 12);
|
||||
@include generatePxToRem('padding-top', 8);
|
||||
@include generatePxToRem('padding-bottom', 8);
|
||||
|
||||
&_title {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&:not(.tabbedSection_tabButton_selected) {
|
||||
border-bottom-color: var(--hr-color);
|
||||
}
|
||||
}
|
||||
|
||||
&_headerFill {
|
||||
border: 0 solid var(--hr-color);
|
||||
border-left-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
flex-grow: 1;
|
||||
@include generatePxToRem('padding-left', 12);
|
||||
}
|
||||
|
||||
&_body {
|
||||
@include generatePxToRem('padding', 12);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p:not(:first-child) {
|
||||
@include generatePxToRem('margin-top', 10);
|
||||
}
|
||||
|
||||
p:not(:last-child) {
|
||||
@include generatePxToRem('margin-bottom', 10);
|
||||
}
|
||||
|
||||
:global(.ch-codeblock) {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/components/TextHeaderWrapper/index.tsx
Normal file
130
src/components/TextHeaderWrapper/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, {
|
||||
createElement,
|
||||
FC,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ScrollContext } from '@/src/components/scrollManager';
|
||||
import { toRouteFormat } from '@/src/utils/routesHelpers';
|
||||
import styles from './styles.module.scss';
|
||||
import { DocsSectionRouteContext } from '@/src/components/Section/section';
|
||||
|
||||
type textHeader = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
|
||||
interface IHeaderProps {
|
||||
headingType: textHeader;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const TextHeaderWrapper: FC<IHeaderProps> = ({
|
||||
headingType,
|
||||
children = '',
|
||||
}) => {
|
||||
const route = useContext(DocsSectionRouteContext);
|
||||
const scrollContext = useContext(ScrollContext);
|
||||
|
||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
||||
|
||||
// Need both of these so we can fade before we switch the icon back
|
||||
const [veryRecentlyCopied, setVeryRecentlyCopied] = useState(false);
|
||||
const [recentlyCopied, setRecentlyCopied] = useState(false);
|
||||
|
||||
const headingValue = toRouteFormat(children?.toString());
|
||||
|
||||
const inputRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
const pagePos = useMemo(
|
||||
() =>
|
||||
inputRef.current
|
||||
? inputRef.current?.getBoundingClientRect().top + window.scrollY
|
||||
: 0,
|
||||
[inputRef.current?.getBoundingClientRect().top],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current && (headingType === 'h1' || headingType === 'h2')) {
|
||||
scrollContext.upsertHeading(
|
||||
route,
|
||||
headingValue,
|
||||
inputRef.current,
|
||||
pagePos,
|
||||
);
|
||||
}
|
||||
}, [pagePos]);
|
||||
|
||||
const heading = createElement(
|
||||
headingType,
|
||||
{
|
||||
ref: inputRef,
|
||||
id: route,
|
||||
},
|
||||
children,
|
||||
);
|
||||
|
||||
const headingsToGiveAnchors = ['h1', 'h2', 'h3'];
|
||||
const headingsToNestAnchors = ['h3'];
|
||||
|
||||
const link = useMemo(() => {
|
||||
const origin =
|
||||
typeof window !== 'undefined' && window.location.origin
|
||||
? window.location.origin
|
||||
: '';
|
||||
|
||||
return headingsToNestAnchors.includes(headingType)
|
||||
? `${origin}${route}#${headingValue}`
|
||||
: `${origin}${route}`;
|
||||
}, [route]);
|
||||
|
||||
const handleAnchorClick = () => {
|
||||
navigator.clipboard.writeText(link);
|
||||
setRecentlyCopied(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (recentlyCopied) {
|
||||
setVeryRecentlyCopied(true);
|
||||
setTimeout(() => setVeryRecentlyCopied(false), 750);
|
||||
setTimeout(() => setRecentlyCopied(false), 1000);
|
||||
}
|
||||
}, [recentlyCopied]);
|
||||
|
||||
const anchorButton = (
|
||||
<button className={styles.anchor} onClick={handleAnchorClick}>
|
||||
{recentlyCopied ? '✓' : '#'}
|
||||
</button>
|
||||
);
|
||||
|
||||
return headingsToGiveAnchors.includes(headingType) ? (
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setIsMouseOver(true)}
|
||||
onMouseLeave={() => setIsMouseOver(false)}
|
||||
>
|
||||
{heading}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-25px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: isMouseOver || veryRecentlyCopied ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{anchorButton}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
heading
|
||||
);
|
||||
};
|
||||
|
||||
export const H3 = ({ children }: { children: ReactNode }) => (
|
||||
<TextHeaderWrapper headingType='h3'>{children}</TextHeaderWrapper>
|
||||
);
|
||||
|
||||
export default TextHeaderWrapper;
|
||||
12
src/components/TextHeaderWrapper/styles.module.scss
Normal file
12
src/components/TextHeaderWrapper/styles.module.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.anchor {
|
||||
@include generatePxToRem(width, 25);
|
||||
@include generatePxToRem(font-size, 20);
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-body);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link);
|
||||
}
|
||||
}
|
||||
78
src/components/ThemeToggle/index.tsx
Normal file
78
src/components/ThemeToggle/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
import LanguageSelector from '@/src/components/LanguageSelector';
|
||||
import Moon from '@/src/icons/Moon';
|
||||
import Sun from '@/src/icons/Sun';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const DARK = 'dark';
|
||||
const LIGHT = 'light';
|
||||
const SYSTEM = 'system';
|
||||
|
||||
const ThemeToggle: FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === SYSTEM) {
|
||||
setTheme(DARK);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDark = theme === DARK || (theme === SYSTEM && resolvedTheme === DARK);
|
||||
|
||||
const onChangeTheme = () => {
|
||||
setTheme(isDark ? LIGHT : DARK);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSelector showIcon={true} />
|
||||
<div className={styles.toggle}>
|
||||
<label htmlFor='switch'>
|
||||
<span>Light</span>
|
||||
<Switch
|
||||
id='switch'
|
||||
className='react-switch'
|
||||
onChange={onChangeTheme}
|
||||
checked={isDark}
|
||||
uncheckedIcon={false}
|
||||
checkedIcon={false}
|
||||
height={31}
|
||||
width={51}
|
||||
handleDiameter={26}
|
||||
onColor='#2a2a2a'
|
||||
offColor='#EFEFF1'
|
||||
onHandleColor='#171717'
|
||||
activeBoxShadow='0px 0px 1px 1px rgba(0, 0, 0, 0.2)'
|
||||
offHandleColor='#FFF'
|
||||
checkedHandleIcon={
|
||||
<div className={styles.checkedIcon}>
|
||||
<Moon />
|
||||
</div>
|
||||
}
|
||||
uncheckedHandleIcon={
|
||||
<div className={styles.checkedIcon}>
|
||||
<Sun />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<span>Dark</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
20
src/components/ThemeToggle/styles.module.scss
Normal file
20
src/components/ThemeToggle/styles.module.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@import '@/src/styles/utils/mixins';
|
||||
|
||||
.toggle {
|
||||
@include generatePxToRem(margin-left, 24);
|
||||
label {
|
||||
@include flex(row, center, center);
|
||||
@include generatePxToRem(column-gap, 8);
|
||||
cursor: pointer;
|
||||
span {
|
||||
@include tabletsMax768 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkedIcon {
|
||||
@include flex(row, center, center);
|
||||
height: 100%;
|
||||
}
|
||||
11
src/components/Watermark/index.tsx
Normal file
11
src/components/Watermark/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const Watermark = () => (
|
||||
<a
|
||||
className={styles.watermark}
|
||||
href='https://speakeasyapi.dev'
|
||||
target='_blank'
|
||||
></a>
|
||||
);
|
||||
|
||||
export default Watermark;
|
||||
30
src/components/Watermark/styles.module.scss
Normal file
30
src/components/Watermark/styles.module.scss
Normal file
File diff suppressed because one or more lines are too long
24
src/components/WithStatusBar/index.tsx
Normal file
24
src/components/WithStatusBar/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const WithStatusBar: FC<{
|
||||
statusItems: [string, string][];
|
||||
children: React.ReactNode;
|
||||
}> = ({ statusItems, children }) => (
|
||||
<div className={styles.withStatusBar}>
|
||||
{children}
|
||||
{statusItems ? (
|
||||
<div className={styles.withStatusBar_infoBar}>
|
||||
{statusItems.map(([title, value], index) => (
|
||||
<div key={index}>
|
||||
<strong>{title}</strong>{' '}
|
||||
<span className={styles.value}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default WithStatusBar;
|
||||
38
src/components/WithStatusBar/styles.module.scss
Normal file
38
src/components/WithStatusBar/styles.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@import '../../styles/utils/mixins';
|
||||
|
||||
.withStatusBar {
|
||||
background-color: #212121;
|
||||
border-radius: 4px;
|
||||
|
||||
:global(.ch-codeblock) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ch-code) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&_infoBar {
|
||||
color: #8D8D90;
|
||||
font-family: var(--font-family);
|
||||
font-weight: normal;
|
||||
border-top: 1px solid #44444A;
|
||||
@include generatePxToRem('font-size', 13);
|
||||
@include generatePxToRem('padding-left', 16);
|
||||
@include generatePxToRem('padding-bottom', 8);
|
||||
@include generatePxToRem('padding-top', 7);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> *:not(:last-child) {
|
||||
@include generatePxToRem('margin-right', 16);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: normal;
|
||||
@include generatePxToRem('font-size', 13);
|
||||
@include generatePxToRem('margin-left', 4);
|
||||
}
|
||||
}
|
||||
49
src/components/customRedirects.tsx
Normal file
49
src/components/customRedirects.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Languages, DefaultLanguage } from '@/content/languages';
|
||||
|
||||
type Redirect = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export const CustomRedirects = () => {
|
||||
const router = useRouter();
|
||||
|
||||
// Static redirects + custom redirects defined in theme.yaml
|
||||
const redirects: Redirect[] = [
|
||||
{
|
||||
from: '/',
|
||||
to: `/${DefaultLanguage}/client_sdks/`,
|
||||
},
|
||||
...Languages.map((lang) => ({
|
||||
from: `/${lang}`,
|
||||
to: `/${lang}/client_sdks/`,
|
||||
})),
|
||||
];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
const matchedRedirect = redirects.find((r) => {
|
||||
if (!r.from.endsWith('*')) {
|
||||
return r.from === currentPath;
|
||||
}
|
||||
|
||||
const basePath = r.from.replace('*', '');
|
||||
|
||||
return currentPath.startsWith(basePath);
|
||||
});
|
||||
|
||||
if (matchedRedirect) {
|
||||
const newPath = matchedRedirect.from.endsWith('*')
|
||||
? matchedRedirect.to.replace('*', '') +
|
||||
currentPath.replace(matchedRedirect.from.replace('*', ''), '')
|
||||
: matchedRedirect.to;
|
||||
|
||||
router.replace(newPath);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
24
src/components/head.tsx
Normal file
24
src/components/head.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useConfig } from '@speakeasy-sdks/nextra-theme-docs';
|
||||
import { theme } from '@/src/utils/theme';
|
||||
|
||||
export const Head = () => {
|
||||
const { frontMatter } = useConfig();
|
||||
|
||||
const title = 'Reference'; // TODO frontMatter.title || 'Reference';
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{title}</title>
|
||||
<meta property='og:title' content={title} />
|
||||
<meta
|
||||
property='og:description'
|
||||
content={frontMatter.description || 'SDK reference'}
|
||||
/>
|
||||
<link rel='preconnect' href='https://fonts.gstatic.com' />
|
||||
<link href={theme.fonts.main.url} rel='stylesheet' />
|
||||
<link href={theme.fonts.code.url} rel='stylesheet' />
|
||||
<link href={theme.favicon} rel={'icon'} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
5
src/components/rootContainer.tsx
Normal file
5
src/components/rootContainer.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const RootContainer = (props: { children: ReactNode }) => (
|
||||
<div>{props.children}</div>
|
||||
);
|
||||
61
src/components/routeProvider.tsx
Normal file
61
src/components/routeProvider.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
type RouteProviderProps = {
|
||||
route: string;
|
||||
setRoute: (route: string) => void;
|
||||
};
|
||||
export const RouteContext = createContext<RouteProviderProps>({
|
||||
route: '/',
|
||||
setRoute: () => {},
|
||||
});
|
||||
|
||||
export const useRoute = () => useContext(RouteContext).route;
|
||||
|
||||
export const RouteProvider = (props: { children: ReactNode }) => {
|
||||
const router = useRouter();
|
||||
const [route, setRoute] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setRoute(window.location.pathname);
|
||||
}, []);
|
||||
|
||||
const handleRouteChange = (newRoute: string) => {
|
||||
const currentBasePage = route.split('/').at(1);
|
||||
|
||||
// If we're on the same root page, just update the URL so that we don't have to reload the page
|
||||
if (newRoute.startsWith('/' + currentBasePage)) {
|
||||
updateUrlShallow(window, newRoute);
|
||||
} else {
|
||||
// This causes the page to reload
|
||||
router.push(newRoute, undefined, { scroll: false });
|
||||
}
|
||||
|
||||
setRoute(newRoute);
|
||||
};
|
||||
|
||||
return (
|
||||
<RouteContext.Provider value={{ route, setRoute: handleRouteChange }}>
|
||||
{props.children}
|
||||
</RouteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// We take in a window to ensure this is only called from where window is available
|
||||
export const updateUrlShallow = (window: Window, url: string) => {
|
||||
window.history.replaceState(
|
||||
{
|
||||
...window.history.state,
|
||||
as: url,
|
||||
url: url,
|
||||
},
|
||||
'',
|
||||
url,
|
||||
);
|
||||
};
|
||||
223
src/components/scrollManager.tsx
Normal file
223
src/components/scrollManager.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RouteContext } from '@/src/components/routeProvider';
|
||||
|
||||
export const MultiPageContext = createContext(false);
|
||||
export const ScrollContext = createContext<{
|
||||
headingToPosition: Record<string, HeadingPosition>;
|
||||
currentHeading: string;
|
||||
visibleHeadings: string[];
|
||||
upsertHeading: (
|
||||
route: string,
|
||||
heading: string,
|
||||
elem: HTMLHeadingElement,
|
||||
position: number,
|
||||
) => void;
|
||||
scrollTo: (route: string) => void;
|
||||
setPage: (route: string) => void;
|
||||
}>({
|
||||
headingToPosition: {},
|
||||
currentHeading: '',
|
||||
visibleHeadings: [],
|
||||
upsertHeading: () => {},
|
||||
scrollTo: () => {},
|
||||
setPage: () => {},
|
||||
});
|
||||
|
||||
export const useSetPage = () => useContext(ScrollContext).setPage;
|
||||
|
||||
type HeadingPosition = {
|
||||
elem: HTMLHeadingElement;
|
||||
position: number;
|
||||
};
|
||||
|
||||
// Used to change the route a bit before the heading is at the top of the page
|
||||
const headingOffset = -200;
|
||||
|
||||
export const ScrollManager = (props: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement => {
|
||||
const isMultipage = useContext(MultiPageContext);
|
||||
// const slug = pathname !== null ? pathname : undefined;
|
||||
const { route, setRoute } = useContext(RouteContext);
|
||||
|
||||
const [initialScrollTarget, setInitialScrollTarget] = useState<string>();
|
||||
const [initialScrollDone, setInitialScrollDone] = useState(false);
|
||||
|
||||
const rootPage = useMemo(
|
||||
() => (isMultipage ? route?.split('/').at(1) ?? '' : ''),
|
||||
[route],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setHeadingToPosition({});
|
||||
setInitialScrollDone(false);
|
||||
setInitialScrollTarget(undefined);
|
||||
};
|
||||
|
||||
const setPage = async (route: string) => {
|
||||
reset();
|
||||
setInitialScrollTarget(route);
|
||||
setRoute(route);
|
||||
// await router.push(route, route, { scroll: false });
|
||||
};
|
||||
|
||||
const [headingToPosition, setHeadingToPosition] = useState<
|
||||
Record<string, HeadingPosition>
|
||||
>({});
|
||||
const upsertHeading = (
|
||||
route: string,
|
||||
heading: string,
|
||||
elem: HTMLHeadingElement,
|
||||
position: number,
|
||||
) => {
|
||||
setHeadingToPosition((currentValues) => {
|
||||
position = position + headingOffset;
|
||||
|
||||
// If there are multiple headings in a section, we want to keep only the topmost one.
|
||||
// As a result, clicking the link in the sidebar will correctly scroll to the top of the section
|
||||
const current = currentValues[route];
|
||||
|
||||
if (current && current.elem !== elem && position > current.position) {
|
||||
route += `#${heading}`;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentValues,
|
||||
[route]: {
|
||||
elem,
|
||||
position,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const [closestHeading, setClosestHeading] = useState<string>('/' + rootPage);
|
||||
const [visibleHeadings, setVisibleHeadings] = useState<string[]>([
|
||||
closestHeading,
|
||||
]);
|
||||
|
||||
/**
|
||||
* This is responsible for setting the route in the URL to the closest heading when the user scrolls.
|
||||
* This is memoized so that it can be removed when the route changes (otherwise it prevents scrolling to the desired heading)
|
||||
*/
|
||||
const scroll = useMemo(
|
||||
() => () => {
|
||||
const entries = Object.entries(headingToPosition);
|
||||
|
||||
const visible = entries
|
||||
.filter(
|
||||
([_, { position }]) =>
|
||||
window.scrollY < position &&
|
||||
position < window.scrollY + window.innerHeight,
|
||||
)
|
||||
.map(([route]) => route);
|
||||
|
||||
// Find the first heading that is below the current scroll position
|
||||
const nextIndex = entries.findIndex(
|
||||
([_, { position }]) => position > window.scrollY,
|
||||
);
|
||||
|
||||
// The current heading is the one before that
|
||||
const currentIndex =
|
||||
nextIndex === -1
|
||||
? entries.length - 1
|
||||
: nextIndex - 1 >= 0
|
||||
? nextIndex - 1
|
||||
: 0;
|
||||
const closest = entries[currentIndex]?.[0];
|
||||
|
||||
setClosestHeading(closest);
|
||||
setVisibleHeadings([closest, ...visible]);
|
||||
},
|
||||
[headingToPosition],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', scroll, false);
|
||||
|
||||
return () => window.removeEventListener('scroll', scroll, false);
|
||||
}, [scroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
closestHeading &&
|
||||
initialScrollDone &&
|
||||
closestHeading.startsWith(`/${rootPage}`) // Make sure we haven't changed pages. Without this, we might overwrite the new route
|
||||
) {
|
||||
setRoute(closestHeading);
|
||||
}
|
||||
}, [closestHeading]);
|
||||
|
||||
// Scrolls the page to the location of the target heading
|
||||
const scrollTo = useMemo(
|
||||
() => (route: string) => {
|
||||
if (headingToPosition[route]) {
|
||||
document.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
setClosestHeading(route);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// Scroll down a bit further than the heading so that it lines up right at the top
|
||||
window.scrollTo({ top: headingToPosition[route].position + 100 });
|
||||
}
|
||||
},
|
||||
[headingToPosition],
|
||||
);
|
||||
|
||||
/**
|
||||
* On initial page load, set the heading to scroll to
|
||||
* This enables linking to a specific section
|
||||
* We don't want to run this every time the slug changes since we change it as the user scrolls
|
||||
*/
|
||||
useEffect(() => {
|
||||
// At first, the slug is simply /[...rest], so wait til it properly pulls in the URL
|
||||
if (route && !initialScrollTarget) {
|
||||
setInitialScrollTarget(route);
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
// Once the initial scroll target is set and we know where that heading is, scroll to it
|
||||
// Do this every time the heading location changes until it stabilizes. This is necessary because the heading
|
||||
// will change positions on the page a few different times as the page loads. We want to scroll to it every time
|
||||
// it changes to reduce the perception of lagginess.
|
||||
useEffect(() => {
|
||||
let t: NodeJS.Timeout;
|
||||
if (
|
||||
initialScrollTarget &&
|
||||
headingToPosition[initialScrollTarget] &&
|
||||
!initialScrollDone
|
||||
) {
|
||||
scrollTo(initialScrollTarget);
|
||||
t = setTimeout(() => {
|
||||
setInitialScrollDone(true);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [initialScrollTarget && headingToPosition[initialScrollTarget]]);
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider
|
||||
value={{
|
||||
headingToPosition,
|
||||
upsertHeading,
|
||||
currentHeading: closestHeading,
|
||||
visibleHeadings,
|
||||
scrollTo,
|
||||
setPage,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
};
|
||||
89
src/components/typeHelpers.tsx
Normal file
89
src/components/typeHelpers.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { partition } from 'lodash';
|
||||
|
||||
// Needs to support both arrays of children and MDXContent nodes (whose children first need to be extracted)
|
||||
export const splitByType = (
|
||||
node: React.ReactNode,
|
||||
type: (props: { children: React.ReactNode }) => React.JSX.Element,
|
||||
): [React.ReactNode[], React.ReactNode[]] => {
|
||||
if (Array.isArray(node)) {
|
||||
return splitElementsByType(node, type);
|
||||
} else {
|
||||
return splitMDXContentChildrenByType(node, type);
|
||||
}
|
||||
};
|
||||
|
||||
// Assumes the parent is an MDXContent node
|
||||
export const splitMDXContentChildrenByType = (
|
||||
parent: React.ReactNode,
|
||||
type: (props: { children: React.ReactNode }) => React.JSX.Element,
|
||||
): [React.ReactNode[], React.ReactNode[]] => {
|
||||
let children = (parent as any).props?.children;
|
||||
|
||||
if (!children) {
|
||||
if (
|
||||
'type' in (parent as any) &&
|
||||
typeof (parent as any).type === 'function'
|
||||
) {
|
||||
children = (parent as any).type().props.children;
|
||||
} else {
|
||||
return [[], []];
|
||||
}
|
||||
}
|
||||
|
||||
return splitElementsByType(children, type);
|
||||
};
|
||||
|
||||
export const splitElementsByType = (
|
||||
elements: React.ReactElement[],
|
||||
type: (props: { children: React.ReactNode }) => React.JSX.Element,
|
||||
): [React.ReactElement[], React.ReactElement[]] =>
|
||||
partition(elements, (e: any) => e?.type === type);
|
||||
|
||||
export const typeMatches = (
|
||||
e: React.ReactNode,
|
||||
type: () => React.JSX.Element,
|
||||
): boolean => 'type' in (e as any) && (e as any).type === type;
|
||||
|
||||
export const splitAround = <T,>(a: T[], fn: (e: T) => boolean): [T[], T[]] => {
|
||||
const breakIndex = a.findIndex(fn);
|
||||
|
||||
if (breakIndex === -1) {
|
||||
return [a, []];
|
||||
}
|
||||
|
||||
return [a.slice(0, breakIndex), a.slice(breakIndex + 1)];
|
||||
};
|
||||
|
||||
export const separateHeadingsAndOthers = (elements: any) => {
|
||||
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||
const headingsArray = [];
|
||||
const othersArray = [];
|
||||
let isHeading = true;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
// Check if the element is a React element
|
||||
if (elements[i]?.['$$typeof'] === Symbol.for('react.element')) {
|
||||
const elementType = elements[i].type?.name?.toLowerCase();
|
||||
|
||||
// Check if the element type is one of the headings
|
||||
if (headings.includes(elementType)) {
|
||||
// If isHeading is true, add to headingsArray, else to othersArray
|
||||
if (isHeading) {
|
||||
headingsArray.push(elements[i]);
|
||||
} else {
|
||||
othersArray.push(elements[i]);
|
||||
}
|
||||
} else {
|
||||
// Non-heading element, so subsequent heading tags should go to othersArray
|
||||
othersArray.push(elements[i]);
|
||||
isHeading = false;
|
||||
}
|
||||
} else {
|
||||
// Non-React element (like '\n'), just add to othersArray
|
||||
othersArray.push(elements[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return [headingsArray, othersArray];
|
||||
};
|
||||
Reference in New Issue
Block a user