diff --git a/packages/speedrun-theme/src/css/custom.css b/packages/speedrun-theme/src/css/custom.css index 3af995a..281aa16 100644 --- a/packages/speedrun-theme/src/css/custom.css +++ b/packages/speedrun-theme/src/css/custom.css @@ -33,14 +33,17 @@ --ifm-table-border-width: 0px; --speeddocs-primary: #030c20; + --speeddocs-primary-rgb-values: 3, 12, 32; --speeddocs-secondary: #14213d; + --speeddocs-secondary-rgb-values: 20, 33, 61; --speeddocs-accent: #fca311; + --speeddocs-accent-rgb-values: 252, 163, 17; --logo-filter: invert(1); - --ifm-navbar-background-color: var(--speeddocs-secondary); + --ifm-navbar-background-color: rgba(var(--speeddocs-secondary-rgb-values), 0.5); --ifm-background-color: var(--speeddocs-primary) !important; --ifm-background-surface-color: var(--speeddocs-secondary) !important; - --ifm-background-surface-color-split: 3, 12, 32; /* Used for the gradients on top of images */ + /* --ifm-background-surface-color-split: 3, 12, 32; Used for the gradients on top of images */ --ifm-navbar-search-input-background-color: var(--speeddocs-primary); --ifm-navbar-search-input-icon: url('data:image/svg+xml;utf8,'); --ifm-footer-background-color: var(--speeddocs-secondary); @@ -89,6 +92,7 @@ .navbar { border-bottom: thin solid var(--speeddocs-accent); + backdrop-filter: saturate(180%) blur(10px); } footer { diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index acd106c..1e20f4d 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -49,6 +49,11 @@ const config: Config = { position: "left", label: "Resources", }, + { + position: "left", + label: "Showcase", + to: "showcase" + }, { href: `https://github.com/${speedrunDocsConfig.github.username}/${speedrunDocsConfig.github.repository}`, position: "right", diff --git a/website/src/pages/showcase/_components/FavouriteIcon/favourite-icon.tsx b/website/src/pages/showcase/_components/FavouriteIcon/favourite-icon.tsx new file mode 100644 index 0000000..f3aa6ea --- /dev/null +++ b/website/src/pages/showcase/_components/FavouriteIcon/favourite-icon.tsx @@ -0,0 +1,34 @@ +/** + * 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, type ComponentProps } from "react"; +import clsx from "clsx"; +import styles from "./styles.module.css"; + +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. +}; + +export default function FavouriteIcon(props: SvgIconProps): JSX.Element { + const { svgClass, colorAttr, children, color = "inherit", size = "medium", viewBox = "0 0 24 24", ...rest } = props; + + return ( + + + + ); +} diff --git a/website/src/pages/showcase/_components/FavouriteIcon/styles.module.css b/website/src/pages/showcase/_components/FavouriteIcon/styles.module.css new file mode 100644 index 0000000..cf39090 --- /dev/null +++ b/website/src/pages/showcase/_components/FavouriteIcon/styles.module.css @@ -0,0 +1,54 @@ +/** + * 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. + */ + +.svgIcon { + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + fill: currentColor; + flex-shrink: 0; + color: inherit; +} + +/* font-size */ +.small { + font-size: 1.25rem; +} + +.medium { + font-size: 1.5rem; +} + +.large { + font-size: 2.185rem; +} + +/* colours */ +.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; +} diff --git a/website/src/pages/showcase/_components/ShowcaseCard/index.tsx b/website/src/pages/showcase/_components/ShowcaseCard/index.tsx new file mode 100644 index 0000000..a522ffc --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseCard/index.tsx @@ -0,0 +1,81 @@ +/** + * 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 Link from "@docusaurus/Link"; +import Translate from "@docusaurus/Translate"; +// import Image from '@theme/IdealImage'; +import FavouriteIcon from "../FavouriteIcon/favourite-icon"; +import { Tags, TagList, type TagType, type Website, type Tag } from "../../data"; +import Heading from "@theme/Heading"; +import Tooltip from "../ShowcaseTooltip"; +import styles from "./styles.module.css"; + +const TagComp = React.forwardRef(({ label, color, description }, ref) => ( +
  • + {label.toLowerCase()} + +
  • +)); + +function ShowcaseCardTag({ tags }: { tags: TagType[] }) { + const tagObjects = tags.map((tag) => ({ tag, ...Tags[tag] })); + + // Keep same order for all tags + const tagObjectsSorted = tagObjects.sort((a, b) => TagList.indexOf(a.tag) - TagList.indexOf(b.tag)); + + return ( + <> + {tagObjectsSorted.map((tagObject, index) => { + const id = `showcase_card_tag_${tagObject.tag}`; + + return ( + + + + ); + })} + + ); +} + +function getCardImage(user: Website): string { + return user.preview ?? `https://slorber-api-screenshot.netlify.app/${encodeURIComponent(user.website)}/showcase`; +} + +function ShowcaseCard({ user }: { user: Website }) { + const image = getCardImage(user); + return ( +
  • +
    + {user.title} +
    +
    +
    + + + {user.title} + + + {user.tags.includes("favourite") && } + {user.source && ( + + source + + )} +
    +

    {user.description}

    +
    + +
  • + ); +} + +export default React.memo(ShowcaseCard); diff --git a/website/src/pages/showcase/_components/ShowcaseCard/styles.module.css b/website/src/pages/showcase/_components/ShowcaseCard/styles.module.css new file mode 100644 index 0000000..4d45056 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseCard/styles.module.css @@ -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. + */ + +.showcaseCardImage { + overflow: hidden; + height: 150px; + border-bottom: 2px solid var(--ifm-color-emphasis-200); +} + +.showcaseCardHeader { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.showcaseCardTitle { + margin-bottom: 0; + flex: 1 1 auto; +} + +.showcaseCardTitle a { + text-decoration: none; + background: linear-gradient( + var(--ifm-color-primary), + var(--ifm-color-primary) + ) + 0% 100% / 0% 1px no-repeat; + transition: background-size ease-out 200ms; +} + +.showcaseCardTitle a:not(:focus):hover { + background-size: 100% 1px; +} + +.showcaseCardTitle, +.showcaseCardHeader .svgIconFavorite { + margin-right: 0.25rem; +} + +.showcaseCardHeader .svgIconFavorite { + color: var(--site-color-svg-icon-favorite); +} + +.showcaseCardSrcBtn { + margin-left: 6px; + padding-left: 12px; + padding-right: 12px; + border: none; +} + +.showcaseCardSrcBtn:focus-visible { + background-color: var(--ifm-color-secondary-dark); +} + +[data-theme='dark'] .showcaseCardSrcBtn { + background-color: var(--ifm-color-emphasis-200) !important; + color: inherit; +} + +[data-theme='dark'] .showcaseCardSrcBtn:hover { + background-color: var(--ifm-color-emphasis-300) !important; +} + +.showcaseCardBody { + font-size: smaller; + line-height: 1.66; +} + +.cardFooter { + display: flex; + flex-wrap: wrap; +} + +.tag { + font-size: 0.675rem; + border: 1px solid var(--ifm-color-secondary-darkest); + cursor: default; + margin-right: 6px; + margin-bottom: 6px !important; + border-radius: 12px; + display: inline-flex; + align-items: center; +} + +.tag .textLabel { + margin-left: 8px; +} + +.tag .colorLabel { + width: 7px; + height: 7px; + border-radius: 50%; + margin-left: 6px; + margin-right: 6px; +} diff --git a/website/src/pages/showcase/_components/ShowcaseFilterToggle/index.tsx b/website/src/pages/showcase/_components/ShowcaseFilterToggle/index.tsx new file mode 100644 index 0000000..78c1126 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseFilterToggle/index.tsx @@ -0,0 +1,85 @@ +/** + * 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 ( +
    + { + if (e.key === 'Enter') { + toggleOperator(); + } + }} + checked={operator} + /> + + + +
    + ); +} diff --git a/website/src/pages/showcase/_components/ShowcaseFilterToggle/styles.module.css b/website/src/pages/showcase/_components/ShowcaseFilterToggle/styles.module.css new file mode 100644 index 0000000..4fde44d --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseFilterToggle/styles.module.css @@ -0,0 +1,57 @@ +/** + * 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. + */ + +.checkboxLabel { + --height: 25px; + --width: 80px; + --border: 2px; + display: flex; + width: var(--width); + height: var(--height); + position: relative; + border-radius: var(--height); + border: var(--border) solid var(--ifm-color-primary-darkest); + cursor: pointer; + justify-content: space-around; + opacity: 0.75; + transition: opacity var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + box-shadow: var(--ifm-global-shadow-md); +} + +.checkboxLabel:hover { + opacity: 1; + box-shadow: var(--ifm-global-shadow-md), + 0 0 2px 1px var(--ifm-color-primary-dark); +} + +.checkboxLabel::after { + position: absolute; + content: ''; + inset: 0; + width: calc(var(--width) / 2); + height: 100%; + border-radius: var(--height); + background-color: var(--ifm-color-primary-darkest); + transition: transform var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + transform: translateX(calc(var(--width) / 2 - var(--border))); +} + +input:focus-visible ~ .checkboxLabel::after { + outline: 2px solid currentColor; +} + +.checkboxLabel > * { + font-size: 0.8rem; + color: inherit; + transition: opacity 150ms ease-in 50ms; +} + +input:checked ~ .checkboxLabel::after { + transform: translateX(calc(-1 * var(--border))); +} diff --git a/website/src/pages/showcase/_components/ShowcaseTagSelect/index.tsx b/website/src/pages/showcase/_components/ShowcaseTagSelect/index.tsx new file mode 100644 index 0000000..88101b6 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseTagSelect/index.tsx @@ -0,0 +1,93 @@ +/** + * 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, { useCallback, useState, useEffect, type ComponentProps, type ReactNode, type ReactElement } from "react"; +import { useHistory, useLocation } from "@docusaurus/router"; +import type { TagType } from "../../data"; + +import { prepareUserState } from "../../index"; +import styles from "./styles.module.css"; + +interface Props extends ComponentProps<"input"> { + icon: ReactElement>; + label: ReactNode; + tag: TagType; +} + +const TagQueryStringKey = "tags"; + +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 toggleListItem(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; +} + +function ShowcaseTagSelect({ id, icon, label, tag, ...rest }: Props, ref: React.ForwardedRef) { + 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]); + return ( + <> + { + if (e.key === "Enter") { + toggleTag(); + } + }} + 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} + /> + + + ); +} + +export default React.forwardRef(ShowcaseTagSelect); diff --git a/website/src/pages/showcase/_components/ShowcaseTagSelect/styles.module.css b/website/src/pages/showcase/_components/ShowcaseTagSelect/styles.module.css new file mode 100644 index 0000000..7367147 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseTagSelect/styles.module.css @@ -0,0 +1,38 @@ +/** + * 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. + */ + +.checkboxLabel:hover { + opacity: 1; + box-shadow: 0 0 2px 1px var(--ifm-color-secondary-darkest); +} + +input[type='checkbox'] + .checkboxLabel { + display: flex; + align-items: center; + cursor: pointer; + line-height: 1.5; + border-radius: 4px; + padding: 0.275rem 0.8rem; + opacity: 0.85; + transition: opacity 200ms ease-out; + border: 2px solid var(--ifm-color-secondary-darkest); +} + +input:focus-visible + .checkboxLabel { + outline: 2px solid currentColor; +} + +input:checked + .checkboxLabel { + opacity: 0.9; + background-color: var(--site-color-checkbox-checked-bg); + border: 2px solid var(--ifm-color-primary-darkest); +} + +input:checked + .checkboxLabel:hover { + opacity: 0.75; + box-shadow: 0 0 2px 1px var(--ifm-color-primary-dark); +} diff --git a/website/src/pages/showcase/_components/ShowcaseTooltip/index.tsx b/website/src/pages/showcase/_components/ShowcaseTooltip/index.tsx new file mode 100644 index 0000000..6ea7df5 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseTooltip/index.tsx @@ -0,0 +1,131 @@ +/** + * 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(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const [container, setContainer] = useState(null); + // const { styles: popperStyles, attributes } = usePopper(referenceElement, popperElement, { + // modifiers: [ + // { + // name: "arrow", + // options: { + // element: arrowElement, + // }, + // }, + // { + // name: "offset", + // options: { + // offset: [0, 8], + // }, + // }, + // ], + // }); + + const timeout = useRef(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 && ( + + ), + container + ) + : container} + + ); +} diff --git a/website/src/pages/showcase/_components/ShowcaseTooltip/styles.module.css b/website/src/pages/showcase/_components/ShowcaseTooltip/styles.module.css new file mode 100644 index 0000000..5500c81 --- /dev/null +++ b/website/src/pages/showcase/_components/ShowcaseTooltip/styles.module.css @@ -0,0 +1,45 @@ +/** + * 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; +} diff --git a/website/src/pages/showcase/data.tsx b/website/src/pages/showcase/data.tsx new file mode 100644 index 0000000..e7ade27 --- /dev/null +++ b/website/src/pages/showcase/data.tsx @@ -0,0 +1,106 @@ +/* + * ADD YOUR SITE TO THE DOCUSAURUS SHOWCASE + * + * Please don't submit a PR yourself: message Clubwho + * + * Instructions for maintainers: + * - Add the site in the json array below + * - `title` is the project's name (no need for the "Docs" suffix) + * - Use relevant tags to categorize the site (read the tag descriptions on the + * https://docusaurus.io/showcase page and some further clarifications below) + * - Add a local image preview (decent screenshot of the Docusaurus site) + * - The image MUST be added to the GitHub repository, and use `require("img")` + * - The image has to have minimum width 640 and an aspect of no wider than 2:1 + * - If a website is open-source, add a source link. The link should open + * to a directory containing the `docusaurus.config.js` file + * - Resize images: node admin/scripts/resizeImage.js + * - Run optimizt manually (see resize image script comment) + * - Open a PR and check for reported CI errors + * + */ + +// LIST OF AVAILABLE TAGS +// Available tags to assign to a showcase site +// Please choose all tags that you think might apply. +// We'll remove inappropriate tags, but it's less likely that we add tags. +export type TagType = + | "favourite" + // Feel free to add the 'design' tag as long as there's _some_ level of + // CSS/swizzling. + | "design" + // Site must have more than one locale. + | "i18n" + // Large sites are defined as those with > 200 pages, excluding versions. + | "large" + | "personal"; + +// Add sites to this list +// prettier-ignore +const Websites: Website[] = [ + { + title: 'Heavenly Bodies', + description: 'Heavenly Bodies', + preview: require('./images/heavenly-bodies.png'), + website: 'https://ewanlyon.github.io/heavenly-bodies-speedrun-docs/', + source: 'https://github.com/ewanlyon/heavenly-bodies-speedrun-docs', + tags: ['design', 'favourite'], + }, + + /* + Pro Tip: add your site in alphabetical order. + Appending your site here (at the end) is more likely to produce Git conflicts. + */ +]; + +export type Website = { + title: string; + description: string; + preview: string | null; // null = use our serverless screenshot service + website: string; + source: string | null; + tags: TagType[]; +}; + +export type Tag = { + label: string; + description: string; + color: string; +}; + +export const Tags: { [type in TagType]: Tag } = { + favourite: { + label:"Favourite", + description: "Our favourite Speedrun-Docs sites that you must absolutely check out!", + color: '#e9669e', + }, + design: { + label: "Design", + description: "Beautiful Speedrun-Docs sites, polished and standing out from the initial template!", + color: "#a44fb7", + }, + i18n: { + label: "I18n", + description: "Translated Speedrun-Docs sites using the internationalization support with more than 1 locale.", + color: "#127f82", + }, + large: { + label: "Large", + description: "Very large Speedrun-Docs sites, including many more pages than the average!", + color: "#8c2f00", + }, + personal: { + label: "Personal", + description: "Personal websites, blogs and digital gardens built with Speedrun-Docs", + color: "#14cfc3", + }, +}; + +export const TagList = Object.keys(Tags) as TagType[]; +function sortWebsites() { + let result = Websites; + // Sort by site name + result = result.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + return result; +} + +export const sortedWebsites = sortWebsites(); diff --git a/website/src/pages/showcase/images/heavenly-bodies.png b/website/src/pages/showcase/images/heavenly-bodies.png new file mode 100644 index 0000000..06ff3c2 Binary files /dev/null and b/website/src/pages/showcase/images/heavenly-bodies.png differ diff --git a/website/src/pages/showcase/index.tsx b/website/src/pages/showcase/index.tsx new file mode 100644 index 0000000..b14a834 --- /dev/null +++ b/website/src/pages/showcase/index.tsx @@ -0,0 +1,291 @@ +/** + * 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 { useState, useMemo, useEffect } from "react"; +import clsx from "clsx"; +import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment"; +import Translate, { translate } from "@docusaurus/Translate"; +import { useHistory, useLocation } from "@docusaurus/router"; +import { usePluralForm } from "@docusaurus/theme-common"; + +import Layout from "@theme/Layout"; +import FavouriteIcon from "./_components/FavouriteIcon/favourite-icon"; +import { sortedWebsites, Tags, TagList, type Website, type TagType } from "./data"; +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"; + +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(websites: Website[], selectedTags: TagType[], operator: Operator, searchName: string | null) { + if (searchName) { + // eslint-disable-next-line no-param-reassign + websites = websites.filter((user) => user.title.toLowerCase().includes(searchName.toLowerCase())); + } + if (selectedTags.length === 0) { + return websites; + } + return websites.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(); + const [operator, setOperator] = useState("OR"); + // On SSR / first mount (hydration) no tag is selected + const [selectedTags, setSelectedTags] = useState([]); + const [searchName, setSearchName] = useState(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(sortedWebsites, selectedTags, operator, searchName), + [selectedTags, operator, searchName] + ); +} + +const TITLE = "Docusaurus Site Showcase"; +const DESCRIPTION = "List of websites people are building with Speedrun-Docs"; + +function ShowcaseHeader() { + return ( +
    + {TITLE} +

    {DESCRIPTION}

    +

    Message Clubwho on Discord to have your site added.

    +
    + ); +} + +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 ( +
    +
    +
    + + Filters + + {siteCountPlural(filteredUsers.length)} +
    + +
    +
      + {TagList.map((tag, i) => { + const { label, description, color } = Tags[tag]; + const id = `showcase_checkbox_id_${tag}`; + + return ( +
    • + + + ) : ( + + ) + } + /> + +
    • + ); + })} +
    +
    + ); +} + +const favouriteWebsites = sortedWebsites.filter((user) => user.tags.includes("favourite")); +const otherUsers = sortedWebsites.filter((user) => !user.tags.includes("favourite")); + +function SearchBar() { + const history = useHistory(); + const location = useLocation(); + const [value, setValue] = useState(null); + useEffect(() => { + setValue(readSearchName(location.search)); + }, [location]); + return ( +
    + { + 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); + }} + /> +
    + ); +} + +function ShowcaseCards() { + const filteredUsers = useFilteredUsers(); + + if (filteredUsers.length === 0) { + return ( +
    +
    + + No result + +
    +
    + ); + } + + return ( +
    + {filteredUsers.length === sortedWebsites.length ? ( + <> +
    +
    +
    + + Our favorites + + +
    +
      + {favouriteWebsites.map((user) => ( + + ))} +
    +
    +
    +
    + + All sites + +
      + {otherUsers.map((user) => ( + + ))} +
    +
    + + ) : ( +
    +
    +
      + {filteredUsers.map((user) => ( + + ))} +
    +
    + )} +
    + ); +} + +export default function Showcase(): JSX.Element { + return ( + +
    + + +
    + +
    + +
    +
    + ); +} diff --git a/website/src/pages/showcase/styles.module.css b/website/src/pages/showcase/styles.module.css new file mode 100644 index 0000000..d9cfd94 --- /dev/null +++ b/website/src/pages/showcase/styles.module.css @@ -0,0 +1,95 @@ +/** + * 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; +}