mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-22 13:37:05 +02:00
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:
parent
792f4ac6fb
commit
458af077ae
10 changed files with 800 additions and 245 deletions
94
website/src/components/showcase/ShowcaseCard/index.js
Normal file
94
website/src/components/showcase/ShowcaseCard/index.js
Normal 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;
|
|
@ -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;
|
||||||
|
}
|
36
website/src/components/showcase/ShowcaseCheckbox/index.js
Normal file
36
website/src/components/showcase/ShowcaseCheckbox/index.js
Normal 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;
|
|
@ -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;
|
||||||
|
}
|
24
website/src/components/showcase/ShowcaseSelect/index.js
Normal file
24
website/src/components/showcase/ShowcaseSelect/index.js
Normal 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;
|
|
@ -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
|
@ -5,26 +5,93 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* 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 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 clsx from 'clsx';
|
||||||
import styles from './styles.module.css';
|
|
||||||
import users from '../../data/users';
|
|
||||||
|
|
||||||
const TITLE = 'Showcase';
|
import {useHistory, useLocation} from '@docusaurus/router';
|
||||||
const DESCRIPTION =
|
|
||||||
'See the awesome websites people are building with Docusaurus';
|
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 =
|
const EDIT_URL =
|
||||||
'https://github.com/facebook/docusaurus/edit/master/website/src/data/users.js';
|
'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 (
|
return (
|
||||||
<Layout title={TITLE} description={DESCRIPTION}>
|
<div className="text--center">
|
||||||
<main className="container margin-vert--lg">
|
|
||||||
<div className="text--center margin-bottom--xl">
|
|
||||||
<h1>{TITLE}</h1>
|
<h1>{TITLE}</h1>
|
||||||
<p>{DESCRIPTION}</p>
|
<p>{DESCRIPTION}</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -32,55 +99,96 @@ function Showcase() {
|
||||||
className={'button button--primary'}
|
className={'button button--primary'}
|
||||||
href={EDIT_URL}
|
href={EDIT_URL}
|
||||||
target={'_blank'}>
|
target={'_blank'}>
|
||||||
Add your site!
|
🙏 Add your site now!
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowcaseFilters({selectedTags, toggleTag, operator, setOperator}) {
|
||||||
|
return (
|
||||||
|
<div className="margin-top--l margin-bottom--md container">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{users.map((user) => (
|
{TagList.map((tag) => {
|
||||||
<div key={user.title} className="col col--4 margin-bottom--lg">
|
const {label, description, icon} = Tags[tag];
|
||||||
<div className={clsx('card', styles.showcaseUser)}>
|
return (
|
||||||
<div className="card__image">
|
<div key={tag} className="col col--2">
|
||||||
<Image img={user.preview} alt={user.title} />
|
<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>
|
||||||
<div className="card__body">
|
);
|
||||||
<div className="avatar">
|
})}
|
||||||
<div className="avatar__intro margin-left--none">
|
<div className="col col--2">
|
||||||
<h4 className="avatar__name">{user.title}</h4>
|
<ShowcaseSelect
|
||||||
<small className="avatar__subtitle">
|
name="operator"
|
||||||
{user.description}
|
value={operator}
|
||||||
</small>
|
onChange={(e) => setOperator(e.target.value)}>
|
||||||
|
<option value="OR">OR</option>
|
||||||
|
<option value="AND">AND</option>
|
||||||
|
</ShowcaseSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(user.website || user.source) && (
|
);
|
||||||
<div className="card__footer">
|
}
|
||||||
<div className="button-group button-group--block">
|
|
||||||
{user.website && (
|
function ShowcaseCards({filteredUsers}) {
|
||||||
<a
|
return (
|
||||||
className="button button--small button--secondary button--block"
|
<section className="container margin-top--lg">
|
||||||
href={user.website}
|
<h2>
|
||||||
target="_blank"
|
{filteredUsers.length} site{filteredUsers.length > 1 ? 's' : ''}
|
||||||
rel="noreferrer noopener">
|
</h2>
|
||||||
Website
|
<div className="margin-top--lg">
|
||||||
</a>
|
{filteredUsers.length > 0 ? (
|
||||||
)}
|
<div className="row">
|
||||||
{user.source && (
|
{filteredUsers.map((user) => (
|
||||||
<a
|
<ShowcaseCard
|
||||||
className="button button--small button--secondary button--block"
|
key={user.title} // Title should be unique
|
||||||
href={user.source}
|
user={user}
|
||||||
target="_blank"
|
/>
|
||||||
rel="noreferrer noopener">
|
|
||||||
Source
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,12 +4,3 @@
|
||||||
* This source code is licensed under the MIT license found in the
|
* This source code is licensed under the MIT license found in the
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.showcaseUser {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.card__image) {
|
|
||||||
max-height: 175px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
27
website/src/utils/jsUtils.js
Normal file
27
website/src/utils/jsUtils.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue