From fb52272594e1e5d7792f133d6b1fd9c1fe66f7ba Mon Sep 17 00:00:00 2001 From: Thomas Osmonson Date: Wed, 12 Aug 2020 12:33:47 -0500 Subject: [PATCH] fix: status checker, other misc --- package.json | 3 + public/static/fonts.css | 32 ++++ src/common/utils/index.ts | 30 ++++ .../custom-blocks/page-reference.tsx | 21 ++- src/components/lazy-image.tsx | 151 ++++++++++++++++++ src/components/mdx/image.tsx | 36 +++-- src/components/status-check.tsx | 67 +++++--- src/components/toc.tsx | 2 +- src/pages/_document.tsx | 69 ++++---- src/pages/api/status.js | 9 ++ yarn.lock | 26 ++- 11 files changed, 368 insertions(+), 78 deletions(-) create mode 100644 public/static/fonts.css create mode 100644 src/components/lazy-image.tsx create mode 100644 src/pages/api/status.js diff --git a/package.json b/package.json index f34b07da..e314a238 100755 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "prettier": "^2.0.5", "preval.macro": "^5.0.0", "react-gesture-responder": "^2.1.0", + "react-intersection-observer": "^8.26.2", + "react-spring": "^8.0.27", "remark": "^12.0.1", "remark-custom-blocks": "^2.5.0", "remark-emoji": "2.1.0", @@ -73,6 +75,7 @@ "unist-util-select": "^3.0.1", "unist-util-visit": "^2.0.3", "use-events": "^1.4.2", + "use-is-in-viewport": "^1.0.9", "yaml-loader": "^0.6.0" }, "devDependencies": { diff --git a/public/static/fonts.css b/public/static/fonts.css new file mode 100644 index 00000000..67f67616 --- /dev/null +++ b/public/static/fonts.css @@ -0,0 +1,32 @@ +@font-face { + font-family: 'Soehne Mono'; + src: url('/static/fonts/soehne-mono-web-buch.woff2') format('woff2'), + url('/static/fonts/soehne-mono-web-buch.woff') format('woff'); + font-weight: 400; + font-display: swap; + font-style: normal; +} +@font-face { + font-family: 'Soehne'; + src: url('/static/fonts/soehne-web-buch.woff2') format('woff2'), + url('/static/fonts/soehne-web-buch.woff') format('woff'); + font-weight: 400; + font-display: swap; + font-style: normal; +} +@font-face { + font-family: 'Soehne'; + src: url('/static/fonts/soehne-web-kraftig_1.woff2') format('woff2'), + url('/static/fonts/soehne-web-kraftig_1.woff') format('woff'); + font-weight: 500; + font-display: swap; + font-style: normal; +} +@font-face { + font-family: 'Soehne'; + src: url('/static/fonts/soehne-web-halbfett_1.woff2') format('woff2'), + url('/static/fonts/soehne-web-halbfett_1.woff') format('woff'); + font-weight: 600; + font-display: swap; + font-style: normal; +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index f223c79f..e39a7f72 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -92,3 +92,33 @@ export const getSlug = (asPath: string) => { } return; }; + +interface CancelablePromise { + promise: Promise; + cancel: () => void; +} + +/** Make a Promise "cancelable". + * + * Rejects with {isCanceled: true} if canceled. + * + * The way this works is by wrapping it with internal hasCanceled_ state + * and checking it before resolving. + */ +export const makeCancelable = (promise: Promise): CancelablePromise => { + let hasCanceled_ = false; + + const wrappedPromise = new Promise((resolve, reject) => { + void promise.then((val: any) => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val))); + void promise.catch((error: any) => + hasCanceled_ ? reject({ isCanceled: true }) : reject(error) + ); + }); + + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true; + }, + }; +}; diff --git a/src/components/custom-blocks/page-reference.tsx b/src/components/custom-blocks/page-reference.tsx index 8bdda901..260794cc 100644 --- a/src/components/custom-blocks/page-reference.tsx +++ b/src/components/custom-blocks/page-reference.tsx @@ -100,12 +100,22 @@ const InlineCard = ({ page }) => { position="relative" {...bind} > - + {`Graphic @@ -171,16 +181,15 @@ const GridCardImage: React.FC< > {alt} diff --git a/src/components/lazy-image.tsx b/src/components/lazy-image.tsx new file mode 100644 index 00000000..af398978 --- /dev/null +++ b/src/components/lazy-image.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { Box, BoxProps, useSafeLayoutEffect } from '@blockstack/ui'; +import { useSpring, animated, config } from 'react-spring'; +import { useInView } from 'react-intersection-observer'; +import { makeCancelable } from '@common/utils'; + +interface ImageProps { + /** The source of the image to load */ + src: string; + + /** The source set of the image to load */ + srcSet?: string; + + /** The alt text description of the image you are loading */ + alt?: string; + + /** Sizes descriptor */ + sizes?: string; +} + +const loadImage = ( + { src, srcSet, alt, sizes }: ImageProps, + experimentalDecode = false +): Promise => + // eslint-disable-next-line @typescript-eslint/no-misused-promises + new Promise((resolve, reject) => { + if (typeof Image !== 'undefined') { + const image = new Image(); + if (srcSet) { + image.srcset = srcSet; + } + if (alt) { + image.alt = alt; + } + if (sizes) { + image.sizes = sizes; + } + image.src = src; + + /** @see: https://www.chromestatus.com/feature/5637156160667648 */ + if (experimentalDecode && 'decode' in image) { + return ( + image + // NOTE: .decode() is not in the TS defs yet + // TODO: consider writing the .decode() definition and sending a PR + //@ts-ignore + .decode() + //@ts-ignore + .then((image: HTMLImageElement) => resolve(image)) + .catch((err: any) => reject(err)) + ); + } + + image.onload = resolve; + image.onerror = reject; + } + }); + +export const LazyImage: React.FC< + BoxProps & { + src?: string; + srcSet?: string; + loading?: string; + placeholder?: string; + } +> = ({ src, srcSet, style = {}, placeholder, ...props }) => { + const [ref, inView] = useInView({ + triggerOnce: true, + rootMargin: '200px 0px', + }); + + const [loading, setLoading] = React.useState(false); + const [source, setSrc] = React.useState({ + src: undefined, + srcSet: undefined, + }); + + const loadingPromise = makeCancelable(loadImage({ src, srcSet }, true)); + + const onLoad = React.useCallback( + () => + requestAnimationFrame(() => { + console.log('on-load'); + setSrc({ src, srcSet }); + }), + [] + ); + + useSafeLayoutEffect(() => { + if (!source.src && !loading && inView) { + setLoading(true); + loadingPromise.promise + .then(_res => { + console.log('loaded'); + onLoad(); + }) + .catch(e => { + // If the Loading Promise was canceled, it means we have stopped + // loading due to unmount, rather than an error. + if (!e.isCanceled) { + console.error('failed to load image'); + } + }); + } + }, [source, loading, inView]); + + const styleProps = useSpring({ opacity: source.src ? 1 : 0, config: config.gentle }); + const placeholderProps = useSpring({ opacity: source.src ? 0 : 1, config: config.gentle }); + + return ( + + + + + {source.src ? ( + + ) : null} + + ); +}; diff --git a/src/components/mdx/image.tsx b/src/components/mdx/image.tsx index edc11707..0a571bc9 100644 --- a/src/components/mdx/image.tsx +++ b/src/components/mdx/image.tsx @@ -34,9 +34,12 @@ const useImgix = (src: string) => { ${_src}&w=480&fit=max&q=20&dpr=3 3x`; const base = `${_src}&w=720&dpr=1&fit=max`; + const placeholder = `${_src}&w=40&dpr=1&fit=max`; + return { srcset, src: base, + placeholder, }; }; @@ -48,21 +51,33 @@ const getAspectRatio = dimensions => { return (height / width) * 100; }; -const BaseImg: React.FC = props => ( - -); +const BaseImg: React.FC< + BoxProps & { + src?: string; + srcSet?: string; + loading?: string; + } +> = ({ style = {}, ...props }) => { + return ( + + ); +}; export const Img: React.FC< BoxProps & { loading?: string; src?: string; alt?: string; dimensions?: any } > = React.memo(({ src: _src, dimensions, ...rest }) => { const { src, srcset } = useImgix(_src); + const props = { src, srcSet: srcset, @@ -73,7 +88,6 @@ export const Img: React.FC< // means the image is local and we can generate the aspect ratio // and prevent the page from jumping due to lack of an image being loaded // (because of the built in lazy-loading) - const aspectRatio = getAspectRatio(dimensions); const width = dimensions.width <= 720 ? dimensions.width : '100%'; diff --git a/src/components/status-check.tsx b/src/components/status-check.tsx index 9d957ad5..c4fe504b 100644 --- a/src/components/status-check.tsx +++ b/src/components/status-check.tsx @@ -1,7 +1,7 @@ import React from 'react'; import useSWR from 'swr'; import { Box, Flex, space, color, BoxProps } from '@blockstack/ui'; -import { border } from '@common/utils'; +import { border, transition } from '@common/utils'; import { Link } from '@components/mdx'; import { LinkProps, Text } from '@components/typography'; import { Spinner } from '@blockstack/ui'; @@ -13,29 +13,46 @@ import { getCapsizeStyles } from '@components/mdx/typography'; const fetcher = url => fetch(url).then(r => r.json()); -const StatusWords: React.FC = ({ status, ...rest }) => ( +type Status = 'online' | 'slow' | 'degraded' | 'loading' | undefined; + +const getColor = (status: Status) => { + switch (status) { + case 'degraded': + return 'feedback-error'; + case 'online': + return 'feedback-success'; + case 'slow': + return 'feedback-alert'; + } +}; + +const StatusWords: React.FC = ({ status, ...rest }) => ( <> : - {`${ - status ? ' online' : ' offline' - }`} + {` ${status}`} ); +const getStatus = (data: number): Status | 'loading' => { + switch (data) { + case 0: + return 'online'; + case 1: + return 'slow'; + case 2: + return 'degraded'; + default: + return 'loading'; + } +}; + export const StatusCheck: React.FC = props => { - const { data, error } = useSWR(`${STATUS_CHECKER_URL}/json`, fetcher); - const [status, setStatus] = React.useState(undefined); + const { data, error } = useSWR(`/api/status`, fetcher); - React.useEffect(() => { - if (data?.masterNodePings?.length > 1) { - setStatus(data.masterNodePings[0].value); - } else if (status) { - setStatus(undefined); - } - }, [data, error]); + const status = getStatus(data); - const critical = error && !status; - const positive = data && status && !error; + const critical = error || status === 'degraded'; + const warn = status === 'slow'; return ( = props => { py={space('tight')} color={color('text-caption')} _hover={{ cursor: 'pointer', bg: color('bg-alt') }} + opacity={data || error ? 1 : 0} + transition={transition()} {...props} > {!data && !error ? ( - - ) : critical ? ( - + + + + ) : critical || warn ? ( + ) : ( - positive && + )} 0 && adjustedLevel <= 2 : true; + const shouldRender = limit ? adjustedLevel > 0 && adjustedLevel <= 1 : true; return shouldRender ? ( diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 394abaf6..bb04ae6d 100755 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -40,45 +40,36 @@ export default class MyDocument extends Document { -