feat: Added first letter filter to theme index (#74)

* Added series and studio index links to the search switcher.
* Added placeholder image to series and studio summary card.
* Random themes can no longer contain spoilers.
* Rewrote listbox with Reach UI to fix various issues (it's also more accessible now).
* Fixed issues with horizontal scroll.
This commit is contained in:
Manuel S
2021-09-01 03:53:04 +02:00
committed by GitHub
parent b2fcd26818
commit c6b2005ff2
9 changed files with 23964 additions and 241 deletions
+23839 -122
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@reach/listbox": "^0.16.1",
"@react-hook/resize-observer": "^1.2.2",
"babel-plugin-prismjs": "^2.0.1",
"babel-plugin-styled-components": "^1.11.1",
+18 -8
View File
@@ -1,6 +1,6 @@
import { Link } from "gatsby";
import { Link, withPrefix } from "gatsby";
import { Text } from "components/text";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Flex } from "components/box";
@@ -10,16 +10,20 @@ const StyledCover = styled.img.attrs({
width: 48px;
height: 64px;
object-fit: cover;
${(props) => props.placeholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
`;
export function SummaryCard({ title, description, image, to, children, ...props }) {
return (
<Card display="flex" flexDirection="row" alignItems="center" p={0} pr="1rem" height="64px" {...props}>
{!!image && (
<Link to={to}>
<StyledCover alt="Cover" src={image} loading="lazy"/>
</Link>
)}
<Link to={to}>
<StyledCover alt="Cover" src={image || withPrefix("/img/logo.svg")} placeholder={!image} loading="lazy"/>
</Link>
<Flex flex={1} flexDirection="column" justifyContent="center" gapsColumn="0.25rem" px="1rem">
<Text fontWeight="600" maxLines={1}>
{typeof title === "string" ? (
@@ -28,7 +32,13 @@ export function SummaryCard({ title, description, image, to, children, ...props
</Link>
) : title}
</Text>
<Text variant="small" maxLines={1}>{description}</Text>
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[ description ]}
</SummaryCard.Description>
) : description}
</Text>
</Flex>
{children}
</Card>
+61 -83
View File
@@ -1,29 +1,37 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faSort, faTimes } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import { useEffect, useRef, useState } from "react";
import theme from "theme";
import { Box } from "components/box";
import { AnimatePresence, motion } from "framer-motion";
import useResizeObserver from "@react-hook/resize-observer";
import { gapsRow } from "styles/mixins";
import { ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from "@reach/listbox";
import "@reach/listbox/styles.css";
const StyledListbox = styled(Box)`
const StyledListbox = styled(ListboxInput)`
display: inline-block;
`;
const StyledListboxButton = styled(Button).attrs({
gapsRow: "0.5rem"
})`
})`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border: none;
`;
const StyledListboxPopover = styled(motion.div)`
position: absolute;
const StyledListboxPopover = styled(ListboxPopover)`
@keyframes flip-down {
0% {
opacity: 0;
transform: perspective(1000px) rotateX(-45deg);
}
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0deg);
}
}
margin-top: 0.5rem;
padding: 0;
@@ -31,119 +39,89 @@ const StyledListboxPopover = styled(motion.div)`
border-radius: 1rem;
overflow: hidden;
will-change: transform, opacity;
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.high};
transition: none;
transform-origin: top;
will-change: transform, opacity;
animation: flip-down 200ms ease-out;
&:focus-within {
outline: none;
box-shadow: ${theme.shadows.high};
}
`;
const StyledListboxList = styled.ul`
const StyledListboxList = styled(ListboxList)`
max-height: 33vh;
margin: 0;
padding: 0;
list-style: none;
overflow: auto;
&:focus {
outline: none;
}
`;
const StyledListboxOption = styled.li`
const StyledListboxOption = styled(ListboxOption)`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
${gapsRow("0.5rem")}
color: ${theme.colors["text-muted"]};
&:hover, &:focus {
color: ${(props) => theme.colors[props.selected ? "text-primary" : "text-muted"]};
&:hover, &[data-current-nav] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
color: ${(props) => theme.colors[props.selected ? "text-primary" : "text"]};
cursor: pointer;
}
${(props) => props.selected && css`
color: ${theme.colors["text-primary"]};
`}
`;
export function Listbox({ options, nullLabel, selectedValue, onSelect, noReset, disabled, defaultValue, ...props }) {
const [showPopover, setShowPopover] = useState(false);
const [buttonWidth, setButtonWidth] = useState(0);
const buttonRef = useRef(null);
const listRef = useRef(null);
useResizeObserver(buttonRef, (entry) => setButtonWidth(entry.target.getBoundingClientRect().width));
useEffect(() => {
if (showPopover) {
listRef.current.focus();
}
}, [ showPopover ]);
function handleButtonClick() {
if (!disabled) {
setShowPopover(true);
}
}
function handleOptionClick(value) {
if (!disabled) {
onSelect(value);
setShowPopover(false);
}
}
function handleResetClick(event) {
event.stopPropagation();
if (!disabled) {
onSelect(null);
setShowPopover(false);
}
}
return (
<StyledListbox {...props}>
<StyledListbox
// Reach UI listbox can't handle null values so we use an empty string instead
value={selectedValue || ""}
onChange={onSelect}
{...props}
>
<StyledListboxButton
ref={buttonRef}
as={ListboxButton}
variant={selectedValue !== null && selectedValue !== defaultValue && "primary"}
onClick={handleButtonClick}
onMouseDown={(event) => event.preventDefault()}
disabled={disabled}
>
<Text>{selectedValue !== null ? selectedValue : nullLabel}</Text>
{(selectedValue !== null && !noReset) ? (
<FontAwesomeIcon icon={faTimes} fixedWidth onClick={handleResetClick}/>
<FontAwesomeIcon
icon={faTimes}
fixedWidth
onMouseDown={handleResetClick}
/>
) : (
<FontAwesomeIcon icon={faSort} fixedWidth/>
)}
</StyledListboxButton>
<AnimatePresence>
{showPopover && (
<StyledListboxPopover
style={{ width: buttonWidth }}
initial={{ rotateX: -45, originY: 0, transformPerspective: 1000 }}
animate={{ rotateX: 0 }}
exit={{ rotateX: -45, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<StyledListboxList ref={listRef} tabIndex={-1} onBlur={() => setShowPopover(false)}>
{options.map((value) => (
<StyledListboxOption key={value} selected={value === selectedValue} onClick={() => handleOptionClick(value)}>
<Text>{value}</Text>
{value === selectedValue && (
<FontAwesomeIcon icon={faCheck} fixedWidth/>
)}
</StyledListboxOption>
))}
</StyledListboxList>
</StyledListboxPopover>
)}
</AnimatePresence>
<StyledListboxPopover>
<StyledListboxList>
{options.map((value) => (
<StyledListboxOption
key={value}
selected={value === selectedValue}
value={value}
>
<Text>{value}</Text>
{value === selectedValue && (
<FontAwesomeIcon icon={faCheck} fixedWidth/>
)}
</StyledListboxOption>
))}
</StyledListboxList>
</StyledListboxPopover>
</StyledListbox>
);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function Navigation() {
<StyledNavigation show={show} onClick={() => setShow(false)}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<StyledLogoContainer to="/">
<StyledLogo className="navigation__logo-image" src={withPrefix("/img/logo.svg")} alt="Logo" />
<StyledLogo src={withPrefix("/img/logo.svg")} alt="Logo" />
</StyledLogoContainer>
<Flex
flexDirection={[ "column", "row" ]}
+5 -1
View File
@@ -1,4 +1,4 @@
import { SearchFilterSortBy, SearchFilterThemeType } from "components/search-filter";
import { SearchFilterFirstLetter, SearchFilterSortBy, SearchFilterThemeType } from "components/search-filter";
import useEntitySearch from "hooks/useEntitySearch";
import { SearchEntity } from "components/search";
import { navigate } from "gatsby";
@@ -15,6 +15,7 @@ const sortByFields = new Map([
const sortByOptions = [ ...sortByFields.keys() ];
export function SearchTheme({ searchQuery, locationState }) {
const filterFirstLetter = locationState?.filterFirstLetter || null;
const filterType = locationState?.filterType || null;
const sortBy = locationState?.sortBy || sortByOptions[0];
@@ -37,6 +38,8 @@ export function SearchTheme({ searchQuery, locationState }) {
isPlaceholderData
} = useEntitySearch("theme", searchQuery, {
filters: {
has: "song",
"song][title][like": filterFirstLetter ? `${filterFirstLetter}%` : null,
type: filterType
},
sortBy: sortByFields.get(sortBy)
@@ -47,6 +50,7 @@ export function SearchTheme({ searchQuery, locationState }) {
searchQuery={searchQuery}
filters={
<>
<SearchFilterFirstLetter value={filterFirstLetter} setValue={updateState("filterFirstLetter")}/>
<SearchFilterThemeType value={filterType} setValue={updateState("filterType")}/>
<SearchFilterSortBy options={sortByOptions} value={sortBy} setValue={updateState("sortBy")}/>
</>
-1
View File
@@ -1,7 +1,6 @@
import styled from "styled-components";
export const HorizontalScroll = styled.div`
position: relative;
overflow-x: auto;
max-width: 100vw;
margin: 0 -1rem;
+12 -5
View File
@@ -4,13 +4,20 @@ const backLog = [];
export async function fetchRandomTheme() {
if (!backLog.length) {
const res = await fetch(`${baseUrl}/api/animetheme?sort=random&include=anime,animethemeentries.videos`);
const res = await fetch(`${baseUrl}/api/animetheme?sort=random&include=anime,animethemeentries.videos&filter[has]=animethemeentries&filter[spoiler]=false`);
const json = await res.json();
backLog.push(...json.animethemes.map((theme) => ({
...theme,
entries: theme.animethemeentries
})));
backLog.push(...json.animethemes.map((theme) => {
// Remove all entries which have spoilers (the filter parameter guarantees at least one spoiler-free entry)
while (theme.animethemeentries[0].spoiler) {
theme.animethemeentries.shift();
}
return {
...theme,
entries: theme.animethemeentries
};
}));
}
return backLog.pop();
+27 -20
View File
@@ -11,6 +11,7 @@ import { SEO } from "components/seo";
import { faCompass, faSearch, faTimes } from "@fortawesome/free-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { HorizontalScroll } from "components/utils";
const StyledSearchOptions = styled(Flex)`
align-items: center;
@@ -82,26 +83,32 @@ export default function SearchPage({ pageContext: { entity }, location: { search
}}
/>
</Box>
<Switcher
items={[ null, "anime", "theme", "artist" ]}
selectedItem={entity}
>
{({ Button, item, selected, content }) => item.value === null ? (
!!entity ? (
<Link key={null} to={`/search${urlSuffix}`}>
<Button>
<Icon icon={faTimes}/>
</Button>
</Link>
) : null
) : (
<Link key={item.value} to={`/search/${item.value}${urlSuffix}`}>
<Button variant={selected && "primary"}>
{content}
</Button>
</Link>
)}
</Switcher>
<div>
<HorizontalScroll>
<div>
<Switcher
items={[ null, "anime", "theme", "artist", "series", "studio" ]}
selectedItem={entity}
>
{({ Button, item, selected, content }) => item.value === null ? (
!!entity ? (
<Link key={null} to={`/search${urlSuffix}`}>
<Button>
<Icon icon={faTimes}/>
</Button>
</Link>
) : null
) : (
<Link key={item.value} to={`/search/${item.value}${urlSuffix}`}>
<Button variant={selected && "primary"}>
{content}
</Button>
</Link>
)}
</Switcher>
</div>
</HorizontalScroll>
</div>
</StyledSearchOptions>
<Search searchEntity={entity} searchQuery={debouncedSearchQuery} locationState={state}/>
</Box>