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.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
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