hooks bypass extreme wip

This commit is contained in:
ozakione 2024-04-11 16:48:51 +02:00
parent 4f64b33e50
commit 0cfdd9d6fd
21 changed files with 644 additions and 992 deletions

View file

@ -247,28 +247,18 @@ declare module '@theme/BlogPostItems' {
export default function BlogPostItem(props: Props): JSX.Element;
}
declare module '@theme/ShowcaseDetails' {
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
export type User = ShowcaseItem;
export type Props = {
content: User;
};
export default function Showcase(props: Props): JSX.Element;
declare module '@theme/Showcase' {
export default function Showcase(): JSX.Element;
}
declare module '@theme/Showcase' {
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
declare module '@theme/Showcase/FavoriteIcon' {
export interface Props {
className?: string;
style?: ComponentProps<'svg'>['style'];
size: 'small' | 'medium' | 'large';
}
export type User = ShowcaseItem;
export type Props = {
content: User[];
};
export default function Showcase(props: Props): JSX.Element;
export default function FavoriteIcon(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseCard' {
@ -287,13 +277,41 @@ declare module '@theme/Showcase/ShowcaseCard' {
export default function ShowcaseCard(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseTagSelect' {
import {type ComponentProps, type ReactNode, type ReactElement} from 'react';
export interface Props extends ComponentProps<'input'> {
icon: ReactElement<ComponentProps<'svg'>>;
label: ReactNode;
declare module '@theme/Showcase/ShowcaseCards' {
export default function ShowcaseCards(): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseTooltip' {
export interface Props {
anchorEl?: HTMLElement | string;
id: string;
text: string;
children: React.ReactElement;
}
export default function ShowcaseTooltip(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseTagSelect' {
import {type ComponentProps, type ReactElement} from 'react';
// TODO use from plugin content showcase
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
interface Props extends ComponentProps<'input'> {
tag: TagType;
label: string;
description: string;
icon: ReactElement<ComponentProps<'svg'>>;
}
export default function ShowcaseTagSelect(props: Props): JSX.Element;
@ -304,27 +322,32 @@ declare module '@theme/Showcase/ShowcaseFilterToggle' {
export default function ShowcaseFilterToggle(): JSX.Element;
}
declare module '@theme/Showcase/FavoriteIcon' {
import {type ReactNode, type ComponentProps} from 'react';
declare module '@theme/Showcase/ShowcaseFilters' {
// TODO use from plugin content showcase
export type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
export type SvgIconProps = ComponentProps<'svg'> & {
viewBox?: string;
size?: 'inherit' | 'small' | 'medium' | 'large';
color?:
| 'inherit'
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning';
svgClass?: string; // Class attribute on the child
colorAttr?: string; // Applies a color attribute to the SVG element.
children: ReactNode; // Node passed into the SVG element.
};
export default function ShowcaseFilters(): JSX.Element;
}
export type Props = Omit<SvgIconProps, 'children'>;
declare module '@theme/Showcase/OperatorButton' {
export default function OperatorButton(): JSX.Element;
}
declare module '@theme/Showcase/ClearAllButton' {
export default function ClearAllButton(): JSX.Element;
}
export default function FavoriteIcon(props: Props): JSX.Element;
declare module '@theme/Showcase/ShowcaseSearchBar' {
export default function ShowcaseSearchBar(): JSX.Element;
}
declare module '@theme/BlogPostItem/Container' {

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

@ -7,36 +7,21 @@
import React from 'react';
import clsx from 'clsx';
import type {Props, SvgIconProps} from '@theme/Showcase/FavoriteIcon';
import type {Props} from '@theme/Showcase/FavoriteIcon';
import styles from './styles.module.css';
function Svg(props: SvgIconProps): JSX.Element {
const {
svgClass,
colorAttr,
children,
color = 'inherit',
size = 'medium',
viewBox = '0 0 24 24',
...rest
} = props;
export default function FavoriteIcon({
size,
className,
style,
}: Props): React.ReactNode {
return (
<svg
viewBox={viewBox}
color={colorAttr}
aria-hidden
className={clsx(styles.svgIcon, styles[color], styles[size], svgClass)}
{...rest}>
{children}
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>
);
}
export default function FavoriteIcon(props: Props): 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

@ -5,50 +5,23 @@
* LICENSE file in the root directory of this source tree.
*/
.svgIcon {
.svg {
user-select: none;
color: #e9669e;
width: 1em;
height: 1em;
display: inline-block;
fill: currentColor;
flex-shrink: 0;
color: inherit;
}
/* font-size */
.small {
font-size: 1.25rem;
font-size: 1rem;
}
.medium {
font-size: 1.5rem;
font-size: 1.25rem;
}
.large {
font-size: 2.185rem;
}
/* colors */
.primary {
color: var(--ifm-color-primary);
}
.secondary {
color: var(--ifm-color-secondary);
}
.success {
color: var(--ifm-color-success);
}
.error {
color: var(--ifm-color-error);
}
.warning {
color: var(--ifm-color-warning);
}
.inherit {
color: inherit;
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(): JSX.Element {
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

@ -8,159 +8,31 @@
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import Translate from '@docusaurus/Translate';
// import Image from '@theme/IdealImage';
import {Tags, TagList, type TagType} from '@site/src/data/users';
import {type User} from '@theme/Showcase/ShowcaseCard';
import {sortBy} from '@site/src/utils/jsUtils';
import Heading from '@theme/Heading';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import styles from './styles.module.css';
const Tags: {[type in TagType]: Tag} = {
favorite: {
label: translate({message: 'Favorite'}),
description: translate({
message:
'Our favorite Docusaurus sites that you must absolutely check out!',
id: 'showcase.tag.favorite.description',
}),
color: '#e9669e',
},
opensource: {
label: translate({message: 'Open-Source'}),
description: translate({
message: 'Open-Source Docusaurus sites can be useful for inspiration!',
id: 'showcase.tag.opensource.description',
}),
color: '#39ca30',
},
product: {
label: translate({message: 'Product'}),
description: translate({
message: 'Docusaurus sites associated to a commercial product!',
id: 'showcase.tag.product.description',
}),
color: '#dfd545',
},
design: {
label: translate({message: 'Design'}),
description: translate({
message:
'Beautiful Docusaurus sites, polished and standing out from the initial template!',
id: 'showcase.tag.design.description',
}),
color: '#a44fb7',
},
i18n: {
label: translate({message: 'I18n'}),
description: translate({
message:
'Translated Docusaurus sites using the internationalization support with more than 1 locale.',
id: 'showcase.tag.i18n.description',
}),
color: '#127f82',
},
versioning: {
label: translate({message: 'Versioning'}),
description: translate({
message:
'Docusaurus sites using the versioning feature of the docs plugin to manage multiple versions.',
id: 'showcase.tag.versioning.description',
}),
color: '#fe6829',
},
large: {
label: translate({message: 'Large'}),
description: translate({
message:
'Very large Docusaurus sites, including many more pages than the average!',
id: 'showcase.tag.large.description',
}),
color: '#8c2f00',
},
meta: {
label: translate({message: 'Meta'}),
description: translate({
message: 'Docusaurus sites of Meta (formerly Facebook) projects',
id: 'showcase.tag.meta.description',
}),
color: '#4267b2', // Facebook blue
},
personal: {
label: translate({message: 'Personal'}),
description: translate({
message:
'Personal websites, blogs and digital gardens built with Docusaurus',
id: 'showcase.tag.personal.description',
}),
color: '#14cfc3',
},
rtl: {
label: translate({message: 'RTL Direction'}),
description: translate({
message:
'Docusaurus sites using the right-to-left reading direction support.',
id: 'showcase.tag.rtl.description',
}),
color: '#ffcfc3',
},
};
const TagList = Object.keys(Tags) as TagType[];
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
type User = {
title: string;
description: string;
preview: string | null; // null = use our serverless screenshot service
website: string;
source: string | null;
tags: TagType[];
};
type Tag = {
function TagItem({
label,
description,
color,
}: {
label: string;
description: string;
color: string;
};
function sortBy<T>(
array: T[],
getter: (item: T) => string | number | boolean,
): T[] {
const sortedArray = [...array];
sortedArray.sort((a, b) =>
// eslint-disable-next-line no-nested-ternary
getter(a) > getter(b) ? 1 : getter(b) > getter(a) ? -1 : 0,
);
return sortedArray;
}
const TagComp = React.forwardRef<HTMLLIElement, Tag>(
({label, color, description}, ref) => (
<li ref={ref} className={styles.tag} title={description}>
}) {
return (
<li className={styles.tag} title={description}>
<span className={styles.textLabel}>{label.toLowerCase()}</span>
<span className={styles.colorLabel} style={{backgroundColor: color}} />
</li>
),
);
);
}
function ShowcaseCardTag({tags}: {tags: TagType[]}) {
const tagObjects = tags.map((tag) => ({tag, ...Tags[tag]}));
@ -173,7 +45,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
return (
<>
{tagObjectsSorted.map((tagObject, index) => {
return <TagComp key={index} {...tagObject} />;
return <TagItem key={index} {...tagObject} />;
})}
</>
);
@ -182,6 +54,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
function getCardImage(user: User): string {
return (
user.preview ??
// TODO make it configurable
`https://slorber-api-screenshot.netlify.app/${encodeURIComponent(
user.website,
)}/showcase`
@ -193,6 +66,7 @@ function ShowcaseCard({user}: {user: User}) {
return (
<li key={user.title} className="card shadow--md">
<div className={clsx('card__image', styles.showcaseCardImage)}>
{/* TODO change back to ideal image */}
<img src={image} alt={user.title} />
</div>
<div className="card__body">
@ -203,7 +77,7 @@ function ShowcaseCard({user}: {user: User}) {
</Link>
</Heading>
{user.tags.includes('favorite') && (
<FavoriteIcon svgClass={styles.svgIconFavorite} size="small" />
<FavoriteIcon size="medium" style={{marginRight: '0.25rem'}} />
)}
{user.source && (
<Link

View file

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

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 type {ReactNode} from 'react';
import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import {sortedUsers} from '@site/src/data/users';
import Heading from '@theme/Heading';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import ShowcaseCard from '@theme/Showcase/ShowcaseCard';
import {type User} from '@theme/Showcase/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(): JSX.Element {
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,101 +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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import type {Operator} from '@theme/Showcase/ShowcaseFilterToggle';
import styles from './styles.module.css';
const OperatorQueryKey = 'operator';
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
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>
{/* eslint-disable-next-line @docusaurus/no-untranslated-text */}
<button
className="button button--outline button--primary"
type="button"
onClick={() => ClearTag()}>
Clear All
</button>
</div>
);
}

View file

@ -0,0 +1,110 @@
/**
* 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 '@theme/Showcase/FavoriteIcon';
import {Tags, TagList} from '@site/src/data/users';
import Heading from '@theme/Heading';
import ShowcaseTagSelect from '@theme/Showcase/ShowcaseTagSelect';
import {type TagType} from '@theme/Showcase/ShowcaseFilters';
import OperatorButton from '@theme/Showcase/OperatorButton';
import ClearAllButton from '@theme/Showcase/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

@ -5,127 +5,54 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {
useCallback,
useState,
useEffect,
type ComponentProps,
type ReactNode,
type ReactElement,
} from 'react';
import {useHistory, useLocation} from '@docusaurus/router';
import React, {useCallback, type ReactNode, useId} from 'react';
import type {Props} from '@theme/Showcase/ShowcaseTagSelect';
import {useTags} from '../../_utils';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import styles from './styles.module.css';
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
function toggleListItem<T>(list: T[], item: T): T[] {
const itemIndex = list.indexOf(item);
if (itemIndex === -1) {
return list.concat(item);
}
const newList = [...list];
newList.splice(itemIndex, 1);
return newList;
}
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
interface Props extends ComponentProps<'input'> {
icon: ReactElement<ComponentProps<'svg'>>;
label: ReactNode;
tag: TagType;
}
const TagQueryStringKey = 'tags';
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(),
function useTagState(tag: string) {
const [tags, setTags] = useTags();
const isSelected = tags.includes(tag);
const toggle = useCallback(() => {
setTags((list) => {
return list.includes(tag)
? list.filter((t) => t !== tag)
: [...list, tag];
});
}, [tag, location, history]);
}, [tag, setTags]);
return [isSelected, toggle] as const;
}
export default function ShowcaseTagSelect({
icon,
label,
description,
tag,
...rest
}: Props): ReactNode {
const id = useId();
const [isSelected, toggle] = useTagState(tag);
return (
<>
<input
type="checkbox"
id={id}
checked={isSelected}
onChange={toggle}
className="screen-reader-only"
onKeyDown={(e) => {
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}
/>
<label ref={ref} htmlFor={id} className={styles.checkboxLabel}>
<label htmlFor={id} className={styles.checkboxLabel} title={description}>
{label}
{icon}
</label>
</>
);
}
export default React.forwardRef(ShowcaseTagSelect);

View file

@ -28,7 +28,7 @@ input:focus-visible + .checkboxLabel {
input:checked + .checkboxLabel {
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);
}
@ -36,3 +36,7 @@ input:checked + .checkboxLabel:hover {
opacity: 0.75;
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

@ -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,26 +5,16 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {useEffect, useMemo, useState} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import {useHistory, useLocation} from 'react-router-dom';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Translate, {translate} from '@docusaurus/Translate';
import {usePluralForm} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import {clientShowcase} from '@docusaurus/plugin-content-showcase/client';
import type {User, Props} from '@theme/Showcase';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import ShowcaseCard from '@theme/Showcase/ShowcaseCard';
import ShowcaseTagSelect from '@theme/Showcase/ShowcaseTagSelect';
import ShowcaseFilterToggle from '@theme/Showcase/ShowcaseFilterToggle';
import type {Operator} from '@theme/Showcase/ShowcaseFilterToggle';
import type {TagType} from '@docusaurus/plugin-content-showcase';
import styles from './styles.module.css';
type Users = User[];
import ShowcaseSearchBar from '@theme/Showcase/ShowcaseSearchBar';
import ShowcaseCards from '@theme/Showcase/ShowcaseCards';
import ShowcaseFilters from '@theme/Showcase/ShowcaseFilters';
const TITLE = translate({message: 'Docusaurus Site Showcase'});
const DESCRIPTION = translate({
@ -32,144 +22,6 @@ const DESCRIPTION = translate({
});
const SUBMIT_URL = 'https://github.com/facebook/docusaurus/discussions/7826';
const OperatorQueryKey = 'operator';
function readOperator(search: string): Operator {
return (new URLSearchParams(search).get(OperatorQueryKey) ??
'OR') as Operator;
}
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
type Tag = {
label: string;
description: string;
color: string;
};
const Tags: {[type in TagType]: Tag} = {
favorite: {
label: translate({message: 'Favorite'}),
description: translate({
message:
'Our favorite Docusaurus sites that you must absolutely check out!',
id: 'showcase.tag.favorite.description',
}),
color: '#e9669e',
},
opensource: {
label: translate({message: 'Open-Source'}),
description: translate({
message: 'Open-Source Docusaurus sites can be useful for inspiration!',
id: 'showcase.tag.opensource.description',
}),
color: '#39ca30',
},
product: {
label: translate({message: 'Product'}),
description: translate({
message: 'Docusaurus sites associated to a commercial product!',
id: 'showcase.tag.product.description',
}),
color: '#dfd545',
},
design: {
label: translate({message: 'Design'}),
description: translate({
message:
'Beautiful Docusaurus sites, polished and standing out from the initial template!',
id: 'showcase.tag.design.description',
}),
color: '#a44fb7',
},
i18n: {
label: translate({message: 'I18n'}),
description: translate({
message:
'Translated Docusaurus sites using the internationalization support with more than 1 locale.',
id: 'showcase.tag.i18n.description',
}),
color: '#127f82',
},
versioning: {
label: translate({message: 'Versioning'}),
description: translate({
message:
'Docusaurus sites using the versioning feature of the docs plugin to manage multiple versions.',
id: 'showcase.tag.versioning.description',
}),
color: '#fe6829',
},
large: {
label: translate({message: 'Large'}),
description: translate({
message:
'Very large Docusaurus sites, including many more pages than the average!',
id: 'showcase.tag.large.description',
}),
color: '#8c2f00',
},
meta: {
label: translate({message: 'Meta'}),
description: translate({
message: 'Docusaurus sites of Meta (formerly Facebook) projects',
id: 'showcase.tag.meta.description',
}),
color: '#4267b2', // Facebook blue
},
personal: {
label: translate({message: 'Personal'}),
description: translate({
message:
'Personal websites, blogs and digital gardens built with Docusaurus',
id: 'showcase.tag.personal.description',
}),
color: '#14cfc3',
},
rtl: {
label: translate({message: 'RTL Direction'}),
description: translate({
message:
'Docusaurus sites using the right-to-left reading direction support.',
id: 'showcase.tag.rtl.description',
}),
color: '#ffcfc3',
},
};
const TagList = Object.keys(Tags) as TagType[];
function sortBy<T>(
array: T[],
getter: (item: T) => string | number | boolean,
): T[] {
const sortedArray = [...array];
sortedArray.sort((a, b) =>
// eslint-disable-next-line no-nested-ternary
getter(a) > getter(b) ? 1 : getter(b) > getter(a) ? -1 : 0,
);
return sortedArray;
}
function sortUsers(users: Users): Users {
// Sort by site name
let result = sortBy(users, (user) => user.title.toLowerCase());
// Sort by favorite tag, favorites first
result = sortBy(result, (user) => (user.tags.includes('favorite') ? -1 : 1));
return result;
}
function ShowcaseHeader() {
return (
<section className="margin-top--lg margin-bottom--lg text--center">
@ -184,282 +36,20 @@ function ShowcaseHeader() {
);
}
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return 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});
}
function filterUsers(
users: Users,
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));
});
}
const SearchNameQueryKey = 'name';
function readSearchName(search: string) {
return new URLSearchParams(search).get(SearchNameQueryKey);
}
const TagQueryStringKey = 'tags';
function readSearchTags(search: string): TagType[] {
return new URLSearchParams(search).getAll(TagQueryStringKey) as TagType[];
}
function useFilteredUsers(users: Users) {
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(sortUsers(users), selectedTags, operator, searchName),
[selectedTags, operator, searchName, users],
);
}
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({users}: {users: Users}) {
const filteredUsers = useFilteredUsers(users);
const siteCountPlural = useSiteCountPlural();
export default function Showcase(): JSX.Element {
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, color} = Tags[tag];
const id = `showcase_checkbox_id_${tag}`;
return (
<li key={i} className={styles.checkboxListItem}>
<ShowcaseTagSelect
tag={tag}
id={id}
label={label}
// description={description} TODO
icon={
tag === 'favorite' ? (
<FavoriteIcon svgClass={styles.svgIconFavoriteXs} />
) : (
<span
style={{
backgroundColor: color,
width: 10,
height: 10,
borderRadius: '50%',
marginLeft: 8,
}}
/>
)
}
/>
</li>
);
})}
</ul>
</section>
);
}
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({users}: {users: Users}) {
const filteredUsers = useFilteredUsers(users);
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>
);
}
const favoriteUsers = users.filter((user) => user.tags.includes('favorite'));
const otherUsers = users.filter((user) => !user.tags.includes('favorite'));
return (
<section className="margin-top--lg margin-bottom--xl">
{filteredUsers.length === sortUsers(users).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(props: Props): JSX.Element {
const users = props.content;
return (
<Layout title="Showcase">
{/* eslint-disable-next-line @docusaurus/prefer-docusaurus-heading */}
<Layout title={TITLE} description={DESCRIPTION}>
{/* eslint-disable-next-line @docusaurus/prefer-docusaurus-heading, @docusaurus/no-untranslated-text */}
<h1>Client showcase API: {clientShowcase}</h1>
<div>{JSON.stringify(props)}</div>
<main className="margin-vert--lg">
<ShowcaseHeader />
<ShowcaseFilters users={users} />
<ShowcaseFilters />
<div
style={{display: 'flex', marginLeft: 'auto'}}
className="container">
<SearchBar />
<ShowcaseSearchBar />
</div>
<ShowcaseCards users={users} />
<ShowcaseCards />
</main>
</Layout>
);

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

@ -1,21 +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 type {Props} from '@theme/ShowcaseDetails';
import Layout from '@theme/Layout';
export default function Showcase(props: Props): JSX.Element {
const {content: MDXPageContent} = props;
const {title, description} = MDXPageContent;
return (
<Layout title="Showcase Details">
<div>Title {JSON.stringify(title)}</div>
<div>Description {JSON.stringify(description)}</div>
</Layout>
);
}