mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
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:
Generated
+23839
-122
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ]}
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user