refactor(website): refactor showcase components (#10023)

This commit is contained in:
Sébastien Lorber 2024-04-10 10:42:27 +02:00 committed by GitHub
parent 73016d4936
commit 964a4e458e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 727 additions and 826 deletions

View file

@ -98,6 +98,13 @@ export {useDocsPreferredVersion} from './contexts/docsPreferredVersion';
export {processAdmonitionProps} from './utils/admonitionUtils'; export {processAdmonitionProps} from './utils/admonitionUtils';
export {
useHistorySelector,
useQueryString,
useQueryStringList,
useClearQueryString,
} from './utils/historyUtils';
export { export {
SkipToContentFallbackId, SkipToContentFallbackId,
SkipToContentLink, SkipToContentLink,

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {useCallback, useEffect, useSyncExternalStore} from 'react'; import {useCallback, useEffect, useMemo, useSyncExternalStore} from 'react';
import {useHistory} from '@docusaurus/router'; import {useHistory} from '@docusaurus/router';
import {useEvent} from './reactUtils'; import {useEvent} from './reactUtils';
@ -74,41 +74,86 @@ export function useQueryStringValue(key: string | null): string | null {
}); });
} }
export function useQueryStringKeySetter(): ( function useQueryStringUpdater(
key: string, key: string,
newValue: string | null, ): (newValue: string | null, options?: {push: boolean}) => void {
options?: {push: boolean},
) => void {
const history = useHistory(); const history = useHistory();
return useCallback( return useCallback(
(key, newValue, options) => { (newValue, options) => {
const searchParams = new URLSearchParams(history.location.search); const searchParams = new URLSearchParams(history.location.search);
if (newValue) { if (newValue) {
searchParams.set(key, newValue); searchParams.set(key, newValue);
} else { } else {
searchParams.delete(key); searchParams.delete(key);
} }
const updaterFn = options?.push ? history.push : history.replace; const updateHistory = options?.push ? history.push : history.replace;
updaterFn({ updateHistory({
search: searchParams.toString(), search: searchParams.toString(),
}); });
}, },
[history], [key, history],
); );
} }
export function useQueryString( export function useQueryString(
key: string, key: string,
): [string, (newValue: string, options?: {push: boolean}) => void] { ): [string, (newValue: string | null, options?: {push: boolean}) => void] {
const value = useQueryStringValue(key) ?? ''; const value = useQueryStringValue(key) ?? '';
const setQueryString = useQueryStringKeySetter(); const update = useQueryStringUpdater(key);
return [ return [value, update];
value, }
useCallback(
(newValue: string, options) => { function useQueryStringListValues(key: string): string[] {
setQueryString(key, newValue, options); // Unfortunately we can't just use searchParams.getAll(key) in the selector
}, // It would create a new array every time and lead to an infinite loop...
[setQueryString, key], // The selector has to return a primitive/string value to avoid that...
), const arrayJsonString = useHistorySelector((history) => {
]; const values = new URLSearchParams(history.location.search).getAll(key);
return JSON.stringify(values);
});
return useMemo(() => JSON.parse(arrayJsonString), [arrayJsonString]);
}
type ListUpdate = string[] | ((oldValues: string[]) => string[]);
type ListUpdateFunction = (
update: ListUpdate,
options?: {push: boolean},
) => void;
function useQueryStringListUpdater(key: string): ListUpdateFunction {
const history = useHistory();
const setValues: ListUpdateFunction = useCallback(
(update, options) => {
const searchParams = new URLSearchParams(history.location.search);
const newValues = Array.isArray(update)
? update
: update(searchParams.getAll(key));
searchParams.delete(key);
newValues.forEach((v) => searchParams.append(key, v));
const updateHistory = options?.push ? history.push : history.replace;
updateHistory({
search: searchParams.toString(),
});
},
[history, key],
);
return setValues;
}
export function useQueryStringList(
key: string,
): [string[], ListUpdateFunction] {
const values = useQueryStringListValues(key);
const setValues = useQueryStringListUpdater(key);
return [values, setValues];
}
export function useClearQueryString(): () => void {
const history = useHistory();
return useCallback(() => {
history.replace({
search: undefined,
});
}, [history]);
} }

View file

@ -301,7 +301,6 @@ rtcts
rtlcss rtlcss
saurus saurus
Scaleway Scaleway
searchbar
Sebastien Sebastien
sebastien sebastien
sebastienlorber sebastienlorber

View file

@ -49,7 +49,6 @@
"@docusaurus/theme-mermaid": "3.2.1", "@docusaurus/theme-mermaid": "3.2.1",
"@docusaurus/utils": "3.2.1", "@docusaurus/utils": "3.2.1",
"@docusaurus/utils-common": "3.2.1", "@docusaurus/utils-common": "3.2.1",
"@popperjs/core": "^2.11.8",
"@swc/core": "1.2.197", "@swc/core": "1.2.197",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"color": "^4.2.3", "color": "^4.2.3",
@ -61,7 +60,6 @@
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-lite-youtube-embed": "^2.3.52", "react-lite-youtube-embed": "^2.3.52",
"react-medium-image-zoom": "^5.1.6", "react-medium-image-zoom": "^5.1.6",
"react-popper": "^2.3.0",
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"swc-loader": "^0.2.3", "swc-loader": "^0.2.3",

View file

@ -1,19 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Svg, {type SvgIconProps} from '@site/src/components/Svg';
export default function FavoriteIcon(
props: Omit<SvgIconProps, 'children'>,
): JSX.Element {
return (
<Svg {...props}>
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" />
</Svg>
);
}

View file

@ -14,11 +14,6 @@
*/ */
--site-primary-hue-saturation: 167 68%; --site-primary-hue-saturation: 167 68%;
--site-primary-hue-saturation-light: 167 56%; /* do we really need this extra one? */ --site-primary-hue-saturation-light: 167 56%; /* do we really need this extra one? */
--site-color-favorite-background: #f6fdfd;
--site-color-tooltip: #fff;
--site-color-tooltip-background: #353738;
--site-color-svg-icon-favorite: #e9669e;
--site-color-checkbox-checked-bg: hsl(167deg 56% 73% / 25%);
--site-color-feedback-background: #f0f8ff; --site-color-feedback-background: #f0f8ff;
--docusaurus-highlighted-code-line-bg: rgb(0 0 0 / 10%); --docusaurus-highlighted-code-line-bg: rgb(0 0 0 / 10%);
/* Use a darker color to ensure contrast, ideally we don't need important */ /* Use a darker color to ensure contrast, ideally we don't need important */
@ -28,8 +23,6 @@
html[data-theme='dark'] { html[data-theme='dark'] {
--site-color-feedback-background: #2a2929; --site-color-feedback-background: #2a2929;
--site-color-favorite-background: #1d1e1e;
--site-color-checkbox-checked-bg: hsl(167deg 56% 73% / 10%);
--docusaurus-highlighted-code-line-bg: rgb(66 66 66 / 35%); --docusaurus-highlighted-code-line-bg: rgb(66 66 66 / 35%);
} }

View file

@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import {useClearQueryString} from '@docusaurus/theme-common';
export default function ClearAllButton(): ReactNode {
const clearQueryString = useClearQueryString();
// TODO translate
return (
<button
className="button button--outline button--primary"
type="button"
onClick={() => clearQueryString()}>
Clear All
</button>
);
}

View file

@ -0,0 +1,32 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ComponentProps} from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';
interface Props {
className?: string;
style?: ComponentProps<'svg'>['style'];
size: 'small' | 'medium' | 'large';
}
export default function FavoriteIcon({
size,
className,
style,
}: Props): React.ReactNode {
return (
<svg
viewBox="0 0 24 24"
className={clsx(styles.svg, styles[size], className)}
style={style}>
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" />
</svg>
);
}

View file

@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.svg {
user-select: none;
color: #e9669e;
width: 1em;
height: 1em;
display: inline-block;
fill: currentColor;
}
.small {
font-size: 1rem;
}
.medium {
font-size: 1.25rem;
}
.large {
font-size: 1.8rem;
}

View file

@ -0,0 +1,41 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useId} from 'react';
import clsx from 'clsx';
import {useOperator} from '../../_utils';
import styles from './styles.module.css';
export default function OperatorButton() {
const id = useId();
const [operator, toggleOperator] = useOperator();
// TODO add translations
return (
<>
<input
id={id}
type="checkbox"
className="screen-reader-only"
aria-label="Toggle between or and and for the tags you selected"
checked={operator === 'AND'}
onChange={toggleOperator}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleOperator();
}
}}
/>
<label htmlFor={id} className={clsx(styles.checkboxLabel, 'shadow--md')}>
{/* eslint-disable @docusaurus/no-untranslated-text */}
<span className={styles.checkboxLabelOr}>OR</span>
<span className={styles.checkboxLabelAnd}>AND</span>
{/* eslint-enable @docusaurus/no-untranslated-text */}
</label>
</>
);
}

View file

@ -10,27 +10,28 @@ import clsx from 'clsx';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import Translate from '@docusaurus/Translate'; import Translate from '@docusaurus/Translate';
import Image from '@theme/IdealImage'; import Image from '@theme/IdealImage';
import FavoriteIcon from '@site/src/components/svgIcons/FavoriteIcon'; import {Tags, TagList, type TagType, type User} from '@site/src/data/users';
import {
Tags,
TagList,
type TagType,
type User,
type Tag,
} from '@site/src/data/users';
import {sortBy} from '@site/src/utils/jsUtils'; import {sortBy} from '@site/src/utils/jsUtils';
import Heading from '@theme/Heading'; import Heading from '@theme/Heading';
import Tooltip from '../ShowcaseTooltip'; import FavoriteIcon from '../FavoriteIcon';
import styles from './styles.module.css'; import styles from './styles.module.css';
const TagComp = React.forwardRef<HTMLLIElement, Tag>( function TagItem({
({label, color, description}, ref) => ( label,
<li ref={ref} className={styles.tag} title={description}> description,
color,
}: {
label: string;
description: string;
color: string;
}) {
return (
<li className={styles.tag} title={description}>
<span className={styles.textLabel}>{label.toLowerCase()}</span> <span className={styles.textLabel}>{label.toLowerCase()}</span>
<span className={styles.colorLabel} style={{backgroundColor: color}} /> <span className={styles.colorLabel} style={{backgroundColor: color}} />
</li> </li>
), );
); }
function ShowcaseCardTag({tags}: {tags: TagType[]}) { function ShowcaseCardTag({tags}: {tags: TagType[]}) {
const tagObjects = tags.map((tag) => ({tag, ...Tags[tag]})); const tagObjects = tags.map((tag) => ({tag, ...Tags[tag]}));
@ -43,17 +44,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
return ( return (
<> <>
{tagObjectsSorted.map((tagObject, index) => { {tagObjectsSorted.map((tagObject, index) => {
const id = `showcase_card_tag_${tagObject.tag}`; return <TagItem key={index} {...tagObject} />;
return (
<Tooltip
key={index}
text={tagObject.description}
anchorEl="#__docusaurus"
id={id}>
<TagComp key={index} {...tagObject} />
</Tooltip>
);
})} })}
</> </>
); );
@ -62,6 +53,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
function getCardImage(user: User): string { function getCardImage(user: User): string {
return ( return (
user.preview ?? user.preview ??
// TODO make it configurable
`https://slorber-api-screenshot.netlify.app/${encodeURIComponent( `https://slorber-api-screenshot.netlify.app/${encodeURIComponent(
user.website, user.website,
)}/showcase` )}/showcase`
@ -83,7 +75,7 @@ function ShowcaseCard({user}: {user: User}) {
</Link> </Link>
</Heading> </Heading>
{user.tags.includes('favorite') && ( {user.tags.includes('favorite') && (
<FavoriteIcon svgClass={styles.svgIconFavorite} size="small" /> <FavoriteIcon size="medium" style={{marginRight: '0.25rem'}} />
)} )}
{user.source && ( {user.source && (
<Link <Link

View file

@ -37,14 +37,10 @@
} }
.showcaseCardTitle, .showcaseCardTitle,
.showcaseCardHeader .svgIconFavorite { .showcaseCardHeader {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.showcaseCardHeader .svgIconFavorite {
color: var(--site-color-svg-icon-favorite);
}
.showcaseCardSrcBtn { .showcaseCardSrcBtn {
margin-left: 6px; margin-left: 6px;
padding-left: 12px; padding-left: 12px;

View file

@ -0,0 +1,98 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import {sortedUsers, type User} from '@site/src/data/users';
import Heading from '@theme/Heading';
import FavoriteIcon from '../FavoriteIcon';
import ShowcaseCard from '../ShowcaseCard';
import {useFilteredUsers} from '../../_utils';
import styles from './styles.module.css';
const favoriteUsers = sortedUsers.filter((user) =>
user.tags.includes('favorite'),
);
const otherUsers = sortedUsers.filter(
(user) => !user.tags.includes('favorite'),
);
function HeadingNoResult() {
return (
<Heading as="h2">
<Translate id="showcase.usersList.noResult">No result</Translate>
</Heading>
);
}
function HeadingFavorites() {
return (
<Heading as="h2" className={styles.headingFavorites}>
<Translate id="showcase.favoritesList.title">Our favorites</Translate>
<FavoriteIcon size="large" style={{marginLeft: '1rem'}} />
</Heading>
);
}
function HeadingAllSites() {
return (
<Heading as="h2">
<Translate id="showcase.usersList.allUsers">All sites</Translate>
</Heading>
);
}
function CardList({heading, items}: {heading?: ReactNode; items: User[]}) {
return (
<div className="container">
{heading}
<ul className={clsx('clean-list', styles.cardList)}>
{items.map((item) => (
<ShowcaseCard key={item.title} user={item} />
))}
</ul>
</div>
);
}
function NoResultSection() {
return (
<section className="margin-top--lg margin-bottom--xl">
<div className="container padding-vert--md text--center">
<HeadingNoResult />
</div>
</section>
);
}
export default function ShowcaseCards() {
const filteredUsers = useFilteredUsers();
if (filteredUsers.length === 0) {
return <NoResultSection />;
}
return (
<section className="margin-top--lg margin-bottom--xl">
{filteredUsers.length === sortedUsers.length ? (
<>
<div className={styles.showcaseFavorite}>
<CardList heading={<HeadingFavorites />} items={favoriteUsers} />
</div>
<div className="margin-top--lg">
<CardList heading={<HeadingAllSites />} items={otherUsers} />
</div>
</>
) : (
<CardList items={filteredUsers} />
)}
</section>
);
}

View file

@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.cardList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.showcaseFavorite {
padding-top: 2rem;
padding-bottom: 2rem;
background-color: #f6fdfd;
}
html[data-theme='dark'] .showcaseFavorite {
background-color: #232525;
}
.headingFavorites {
display: flex;
align-items: center;
}

View file

@ -1,85 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useState, useEffect, useCallback} from 'react';
import clsx from 'clsx';
import {useHistory, useLocation} from '@docusaurus/router';
import {prepareUserState} from '../../index';
import styles from './styles.module.css';
export type Operator = 'OR' | 'AND';
export const OperatorQueryKey = 'operator';
export function readOperator(search: string): Operator {
return (new URLSearchParams(search).get(OperatorQueryKey) ??
'OR') as Operator;
}
export default function ShowcaseFilterToggle(): JSX.Element {
const id = 'showcase_filter_toggle';
const location = useLocation();
const history = useHistory();
const [operator, setOperator] = useState(false);
useEffect(() => {
setOperator(readOperator(location.search) === 'AND');
}, [location]);
const toggleOperator = useCallback(() => {
setOperator((o) => !o);
const searchParams = new URLSearchParams(location.search);
searchParams.delete(OperatorQueryKey);
if (!operator) {
searchParams.append(OperatorQueryKey, 'AND');
}
history.push({
...location,
search: searchParams.toString(),
state: prepareUserState(),
});
}, [operator, location, history]);
const ClearTag = () => {
history.push({
...location,
search: '',
state: prepareUserState(),
});
};
return (
<div className="row" style={{alignItems: 'center'}}>
<input
type="checkbox"
id={id}
className="screen-reader-only"
aria-label="Toggle between or and and for the tags you selected"
onChange={toggleOperator}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleOperator();
}
}}
checked={operator}
/>
<label htmlFor={id} className={clsx(styles.checkboxLabel, 'shadow--md')}>
{/* eslint-disable @docusaurus/no-untranslated-text */}
<span className={styles.checkboxLabelOr}>OR</span>
<span className={styles.checkboxLabelAnd}>AND</span>
{/* eslint-enable @docusaurus/no-untranslated-text */}
</label>
<button
className="button button--outline button--primary"
type="button"
onClick={() => ClearTag()}>
Clear All
</button>
</div>
);
}

View file

@ -0,0 +1,109 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {ReactNode, CSSProperties} from 'react';
import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import FavoriteIcon from '@site/src/pages/showcase/_components/FavoriteIcon';
import {Tags, TagList, type TagType} from '@site/src/data/users';
import Heading from '@theme/Heading';
import ShowcaseTagSelect from '../ShowcaseTagSelect';
import OperatorButton from '../OperatorButton';
import ClearAllButton from '../ClearAllButton';
import {useFilteredUsers, useSiteCountPlural} from '../../_utils';
import styles from './styles.module.css';
function TagCircleIcon({color, style}: {color: string; style?: CSSProperties}) {
return (
<span
style={{
backgroundColor: color,
width: 10,
height: 10,
borderRadius: '50%',
...style,
}}
/>
);
}
function ShowcaseTagListItem({tag}: {tag: TagType}) {
const {label, description, color} = Tags[tag];
return (
<li className={styles.tagListItem}>
<ShowcaseTagSelect
tag={tag}
label={label}
description={description}
icon={
tag === 'favorite' ? (
<FavoriteIcon size="small" style={{marginLeft: 8}} />
) : (
<TagCircleIcon
color={color}
style={{
backgroundColor: color,
marginLeft: 8,
}}
/>
)
}
/>
</li>
);
}
function ShowcaseTagList() {
return (
<ul className={clsx('clean-list', styles.tagList)}>
{TagList.map((tag) => {
return <ShowcaseTagListItem key={tag} tag={tag} />;
})}
</ul>
);
}
function HeadingText() {
const filteredUsers = useFilteredUsers();
const siteCountPlural = useSiteCountPlural();
return (
<div className={styles.headingText}>
<Heading as="h2">
<Translate id="showcase.filters.title">Filters</Translate>
</Heading>
<span>{siteCountPlural(filteredUsers.length)}</span>
</div>
);
}
function HeadingButtons() {
return (
<div className={styles.headingButtons} style={{alignItems: 'center'}}>
<OperatorButton />
<ClearAllButton />
</div>
);
}
function HeadingRow() {
return (
<div className={clsx('margin-bottom--sm', styles.headingRow)}>
<HeadingText />
<HeadingButtons />
</div>
);
}
export default function ShowcaseFilters(): ReactNode {
return (
<section className="container margin-top--l margin-bottom--lg">
<HeadingRow />
<ShowcaseTagList />
</section>
);
}

View file

@ -0,0 +1,53 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.headingRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.headingText {
display: flex;
align-items: baseline;
}
.headingText > h2 {
margin-bottom: 0;
}
.headingText > span {
margin-left: 8px;
}
.headingButtons {
display: flex;
align-items: center;
}
.headingButtons > * {
margin-left: 8px;
}
.tagList {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.tagListItem {
user-select: none;
white-space: nowrap;
height: 32px;
font-size: 0.8rem;
margin-top: 0.5rem;
margin-right: 0.5rem;
}
.tagListItem:last-child {
margin-right: 0;
}

View file

@ -0,0 +1,29 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {type ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import {useSearchName} from '@site/src/pages/showcase/_utils';
import styles from './styles.module.css';
export default function ShowcaseSearchBar(): ReactNode {
const [searchName, setSearchName] = useSearchName();
return (
<div className={styles.searchBar}>
<input
placeholder={translate({
message: 'Search for site name...',
id: 'showcase.searchBar.placeholder',
})}
value={searchName}
onInput={(e) => {
setSearchName(e.currentTarget.value);
}}
/>
</div>
);
}

View file

@ -0,0 +1,17 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.searchBar {
margin-left: auto;
}
.searchBar input {
height: 30px;
border-radius: 15px;
padding: 10px;
border: 1px solid gray;
}

View file

@ -7,90 +7,65 @@
import React, { import React, {
useCallback, useCallback,
useState,
useEffect,
type ComponentProps, type ComponentProps,
type ReactNode, type ReactNode,
type ReactElement, type ReactElement,
useId,
} from 'react'; } from 'react';
import {useHistory, useLocation} from '@docusaurus/router';
import {toggleListItem} from '@site/src/utils/jsUtils';
import type {TagType} from '@site/src/data/users'; import type {TagType} from '@site/src/data/users';
import {useTags} from '../../_utils';
import {prepareUserState} from '../../index';
import styles from './styles.module.css'; import styles from './styles.module.css';
interface Props extends ComponentProps<'input'> { function useTagState(tag: string) {
icon: ReactElement<ComponentProps<'svg'>>; const [tags, setTags] = useTags();
label: ReactNode; const isSelected = tags.includes(tag);
tag: TagType; const toggle = useCallback(() => {
} setTags((list) => {
return list.includes(tag)
const TagQueryStringKey = 'tags'; ? list.filter((t) => t !== tag)
: [...list, tag];
export function readSearchTags(search: string): TagType[] {
return new URLSearchParams(search).getAll(TagQueryStringKey) as TagType[];
}
function replaceSearchTags(search: string, newTags: TagType[]) {
const searchParams = new URLSearchParams(search);
searchParams.delete(TagQueryStringKey);
newTags.forEach((tag) => searchParams.append(TagQueryStringKey, tag));
return searchParams.toString();
}
function ShowcaseTagSelect(
{id, icon, label, tag, ...rest}: Props,
ref: React.ForwardedRef<HTMLLabelElement>,
) {
const location = useLocation();
const history = useHistory();
const [selected, setSelected] = useState(false);
useEffect(() => {
const tags = readSearchTags(location.search);
setSelected(tags.includes(tag));
}, [tag, location]);
const toggleTag = useCallback(() => {
const tags = readSearchTags(location.search);
const newTags = toggleListItem(tags, tag);
const newSearch = replaceSearchTags(location.search, newTags);
history.push({
...location,
search: newSearch,
state: prepareUserState(),
}); });
}, [tag, location, history]); }, [tag, setTags]);
return [isSelected, toggle] as const;
}
interface Props extends ComponentProps<'input'> {
tag: TagType;
label: string;
description: string;
icon: ReactElement<ComponentProps<'svg'>>;
}
export default function ShowcaseTagSelect({
icon,
label,
description,
tag,
...rest
}: Props): ReactNode {
const id = useId();
const [isSelected, toggle] = useTagState(tag);
return ( return (
<> <>
<input <input
type="checkbox" type="checkbox"
id={id} id={id}
checked={isSelected}
onChange={toggle}
className="screen-reader-only" className="screen-reader-only"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
toggleTag(); toggle();
} }
}} }}
onFocus={(e) => {
if (e.relatedTarget) {
e.target.nextElementSibling?.dispatchEvent(
new KeyboardEvent('focus'),
);
}
}}
onBlur={(e) => {
e.target.nextElementSibling?.dispatchEvent(new KeyboardEvent('blur'));
}}
onChange={toggleTag}
checked={selected}
{...rest} {...rest}
/> />
<label ref={ref} htmlFor={id} className={styles.checkboxLabel}> <label htmlFor={id} className={styles.checkboxLabel} title={description}>
{label} {label}
{icon} {icon}
</label> </label>
</> </>
); );
} }
export default React.forwardRef(ShowcaseTagSelect);

View file

@ -28,7 +28,7 @@ input:focus-visible + .checkboxLabel {
input:checked + .checkboxLabel { input:checked + .checkboxLabel {
opacity: 0.9; opacity: 0.9;
background-color: var(--site-color-checkbox-checked-bg); background-color: hsl(167deg 56% 73% / 25%);
border: 2px solid var(--ifm-color-primary-darkest); border: 2px solid var(--ifm-color-primary-darkest);
} }
@ -36,3 +36,7 @@ input:checked + .checkboxLabel:hover {
opacity: 0.75; opacity: 0.75;
box-shadow: 0 0 2px 1px var(--ifm-color-primary-dark); box-shadow: 0 0 2px 1px var(--ifm-color-primary-dark);
} }
html[data-theme='dark'] input:checked + .checkboxLabel {
background-color: hsl(167deg 56% 73% / 10%);
}

View file

@ -1,145 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useEffect, useState, useRef} from 'react';
import ReactDOM from 'react-dom';
import {usePopper} from 'react-popper';
import styles from './styles.module.css';
interface Props {
anchorEl?: HTMLElement | string;
id: string;
text: string;
children: React.ReactElement;
}
export default function Tooltip({
children,
id,
anchorEl,
text,
}: Props): JSX.Element {
const [open, setOpen] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLElement | null>(
null,
);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [arrowElement, setArrowElement] = useState<HTMLElement | null>(null);
const [container, setContainer] = useState<Element | null>(null);
const {styles: popperStyles, attributes} = usePopper(
referenceElement,
popperElement,
{
modifiers: [
{
name: 'arrow',
options: {
element: arrowElement,
},
},
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
},
);
const timeout = useRef<number | null>(null);
const tooltipId = `${id}_tooltip`;
useEffect(() => {
if (anchorEl) {
if (typeof anchorEl === 'string') {
setContainer(document.querySelector(anchorEl));
} else {
setContainer(anchorEl);
}
} else {
setContainer(document.body);
}
}, [container, anchorEl]);
useEffect(() => {
const showEvents = ['mouseenter', 'focus'];
const hideEvents = ['mouseleave', 'blur'];
const handleOpen = () => {
// There is no point in displaying an empty tooltip.
if (text === '') {
return;
}
// Remove the title ahead of time to avoid displaying
// two tooltips at the same time (native + this one).
referenceElement?.removeAttribute('title');
timeout.current = window.setTimeout(() => {
setOpen(true);
}, 400);
};
const handleClose = () => {
clearInterval(timeout.current!);
setOpen(false);
};
if (referenceElement) {
showEvents.forEach((event) => {
referenceElement.addEventListener(event, handleOpen);
});
hideEvents.forEach((event) => {
referenceElement.addEventListener(event, handleClose);
});
}
return () => {
if (referenceElement) {
showEvents.forEach((event) => {
referenceElement.removeEventListener(event, handleOpen);
});
hideEvents.forEach((event) => {
referenceElement.removeEventListener(event, handleClose);
});
}
};
}, [referenceElement, text]);
return (
<>
{React.cloneElement(children, {
ref: setReferenceElement,
'aria-describedby': open ? tooltipId : undefined,
})}
{container
? ReactDOM.createPortal(
open && (
<div
id={tooltipId}
role="tooltip"
ref={setPopperElement}
className={styles.tooltip}
style={popperStyles.popper}
{...attributes.popper}>
{text}
<span
ref={setArrowElement}
className={styles.tooltipArrow}
style={popperStyles.arrow}
/>
</div>
),
container,
)
: container}
</>
);
}

View file

@ -1,45 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.tooltip {
border-radius: 4px;
padding: 4px 8px;
color: var(--site-color-tooltip);
background: var(--site-color-tooltip-background);
font-size: 0.8rem;
z-index: 500;
line-height: 1.4;
font-weight: 500;
max-width: 300px;
opacity: 0.92;
}
.tooltipArrow {
visibility: hidden;
}
.tooltipArrow,
.tooltipArrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
.tooltipArrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.tooltip[data-popper-placement^='top'] > .tooltipArrow {
bottom: -4px;
}
.tooltip[data-popper-placement^='bottom'] > .tooltipArrow {
top: -4px;
}

View file

@ -0,0 +1,99 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {useCallback, useMemo} from 'react';
import {translate} from '@docusaurus/Translate';
import {
usePluralForm,
useQueryString,
useQueryStringList,
} from '@docusaurus/theme-common';
import type {TagType, User} from '@site/src/data/users';
import {sortedUsers} from '@site/src/data/users';
export function useSearchName() {
return useQueryString('name');
}
export function useTags() {
return useQueryStringList('tags');
}
type Operator = 'OR' | 'AND';
export function useOperator() {
const [searchOperator, setSearchOperator] = useQueryString('operator');
const operator: Operator = searchOperator === 'AND' ? 'AND' : 'OR';
const toggleOperator = useCallback(() => {
const newOperator = operator === 'OR' ? 'AND' : null;
setSearchOperator(newOperator);
}, [operator, setSearchOperator]);
return [operator, toggleOperator] as const;
}
function filterUsers({
users,
tags,
operator,
searchName,
}: {
users: User[];
tags: TagType[];
operator: Operator;
searchName: string | null;
}) {
if (searchName) {
// eslint-disable-next-line no-param-reassign
users = users.filter((user) =>
user.title.toLowerCase().includes(searchName.toLowerCase()),
);
}
if (tags.length === 0) {
return users;
}
return users.filter((user) => {
if (user.tags.length === 0) {
return false;
}
if (operator === 'AND') {
return tags.every((tag) => user.tags.includes(tag));
}
return tags.some((tag) => user.tags.includes(tag));
});
}
export function useFilteredUsers() {
const [tags] = useTags();
const [searchName] = useSearchName();
const [operator] = useOperator();
return useMemo(
() =>
filterUsers({
users: sortedUsers,
tags: tags as TagType[],
operator,
searchName,
}),
[tags, operator, searchName],
);
}
export function useSiteCountPlural() {
const {selectMessage} = usePluralForm();
return (sitesCount: number) =>
selectMessage(
sitesCount,
translate(
{
id: 'showcase.filters.resultCount',
description:
'Pluralized label for the number of sites found on the showcase. Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: '1 site|{sitesCount} sites',
},
{sitesCount},
),
);
}

View file

@ -5,35 +5,15 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {useState, useMemo, useEffect} from 'react';
import clsx from 'clsx';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Translate, {translate} from '@docusaurus/Translate'; import Translate, {translate} from '@docusaurus/Translate';
import {useHistory, useLocation} from '@docusaurus/router';
import {usePluralForm} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import FavoriteIcon from '@site/src/components/svgIcons/FavoriteIcon';
import {
sortedUsers,
Tags,
TagList,
type User,
type TagType,
} from '@site/src/data/users';
import Heading from '@theme/Heading'; import Heading from '@theme/Heading';
import ShowcaseTagSelect, {
readSearchTags,
} from './_components/ShowcaseTagSelect';
import ShowcaseFilterToggle, {
type Operator,
readOperator,
} from './_components/ShowcaseFilterToggle';
import ShowcaseCard from './_components/ShowcaseCard';
import ShowcaseTooltip from './_components/ShowcaseTooltip';
import styles from './styles.module.css'; import ShowcaseSearchBar from '@site/src/pages/showcase/_components/ShowcaseSearchBar';
import ShowcaseCards from './_components/ShowcaseCards';
import ShowcaseFilters from './_components/ShowcaseFilters';
const TITLE = translate({message: 'Docusaurus Site Showcase'}); const TITLE = translate({message: 'Docusaurus Site Showcase'});
const DESCRIPTION = translate({ const DESCRIPTION = translate({
@ -41,85 +21,6 @@ const DESCRIPTION = translate({
}); });
const SUBMIT_URL = 'https://github.com/facebook/docusaurus/discussions/7826'; const SUBMIT_URL = 'https://github.com/facebook/docusaurus/discussions/7826';
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
function restoreUserState(userState: UserState | null) {
const {scrollTopPosition, focusedElementId} = userState ?? {
scrollTopPosition: 0,
focusedElementId: undefined,
};
// @ts-expect-error: if focusedElementId is undefined it returns null
document.getElementById(focusedElementId)?.focus();
window.scrollTo({top: scrollTopPosition});
}
export function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
const SearchNameQueryKey = 'name';
function readSearchName(search: string) {
return new URLSearchParams(search).get(SearchNameQueryKey);
}
function filterUsers(
users: User[],
selectedTags: TagType[],
operator: Operator,
searchName: string | null,
) {
if (searchName) {
// eslint-disable-next-line no-param-reassign
users = users.filter((user) =>
user.title.toLowerCase().includes(searchName.toLowerCase()),
);
}
if (selectedTags.length === 0) {
return users;
}
return users.filter((user) => {
if (user.tags.length === 0) {
return false;
}
if (operator === 'AND') {
return selectedTags.every((tag) => user.tags.includes(tag));
}
return selectedTags.some((tag) => user.tags.includes(tag));
});
}
function useFilteredUsers() {
const location = useLocation<UserState>();
const [operator, setOperator] = useState<Operator>('OR');
// On SSR / first mount (hydration) no tag is selected
const [selectedTags, setSelectedTags] = useState<TagType[]>([]);
const [searchName, setSearchName] = useState<string | null>(null);
// Sync tags from QS to state (delayed on purpose to avoid SSR/Client
// hydration mismatch)
useEffect(() => {
setSelectedTags(readSearchTags(location.search));
setOperator(readOperator(location.search));
setSearchName(readSearchName(location.search));
restoreUserState(location.state);
}, [location]);
return useMemo(
() => filterUsers(sortedUsers, selectedTags, operator, searchName),
[selectedTags, operator, searchName],
);
}
function ShowcaseHeader() { function ShowcaseHeader() {
return ( return (
<section className="margin-top--lg margin-bottom--lg text--center"> <section className="margin-top--lg margin-bottom--lg text--center">
@ -134,193 +35,6 @@ function ShowcaseHeader() {
); );
} }
function useSiteCountPlural() {
const {selectMessage} = usePluralForm();
return (sitesCount: number) =>
selectMessage(
sitesCount,
translate(
{
id: 'showcase.filters.resultCount',
description:
'Pluralized label for the number of sites found on the showcase. Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: '1 site|{sitesCount} sites',
},
{sitesCount},
),
);
}
function ShowcaseFilters() {
const filteredUsers = useFilteredUsers();
const siteCountPlural = useSiteCountPlural();
return (
<section className="container margin-top--l margin-bottom--lg">
<div className={clsx('margin-bottom--sm', styles.filterCheckbox)}>
<div>
<Heading as="h2">
<Translate id="showcase.filters.title">Filters</Translate>
</Heading>
<span>{siteCountPlural(filteredUsers.length)}</span>
</div>
<ShowcaseFilterToggle />
</div>
<ul className={clsx('clean-list', styles.checkboxList)}>
{TagList.map((tag, i) => {
const {label, description, color} = Tags[tag];
const id = `showcase_checkbox_id_${tag}`;
return (
<li key={i} className={styles.checkboxListItem}>
<ShowcaseTooltip
id={id}
text={description}
anchorEl="#__docusaurus">
<ShowcaseTagSelect
tag={tag}
id={id}
label={label}
icon={
tag === 'favorite' ? (
<FavoriteIcon svgClass={styles.svgIconFavoriteXs} />
) : (
<span
style={{
backgroundColor: color,
width: 10,
height: 10,
borderRadius: '50%',
marginLeft: 8,
}}
/>
)
}
/>
</ShowcaseTooltip>
</li>
);
})}
</ul>
</section>
);
}
const favoriteUsers = sortedUsers.filter((user) =>
user.tags.includes('favorite'),
);
const otherUsers = sortedUsers.filter(
(user) => !user.tags.includes('favorite'),
);
function SearchBar() {
const history = useHistory();
const location = useLocation();
const [value, setValue] = useState<string | null>(null);
useEffect(() => {
setValue(readSearchName(location.search));
}, [location]);
return (
<div className={styles.searchContainer}>
<input
id="searchbar"
placeholder={translate({
message: 'Search for site name...',
id: 'showcase.searchBar.placeholder',
})}
value={value ?? undefined}
onInput={(e) => {
setValue(e.currentTarget.value);
const newSearch = new URLSearchParams(location.search);
newSearch.delete(SearchNameQueryKey);
if (e.currentTarget.value) {
newSearch.set(SearchNameQueryKey, e.currentTarget.value);
}
history.push({
...location,
search: newSearch.toString(),
state: prepareUserState(),
});
setTimeout(() => {
document.getElementById('searchbar')?.focus();
}, 0);
}}
/>
</div>
);
}
function ShowcaseCards() {
const filteredUsers = useFilteredUsers();
if (filteredUsers.length === 0) {
return (
<section className="margin-top--lg margin-bottom--xl">
<div className="container padding-vert--md text--center">
<Heading as="h2">
<Translate id="showcase.usersList.noResult">No result</Translate>
</Heading>
</div>
</section>
);
}
return (
<section className="margin-top--lg margin-bottom--xl">
{filteredUsers.length === sortedUsers.length ? (
<>
<div className={styles.showcaseFavorite}>
<div className="container">
<div
className={clsx(
'margin-bottom--md',
styles.showcaseFavoriteHeader,
)}>
<Heading as="h2">
<Translate id="showcase.favoritesList.title">
Our favorites
</Translate>
</Heading>
<FavoriteIcon svgClass={styles.svgIconFavorite} />
</div>
<ul
className={clsx(
'container',
'clean-list',
styles.showcaseList,
)}>
{favoriteUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
</div>
<div className="container margin-top--lg">
<Heading as="h2" className={styles.showcaseHeader}>
<Translate id="showcase.usersList.allUsers">All sites</Translate>
</Heading>
<ul className={clsx('clean-list', styles.showcaseList)}>
{otherUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
</>
) : (
<div className="container">
<div
className={clsx('margin-bottom--md', styles.showcaseFavoriteHeader)}
/>
<ul className={clsx('clean-list', styles.showcaseList)}>
{filteredUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
)}
</section>
);
}
export default function Showcase(): JSX.Element { export default function Showcase(): JSX.Element {
return ( return (
<Layout title={TITLE} description={DESCRIPTION}> <Layout title={TITLE} description={DESCRIPTION}>
@ -330,7 +44,7 @@ export default function Showcase(): JSX.Element {
<div <div
style={{display: 'flex', marginLeft: 'auto'}} style={{display: 'flex', marginLeft: 'auto'}}
className="container"> className="container">
<SearchBar /> <ShowcaseSearchBar />
</div> </div>
<ShowcaseCards /> <ShowcaseCards />
</main> </main>

View file

@ -1,95 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.filterCheckbox {
justify-content: space-between;
}
.filterCheckbox,
.checkboxList {
display: flex;
align-items: center;
}
.filterCheckbox > div:first-child {
display: flex;
flex: 1 1 auto;
align-items: center;
}
.filterCheckbox > div > * {
margin-bottom: 0;
margin-right: 8px;
}
.checkboxList {
flex-wrap: wrap;
}
.checkboxListItem {
user-select: none;
white-space: nowrap;
height: 32px;
font-size: 0.8rem;
margin-top: 0.5rem;
margin-right: 0.5rem;
}
.checkboxListItem:last-child {
margin-right: 0;
}
.searchContainer {
margin-left: auto;
}
.searchContainer input {
height: 30px;
border-radius: 15px;
padding: 10px;
border: 1px solid gray;
}
.showcaseList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.showcaseFavorite {
padding-top: 2rem;
padding-bottom: 2rem;
background-color: var(--site-color-favorite-background);
}
.showcaseFavoriteHeader {
display: flex;
align-items: center;
}
.showcaseFavoriteHeader > h2 {
margin-bottom: 0;
}
.showcaseFavoriteHeader > svg {
width: 30px;
height: 30px;
}
.svgIconFavoriteXs,
.svgIconFavorite {
color: var(--site-color-svg-icon-favorite);
}
.svgIconFavoriteXs {
margin-left: 0.625rem;
font-size: 1rem;
}
.svgIconFavorite {
margin-left: 1rem;
}

View file

@ -1639,7 +1639,7 @@
"@docsearch/css" "3.5.2" "@docsearch/css" "3.5.2"
algoliasearch "^4.19.1" algoliasearch "^4.19.1"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": "@docusaurus/react-loadable@5.5.2":
version "5.5.2" version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@ -2629,11 +2629,6 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@popperjs/core@^2.11.8":
version "2.11.8"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@rollup/plugin-babel@^5.2.0": "@rollup/plugin-babel@^5.2.0":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@ -13741,7 +13736,7 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: react-fast-compare@^3.2.0:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
@ -13793,19 +13788,19 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies: dependencies:
"@babel/runtime" "^7.10.3" "@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-medium-image-zoom@^5.1.6: react-medium-image-zoom@^5.1.6:
version "5.1.6" version "5.1.6"
resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-5.1.6.tgz#1ec9dabbc88da664f3aacc03a93cf79cb1b70a23" resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-5.1.6.tgz#1ec9dabbc88da664f3aacc03a93cf79cb1b70a23"
integrity sha512-0QolPce1vNJsF5HKrGkU1UT6kLNvY9EOnLBqz++LlVnBQduaHLkJlY73ayj3SxY09XWRrnxKDMTHPDkrQYdREw== integrity sha512-0QolPce1vNJsF5HKrGkU1UT6kLNvY9EOnLBqz++LlVnBQduaHLkJlY73ayj3SxY09XWRrnxKDMTHPDkrQYdREw==
react-popper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-router-config@^5.1.1: react-router-config@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@ -15173,7 +15168,16 @@ string-length@^4.0.1:
char-regex "^1.0.2" char-regex "^1.0.2"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -15272,7 +15276,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -16497,13 +16508,6 @@ walker@^1.0.8:
dependencies: dependencies:
makeerror "1.0.12" makeerror "1.0.12"
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack@^2.4.0: watchpack@^2.4.0:
version "2.4.0" version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
@ -16944,7 +16948,7 @@ workbox-window@7.0.0, workbox-window@^7.0.0:
"@types/trusted-types" "^2.0.2" "@types/trusted-types" "^2.0.2"
workbox-core "7.0.0" workbox-core "7.0.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -16962,6 +16966,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"