feat(v2): new showcase page with tags and filters (#4515)

* docs: add ToggleTags component for showcase

* docs: add filter functionality

* docs: remove redundant variables

* docs: use react state for selectedTags

* docs: add control to tag checkbox

* docs: use useMemo for filteredUsers

* docs: change names of tags

* revert name tag changes

* polish the showcase page

* cleanup tags on the users list

* minor polish

* add querystring filtering

* typo

* Add title/arialabel to emulate tooltip

Co-authored-by: Javid <singularity.javid@gmail.com>
Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Lisa Chandra 2021-04-22 20:45:28 +05:30 committed by GitHub
parent 792f4ac6fb
commit 458af077ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 800 additions and 245 deletions

View file

@ -0,0 +1,94 @@
/**
* 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, {memo} from 'react';
import styles from './styles.module.css';
import clsx from 'clsx';
import Image from '@theme/IdealImage';
import {Tags, TagList} from '../../../data/users';
import {sortBy} from '../../../utils/jsUtils';
function TagIcon({label, description, icon}) {
return (
<span
className={styles.tagIcon}
// TODO add a proper tooltip
title={`${label}: ${description}`}
aria-label={`${label}: ${description}`}>
{icon}
</span>
);
}
function ShowcaseCardTagIcons({tags}) {
const tagObjects = tags
.map((tag) => ({tag, ...Tags[tag]}))
.filter((tagObject) => !!tagObject.icon);
// Keep same order of icons for all tags
const tagObjectsSorted = sortBy(tagObjects, (tagObject) =>
TagList.indexOf(tagObject.tag),
);
return tagObjectsSorted.map((tagObject, index) => (
<TagIcon key={index} {...tagObject} />
));
}
const ShowcaseCard = memo(function ({user}) {
return (
<div key={user.title} className="col col--4 margin-bottom--lg">
<div className={clsx('card', styles.showcaseCard)}>
<div className={clsx('card__image', styles.showcaseCardImage)}>
<Image img={user.preview} alt={user.title} />
</div>
<div className="card__body">
<div className="avatar">
<div className="avatar__intro margin-left--none">
<div className={styles.titleIconsRow}>
<div className={styles.titleIconsRowTitle}>
<h4 className="avatar__name">{user.title}</h4>
</div>
<div className={styles.titleIconsRowIcons}>
<ShowcaseCardTagIcons tags={user.tags} />
</div>
</div>
<small className="avatar__subtitle">{user.description}</small>
</div>
</div>
</div>
{(user.website || user.source) && (
<div className="card__footer">
<div className="button-group button-group--block">
{user.website && (
<a
className="button button--small button--secondary button--block"
href={user.website}
target="_blank"
rel="noreferrer noopener">
Website
</a>
)}
{user.source && (
<a
className="button button--small button--secondary button--block"
href={user.source}
target="_blank"
rel="noreferrer noopener">
Source
</a>
)}
</div>
</div>
)}
</div>
</div>
);
});
export default ShowcaseCard;

View file

@ -0,0 +1,35 @@
/**
* 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.
*/
.showcaseCard {
height: 100%;
}
.showcaseCardImage {
max-height: 175px;
overflow: hidden;
}
.titleIconsRow {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
.titleIconsRowTitle {
flex: 1;
}
.titleIconsRowIcons {
flex-grow: 0;
flex-shrink: 0;
}
.tagIcon {
margin: 0.2rem;
user-select: none;
}

View file

@ -0,0 +1,36 @@
/**
* 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 clsx from 'clsx';
import styles from './styles.module.css';
function ShowcaseCheckbox({
className,
name,
label,
onChange,
checked,
...props
}) {
const id = `showcase_checkbox_id_${name};`;
return (
<div className={clsx(props.className, styles.checkboxContainer)} {...props}>
<input
type="checkbox"
id={id}
name={name}
onChange={onChange}
checked={checked}
/>
<label htmlFor={id}>{label}</label>
</div>
);
}
export default ShowcaseCheckbox;

View file

@ -0,0 +1,21 @@
/**
* 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.
*/
.checkboxContainer {
display: inline;
padding: 5px;
user-select: none;
}
.checkboxContainer, .checkboxContainer > * {
cursor: pointer;
}
.checkboxContainer label {
margin-left: 0.5rem;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,24 @@
/**
* 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 styles from './styles.module.css';
function ShowcaseSelect({tag, label, onChange, value, children}) {
const id = `showcase_select_id_${tag};`;
return (
<div className={styles.selectContainer}>
<select id={id} name={tag} onChange={onChange} value={value}>
{children}
</select>
<label htmlFor={id}>{label}</label>
</div>
);
}
export default ShowcaseSelect;

View file

@ -0,0 +1,12 @@
/**
* 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.
*/
.selectContainer {
display: inline;
padding: 5px;
user-select: none;
}

File diff suppressed because it is too large Load diff

View file

@ -5,26 +5,93 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import React, {useState, useMemo, useCallback, useEffect} from 'react';
import Image from '@theme/IdealImage';
import Layout from '@theme/Layout';
import ShowcaseCheckbox from '@site/src/components/showcase/ShowcaseCheckbox';
import ShowcaseSelect from '@site/src/components/showcase/ShowcaseSelect';
import ShowcaseCard from '@site/src/components/showcase/ShowcaseCard';
import clsx from 'clsx';
import styles from './styles.module.css';
import users from '../../data/users';
const TITLE = 'Showcase';
const DESCRIPTION =
'See the awesome websites people are building with Docusaurus';
import {useHistory, useLocation} from '@docusaurus/router';
import {toggleListItem} from '../../utils/jsUtils';
import {SortedUsers, Tags, TagList} from '../../data/users';
const TITLE = 'Docusaurus Site Showcase';
const DESCRIPTION = 'List of websites people are building with Docusaurus';
const EDIT_URL =
'https://github.com/facebook/docusaurus/edit/master/website/src/data/users.js';
function Showcase() {
function filterUsers(users, selectedTags, operator) {
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));
} else {
return selectedTags.some((tag) => user.tags.includes(tag));
}
});
}
function useFilteredUsers(users, selectedTags, operator) {
return useMemo(() => filterUsers(users, selectedTags, operator), [
users,
selectedTags,
operator,
]);
}
const TagQueryStringKey = 'tags';
function readSearchTags(search) {
return new URLSearchParams(search).getAll(TagQueryStringKey);
}
function replaceSearchTags(search, newTags) {
const searchParams = new URLSearchParams(search);
searchParams.delete(TagQueryStringKey);
newTags.forEach((tag) => searchParams.append(TagQueryStringKey, tag));
return searchParams.toString();
}
function useSelectedTags() {
// The search query-string is the source of truth!
const location = useLocation();
const {push} = useHistory();
// On SSR / first mount (hydration) no tag is selected
const [selectedTags, setSelectedTags] = useState([]);
// Sync tags from QS to state (delayed on purpose to avoid SSR/Client hydration mismatch)
useEffect(() => {
const tags = readSearchTags(location.search);
setSelectedTags(tags);
}, [location, setSelectedTags]);
// Update the QS value
const toggleTag = useCallback(
(tag) => {
const tags = readSearchTags(location.search);
const newTags = toggleListItem(tags, tag);
const newSearch = replaceSearchTags(location.search, newTags);
push({...location, search: newSearch});
// no need to call setSelectedTags, useEffect will do the sync
},
[location, push],
);
return {selectedTags, toggleTag};
}
function ShowcaseHeader() {
return (
<Layout title={TITLE} description={DESCRIPTION}>
<main className="container margin-vert--lg">
<div className="text--center margin-bottom--xl">
<div className="text--center">
<h1>{TITLE}</h1>
<p>{DESCRIPTION}</p>
<p>
@ -32,55 +99,96 @@ function Showcase() {
className={'button button--primary'}
href={EDIT_URL}
target={'_blank'}>
Add your site!
🙏 Add your site now!
</a>
</p>
</div>
);
}
function ShowcaseFilters({selectedTags, toggleTag, operator, setOperator}) {
return (
<div className="margin-top--l margin-bottom--md container">
<div className="row">
{users.map((user) => (
<div key={user.title} className="col col--4 margin-bottom--lg">
<div className={clsx('card', styles.showcaseUser)}>
<div className="card__image">
<Image img={user.preview} alt={user.title} />
{TagList.map((tag) => {
const {label, description, icon} = Tags[tag];
return (
<div key={tag} className="col col--2">
<ShowcaseCheckbox
// TODO add a proper tooltip
title={`${label}: ${description}`}
aria-label={`${label}: ${description}`}
name={tag}
label={
icon ? (
<>
{icon} {label}
</>
) : (
label
)
}
onChange={() => toggleTag(tag)}
checked={selectedTags.includes(tag)}
/>
</div>
<div className="card__body">
<div className="avatar">
<div className="avatar__intro margin-left--none">
<h4 className="avatar__name">{user.title}</h4>
<small className="avatar__subtitle">
{user.description}
</small>
);
})}
<div className="col col--2">
<ShowcaseSelect
name="operator"
value={operator}
onChange={(e) => setOperator(e.target.value)}>
<option value="OR">OR</option>
<option value="AND">AND</option>
</ShowcaseSelect>
</div>
</div>
</div>
{(user.website || user.source) && (
<div className="card__footer">
<div className="button-group button-group--block">
{user.website && (
<a
className="button button--small button--secondary button--block"
href={user.website}
target="_blank"
rel="noreferrer noopener">
Website
</a>
)}
{user.source && (
<a
className="button button--small button--secondary button--block"
href={user.source}
target="_blank"
rel="noreferrer noopener">
Source
</a>
)}
</div>
</div>
)}
</div>
</div>
);
}
function ShowcaseCards({filteredUsers}) {
return (
<section className="container margin-top--lg">
<h2>
{filteredUsers.length} site{filteredUsers.length > 1 ? 's' : ''}
</h2>
<div className="margin-top--lg">
{filteredUsers.length > 0 ? (
<div className="row">
{filteredUsers.map((user) => (
<ShowcaseCard
key={user.title} // Title should be unique
user={user}
/>
))}
</div>
) : (
<div className={clsx('padding-vert--md text--center')}>
<h3>No result</h3>
</div>
)}
</div>
</section>
);
}
function Showcase() {
const {selectedTags, toggleTag} = useSelectedTags();
const [operator, setOperator] = useState('OR');
const filteredUsers = useFilteredUsers(SortedUsers, selectedTags, operator);
return (
<Layout title={TITLE} description={DESCRIPTION}>
<main className="container margin-vert--lg">
<ShowcaseHeader />
<ShowcaseFilters
selectedTags={selectedTags}
toggleTag={toggleTag}
operator={operator}
setOperator={setOperator}
/>
<ShowcaseCards filteredUsers={filteredUsers} />
</main>
</Layout>
);

View file

@ -4,12 +4,3 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.showcaseUser {
height: 100%;
}
:global(.card__image) {
max-height: 175px;
overflow: hidden;
}

View file

@ -0,0 +1,27 @@
// Inspired by https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_difference
export function difference(...arrays) {
return arrays.reduce((a, b) => a.filter((c) => !b.includes(c)));
}
// Inspired by https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_sortby-and-_orderby
export function sortBy(array, getter) {
function compareBy(getter) {
return (a, b) =>
getter(a) > getter(b) ? 1 : getter(b) > getter(a) ? -1 : 0;
}
const sortedArray = [...array];
sortedArray.sort(compareBy(getter));
return sortedArray;
}
export function toggleListItem(list, item) {
const itemIndex = list.indexOf(item);
if (itemIndex === -1) {
return list.concat(item);
} else {
const newList = [...list];
newList.splice(itemIndex, 1);
return newList;
}
}