12 Commits

Author SHA1 Message Date
Manuel S 4958f5d462 feat: Added separate minimal build setting (#144) 2022-08-27 20:06:36 +02:00
Manuel S 6394e50232 fix: Fixed incorrectly spinning icon (#143) 2022-08-27 15:27:14 +02:00
Manuel S 67c2f2180d fix: Improved bundle size (#142)
* Framer Motion is now lazy loaded.
* FontAwesome is now a custom component removing all the bloat we don't need.
* Imports and exports for types now use the proper syntax to get excluded by bundlers.
2022-08-27 03:28:36 +02:00
Manuel S 91f7be77d1 fix: Fixed audio mode for mobile background player (#141)
* Fixed "Play Random".
2022-08-24 23:17:23 +02:00
Manuel S 47daa23e9c feat: Added audio mode (#140)
* Added support for "as" field of artist memberships.
* Bracket pages no longer get prebuilt.
* Fixed wrong order of theme entries.
* Fixed studio cover background on studio page.
* Fixed color theme selectors.
* Fixed wrong alignment of "My Profile" on mobile navigation.
* Fixed very long song titles not wrapping correctly.
* Fixed play random feature.
* Fixed manual rebuilding.
2022-08-19 02:01:25 +02:00
Manuel S d6caa6487a feat: Migrated to TypeScript (#138) 2022-07-23 03:44:41 +02:00
Gaporigo 530c11c858 fix: Fixed grammar on anime page (#137) 2022-06-29 23:53:58 +02:00
Manuel S b195c9d667 feat: Added missing brackets (@theinternetftw) (#136)
* Added bracket chart.
* Added border radius to all sides of cards.
* Added skeletons to home page.
* Added support for system color theme.
* Added code-splitting for GraphQL.
* Added config constants.
* Added warning banner on staging.
* Added URL caching to API resolver (only for non-page request for now).
* Made featured theme load by default.
* Changed static path fetching to only fetch paths if not on staging (except for bracket pages).
* Changed default color theme to system.
* Moved color theme toggle from navigation to profile page.
* Replaced sketchy local storage implementation with external package.
* Fixed wrong fallback for featured theme on devices which don't support our codecs.
* Fixed wrong links in footer.
* Fixed code-splitting of Prism package.
* Fixed "/" character not working in search input.
* Fixed glitching multi cover image on playlist page.
* Updated README.md.
2022-06-08 00:20:46 +02:00
Manuel S d7329f73b7 feat: Upgraded to FontAwesome 6 Pro (#135)
* Replaced various icons with better fitting ones.
* Improved tag styling in general.
* Fixed pages throwing 500 errors instead of 404.
* Fixed manual revalidation.
2022-06-03 02:48:21 +02:00
Manuel S 38675ab9ad fix: Removed more hardcoded /api from URLs (#133) 2022-06-01 05:24:24 +02:00
Manuel S 385a1419ba fix: Removed hardcoded /video from video URL (#132) 2022-06-01 03:38:20 +02:00
Manuel S 76db2186dd fix: Removed hardcoded /api from API URL (#131)
* Fixed recent themes not being ten entries.
* Fixed "Performed As" filter on artist page.
2022-06-01 03:34:42 +02:00
269 changed files with 21512 additions and 7109 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://staging.animethemes.moe
+69 -43
View File
@@ -1,47 +1,73 @@
{
"extends": [
"next/core-web-vitals",
"plugin:react/recommended"
],
"rules": {
"semi": [
"error",
"always"
"extends": [
"next/core-web-vitals",
"plugin:react/recommended",
"eslint:recommended"
],
"quotes": [
"error",
"double",
{
"allowTemplateLiterals": true
}
"ignorePatterns": [
"src/generated"
],
"curly": [
"error",
"all"
],
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-duplicate-imports": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
"components/*/*"
]
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
}
"rules": {
"semi": [
"error",
"always"
],
"quotes": [
"error",
"double",
{
"allowTemplateLiterals": true
}
],
"curly": [
"error",
"all"
],
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
},
"overrides": [
{
"files": [
"**/*.{ts,tsx}"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"project": [
"./tsconfig.json"
]
},
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error"
}
}
]
}
+40 -13
View File
@@ -8,28 +8,55 @@
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory.
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or staging URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
```
To get to use all features of the client, you may configure some additional variables.
This is a list of all available options:
```ini
; Back-end API configuration
; These values are required.
; ===== Server-side =====
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; If specified, this API key will be used to make requests to the AnimeThemes API.
; This is used to by-pass rate limiting.
ANIMETHEMES_API_KEY=...
; (Optional) The URL to the AnimeThemes API to use on the front-end.
; For development this is "http://localhost" by default.
; For production this is "https://staging.animethemes.moe" by default.
NEXT_PUBLIC_API_URL=http://localhost
; The token to use for manual revalidation.
REVALIDATE_TOKEN=secret
; (Optional) The URL from which video files should be served.
; By default this is "https://animethemes.moe".
NEXT_PUBLIC_VIDEO_URL=https://animethemes.moe
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
; (Optional) The base path the app should be hosted on.
; By default the app is hosted on the root path (/).
; ===== Server-side + Client-side =====
; The base path the app should be hosted on.
NEXT_PUBLIC_BASE_PATH=/wiki
; The URL to the AnimeThemes API which will be used on the browser.
NEXT_PUBLIC_API_URL=https://api.animethemes.moe
; The URL from which video files should be served.
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
; The URL to use for app links.
NEXT_PUBLIC_APP_URL=https://app.animethemes.moe
; Set to any truthy value to activate staging mode.
; Staging mode only pre-renders a small subset of pages to reduce build time.
STAGING=true
```
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
@@ -56,4 +83,4 @@ For more information on environment variables see the [Next.js documentation](ht
### APIs
- [AnimeThemes API](https://staging.animethemes.moe/api/docs/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+23
View File
@@ -0,0 +1,23 @@
overwrite: true
schema:
- "src/lib/common/animethemes/type-defs.ts"
- "src/lib/server/animebracket/type-defs.ts"
- "src/lib/client/search.ts"
documents:
- "src/**/*.js"
- "src/**/*.ts"
- "src/**/*.tsx"
generates:
src/generated/graphql.ts:
plugins:
- "typescript"
- "typescript-operations"
config:
avoidOptionals:
object: true
field: true
inputValue: false
enumsAsTypes: true
skipTypename: true
require:
- ts-node/register
+14 -5
View File
@@ -1,18 +1,27 @@
const { info } = require("next/dist/build/output/log");
const { info, error } = require("next/dist/build/output/log");
const { ANALYZE, STAGING, BASE_PATH, validateConfig } = require("./src/utils/config");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
enabled: ANALYZE,
});
if (process.env.NEXT_PUBLIC_STAGING) {
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
module.exports = withBundleAnalyzer({
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600
staticPageGenerationTimeout: 3600,
experimental: {
newNextLinkBehavior: true
}
});
+9941 -1507
View File
File diff suppressed because it is too large Load Diff
+29 -13
View File
@@ -1,32 +1,32 @@
{
"name": "animethemes-web",
"version": "2.0.0",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"graphql-codegen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@reach/listbox": "^0.16.2",
"@reach/menu-button": "^0.16.2",
"@reach/listbox": "^0.17.0",
"@reach/menu-button": "^0.17.0",
"common-tags": "^1.8.0",
"framer-motion": "^6.2.1",
"graphql": "^15.6.1",
"framer-motion": "^6.5.1",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"lodash-es": "^4.17.21",
"marked": "^4.0.12",
"next": "^12.1.6",
"next": "^12.2.5",
"p-limit": "^3.1.0",
"prismjs": "^1.27.0",
"react": "^17.0.2",
@@ -36,10 +36,26 @@
"rehype-react": "^7.0.4",
"sass": "^1.43.4",
"styled-components": "^5.3.3",
"unified": "^10.1.2"
"unified": "^10.1.2",
"use-local-storage-state": "^17.2.0",
"use-session-storage-state": "^17.2.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.9.1",
"@graphql-codegen/typescript": "2.5.0",
"@graphql-codegen/typescript-operations": "2.4.1",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.3",
"@types/node": "^17.0.41",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"eslint": "7.32.0",
"eslint-config-next": "^12.0.8"
"eslint-config-next": "^12.0.8",
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
}
}
@@ -1,6 +1,14 @@
import styled from "styled-components";
import type { Property } from "csstype";
const Flex = styled.div`
const Flex = styled.div<{
$wrap?: boolean
style?: {
"--justify-content"?: Property.JustifyContent
"--align-items"?: Property.AlignItems
"--gap"?: Property.Gap
}
}>`
--justify-content: initial;
--align-items: initial;
--gap: initial;
+225
View File
@@ -0,0 +1,225 @@
import styled from "styled-components";
import { Text } from "components/text";
import { faDiagramProject } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import type { RefObject } from "react";
import { memo, useCallback, useRef, useState } from "react";
import theme from "theme";
import { m } from "framer-motion";
import { Button } from "components/button";
import { BracketThemeSummaryCard } from "components/bracket/BracketThemeSummaryCard";
import type { BracketPageQuery } from "generated/graphql";
import type { RequiredNonNullable } from "utils/types";
const StyledBracketContainer = styled.div`
flex: 1 1 0;
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
`;
const StyledBracket = styled(m.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
`;
const StyledCanvas = styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
`;
const StyledRound = styled.div`
display: flex;
flex-direction: column;
gap: 64px;
`;
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
`;
const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
width: 384px;
`;
interface BracketChartProps extends RequiredNonNullable<BracketPageQuery> {}
export function BracketChart({ bracket }: BracketChartProps) {
const [showBracketChart, setShowBracketChart] = useState(false);
const bracketRef = useRef<HTMLDivElement>();
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
bracketRef.current = bracket;
if (bracket) {
bracket.requestFullscreen();
bracket.addEventListener("fullscreenchange", onFullscreenChange);
}
function onFullscreenChange() {
if (!document.fullscreenElement) {
setShowBracketChart(false);
bracket.removeEventListener("fullscreenchange", onFullscreenChange);
}
}
}, []);
const onCanvasInit = useCallback((canvas: HTMLCanvasElement) => {
if (!canvas?.parentNode) {
return;
}
const parentRect = (canvas.parentNode as HTMLDivElement).getBoundingClientRect();
canvas.width = parentRect.width;
canvas.height = parentRect.height;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.strokeStyle = "#75ead4";
ctx.lineWidth = 2;
ctx.translate(-parentRect.left, -parentRect.top);
const cards = document.querySelectorAll<HTMLDivElement>("[data-theme]");
const cardsById = [...cards].reduce<Record<string, Array<HTMLDivElement>>>((map, card) => {
const id = card.dataset.theme;
if (id) {
if (!map[id]) {
map[id] = [];
}
map[id].push(card);
}
return map;
}, {});
for (const cards of Object.values(cardsById)) {
for (let i = 0; i < cards.length - 1; i++) {
const current = cards[i];
const currentRect = current.getBoundingClientRect();
const next = cards[i + 1];
const nextRect = next.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(currentRect.left + currentRect.width, currentRect.top + currentRect.height / 2);
ctx.bezierCurveTo(
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
currentRect.top + currentRect.height / 2,
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2
);
ctx.stroke();
}
}
}, []);
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject}/>
<span>Open Bracket Chart</span>
</Button>
{showBracketChart ? (
<StyledBracketContainer ref={onBracketInit}>
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement>}>
<StyledCanvas ref={onCanvasInit}/>
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => (
<BracketRound key={round.tier} round={round}/>
))}
</StyledBracket>
</StyledBracketContainer>
) : null}
</>
);
}
interface BracketRoundProps {
round: BracketChartProps["bracket"]["rounds"][number]
}
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
if (!round.pairings || !round.pairings.length) {
return null;
}
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings}/>
</StyledRound>
);
});
interface BracketPairingsProps {
pairings: BracketRoundProps["round"]["pairings"]
}
function BracketPairings({ pairings }: BracketPairingsProps) {
const sortedPairings = pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
return <>{sortedPairings}</>;
}
interface ContestantCardProps {
contestant: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
opponent: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
contestantVotes: number | null
opponentVotes: number | null
}
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
const isVoted = !!contestantVotes && !!opponentVotes;
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
contestant={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
votes={contestantVotes}
data-theme={contestant.id}
/>
);
}
@@ -0,0 +1,106 @@
import styled from "styled-components";
import { SummaryCard, ThemeSummaryCard } from "components/card";
import { Text } from "components/text";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SongTitleWithArtists } from "components/utils";
import { Column } from "components/box";
import { Icon } from "components/icon";
import { faAward, faSeedling, faUsers } from "@fortawesome/pro-solid-svg-icons";
import { CornerIcon } from "components/icon/CornerIcon";
import gql from "graphql-tag";
import type { BracketThemeSummaryCardConstestantFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { TextLink } from "components/text/TextLink";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
const StyledRank = styled(Text)`
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
`;
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
contestant: BracketThemeSummaryCardConstestantFragment
isVoted: boolean
isWinner: boolean
seed: number | null
votes: number | null
}
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
const theme = contestant.theme;
const { smallCover } = extractImages(theme?.anime);
let to;
let description: ReactNode = contestant.source;
if (theme?.anime) {
const entry = theme.entries?.[0];
const video = entry?.videos?.[0];
const videoSlug = createVideoSlug(theme, entry, video);
to = `/anime/${theme.anime.slug}/${videoSlug}`;
description = (
<SummaryCard.Description>
<span>{theme.slug}</span>
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
</SummaryCard.Description>
);
}
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
description={description}
image={smallCover ?? contestant.image}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
</StyledSummaryCardWrapper>
);
}
BracketThemeSummaryCard.fragments = {
contestant: gql`
${ThemeSummaryCard.fragments.theme}
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
name
source
image
theme {
...ThemeSummaryCardTheme
}
}
`,
};
@@ -2,18 +2,25 @@ import styled, { css } from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from "react";
import { forwardRef } from "react";
export const Button = forwardRef(ButtonWithRef);
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
children?: ReactNode
variant?: "solid" | "primary" | "silent"
isCircle?: boolean
disabled?: boolean
}
function ButtonWithRef({
children,
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}, ref) {
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
let Component;
if (variant === "solid") {
Component = SolidButton;
@@ -33,11 +40,11 @@ function ButtonWithRef({
aria-label={title}
ref={ref}
{...props}
>{children}</Component>
/>
);
}
const BaseButton = styled.button`
const BaseButton = styled.button<{ $isCircle: boolean }>`
--gap: 0;
display: inline-flex;
@@ -1,8 +0,0 @@
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "components/button";
export function FilterToggleButton(props) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
);
}
@@ -0,0 +1,9 @@
import { faFilter } from "@fortawesome/pro-solid-svg-icons";
import { IconTextButton } from "components/button";
import type { ComponentPropsWithoutRef } from "react";
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
);
}
@@ -3,12 +3,14 @@ import { Button } from "components/button";
import styled, { css } from "styled-components";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
const StyledButton = styled(Button)`
gap: 8px;
`;
const StyledText = styled.span`
const StyledText = styled.span<{ collapsible: boolean }>`
${(props) => props.collapsible && css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
@@ -16,7 +18,13 @@ const StyledText = styled.span`
`}
`;
export function IconTextButton({ icon, children, collapsible, ...props }) {
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
icon: IconDefinition
children: ReactNode
collapsible?: boolean
}
export function IconTextButton({ icon, children, collapsible = false, ...props }: IconTextButtonProps) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const isCollapsed = collapsible && isMobile;
-25
View File
@@ -1,25 +0,0 @@
import { useContext } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import { VideoTags } from "components/utils";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
export function VideoButton({ anime, theme, entry, video, ...props }) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo && currentVideo.filename === video.filename;
return (
<Link href={`/anime/${anime.slug}/${videoSlug}`} passHref prefetch={false}>
<Button as="a" {...props}>
<Button as="span" variant="primary" isCircle>
<Icon icon={isPlaying ? faCompactDisc : faPlay} spin={isPlaying}/>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ComponentPropsWithoutRef } from "react";
import { useContext } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/pro-solid-svg-icons";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
import { VideoTags } from "components/tag";
import gql from "graphql-tag";
import type {
VideoButtonAnimeFragment,
VideoButtonEntryFragment,
VideoButtonThemeFragment,
VideoButtonVideoFragment
} from "generated/graphql";
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
anime: VideoButtonAnimeFragment
theme: VideoButtonThemeFragment
entry: VideoButtonEntryFragment
video: VideoButtonVideoFragment
}
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo ? currentVideo.filename === video.filename : false;
return (
<Link
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior>
<Button as="a" {...props}>
<Button as="span" variant="primary" isCircle>
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined}/>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
VideoButton.fragments = {
anime: gql`
fragment VideoButtonAnime on Anime {
slug
}
`,
theme: gql`
fragment VideoButtonTheme on Theme {
slug
}
`,
entry: gql`
fragment VideoButtonEntry on Entry {
version
}
`,
video: gql`
${VideoTags.fragments.video}
fragment VideoButtonVideo on Video {
...VideoTagsVideo
filename
tags
}
`,
};
@@ -1,19 +1,20 @@
import { Fragment } from "react";
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import { Icon } from "components/icon";
import { SummaryCard } from "components/card";
import styled from "styled-components";
import useToggle from "hooks/useToggle";
import theme from "theme";
import gql from "graphql-tag";
import { uniq } from "lodash-es";
import { Collapse } from "components/utils";
import { ThemeTable } from "components/table";
import useMediaQuery from "hooks/useMediaQuery";
import gql from "graphql-tag";
import type { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "generated/graphql";
import { TextLink } from "components/text/TextLink";
const StyledWrapper = styled.div`
position: relative
@@ -57,9 +58,17 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
export function AnimeSummaryCard({ anime, previewThemes = false, expandable = false, ...props }) {
type AnimeSummaryCardProps = {
anime: AnimeSummaryCardAnimeFragment
expandable?: false
} | {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
expandable: true
};
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(anime);
const { smallCover } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniq(anime.themes.map((theme) => theme.group));
@@ -73,20 +82,20 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
premiereLink += `/${anime.season.toLowerCase()}`;
}
let description = (
const description = (
<SummaryCard.Description>
<span>Anime</span>
{!!anime.year && (
<Link href={premiereLink} passHref prefetch={false}>
<Text as="a" link>{premiere}</Text>
</Link>
<TextLink href={premiereLink}>
{premiere}
</TextLink>
)}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
@@ -109,19 +118,22 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
{/* TODO: Context Menu */}
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledThemeContainerInline>
)}
</SummaryCard>
{expandable && (
{expandable ? (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
@@ -129,22 +141,22 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
{!!group && (
<Text variant="h2">{group}</Text>
)}
<ThemeTable themes={anime.themes.filter((theme) => theme.group === group)}/>
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group === group)}/>
</Fragment>
))}
</StyledThemeGroupContainer>
</Collapse>
)}
) : null}
</StyledWrapper>
);
}
AnimeSummaryCard.fragments = {
anime: gql`
${useImage.fragment}
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCard_anime on Anime {
...useImage_resourceWithImages
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
name
year
@@ -157,9 +169,9 @@ AnimeSummaryCard.fragments = {
expandable: gql`
${ThemeTable.fragments.theme}
fragment AnimeSummaryCard_anime_expandable on Anime {
fragment AnimeSummaryCardAnimeExpandable on Anime {
themes {
...ThemeTable_theme
...ThemeTableTheme
group
}
}
-27
View File
@@ -1,27 +0,0 @@
import useImage from "hooks/useImage";
import { SummaryCard } from "components/card";
export function ArtistSummaryCard({ artist, as }) {
const { smallCover } = useImage(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!artist.performances && (
<span>{artist.performances.length} performance{artist.performances.length === 1 ? "" : "s"}</span>
)}
{!!as && (
<span>As {as}</span>
)}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import extractImages from "utils/extractImages";
import { SummaryCard } from "components/card";
import gql from "graphql-tag";
import type { ArtistSummaryCardArtistFragment } from "generated/graphql";
type ArtistSummaryCardProps = {
artist: ArtistSummaryCardArtistFragment
as?: string | null
};
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
const { smallCover } = extractImages(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!as && (
<span>As {as}</span>
)}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
);
}
ArtistSummaryCard.fragments = {
artist: gql`
fragment ArtistSummaryCardArtist on Artist {
slug
name
images {
link
facet
}
}
`
};
-24
View File
@@ -1,24 +0,0 @@
import styled, { css } from "styled-components";
import { Solid } from "components/box";
import theme from "theme";
import { withHover } from "styles/mixins";
export const Card = styled(Solid)`
display: block;
padding: 16px 24px;
border-color: ${(props) => theme.colors[props.color] || props.color || theme.colors["text-primary"]};
border-left-width: 4px;
border-left-style: solid;
border-radius: 0 8px 8px 0;
box-shadow: ${theme.shadows.medium};
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
`;
+42
View File
@@ -0,0 +1,42 @@
import styled, { css } from "styled-components";
import { Solid } from "components/box";
import theme from "theme";
import { withHover } from "styles/mixins";
import type { Colors } from "theme/colors";
export const Card = styled(Solid)<{
hoverable?: boolean
color?: keyof Colors
}>`
display: block;
position: relative;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
box-shadow: ${theme.shadows.medium};
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
&:before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
}
`;
@@ -1,17 +1,15 @@
import { Card } from "components/card";
import { Row } from "components/box";
import { Text } from "components/text";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
import { faExclamation } from "@fortawesome/pro-solid-svg-icons";
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.colors["text-warning"]};
`;
const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
@@ -20,12 +18,16 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
overflow: auto;
`;
export function ErrorCard({ error }) {
interface ErrorCardProps {
error: any
}
export function ErrorCard({ error }: ErrorCardProps) {
return (
<StyledCard>
<StyledCard color="text-warning">
<Row style={{ "--gap": "1rem" }}>
<Text color="text-warning">
<FontAwesomeIcon icon={faExclamation}/>
<Icon icon={faExclamation}/>
</Text>
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
</Row>
@@ -36,4 +38,4 @@ export function ErrorCard({ error }) {
</pre>
</StyledCard>
);
}
}
-96
View File
@@ -1,96 +0,0 @@
import Link from "next/link";
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 0;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})`
width: 48px;
height: 64px;
object-fit: cover;
${loadingAnimation}
${(props) => props.isPlaceholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
export function SummaryCard({ title, description, image, to, children, ...props }) {
const [ imageNotFound, setImageNotFound ] = useState(false);
return (
<StyledSummaryCard {...props}>
<Link href={to} prefetch={false}>
<a>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isPlaceholder={!image || imageNotFound}
loading="lazy"
onError={() => setImageNotFound(true)}
/>
</a>
</Link>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
<Link href={to} passHref prefetch={false}>
<Text as="a" link>{title}</Text>
</Link>
) : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
SummaryCard.Description = function SummaryCardDescription({ children }) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
};
+139
View File
@@ -0,0 +1,139 @@
import Link from "next/link";
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
import { TextLink } from "components/text/TextLink";
import { ConditionalWrapper } from "components/utils/ConditionalWrapper";
import type { Property } from "csstype";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
title: string | ReactNode
description?: string | ReactNode
image?: string
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>
to?: string
children?: ReactNode
};
export function SummaryCard({
title,
description,
image,
imageProps,
to,
children,
...props
}: SummaryCardProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
return (
<StyledSummaryCard {...props}>
<ConditionalWrapper
condition={!!to}
wrap={(children) => <Link href={to as string}>{children}</Link>}
>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isLoading={imageLoading}
isPlaceholder={!image || imageNotFound}
loading="lazy"
{...imageProps}
onLoad={(event) => {
setImageLoading(false);
if (imageProps?.onLoad) {
imageProps.onLoad(event);
}
}}
onError={() => {
setImageNotFound(true);
setImageLoading(false);
if (imageProps?.onError) {
imageProps.onError();
}
}}
/>
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
to ? <TextLink href={to}>{title}</TextLink> : title
) : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
type SummaryCardDescriptionProps = {
children: Array<ReactNode>
};
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
};
-49
View File
@@ -1,49 +0,0 @@
import {
StyledRow,
StyledSequence,
StyledThemeCard,
StyledVideoList,
StyledVideoListContainer
} from "./ThemeDetailCard.style";
import { VideoButton } from "components/button";
import { Performances, SongTitle, ThemeEntryTags } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu";
export function ThemeDetailCard({ theme }) {
return (
<StyledThemeCard>
<StyledRow>
<StyledSequence>{theme.type}{theme.sequence || null}</StyledSequence>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
</Text>
<ThemeMenu theme={theme}/>
</StyledRow>
{theme.entries.map(entry => (
<StyledRow key={entry.version || 0}>
<StyledSequence secondary>{!!entry.version && `v${entry.version}`}</StyledSequence>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={theme.anime}
theme={theme}
entry={entry}
video={video}
/>
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
@@ -1,40 +0,0 @@
import styled from "styled-components";
import { Row } from "components/box";
import { Text } from "components/text";
import { Card } from "components/card";
import theme from "theme";
export const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
export const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
export const StyledSequence = styled(Text).attrs((props) => ({
variant: "small",
color: props.secondary ? "text-muted" : "text"
}))``;
export const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
export const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
+131
View File
@@ -0,0 +1,131 @@
import { VideoButton } from "components/button";
import { Performances, SongTitle } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu";
import { ThemeEntryTags } from "components/tag";
import { VideoTags } from "components/tag/VideoTags";
import gql from "graphql-tag";
import type { ThemeDetailCardThemeFragment } from "generated/graphql";
import styled from "styled-components";
import { Card } from "components/card/Card";
import theme from "theme";
import { Row } from "components/box";
import { entryVersionComparator } from "utils/comparators";
const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
interface ThemeDetailCardProps {
theme: ThemeDetailCardThemeFragment
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
const { anime } = theme;
if (!anime) {
return null;
}
return (
<StyledThemeCard>
<StyledRow>
<Text variant="small" color="text">{theme.type}{theme.sequence || null}</Text>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
</Text>
<ThemeMenu theme={theme}/>
</StyledRow>
{theme.entries.sort(entryVersionComparator).map(entry => (
<StyledRow key={entry.version || 0}>
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={anime}
theme={theme}
entry={entry}
video={video}
/>
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
ThemeDetailCard.fragments = {
theme: gql`
${ThemeMenu.fragments.theme}
${VideoTags.fragments.video}
fragment ThemeDetailCardTheme on Theme {
...ThemeMenuTheme
slug
type
sequence
group
anime {
slug
name
}
song {
title
performances {
as
artist {
slug
name
}
}
}
entries {
version
episodes
spoiler
nsfw
videos {
...VideoTagsVideo
filename
tags
}
}
}
`
};
@@ -1,20 +1,29 @@
import Link from "next/link";
import { Collapse, SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
import { ThemeMenu } from "components/menu";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import { Icon } from "components/icon";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import useToggle from "hooks/useToggle";
import styled from "styled-components";
import { Table, ThemeTable } from "components/table";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
import type {
ThemeSummaryCardArtistFragment,
ThemeSummaryCardQuery,
ThemeSummaryCardThemeExpandableFragment,
ThemeSummaryCardThemeFragment
} from "generated/graphql";
import type { PropsWithChildren } from "react";
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "components/table/Table";
import { TextLink } from "components/text/TextLink";
const StyledWrapper = styled.div`
position: relative
@@ -53,38 +62,43 @@ const StyledPerformedWith = styled.div`
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
type ThemeSummaryCardProps = {
theme: ThemeSummaryCardThemeFragment
artist?: ThemeSummaryCardArtistFragment
expandable?: false
} | {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
artist?: ThemeSummaryCardArtistFragment
expandable: true
};
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable = false, ...props }) {
export function ThemeSummaryCard({ theme, artist, children, expandable, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(theme.anime);
const isMobile = useIsMobile();
if (!theme.entries.length) {
return null;
}
const anime = theme.anime;
const entry = theme.entries[0];
const video = entry?.videos[0];
if (!entry.videos.length) {
if (!anime || !entry || !video) {
return null;
}
const video = entry.videos[0];
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
const to = `/anime/${anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref prefetch={false}>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
@@ -106,13 +120,16 @@ export function ThemeSummaryCard({ theme, artist, children, expandable = false,
<ThemeMenu theme={theme}/>
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledOverlayButtons>
@@ -121,27 +138,31 @@ export function ThemeSummaryCard({ theme, artist, children, expandable = false,
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]}/>
{theme.song?.performances?.length > (artist ? 1 : 0) && (
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<Table.Head>
<Table.HeadCell>Performed {artist ? "With" : "By"}</Table.HeadCell>
</Table.Head>
<Table.Body>
<TableHead>
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
</TableHead>
<TableBody>
{theme.song?.performances
?.filter((performance) => performance.artist.slug !== artist?.slug)
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
.map((performance) => (
<Link key={performance.artist.slug} href={`/artist/${performance.artist.slug}`} passHref prefetch={false}>
<Table.Row as="a">
<Table.Cell>
<Link
key={performance.artist.slug}
href={`/artist/${performance.artist.slug}`}
passHref
legacyBehavior>
<TableRow as="a">
<TableCell>
<Text color="text-primary" weight="600">
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
</Text>
</Table.Cell>
</Table.Row>
</TableCell>
</TableRow>
</Link>
))}
</Table.Body>
</TableBody>
</Table>
)}
</StyledPerformedWith>
@@ -154,31 +175,31 @@ export function ThemeSummaryCard({ theme, artist, children, expandable = false,
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${useImage.fragment}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragment}
${ThemeMenu.fragments.theme}
fragment ThemeSummaryCard_theme on Theme {
...createVideoSlug_theme
...ThemeMenu_theme
fragment ThemeSummaryCardTheme on Theme {
...createVideoSlugTheme
...ThemeMenuTheme
slug
type
sequence
group
anime {
...useImage_resourceWithImages
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtists_song
...SongTitleWithArtistsSong
}
entries {
...createVideoSlug_entry
...createVideoSlugEntry
videos {
...createVideoSlug_video
...createVideoSlugVideo
}
}
}
@@ -186,31 +207,33 @@ ThemeSummaryCard.fragments = {
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCard_artist on Artist {
...SongTitleWithArtists_artist
fragment ThemeSummaryCardArtist on Artist {
...SongTitleWithArtistsArtist
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment ThemeSummaryCard_theme_expandable on Theme {
...ThemeTable_theme
fragment ThemeSummaryCardThemeExpandable on Theme {
...ThemeTableTheme
}
`
};
ThemeSummaryCard.fetchData = async function (id) {
return fetchDataClient(gql`
${ThemeSummaryCard.fragments.theme}
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
query($themeId: Int!) {
export const fetchThemeSummaryCardData = async function (id: number): Promise<FetchThemeSummaryCardData> {
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(gql`
${ThemeSummaryCard.fragments.theme}
query ThemeSummaryCard($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCard_theme
...ThemeSummaryCardTheme
anime {
year
season
}
}
}
`, { themeId: id }).then((result) => result.data?.theme);
`, { themeId: id }).then((result) => result.data?.theme ?? null);
};
@@ -1,5 +1,6 @@
import styled from "styled-components";
import { Text } from "components/text";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
const StyledDescriptionList = styled.dl`
display: flex;
@@ -19,7 +20,11 @@ const StyledValue = styled.dd`
}
`;
export function DescriptionList({ children, ...props }) {
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
children: ReactNode
}
export function DescriptionList({ children, ...props }: DescriptionListProps) {
return (
<StyledDescriptionList {...props}>
{children}
@@ -27,7 +32,12 @@ export function DescriptionList({ children, ...props }) {
);
}
DescriptionList.Item = function DescriptionListItem({ title, children }) {
interface DescriptionListItemProps {
title: string
children: ReactNode
}
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
return (
<>
<StyledKey>
@@ -1,8 +1,14 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { faChevronCircleRight } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export function ExternalLink({ href, children, ...props }) {
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
href?: string | null
children: ReactNode
}
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
return (
<Text as="a" link href={href} target="_blank" rel="noopener" {...props}>
<Text>{children}</Text>
@@ -2,16 +2,16 @@ import { FeaturedThemePreview } from "utils/settings";
import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import { useEffect, useState } from "react";
import useSetting from "hooks/useSetting";
import useCompatability from "hooks/useCompatability";
import gql from "graphql-tag";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
import { VIDEO_URL } from "utils/config";
import gql from "graphql-tag";
import type { FeaturedThemeThemeFragment } from "generated/graphql";
const slowPan = keyframes`
from {
@@ -75,6 +75,7 @@ const StyledCenter = styled.div`
const StyledVideo = styled.video`
width: 100%;
filter: blur(5px);
background-color: ${theme.colors["solid-on-card"]};
`;
const StyledCover = styled.img`
@@ -115,8 +116,12 @@ const StyledGrill = styled.img`
const Box = styled.div``;
export function FeaturedTheme({ theme }) {
const [ grill, setGrill ] = useState(null);
interface FeaturedThemeProps {
theme: FeaturedThemeThemeFragment
}
export function FeaturedTheme({ theme }: FeaturedThemeProps) {
const [ grill, setGrill ] = useState<string | null>(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
useEffect(() => {
@@ -150,13 +155,13 @@ export function FeaturedTheme({ theme }) {
);
}
function FeaturedThemeBackground({ theme }) {
function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability({ canPlayVideo: false });
const { canPlayVideo } = useCompatability();
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const { smallCover: featuredCover } = useImage(theme.anime);
const { smallCover: featuredCover } = extractImages(theme.anime);
if (!theme.entries.length) {
if (!theme.anime || !theme.entries.length) {
return null;
}
@@ -172,26 +177,29 @@ function FeaturedThemeBackground({ theme }) {
const linkProps = {
href: `/anime/${theme.anime.slug}/${videoSlug}`,
passHref: true,
prefetch: false
};
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<Link {...linkProps}>
<Link {...linkProps} legacyBehavior>
<StyledOverflowHidden>
<StyledVideo
src={`${videoBaseUrl}/video/${video.basename}`}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
/>
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
</StyledVideo>
</StyledOverflowHidden>
</Link>
);
} else if (featuredThemePreview === FeaturedThemePreview.COVER || fallbackToCover) {
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<Link {...linkProps}>
<Link {...linkProps} legacyBehavior>
<StyledOverflowHidden>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
@@ -202,19 +210,21 @@ function FeaturedThemeBackground({ theme }) {
return null;
}
FeaturedTheme.fragment = gql`
${ThemeSummaryCard.fragments.theme}
${useImage.fragment}
fragment FeaturedTheme_theme on Theme {
...ThemeSummaryCard_theme
anime {
...useImage_resourceWithImages
}
entries {
videos {
basename
FeaturedTheme.fragments = {
theme: gql`
${ThemeSummaryCard.fragments.theme}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeTheme on Theme {
...ThemeSummaryCardTheme
anime {
...extractImagesResourceWithImages
}
entries {
videos {
basename
}
}
}
}
`;
`
};
@@ -3,32 +3,47 @@ import { Column, Row } from "components/box";
import { ThemeDetailCard } from "components/card";
import { Listbox } from "components/listbox";
import { HorizontalScroll } from "components/utils";
import { chain, themeIndexComparator, themeTypeComparator } from "utils/comparators";
import { either, themeIndexComparator, themeTypeComparator } from "utils/comparators";
import gql from "graphql-tag";
import type { AnimeThemeFilterThemeFragment } from "generated/graphql";
export const AnimeThemeFilter = memo(function AnimeThemeFilter({ themes }) {
const groups = useMemo(() => themes.reduce((groups, theme) => {
const groupName = theme.group || "Original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
}, []), [ themes ]);
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>
}
const [ activeGroup, setActiveGroup ] = useState(groups[0].name);
const activeThemes = groups.find((group) => group.name === activeGroup).themes;
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
const groups = useMemo(
() => themes.reduce<{
name: string,
themes: typeof themes
}[]>(
(groups, theme) => {
const groupName = theme.group || "Original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
},
[]
),
[ themes ]
);
const [ activeGroup, setActiveGroup ] = useState(groups[0]?.name);
const activeThemes = groups.find((group) => group.name === activeGroup)?.themes ?? [];
const hasMultipleTypes = activeThemes.find((theme) => theme.type === "OP") && activeThemes.find((theme) => theme.type === "ED");
const [ filterType, setFilterType ] = useState(null);
const filteredThemes = activeThemes
.filter((theme) => !filterType || theme.type === filterType)
.sort(chain(themeTypeComparator, themeIndexComparator));
.sort(either(themeTypeComparator).or(themeIndexComparator).chain());
return (
<Column style={{ "--gap": "16px" }}>
@@ -57,4 +72,18 @@ export const AnimeThemeFilter = memo(function AnimeThemeFilter({ themes }) {
))}
</Column>
);
});
}
AnimeThemeFilterInternal.fragments = {
theme: gql`
${ThemeDetailCard.fragments.theme}
fragment AnimeThemeFilterTheme on Theme {
...ThemeDetailCardTheme
type
group
}
`
};
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
@@ -7,7 +7,8 @@ import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-bran
import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import { forwardRef } from "react";
import type { ComponentPropsWithoutRef } from "react";
import { APP_URL } from "utils/config";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -45,25 +46,21 @@ export function Footer() {
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/transparency">
<FooterTextLink href={`${APP_URL}/transparency`}>
Transparency
</FooterTextLink>
<Link href="/wiki/donate" passHref>
<FooterTextLink target="_self">
Donate
</FooterTextLink>
</Link>
<Link href="/wiki/faq" passHref>
<FooterTextLink target="_self">
FAQ
</FooterTextLink>
</Link>
<FooterTextLink forwardedAs={Link} href="/wiki/donate" target="_self">
Donate
</FooterTextLink>
<FooterTextLink forwardedAs={Link} href="/wiki/faq" target="_self">
FAQ
</FooterTextLink>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/terms-of-service">
<FooterTextLink href={`${APP_URL}/terms-of-service`}>
Terms of Service
</FooterTextLink>
<FooterTextLink href="https://staging.animethemes.moe/privacy-policy">
<FooterTextLink href={`${APP_URL}/privacy-policy`}>
Privacy Policy
</FooterTextLink>
<FooterTextLink href="mailto:admin@animethemes.moe">
@@ -97,18 +94,14 @@ export function Footer() {
);
}
const FooterLink = forwardRef(function FooterLink({ children, ...props }, ref) {
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
return (
<Text ref={ref} as="a" target="_blank" rel="noopener" {...props}>
{children}
</Text>
<Text as="a" target="_blank" rel="noopener" {...props}/>
);
});
}
const FooterTextLink = forwardRef(function FooterTextLink({ children, ...props }, ref) {
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
return (
<FooterLink ref={ref} link block color="text-muted" noWrap {...props}>
{children}
</FooterLink>
<FooterLink link block color="text-muted" noWrap {...props}/>
);
});
}
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
export const CornerIcon = styled(Icon).attrs({
size: "2x"
})`
position: absolute;
right: 0;
top: 0;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
+43
View File
@@ -0,0 +1,43 @@
import type { SVGProps } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
icon: IconDefinition
title?: string
}
const xmlns = "http://www.w3.org/2000/svg";
export function FontAwesomeIcon(props: FontAwesomeIconProps) {
const { icon: iconProps, children, className = "", title, ...rest } = props;
const { prefix, iconName, icon } = iconProps;
const [width, height, , , svgPathData] = icon;
const dataFa = `${prefix}-${iconName}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
xmlns={xmlns}
role={"img"}
aria-hidden="true"
data-fa={dataFa}
className={`${className} svg-inline--fa fa-fw`}
{...rest}
>
{title ? (
<title>{title}</title>
) : null}
{children}
{Array.isArray(svgPathData) ? (
<g>
<path d={svgPathData[0]}/>
<path d={svgPathData[1]}/>
</g>
) : (
<path fill="currentColor" d={svgPathData}/>
)}
</svg>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styled, { css } from "styled-components";
import theme from "theme";
export const Icon = styled(FontAwesomeIcon).attrs({
fixedWidth: true
})`
color: ${(props) => theme.colors[props.color] || props.color};
${(props) => props.transition && css`
transition: ${(props) => props.transition};
`}
`;
+11
View File
@@ -0,0 +1,11 @@
import { FontAwesomeIcon } from "components/icon/FontAwesomeIcon";
import styled from "styled-components";
import theme from "theme";
import type { Property } from "csstype";
import type { Colors } from "theme/colors";
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors, transition?: Property.Transition }>`
color: ${(props) => props.color && theme.colors[props.color]};
transition: ${(props) => props.transition}
`;
-13
View File
@@ -1,13 +0,0 @@
import useImage from "hooks/useImage";
import { AspectRatio } from "components/utils";
import { FullWidthImage } from "components/image";
export function CoverImage({ resourceWithImages, ...props }) {
const { smallCover, largeCover } = useImage(resourceWithImages);
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
</AspectRatio>
);
}
+30
View File
@@ -0,0 +1,30 @@
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import { FullWidthImage } from "components/image";
import gql from "graphql-tag";
import type { CoverImageResourceWithImagesFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef } from "react";
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
resourceWithImages: CoverImageResourceWithImagesFragment
}
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
const { smallCover, largeCover } = extractImages(resourceWithImages);
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
</AspectRatio>
);
}
CoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment CoverImageResourceWithImages on ResourceWithImages {
...extractImagesResourceWithImages
}
`,
};
@@ -1,4 +1,6 @@
export function Logo(props) {
import type { SVGAttributes } from "react";
export function Logo(props: SVGAttributes<SVGElement>) {
return (
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
+27
View File
@@ -0,0 +1,27 @@
import styled from "styled-components";
import theme from "theme";
import { Logo } from "components/image/Logo";
import type { ComponentPropsWithoutRef } from "react";
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
return (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
);
}
@@ -1,10 +1,12 @@
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import styled, { css } from "styled-components";
import theme from "theme";
import { Logo } from "components/image";
import type { ComponentPropsWithoutRef } from "react";
import type { MultiCoverImageResourceWithImagesFragment } from "generated/graphql";
import gql from "graphql-tag";
import { LogoPlaceholder } from "components/image/LogoPlaceholder";
function getTranslationX(item, itemCount) {
function getTranslationX(item: number, itemCount: number) {
switch (itemCount) {
case 4:
switch (item) {
@@ -31,7 +33,7 @@ const StyledCoverContainer = styled.div`
border-radius: 0.5rem;
overflow: hidden;
`;
const StyledCoverItemContainer = styled.div`
const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
position: absolute;
top: 0;
left: 0;
@@ -106,27 +108,17 @@ const StyledCover = styled.img`
transform: scale(1.1);
}
`;
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function MultiCoverImage({ resourcesWithImages, ...props }) {
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
}
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
const images = [
[ useImage(resourcesWithImages[0]), resourcesWithImages[0] ],
[ useImage(resourcesWithImages[1]), resourcesWithImages[1] ],
[ useImage(resourcesWithImages[2]), resourcesWithImages[2] ],
[ useImage(resourcesWithImages[3]), resourcesWithImages[3] ]
[ extractImages(resourcesWithImages[0]), resourcesWithImages[0] ] as const,
[ extractImages(resourcesWithImages[1]), resourcesWithImages[1] ] as const,
[ extractImages(resourcesWithImages[2]), resourcesWithImages[2] ] as const,
[ extractImages(resourcesWithImages[3]), resourcesWithImages[3] ] as const
]
.map(([ images, resource ]) => ({
largeCover: images.largeCover,
@@ -150,11 +142,28 @@ export function MultiCoverImage({ resourcesWithImages, ...props }) {
/>
</StyledCoverItemContainer>
)) : (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
<LogoPlaceholder {...props}/>
)}
</StyledCoverContainer>
</AspectRatio>
);
}
MultiCoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment MultiCoverImageResourceWithImages on ResourceWithImages {
... on Anime {
name
}
... on Artist {
name
}
... on Studio {
name
}
...extractImagesResourceWithImages
}
`,
};
+57
View File
@@ -0,0 +1,57 @@
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import { FullWidthImage, MultiCoverImage } from "components/image";
import gql from "graphql-tag";
import type { ComponentPropsWithoutRef } from "react";
import { useState } from "react";
import type { StudioCoverImageStudioFragment } from "generated/graphql";
import styled from "styled-components";
const StyledImage = styled(FullWidthImage)`
object-fit: contain;
background-size: contain;
`;
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
studio: StudioCoverImageStudioFragment
}
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
const { largeCover } = extractImages(studio);
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
return (
<AspectRatio ratio={2 / 3}>
{!imageNotFound ? (
<StyledImage
key={largeCover}
src={largeCover}
style={{ background: `url(${largeCover})`, backgroundSize: "10000%" }}
onError={() => setImageNotFound(true)}
{...props}
/>
) : (
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
)}
</AspectRatio>
);
}
StudioCoverImage.fragments = {
studio: gql`
fragment StudioCoverImageStudio on Studio {
images {
link
facet
}
anime {
name
images {
link
facet
}
}
}
`
};
-55
View File
@@ -1,55 +0,0 @@
import React from "react";
import Link from "next/link";
import { groupBy } from "lodash-es";
import { Text } from "components/text";
import styled from "styled-components";
const StyledLetterList = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
`;
const StyledItemGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
justify-content: flex-start;
grid-gap: 16px;
`;
export function AlphabeticalIndex({ items, children }) {
const itemsByFirstLetter = Object.entries(
groupBy(
items.sort((a, b) => a.name.localeCompare(b.name)),
(item) => {
const firstLetter = item.name[0].toLowerCase();
if (firstLetter.match(/[a-z]/)) {
return firstLetter;
}
return "0-9";
}
)
)
.sort(([ a ], [ b ]) => a.localeCompare(b));
return (
<>
<StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter ]) => (
<Link key={firstLetter} href={`#${firstLetter}`} passHref prefetch={false}>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
))}
</StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter, itemsWithFirstLetter ]) => (
<React.Fragment key={firstLetter}>
<Text id={firstLetter} variant="h2">{firstLetter}</Text>
<StyledItemGrid>
{itemsWithFirstLetter.map((item) => children(item))}
</StyledItemGrid>
</React.Fragment>
))}
</>
);
}
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import React from "react";
import Link from "next/link";
import { groupBy } from "lodash-es";
import { Text } from "components/text";
import styled from "styled-components";
const StyledLetterList = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
`;
const StyledItemGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
justify-content: flex-start;
justify-items: flex-start;
grid-gap: 16px;
`;
type AlphabeticalIndexItem = { name: string };
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
items: Array<T>
children: (item: T) => ReactNode
};
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
const itemsByFirstLetter = Object.entries(
groupBy(
items.sort((a, b) => a.name.localeCompare(b.name)),
(item) => {
const firstLetter = item.name[0].toLowerCase();
if (firstLetter.match(/[a-z]/)) {
return firstLetter;
}
return "0-9";
}
)
)
.sort(([ a ], [ b ]) => a.localeCompare(b));
return <>
<StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter ]) => (
<Link
key={firstLetter}
href={`#${firstLetter}`}
passHref
prefetch={false}
legacyBehavior>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
))}
</StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter, itemsWithFirstLetter ]) => (
<React.Fragment key={firstLetter}>
<Text id={firstLetter} variant="h2">{firstLetter}</Text>
<StyledItemGrid>
{itemsWithFirstLetter.map((item) => children(item))}
</StyledItemGrid>
</React.Fragment>
))}
</>;
}
@@ -1,8 +1,11 @@
import styled from "styled-components";
import styled, { css } from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef, FormEvent } from "react";
import { withColorTheme } from "styles/mixins";
const StyledSearchInput = styled.div`
display: flex;
@@ -19,9 +22,9 @@ const StyledSearchInput = styled.div`
&:focus-within {
box-shadow: ${theme.shadows.low};
[theme="dark"] & {
${withColorTheme("dark", css`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`)}
}
`;
const StyledForm = styled.form`
@@ -39,7 +42,24 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
}
`;
export function Input({ value, onChange, onSubmit, resettable = false, icon = null, inputProps = {}, ...props }) {
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
value: string
onChange: (value: string) => void
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
resettable?: boolean
icon?: IconDefinition
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
}
export function Input({
value,
onChange,
onSubmit,
resettable = false,
icon,
inputProps = {},
...props
}: InputProps) {
return (
<StyledSearchInput {...props}>
{icon && (
-1
View File
@@ -1 +0,0 @@
export { Input } from "./Input";
+1
View File
@@ -0,0 +1 @@
export { Input } from "components/input/Input";
@@ -1,11 +1,24 @@
import { ListboxCustom, ListboxNative } from "components/listbox";
import theme from "theme";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import useMediaQuery from "hooks/useMediaQuery";
const ListboxContext = createContext();
const ListboxContext = createContext({
isMobile: false
});
export function Listbox({
export interface ListboxProps<T extends string | null> {
children: ReactNode
value: T
onChange: (newValue: T) => void
resettable?: boolean
defaultValue?: T | null
highlightNonDefault?: boolean
disabled?: boolean
}
export function Listbox<T extends string | null>({
value,
onChange,
@@ -20,7 +33,7 @@ export function Listbox({
disabled = false,
...props
}) {
}: ListboxProps<T>) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
if (isMobile) {
@@ -54,7 +67,13 @@ export function Listbox({
);
}
Listbox.Option = function ListboxOption(props) {
export interface ListboxOptionProps {
value?: string | null
children: string
hidden?: boolean
}
Listbox.Option = function ListboxOption(props: ListboxOptionProps) {
const { isMobile } = useContext(ListboxContext);
if (isMobile) {
@@ -1,6 +1,5 @@
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faSort, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
@@ -14,6 +13,8 @@ import {
} from "@reach/listbox";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
import type { SyntheticEvent } from "react";
// ReachUI's listbox can't handle null values, so we are giving it a fake null value.
// The caveat is, that we can't use this value normally, thus it's obscure name.
@@ -84,22 +85,31 @@ const StyledListboxOption = styled(ListboxOption)`
}
`;
export function ListboxCustom({ children, value, onChange, resettable, defaultValue, highlightNonDefault, disabled, ...props }) {
function handleChange(newValue) {
export function ListboxCustom<T extends string | null>({
children,
value,
onChange,
resettable,
defaultValue = null,
highlightNonDefault,
disabled,
...props
}: ListboxProps<T>) {
function handleChange(newValue: unknown) {
if (newValue === NULL_VALUE) {
onChange(null);
onChange(null as T);
} else{
onChange(newValue);
onChange(newValue as T);
}
}
function handleResetClick() {
if (!disabled) {
onChange(defaultValue);
onChange(defaultValue as T);
}
}
function stopPropagation(event) {
function stopPropagation(event: SyntheticEvent) {
event.stopPropagation();
}
@@ -139,7 +149,7 @@ export function ListboxCustom({ children, value, onChange, resettable, defaultVa
);
}
ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden = false }) {
ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden = false }: ListboxOptionProps) {
const context = useListboxContext();
if (!context) {
@@ -150,7 +160,7 @@ ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden =
<StyledListboxOption value={value ?? NULL_VALUE} hidden={hidden}>
<Text>{children}</Text>
{value === context.value && (
<FontAwesomeIcon icon={faCheck} fixedWidth/>
<Icon icon={faCheck}/>
)}
</StyledListboxOption>
);
@@ -1,14 +1,24 @@
import styled from "styled-components";
import { faSort, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
import type { SyntheticEvent } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Icon } from "components/icon";
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
const NULL_VALUE = "__NULL__";
const ListboxContext = createContext();
interface IListboxContext {
setLabel: (value: string | null, label: string) => void
removeLabel: (value: string | null) => void
}
const ListboxContext = createContext<IListboxContext>({
setLabel: () => { /* Do nothing. */ },
removeLabel: () => { /* Do nothing. */ },
});
const StyledListbox = styled.div`
display: inline-block;
@@ -40,10 +50,19 @@ const StyledSelect = styled.select`
color: ${theme.colors["text-muted"]};
`;
export function ListboxNative({ children, value, onChange, resettable, defaultValue, highlightNonDefault, disabled, ...props }) {
export function ListboxNative<T extends string | null>({
children,
value,
onChange,
resettable,
defaultValue = null,
highlightNonDefault,
disabled,
...props
}: ListboxProps<T>) {
const [valueLabels, setValueLabels] = useState(() => new Map());
const contextValue = {
const contextValue: IListboxContext = {
setLabel: useCallback((value, label) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
@@ -60,9 +79,9 @@ export function ListboxNative({ children, value, onChange, resettable, defaultVa
}, [])
};
function handleChange(newValue) {
function handleChange(newValue: T) {
if (newValue === NULL_VALUE) {
onChange(null);
onChange(null as T);
} else{
onChange(newValue);
}
@@ -70,11 +89,11 @@ export function ListboxNative({ children, value, onChange, resettable, defaultVa
function handleResetClick() {
if (!disabled) {
onChange(defaultValue);
onChange(defaultValue as T);
}
}
function stopPropagation(event) {
function stopPropagation(event: SyntheticEvent) {
event.stopPropagation();
}
@@ -82,7 +101,7 @@ export function ListboxNative({ children, value, onChange, resettable, defaultVa
<StyledListbox {...props}>
<StyledSelect
value={value ?? NULL_VALUE}
onChange={(event) => handleChange(event.target.value)}
onChange={(event) => handleChange(event.target.value as T)}
>
<ListboxContext.Provider value={contextValue}>
{children}
@@ -108,7 +127,7 @@ export function ListboxNative({ children, value, onChange, resettable, defaultVa
);
}
ListboxNative.Option = function ListboxNativeOption({ value = null, children }) {
ListboxNative.Option = function ListboxNativeOption({ value = null, children }: ListboxOptionProps) {
const { setLabel, removeLabel } = useContext(ListboxContext);
useEffect(() => {
@@ -4,8 +4,9 @@ import { Button } from "components/button";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Icon } from "components/icon";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { faEllipsisV } from "@fortawesome/pro-solid-svg-icons";
import { fadeIn, flipDown, slideIn } from "styles/animations";
import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from "react";
const StyledMenuItems = styled(MenuItems)`
z-index: ${theme.zIndices.menuPopover};
@@ -60,7 +61,11 @@ const StyledMenuOverlay = styled(MenuPopover)`
}
`;
export function Menu({ button, children }) {
interface MenuProps extends PropsWithChildren {
button?: (button: typeof MenuButton) => ReactNode
}
export function Menu({ button, children }: MenuProps) {
return (
<ReachMenu>
{button ? button(MenuButton) : (
@@ -77,9 +82,11 @@ export function Menu({ button, children }) {
);
}
Menu.Option = function MenuOption({ children, ...props }) {
interface MenuOptionProps extends PropsWithChildren, ComponentPropsWithoutRef<typeof StyledMenuItem> {}
Menu.Option = function MenuOption({ children, ...props }: MenuOptionProps) {
return (
<StyledMenuItem onSelect={() => {}} {...props}>
<StyledMenuItem onSelect={() => { /* do nothing */ }} {...props}>
{children}
</StyledMenuItem>
);
@@ -1,11 +1,16 @@
import { Icon } from "components/icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Menu } from "components/menu";
import { useLocalPlaylist } from "context/localPlaylistContext";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import gql from "graphql-tag";
import type { ThemeMenuThemeFragment } from "generated/graphql";
export function ThemeMenu({ theme }) {
interface ThemeMenuProps {
theme: ThemeMenuThemeFragment
}
export function ThemeMenu({ theme }: ThemeMenuProps) {
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
const options = [
@@ -33,12 +38,14 @@ export function ThemeMenu({ theme }) {
);
}
ThemeMenu.fragment = gql`
fragment ThemeMenu_theme on Theme {
id
# Hidden inside local playlist context
song {
title
ThemeMenu.fragments = {
theme: gql`
fragment ThemeMenuTheme on Theme {
id
# Hidden inside local playlist context
song {
title
}
}
}
`;
`
};
-2
View File
@@ -1,2 +0,0 @@
export { Menu } from "./Menu";
export { ThemeMenu } from "./ThemeMenu";
+2
View File
@@ -0,0 +1,2 @@
export { Menu } from "components/menu/Menu";
export { ThemeMenu } from "./ThemeMenu";
-102
View File
@@ -1,102 +0,0 @@
import { useContext, useState } from "react";
import Link from "next/link";
import {
faBars,
faLightbulb,
faMoon,
faRandom,
faSearch,
faSpinner,
faTimes,
faTv,
faUser
} from "@fortawesome/free-solid-svg-icons";
import {
StyledCollapsibleLink,
StyledLogo,
StyledLogoContainer,
StyledMobileToggle,
StyledNavigation,
StyledNavigationContainer,
StyledNavigationLinks
} from "./Navigation.style";
import { Button } from "components/button";
import ColorThemeContext from "context/colorThemeContext";
import { Icon } from "components/icon";
import { Text } from "components/text";
import useCurrentSeason from "hooks/useCurrentSeason";
import navigateToRandomTheme from "utils/navigateToRandomTheme";
import { useRouter } from "next/router";
export function Navigation({ offsetToggleButton = false }) {
const [ show, setShow ] = useState(false);
const { colorTheme, toggleColorTheme } = useContext(ColorThemeContext);
const { currentYear, currentSeason } = useCurrentSeason();
const router = useRouter();
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
// If the user clicks on a link which sends them to another page,
// we want to close the modal navigation.
if (router.pathname !== prevPathname) {
setShow(false);
setPrevPathname(router.pathname);
return null;
}
return (
<>
<StyledNavigation show={show} onClick={() => setShow(false)}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref prefetch={false}>
<StyledLogoContainer>
<StyledLogo width="277" height="150"/>
</StyledLogoContainer>
</Link>
<StyledNavigationLinks>
<Link href="/search" passHref prefetch={false}>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faSearch}/>
<Text>Search</Text>
</Button>
</Link>
<Button variant="silent" style={{ "--gap": "8px" }} onClick={navigateToRandomTheme}>
<Icon icon={faRandom}/>
<Text>Play Random</Text>
</Button>
<Link href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"} passHref prefetch={false}>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faTv}/>
<Text>Current Season</Text>
</Button>
</Link>
<Link href="/profile" passHref prefetch={false}>
<StyledCollapsibleLink forwardedAs="a" title="My Profile">
<Icon icon={faUser}/>
<span>My Profile</span>
</StyledCollapsibleLink>
</Link>
<Button
variant="silent"
isCircle
onClick={toggleColorTheme}
alignSelf="center"
title="Toggle color theme"
>
<Icon icon={colorTheme === null ? faSpinner : colorTheme === "dark" ? faLightbulb : faMoon} spin={colorTheme === null} />
</Button>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
<StyledMobileToggle
variant={show ? "solid" : "primary"}
isCircle
offsetToggleButton={offsetToggleButton}
onClick={() => setShow(!show)}
>
<Icon icon={show ? faTimes : faBars}/>
</StyledMobileToggle>
</>
);
}
@@ -5,8 +5,9 @@ import theme from "theme";
import { blurOut, zoomIn } from "styles/animations";
import { Solid } from "components/box";
import { Logo } from "components/image";
import { withColorTheme } from "styles/mixins";
export const StyledNavigation = styled(Solid).attrs({ as: "nav" })`
export const StyledNavigation = styled(Solid).attrs({ as: "nav" })<{ show: boolean }>`
position: sticky;
top: 0;
z-index: ${theme.zIndices.navigation};
@@ -56,9 +57,9 @@ export const StyledNavigationContainer = styled(Container)`
animation: ${zoomIn} 250ms ease;
will-change: transform;
[theme="dark"] & {
${withColorTheme("dark", css`
border: 2px solid ${theme.colors["text-disabled"]};
}
`)}
}
`;
@@ -71,10 +72,6 @@ export const StyledNavigationLinks = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-direction: column;
align-items: flex-start;
& :last-child {
align-self: center;
}
}
`;
+86
View File
@@ -0,0 +1,86 @@
import { useState } from "react";
import Link from "next/link";
import { faBars, faRandom, faSearch, faTimes, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
import {
StyledCollapsibleLink,
StyledLogo,
StyledLogoContainer,
StyledMobileToggle,
StyledNavigation,
StyledNavigationContainer,
StyledNavigationLinks
} from "./Navigation.style";
import { Button } from "components/button";
import { Icon } from "components/icon";
import { Text } from "components/text";
import useCurrentSeason from "hooks/useCurrentSeason";
import navigateToRandomTheme from "utils/navigateToRandomTheme";
import { useRouter } from "next/router";
interface NavigationProps {
offsetToggleButton?: boolean
}
export function Navigation({ offsetToggleButton = false }: NavigationProps) {
const [ show, setShow ] = useState(false);
const { currentYear, currentSeason } = useCurrentSeason();
const router = useRouter();
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
// If the user clicks on a link which sends them to another page,
// we want to close the modal navigation.
if (router.pathname !== prevPathname) {
setShow(false);
setPrevPathname(router.pathname);
return null;
}
return <>
<StyledNavigation show={show} onClick={() => setShow(false)}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref legacyBehavior>
<StyledLogoContainer>
<StyledLogo width="277" height="150"/>
</StyledLogoContainer>
</Link>
<StyledNavigationLinks>
<Link href="/search" passHref legacyBehavior>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faSearch}/>
<Text>Search</Text>
</Button>
</Link>
<Button variant="silent" style={{ "--gap": "8px" }} onClick={navigateToRandomTheme}>
<Icon icon={faRandom}/>
<Text>Play Random</Text>
</Button>
<Link
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
passHref
legacyBehavior>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faTv}/>
<Text>Current Season</Text>
</Button>
</Link>
<Link href="/profile" passHref legacyBehavior>
<StyledCollapsibleLink forwardedAs="a" title="My Profile">
<Icon icon={faUser}/>
<span>My Profile</span>
</StyledCollapsibleLink>
</Link>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
<StyledMobileToggle
variant={show ? "solid" : "primary"}
isCircle
offsetToggleButton={offsetToggleButton}
onClick={() => setShow(!show)}
>
<Icon icon={show ? faTimes : faBars}/>
</StyledMobileToggle>
</>;
}
@@ -1,123 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Text } from "components/text";
import { Input } from "components/input";
import { HorizontalScroll } from "components/utils";
import { Switcher } from "components/switcher";
import styled from "styled-components";
import theme from "theme";
import { useRouter } from "next/router";
import { capitalize, debounce } from "lodash-es";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
const StyledSearchOptions = styled.div`
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
grid-gap: 1rem;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
align-items: stretch;
}
`;
const updateSearchQuery = debounce((router, newSearchQuery) => {
// Update URL to maintain the searchQuery on page navigation.
const newUrlParams = {
...router.query,
q: newSearchQuery
};
if (!newUrlParams.q) {
delete newUrlParams.q;
}
router.replace({
pathname: router.pathname,
query: newUrlParams
}, null, {
shallow: true
});
}, 500);
export function SearchNavigation() {
const router = useRouter();
const { entity, ...query } = router.query;
const { q: initialSearchQuery = "" } = query;
const [inputSearchQuery, setInputSearchQuery] = useState("");
const updateInputSearchQuery = (newInputSearchQuery) => {
setInputSearchQuery(newInputSearchQuery);
updateSearchQuery(router, newInputSearchQuery);
};
useEffect(() => {
if (router.isReady) {
setInputSearchQuery(initialSearchQuery);
}
}, [router.isReady]);
const inputRef = useRef();
const onMountInput = useCallback((input) => {
// Only focus the input on desktop devices
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
input?.focus({
preventScroll: true
});
}
inputRef.current = input;
}, []);
useEffect(() => {
const hotkeyListener = (event) => {
if ((event.key === "s" && event.ctrlKey) || (event.key === "/")) {
event.preventDefault();
inputRef.current?.focus({
preventScroll: true
});
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
window.addEventListener("keydown", hotkeyListener);
return () => window.removeEventListener("keydown", hotkeyListener);
}, []);
return (
<>
<Text variant="h1">Search</Text>
<StyledSearchOptions>
<Input
value={inputSearchQuery}
onChange={updateInputSearchQuery}
inputProps={{
ref: onMountInput,
spellCheck: false,
placeholder: "Search"
}}
resettable
icon={faSearch}
/>
<HorizontalScroll fixShadows>
<Switcher selectedItem={entity || null}>
<Link href={{ pathname: "/search", query }} passHref prefetch={false}>
<Switcher.Reset as="a"/>
</Link>
{[ "anime", "theme", "artist", "series", "studio" ].map((entity) => (
<Link key={entity} href={{ pathname: `/search/${entity}`, query }} passHref prefetch={false}>
<Switcher.Option as="a" value={entity}>
{capitalize(entity)}
</Switcher.Option>
</Link>
))}
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>
);
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Text } from "components/text";
import { Input } from "components/input";
import { HorizontalScroll } from "components/utils";
import { Switcher } from "components/switcher";
import styled from "styled-components";
import theme from "theme";
import { useRouter } from "next/router";
import { debounce } from "lodash-es";
import { faSearch } from "@fortawesome/pro-solid-svg-icons";
import { SwitcherOption, SwitcherReset } from "components/switcher/Switcher";
const StyledSearchOptions = styled.div`
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
grid-gap: 1rem;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
align-items: stretch;
}
`;
const updateSearchQuery = debounce((router, newSearchQuery) => {
// Update URL to maintain the searchQuery on page navigation.
const newUrlParams = {
...router.query,
q: newSearchQuery
};
if (!newUrlParams.q) {
delete newUrlParams.q;
}
router.replace({
pathname: router.pathname,
query: newUrlParams
}, null, {
shallow: true
});
}, 500);
export function SearchNavigation() {
const router = useRouter();
const [routerWasReady, setRouterWasReady] = useState(router.isReady);
const { entity, ...query } = router.query;
const { q: initialSearchQuery = "" } = query;
const [inputSearchQuery, setInputSearchQuery] = useState("");
const updateInputSearchQuery = (newInputSearchQuery: string) => {
setInputSearchQuery(newInputSearchQuery);
updateSearchQuery(router, newInputSearchQuery);
};
const inputRef = useRef<HTMLInputElement>();
const onMountInput = useCallback((input: HTMLInputElement) => {
// Only focus the input on desktop devices
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
input?.focus({
preventScroll: true
});
}
inputRef.current = input;
}, []);
useEffect(() => {
const hotkeyListener = (event: KeyboardEvent) => {
if (inputRef.current !== document.activeElement && ((event.key === "s" && event.ctrlKey) || (event.key === "/"))) {
event.preventDefault();
inputRef.current?.focus({
preventScroll: true
});
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
window.addEventListener("keydown", hotkeyListener);
return () => window.removeEventListener("keydown", hotkeyListener);
}, []);
if (router.isReady !== routerWasReady) {
setRouterWasReady(router.isReady);
setInputSearchQuery(initialSearchQuery as string);
return null;
}
return <>
<Text variant="h1">Search</Text>
<StyledSearchOptions>
<Input
value={inputSearchQuery}
onChange={updateInputSearchQuery}
inputProps={{
ref: onMountInput,
spellCheck: false,
placeholder: "Search"
}}
resettable
icon={faSearch}
/>
<HorizontalScroll fixShadows>
<Switcher selectedItem={entity as string || null}>
<SwitcherReset as={Link} href={{ pathname: "/search", query }}/>
<SwitcherOption as={Link} href={{ pathname: "/search/anime", query }} value="anime">Anime</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/theme", query }} value="theme">Theme</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>;
}
@@ -1,20 +0,0 @@
import { Row } from "components/box";
import { Switcher } from "components/switcher";
import Link from "next/link";
import { HorizontalScroll } from "components/utils";
export function SeasonNavigation({ year, season, seasonList }) {
return (
<Row style={{ "--justify-content": "center" }}>
<HorizontalScroll fixShadows>
<Switcher items={seasonList.map((s) => s.toLowerCase())} selectedItem={season && season.toLowerCase()}>
{seasonList.map((season) => (
<Link key={season} href={`/year/${year}/${season.toLowerCase()}`} passHref prefetch={false}>
<Switcher.Option as="a" value={season.toLowerCase()}>{season}</Switcher.Option>
</Link>
))}
</Switcher>
</HorizontalScroll>
</Row>
);
}
@@ -0,0 +1,30 @@
import { Row } from "components/box";
import { Switcher } from "components/switcher";
import Link from "next/link";
import { HorizontalScroll } from "components/utils";
import type { YearDetailPageProps } from "pages/year/[year]";
import { SwitcherOption } from "components/switcher/Switcher";
import type { SeasonDetailPageProps } from "pages/year/[year]/[season]";
export function SeasonNavigation(props: YearDetailPageProps | SeasonDetailPageProps) {
const { year } = props;
return (
<Row style={{ "--justify-content": "center" }}>
<HorizontalScroll fixShadows>
<Switcher selectedItem={"season" in props ? props.season.value.toLowerCase() : null}>
{year.seasons.map((season) => (
<SwitcherOption
key={season.value}
href={`/year/${year.value}/${season.value.toLowerCase()}`}
as={Link}
value={season.value.toLowerCase()}
>
{season.value}
</SwitcherOption>
))}
</Switcher>
</HorizontalScroll>
</Row>
);
}
@@ -3,6 +3,7 @@ import { Button } from "components/button";
import styled from "styled-components";
import { Row } from "components/box";
import { Text } from "components/text";
import type { YearDetailPageProps } from "pages/year/[year]";
const StyledYear = styled.div`
flex: 1;
@@ -11,34 +12,36 @@ const StyledYear = styled.div`
margin: 0 1rem;
`;
const StyledYearPrevious = styled(StyledYear)`
justify-content: flex-end;
`;
const StyledYearNext = styled(StyledYear)`
justify-content: flex-start;
`;
export function YearNavigation({ year, yearList }) {
const previousYear = yearList.indexOf(year) > 0 ? yearList[yearList.indexOf(year) - 1] : null;
const nextYear = yearList.indexOf(year) < yearList.length - 1 ? yearList[yearList.indexOf(year) + 1] : null;
export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
const previousYear = yearAll.find((y) => y.value === year.value - 1)?.value ?? null;
const nextYear = yearAll.find((y) => y.value === year.value + 1)?.value ?? null;
return (
<Row style={{ "--align-items": "center" }}>
<StyledYearPrevious>
{previousYear && (
<Link href={`/year/${previousYear}`} passHref prefetch={false}>
<Link href={`/year/${previousYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{previousYear}</Button>
</Link>
)}
</StyledYearPrevious>
<Link href={`/year`} passHref prefetch={false}>
<Link href={`/year`} passHref legacyBehavior>
<Button as="a" variant="silent">
<Text variant="h1">{year}</Text>
<Text variant="h1">{year.value}</Text>
</Button>
</Link>
<StyledYearNext>
{nextYear && (
<Link href={`/year/${nextYear}`} passHref prefetch={false}>
<Link href={`/year/${nextYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{nextYear}</Button>
</Link>
)}
@@ -1,13 +0,0 @@
import styled from "styled-components";
const StyledSearchFilter = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
export function SearchFilter({ ...props }) {
return (
<StyledSearchFilter {...props}/>
);
}
@@ -0,0 +1,7 @@
import styled from "styled-components";
export const SearchFilter = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
@@ -4,7 +4,12 @@ import { SearchFilter } from "components/search-filter";
const letters = createLetters();
export function SearchFilterFirstLetter({ value, setValue }) {
interface SearchFilterFirstLetterProps {
value: string | null
setValue: (newValue: string | null) => void
}
export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLetterProps) {
return (
<SearchFilter>
<Text variant="h2">First Letter</Text>
@@ -1,15 +0,0 @@
import styled from "styled-components";
const StyledSearchFilterGroup = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 16px;
`;
export function SearchFilterGroup({ children }) {
return (
<StyledSearchFilterGroup>
{children}
</StyledSearchFilterGroup>
);
}
@@ -0,0 +1,7 @@
import styled from "styled-components";
export const SearchFilterGroup = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 16px;
`;
@@ -2,7 +2,12 @@ import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
export function SearchFilterSeason({ value, setValue }) {
interface SearchFilterSeasonProps {
value: string | null
setValue: (newValue: string | null) => void
}
export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps) {
return (
<SearchFilter>
<Text variant="h2">Season</Text>
@@ -1,8 +1,15 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export function SearchFilterSortBy({ children, value, setValue }) {
interface SearchFilterSortByProps<T extends string | null> {
children: ReactNode
value: T
setValue: (newValue: T) => void
}
export function SearchFilterSortBy<T extends string | null>({ children, value, setValue }: SearchFilterSortByProps<T>) {
return (
<SearchFilter>
<Text variant="h2">Sort by</Text>
@@ -13,6 +20,6 @@ export function SearchFilterSortBy({ children, value, setValue }) {
);
}
SearchFilterSortBy.Option = function SearchFilterSortByOption(props) {
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ComponentPropsWithoutRef<typeof Listbox.Option>) {
return <Listbox.Option {...props}/>;
};
@@ -2,7 +2,12 @@ import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
export function SearchFilterThemeType({ value, setValue }) {
interface SearchFilterThemeTypeProps {
value: string | null
setValue: (newValue: string | null) => void
}
export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeTypeProps) {
return (
<SearchFilter>
<Text variant="h2">Type</Text>
@@ -3,7 +3,12 @@ import { Listbox } from "components/listbox";
import useYearList from "hooks/useYearList";
import { SearchFilter } from "components/search-filter";
export function SearchFilterYear({ value, setValue }) {
interface SearchFilterYearProps {
value: string | null
setValue: (newValue: string | null) => void
}
export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
const yearList = useYearList();
return (
@@ -12,7 +17,7 @@ export function SearchFilterYear({ value, setValue }) {
<Listbox value={value ? String(value) : null} onChange={setValue} resettable highlightNonDefault>
<Listbox.Option value={null}>Any</Listbox.Option>
{yearList.map((year) => (
<Listbox.Option key={year} value={year ? String(year) : null}>{year}</Listbox.Option>
<Listbox.Option key={year} value={year ? String(year) : null}>{String(year)}</Listbox.Option>
))}
</Listbox>
</SearchFilter>
@@ -6,8 +6,11 @@ import {
} from "components/search-filter";
import { SearchEntity } from "components/search";
import { AnimeSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
import { useState } from "react";
import gql from "graphql-tag";
import type { SearchAnimeQuery, SearchAnimeQueryVariables } from "generated/graphql";
import { fetchDataClient } from "lib/client";
import useFilterStorage from "hooks/useFilterStorage";
const initialFilter = {
firstLetter: null,
@@ -16,22 +19,26 @@ const initialFilter = {
sortBy: "name",
};
export function SearchAnime({ searchQuery }) {
const { updateDataField: updateFilter, data: filter } = useSessionStorage("filter-anime", {
interface SearchAnimeProps {
searchQuery?: string
}
export function SearchAnime({ searchQuery }: SearchAnimeProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-anime", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy")(initialFilter.sortBy);
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy")(null);
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
@@ -39,22 +46,44 @@ export function SearchAnime({ searchQuery }) {
return (
<SearchEntity
<SearchAnimeQuery["searchAnime"]["data"][number]>
entity="anime"
searchQuery={searchQuery}
searchParams={{
searchArgs={{
query: searchQuery,
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
season: filter.season,
year: filter.year
},
sortBy: filter.sortBy
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchAnimeQuery, SearchAnimeQueryVariables>(gql`
${AnimeSummaryCard.fragments.anime}
${AnimeSummaryCard.fragments.expandable}
query SearchAnime($args: SearchArgs!) {
searchAnime(args: $args) {
data {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
nextPage
}
}
`, { args: searchArgs });
return data.searchAnime;
}}
renderResult={(anime) => (
<AnimeSummaryCard key={anime.slug} anime={anime} expandable/>
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSeason value={filter.season} setValue={updateFilter("season")}/>
<SearchFilterYear value={filter.year} setValue={updateFilter("year")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={updateFilter("sortBy")}>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")}/>
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
@@ -66,7 +95,6 @@ export function SearchAnime({ searchQuery }) {
</SearchFilterSortBy>
</>
}
renderResult={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable/>}
/>
);
}
@@ -1,30 +1,37 @@
import { SearchFilterFirstLetter, SearchFilterSortBy } from "components/search-filter";
import { SearchEntity } from "components/search";
import { ArtistSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
import { useState } from "react";
import { fetchDataClient } from "lib/client";
import type { SearchArtistQuery, SearchArtistQueryVariables } from "generated/graphql";
import gql from "graphql-tag";
import useFilterStorage from "hooks/useFilterStorage";
const initialFilter = {
firstLetter: null,
sortBy: "name",
};
export function SearchArtist({ searchQuery }) {
const { updateDataField: updateFilter, data: filter } = useSessionStorage("filter-artist", {
interface SearchArtistProps {
searchQuery?: string
}
export function SearchArtist({ searchQuery }: SearchArtistProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-artist", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy")(initialFilter.sortBy);
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy")(null);
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
@@ -32,18 +39,38 @@ export function SearchArtist({ searchQuery }) {
return (
<SearchEntity
<SearchArtistQuery["searchArtist"]["data"][number]>
entity="artist"
searchQuery={searchQuery}
searchParams={{
searchArgs={{
query: searchQuery,
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: filter.sortBy
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchArtistQuery, SearchArtistQueryVariables>(gql`
${ArtistSummaryCard.fragments.artist}
query SearchArtist($args: SearchArgs!) {
searchArtist(args: $args) {
data {
...ArtistSummaryCardArtist
}
nextPage
}
}
`, { args: searchArgs });
return data.searchArtist;
}}
renderResult={(artist) => (
<ArtistSummaryCard key={artist.slug} artist={artist}/>
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={updateFilter("sortBy")}>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
@@ -53,7 +80,6 @@ export function SearchArtist({ searchQuery }) {
</SearchFilterSortBy>
</>
}
renderResult={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist}/>}
/>
);
}
@@ -1,4 +1,4 @@
import { faChevronDown, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown, faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import { Column, Row } from "components/box";
@@ -6,8 +6,22 @@ import { Icon } from "components/icon";
import { SearchFilterGroup } from "components/search-filter";
import { ErrorCard } from "components/card";
import useEntitySearch from "hooks/useEntitySearch";
import type { ReactNode } from "react";
import type { SearchArgs } from "generated/graphql";
import type { SimpleSearchArgs } from "lib/client/search";
export function SearchEntity({ entity, searchQuery, searchParams, filters, renderResult }) {
interface SearchEntityProps<T> {
entity: string
fetchResults: (searchArgs: SearchArgs) => Promise<{
data: Array<T>
nextPage: number | null
}>
searchArgs: SimpleSearchArgs
filters: ReactNode
renderResult: (result: T) => ReactNode
}
export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, renderResult }: SearchEntityProps<T>) {
const {
data,
error,
@@ -17,10 +31,10 @@ export function SearchEntity({ entity, searchQuery, searchParams, filters, rende
isFetchingNextPage,
isLoading,
isPlaceholderData,
} = useEntitySearch(
} = useEntitySearch<T>(
entity,
searchQuery,
searchParams,
fetchResults,
searchArgs
);
return (
@@ -43,12 +57,12 @@ export function SearchEntity({ entity, searchQuery, searchParams, filters, rende
);
}
const results = data.pages.flatMap((page) => page.data);
const results = data?.pages.flatMap((page) => page.data) ?? [];
if (!results.length) {
if (searchQuery) {
if (searchArgs.query) {
return (
<Text block>No results found for query &quot;{searchQuery}&quot;. Did you spell it correctly?</Text>
<Text block>No results found for query &quot;{searchArgs.query}&quot;. Did you spell it correctly?</Text>
);
} else {
return (
@@ -67,7 +81,7 @@ export function SearchEntity({ entity, searchQuery, searchParams, filters, rende
{(hasNextPage || isPlaceholderData) && (
<Row style={{ "--justify-content": "center" }}>
<Button variant="silent" isCircle onClick={() => !isLoadingMore && fetchNextPage()} title="Load more">
<Icon icon={isLoadingMore ? faSpinner : faChevronDown} spin={isLoadingMore}/>
<Icon icon={isLoadingMore ? faSpinner : faChevronDown} className={isLoadingMore ? "fa-spin" : undefined}/>
</Button>
</Row>
)}
@@ -1,20 +1,53 @@
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import { Column, Row } from "components/box";
import { Icon } from "components/icon";
import { useQuery } from "react-query";
import { fetchGlobalSearchResults } from "lib/client/search";
import { AnimeSummaryCard, ArtistSummaryCard, ErrorCard, SummaryCard, ThemeSummaryCard } from "components/card";
import { useRouter } from "next/router";
import { fetchDataClient } from "lib/client";
import gql from "graphql-tag";
import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "generated/graphql";
import type { ReactNode } from "react";
export function SearchGlobal({ searchQuery }) {
const fetchSearchResults = () => fetchGlobalSearchResults(
searchQuery,
4,
["anime", "theme", "artist", "series", "studio"]
);
interface SearchGlobalProps {
searchQuery?: string
}
export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
const fetchSearchResults = () => fetchDataClient<SearchGlobalQuery, SearchGlobalQueryVariables>(gql`
${AnimeSummaryCard.fragments.anime}
${AnimeSummaryCard.fragments.expandable}
${ThemeSummaryCard.fragments.theme}
${ThemeSummaryCard.fragments.expandable}
${ArtistSummaryCard.fragments.artist}
query SearchGlobal($args: SearchArgs!) {
search(args: $args) {
anime {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
themes {
...ThemeSummaryCardTheme
...ThemeSummaryCardThemeExpandable
}
artists {
...ArtistSummaryCardArtist
}
series {
slug
name
}
studios {
slug
name
}
}
}
`, { args: { query: searchQuery ?? null } });
const {
data,
@@ -35,7 +68,7 @@ export function SearchGlobal({ searchQuery }) {
);
}
if (isLoading) {
if (isLoading || !data) {
return (
<Text block>Searching...</Text>
);
@@ -43,11 +76,11 @@ export function SearchGlobal({ searchQuery }) {
const {
anime: animeResults = [],
theme: themeResults = [],
artist: artistResults = [],
themes: themeResults = [],
artists: artistResults = [],
series: seriesResults = [],
studio: studioResults = []
} = data;
studios: studioResults = []
} = data.data.search;
const totalResults =
animeResults.length +
@@ -74,7 +107,7 @@ export function SearchGlobal({ searchQuery }) {
entity="theme"
title="Themes"
results={themeResults}
renderSummaryCard={(theme) => <ThemeSummaryCard key={theme.anime.slug + theme.slug} theme={theme} expandable/>}
renderSummaryCard={(theme) => <ThemeSummaryCard key={theme.anime?.slug + theme.slug} theme={theme} expandable/>}
/>
<GlobalSearchSection
entity="artist"
@@ -104,7 +137,14 @@ export function SearchGlobal({ searchQuery }) {
);
}
function GlobalSearchSection({ entity, title, results, renderSummaryCard }) {
interface GlobalSearchSectionProps<T> {
entity: string
title: string
results: Array<T>
renderSummaryCard: (result: T) => ReactNode
}
function GlobalSearchSection<T>({ entity, title, results, renderSummaryCard }: GlobalSearchSectionProps<T>) {
const router = useRouter();
const urlParams = router.query;
@@ -115,21 +155,22 @@ function GlobalSearchSection({ entity, title, results, renderSummaryCard }) {
const resultsPreview = results.slice(0, 3);
const hasMoreResults = results.length > 3;
return (
<>
<Text variant="h2">{title}</Text>
<Column style={{ "--gap": "16px" }}>
{resultsPreview.map(renderSummaryCard)}
</Column>
{hasMoreResults && (
<Row style={{ "--justify-content": "center" }}>
<Link href={{ pathname: `/search/${entity}`, query: urlParams }} passHref prefetch={false}>
<Button as="a" variant="silent" isCircle title="See all results">
<Icon icon={faChevronDown}/>
</Button>
</Link>
</Row>
)}
</>
);
return <>
<Text variant="h2">{title}</Text>
<Column style={{ "--gap": "16px" }}>
{resultsPreview.map(renderSummaryCard)}
</Column>
{hasMoreResults && (
<Row style={{ "--justify-content": "center" }}>
<Link
href={{ pathname: `/search/${entity}`, query: urlParams }}
passHref
legacyBehavior>
<Button as="a" variant="silent" isCircle title="See all results">
<Icon icon={faChevronDown}/>
</Button>
</Link>
</Row>
)}
</>;
}
@@ -1,30 +1,37 @@
import { SearchFilterFirstLetter, SearchFilterSortBy } from "components/search-filter";
import { SearchEntity } from "components/search";
import { SummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
import { useState } from "react";
import { fetchDataClient } from "lib/client";
import type { SearchSeriesQuery, SearchSeriesQueryVariables } from "generated/graphql";
import gql from "graphql-tag";
import useFilterStorage from "hooks/useFilterStorage";
const initialFilter = {
firstLetter: null,
sortBy: "name",
};
export function SearchSeries({ searchQuery }) {
const { updateDataField: updateFilter, data: filter } = useSessionStorage("filter-series", {
interface SearchSeriesProps {
searchQuery?: string
}
export function SearchSeries({ searchQuery }: SearchSeriesProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-series", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy")(initialFilter.sortBy);
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy")(null);
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
@@ -32,18 +39,37 @@ export function SearchSeries({ searchQuery }) {
return (
<SearchEntity
<SearchSeriesQuery["searchSeries"]["data"][number]>
entity="series"
searchQuery={searchQuery}
searchParams={{
searchArgs={{
query: searchQuery,
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(gql`
query SearchSeries($args: SearchArgs!) {
searchSeries(args: $args) {
data {
slug
name
}
nextPage
}
}
`, { args: searchArgs });
return data.searchSeries;
}}
renderResult={(series) => (
<SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={updateFilter("sortBy")}>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
@@ -53,7 +79,6 @@ export function SearchSeries({ searchQuery }) {
</SearchFilterSortBy>
</>
}
renderResult={(series) => <SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />}
/>
);
}
-59
View File
@@ -1,59 +0,0 @@
import { SearchFilterFirstLetter, SearchFilterSortBy } from "components/search-filter";
import { SearchEntity } from "components/search";
import { SummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
import { useState } from "react";
const initialFilter = {
firstLetter: null,
sortBy: "name",
};
export function SearchStudio({ searchQuery }) {
const { updateDataField: updateFilter, data: filter } = useSessionStorage("filter-studio", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy")(initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy")(null);
}
setPrevSearchQuery(searchQuery);
return null;
}
return (
<SearchEntity
entity="studio"
searchQuery={searchQuery}
searchParams={{
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: filter.sortBy,
}}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</>
}
renderResult={(studio) => <SummaryCard key={studio.slug} title={studio.name} description="Studio" to={`/studio/${studio.slug}`} />}
/>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { SearchFilterFirstLetter, SearchFilterSortBy } from "components/search-filter";
import { SearchEntity } from "components/search";
import { SummaryCard } from "components/card";
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { fetchDataClient } from "lib/client";
import type { SearchStudioQuery, SearchStudioQueryVariables } from "generated/graphql";
import gql from "graphql-tag";
import { StudioCoverImage } from "components/image/StudioCoverImage";
import extractImages from "utils/extractImages";
import extractBackgroundColor from "utils/extractBackgroundColor";
import type { Property } from "csstype";
import useFilterStorage from "hooks/useFilterStorage";
const initialFilter = {
firstLetter: null,
sortBy: "name",
};
interface SearchStudioProps {
searchQuery?: string
}
export function SearchStudio({ searchQuery }: SearchStudioProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-studio", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
}
return (
<SearchEntity
<SearchStudioQuery["searchStudio"]["data"][number]>
entity="studio"
searchArgs={{
query: searchQuery,
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchStudioQuery, SearchStudioQueryVariables>(gql`
${StudioCoverImage.fragments.studio}
query SearchStudio($args: SearchArgs!) {
searchStudio(args: $args) {
data {
...StudioCoverImageStudio
slug
name
}
nextPage
}
}
`, { args: searchArgs });
return data.searchStudio;
}}
renderResult={(studio) => (
<StudioSummaryCard key={studio.slug} studio={studio}/>
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</>
}
/>
);
}
interface StudioSummaryCardProps {
studio: SearchStudioQuery["searchStudio"]["data"][number]
}
function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
const [backgroundColor, setBackgroundColor] = useState<Property.Background>();
function handleLoad(event: SyntheticEvent<HTMLImageElement>) {
const image = event.currentTarget;
const color = extractBackgroundColor(image);
if (color) {
setBackgroundColor(color);
}
}
return (
<SummaryCard
key={studio.slug}
title={studio.name}
description="Studio"
to={`/studio/${studio.slug}`}
image={extractImages(studio).largeCover}
imageProps={{
objectFit: "contain",
backgroundColor,
onLoad: handleLoad
}}
/>
);
}
@@ -1,8 +1,11 @@
import { SearchFilterFirstLetter, SearchFilterSortBy, SearchFilterThemeType } from "components/search-filter";
import { SearchEntity } from "components/search";
import { ThemeSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
import { useState } from "react";
import { fetchDataClient } from "lib/client";
import type { SearchThemeQuery, SearchThemeQueryVariables } from "generated/graphql";
import gql from "graphql-tag";
import useFilterStorage from "hooks/useFilterStorage";
const initialFilter = {
firstLetter: null,
@@ -10,22 +13,26 @@ const initialFilter = {
sortBy: "song.title",
};
export function SearchTheme({ searchQuery }) {
const { updateDataField: updateFilter, data: filter } = useSessionStorage("filter-theme", {
interface SearchThemeProps {
searchQuery?: string
}
export function SearchTheme({ searchQuery }: SearchThemeProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-theme", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy")(initialFilter.sortBy);
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy")(null);
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
@@ -33,9 +40,10 @@ export function SearchTheme({ searchQuery }) {
return (
<SearchEntity
<SearchThemeQuery["searchTheme"]["data"][number]>
entity="theme"
searchQuery={searchQuery}
searchParams={{
searchArgs={{
query: searchQuery,
filters: {
has: "song",
"song][title-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
@@ -43,11 +51,32 @@ export function SearchTheme({ searchQuery }) {
},
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchThemeQuery, SearchThemeQueryVariables>(gql`
${ThemeSummaryCard.fragments.theme}
${ThemeSummaryCard.fragments.expandable}
query SearchTheme($args: SearchArgs!) {
searchTheme(args: $args) {
data {
...ThemeSummaryCardTheme
...ThemeSummaryCardThemeExpandable
}
nextPage
}
}
`, { args: searchArgs });
return data.searchTheme;
}}
renderResult={(theme) => (
<ThemeSummaryCard key={theme.anime?.slug + theme.slug} theme={theme} expandable/>
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterThemeType value={filter.type} setValue={updateFilter("type")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={updateFilter("sortBy")}>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : null}
@@ -59,7 +88,6 @@ export function SearchTheme({ searchQuery }) {
</SearchFilterSortBy>
</>
}
renderResult={(theme) => <ThemeSummaryCard key={theme.anime.slug + theme.slug} theme={theme} expandable/>}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More