feat: Added Prettier (#220)

* Reformated all files with Prettier.
* Updated GraphQL codegen.
* Updated ESLint (including rule config).
* Updated TypeScript.
* Eliminated most ESLint warnings (especially usage of the `any` type).
* Rewrote parts of the API resolver logic to be more type-safe.
* Added IP forwarding for API requests.
This commit is contained in:
Mani
2024-06-08 03:30:10 +02:00
committed by GitHub
parent b2e68af2ab
commit f85e21b8c9
197 changed files with 32616 additions and 26639 deletions
+22 -75
View File
@@ -1,44 +1,11 @@
{
"extends": [
"next/core-web-vitals",
"plugin:react/recommended",
"eslint:recommended"
],
"ignorePatterns": [
"src/generated"
],
"plugins": ["simple-import-sort"],
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"ignorePatterns": ["src/generated"],
"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",
// Sort imports
"simple-import-sort/imports": [
"error",
@@ -56,41 +23,21 @@
]
}
],
"simple-import-sort/exports": "error"
},
"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",
{
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/consistent-type-exports": "error"
"simple-import-sort/exports": "error",
// TypeScript rules
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
}
]
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/consistent-type-exports": "error"
}
}
+1
View File
@@ -1,3 +1,4 @@
npm run graphql-codegen
git add src/generated
npm run lint
npm run type-check
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"printWidth": 120
}
+6 -6
View File
@@ -86,12 +86,12 @@ For more information on environment variables see the [Next.js documentation](ht
## Used technologies
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
### APIs
- [AnimeThemes API](https://api-docs.animethemes.moe/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+47 -9
View File
@@ -7,17 +7,10 @@ const config: CodegenConfig = {
"src/lib/server/animebracket/type-defs.ts",
"src/lib/client/search.ts",
],
documents: [
"src/**/*.js",
"src/**/*.ts",
"src/**/*.tsx",
],
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
generates: {
"src/generated/graphql.ts": {
plugins: [
"typescript",
"typescript-operations",
],
plugins: ["typescript", "typescript-operations"],
config: {
avoidOptionals: {
object: true,
@@ -26,8 +19,53 @@ const config: CodegenConfig = {
},
enumsAsTypes: true,
skipTypename: true,
useTypeImports: true,
},
},
"src/generated/graphql-resolvers.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
useTypeImports: true,
// This fixes an issue with the Maybe type, leading to unclear error messages.
// Taken from here: https://github.com/dotansimha/graphql-code-generator/issues/3174#issuecomment-595398571
maybeValue: "T extends PromiseLike<infer U> ? Promise<U | null> : T | null",
mappers: {
Anime: "@/lib/common/animethemes/types#ApiAnime",
Announcement: "@/lib/common/animethemes/types#ApiAnnouncement",
Artist: "@/lib/common/animethemes/types#ApiArtist",
Audio: "@/lib/common/animethemes/types#ApiAudio",
Dump: "@/lib/common/animethemes/types#ApiDump",
Entry: "@/lib/common/animethemes/types#ApiEntry",
FeaturedTheme: "@/lib/common/animethemes/types#ApiFeaturedTheme",
Group: "@/lib/common/animethemes/types#ApiGroup",
Page: "@/lib/common/animethemes/types#ApiPage",
Performance: "@/lib/common/animethemes/types#ApiPerformance",
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
Season: "@/lib/common/animethemes/types#ApiSeason",
Series: "@/lib/common/animethemes/types#ApiSeries",
Song: "@/lib/common/animethemes/types#ApiSong",
Studio: "@/lib/common/animethemes/types#ApiStudio",
Synonym: "@/lib/common/animethemes/types#ApiSynonym",
Theme: "@/lib/common/animethemes/types#ApiTheme",
UserAuth: "@/lib/common/animethemes/types#ApiUser",
UserPublic: "@/lib/common/animethemes/types#ApiUser",
UserRole: "@/lib/common/animethemes/types#ApiUserRole",
UserScopedQuery: "{}",
Video: "@/lib/common/animethemes/types#ApiVideo",
VideoOverlap: "string",
VideoScript: "@/lib/common/animethemes/types#ApiVideoScript",
Year: "@/lib/common/animethemes/types#ApiYear",
Bracket: "@/lib/server/animebracket/resolvers#ModelBracket",
BracketRound: "@/lib/server/animebracket/resolvers#ModelBracketRound",
BracketPairing: "@/lib/server/animebracket/resolvers#ModelBracketPairing",
BracketCharacter: "@/lib/server/animebracket/resolvers#ModelBracketCharacter",
},
},
},
},
hooks: {
afterAllFileWrite: ["prettier --write"],
},
};
+5 -5
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
}
}
}
+12 -14
View File
@@ -22,7 +22,7 @@ const nextConfig: NextConfig = {
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
styledComponents: true,
},
staticPageGenerationTimeout: 3600,
experimental: {
@@ -31,9 +31,7 @@ const nextConfig: NextConfig = {
workerThreads: false,
cpus: 1,
},
transpilePackages: [
"ahooks"
],
transpilePackages: ["ahooks"],
async headers() {
return [
{
@@ -41,30 +39,30 @@ const nextConfig: NextConfig = {
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload"
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-XSS-Protection",
value: "1; mode=block"
value: "1; mode=block",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN"
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff"
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin"
}
]
}
value: "origin-when-cross-origin",
},
],
},
];
},
async redirects() {
@@ -80,7 +78,7 @@ const nextConfig: NextConfig = {
permanent: true,
},
];
}
},
};
export default withBundleAnalyzer(nextConfig);
+23000 -21459
View File
File diff suppressed because it is too large Load Diff
+81 -79
View File
@@ -1,81 +1,83 @@
{
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
"prestart": "npm run compile-config",
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
"lint": "next lint",
"type-check": "tsc",
"graphql-codegen": "graphql-codegen --config codegen.ts",
"prepare": "husky"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-switch": "^1.0.1",
"ahooks": "^3.7.6",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"framer-motion": "^11.2.4",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"next": "^13.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"picocolors": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.29.0",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"swr": "^2.2.4",
"unist-util-visit": "^4.1.2",
"use-local-storage-state": "^18.3.0",
"use-session-storage-state": "^18.1.1"
},
"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/md5": "^2.3.2",
"@types/node": "^17.0.41",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"esbuild": "^0.21.3",
"eslint": "7.32.0",
"eslint-config-next": "^13.3.0",
"eslint-plugin-simple-import-sort": "^12.1.0",
"husky": "^9.0.11",
"ts-node": "^10.8.1",
"typescript": "^4.9.5"
}
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
"prestart": "npm run compile-config",
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
"lint": "next lint",
"type-check": "tsc",
"graphql-codegen": "graphql-codegen --config codegen.ts",
"prepare": "husky"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-switch": "^1.0.1",
"ahooks": "^3.7.6",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"framer-motion": "^11.2.4",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"next": "^13.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"picocolors": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.29.0",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"swr": "^2.2.4",
"unist-util-visit": "^4.1.2",
"use-local-storage-state": "^18.3.0",
"use-session-storage-state": "^18.1.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/typescript": "^4.0.7",
"@graphql-codegen/typescript-operations": "^4.2.1",
"@graphql-codegen/typescript-resolvers": "^4.1.0",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.41",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"esbuild": "^0.21.3",
"eslint": "^8.57.0",
"eslint-config-next": "^13.5.6",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-simple-import-sort": "^12.1.0",
"husky": "^9.0.11",
"prettier": "^3.2.5",
"ts-node": "^10.8.1",
"typescript": "^5.4.5"
}
}
+1 -5
View File
@@ -15,11 +15,7 @@ export function LoginGate({ children }: LoginGateProps) {
if (me.user) {
// User is already logged in, so we can safely show the content.
return (
<>
{children}
</>
);
return <>{children}</>;
}
return (
+7 -7
View File
@@ -3,21 +3,21 @@ import styled from "styled-components";
import type { Property } from "csstype";
const Flex = styled.div<{
$wrap?: boolean
$wrap?: boolean;
style?: {
"--justify-content"?: Property.JustifyContent
"--align-items"?: Property.AlignItems
"--gap"?: Property.Gap
}
"--justify-content"?: Property.JustifyContent;
"--align-items"?: Property.AlignItems;
"--gap"?: Property.Gap;
};
}>`
--justify-content: initial;
--align-items: initial;
--gap: initial;
display: flex;
flex-wrap: ${(props) => props.$wrap && "wrap"};
justify-content: var(--justify-content);
align-items: var(--align-items);
gap: var(--gap);
+48 -38
View File
@@ -18,13 +18,13 @@ const StyledBracketContainer = styled.div`
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
@@ -33,7 +33,7 @@ const StyledBracketContainer = styled.div`
const StyledBracket = styled(m.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
@@ -54,7 +54,7 @@ const StyledRound = styled.div`
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
@@ -131,7 +131,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2
nextRect.top + nextRect.height / 2,
);
ctx.stroke();
}
@@ -141,16 +141,18 @@ export function BracketChart({ bracket }: BracketChartProps) {
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject}/>
<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}/>
))}
<StyledCanvas ref={onCanvasInit} />
{bracket.rounds
.sort((a, b) => a.tier - b.tier)
.map((round) => (
<BracketRound key={round.tier} round={round} />
))}
</StyledBracket>
</StyledBracketContainer>
) : null}
@@ -159,7 +161,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
}
interface BracketRoundProps {
round: BracketChartProps["bracket"]["rounds"][number]
round: BracketChartProps["bracket"]["rounds"][number];
}
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
@@ -170,49 +172,57 @@ const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings}/>
<BracketPairings pairings={round.pairings} />
</StyledRound>
);
});
interface BracketPairingsProps {
pairings: BracketRoundProps["round"]["pairings"]
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>
));
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
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);
const isWinner =
isVoted &&
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
@@ -18,13 +18,13 @@ import extractImages from "@/utils/extractImages";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
@@ -34,14 +34,21 @@ const StyledRank = styled(Text)`
`;
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
contestant: BracketThemeSummaryCardConstestantFragment
isVoted: boolean
isWinner: boolean
seed: number | null
votes: number | null
contestant: BracketThemeSummaryCardConstestantFragment;
isVoted: boolean;
isWinner: boolean;
seed: number | null;
votes: number | null;
}
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
export function BracketThemeSummaryCard({
contestant,
isVoted,
isWinner,
seed,
votes,
...props
}: BracketThemeSummaryCardProps) {
const theme = contestant.theme;
const { smallCover } = extractImages(theme?.anime);
@@ -66,7 +73,7 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : contestant.name}
description={description}
image={smallCover ?? contestant.image}
to={to}
@@ -74,21 +81,18 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<Icon icon={faSeedling} />
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<Icon icon={faUsers} />
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
</StyledSummaryCardWrapper>
);
}
@@ -96,7 +100,7 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
BracketThemeSummaryCard.fragments = {
contestant: gql`
${ThemeSummaryCard.fragments.theme}
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
name
source
+6 -6
View File
@@ -1,4 +1,4 @@
import React, { useContext,useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import styled from "styled-components";
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
@@ -11,12 +11,12 @@ import { withHover } from "@/styles/mixins";
import theme from "@/theme";
const ScrollButton = styled(m(Button))<{ $bottomOffset: number }>`
position: fixed;
right: 16px;
bottom: ${(props) => 16 + props.$bottomOffset}px;
padding: 16px;
position: fixed;
right: 16px;
bottom: ${(props) => 16 + props.$bottomOffset}px;
padding: 16px;
${withHover`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-primary"]};
`}
+20 -33
View File
@@ -9,19 +9,16 @@ import theme from "@/theme";
export const Button = forwardRef(ButtonWithRef);
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
children?: ReactNode
variant?: "solid" | "primary" | "warning" | "silent"
isCircle?: boolean
disabled?: boolean
children?: ReactNode;
variant?: "solid" | "primary" | "warning" | "silent";
isCircle?: boolean;
disabled?: boolean;
}
function ButtonWithRef({
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
function ButtonWithRef(
{ variant = "solid", isCircle = false, disabled = false, title, ...props }: ButtonProps,
ref: ForwardedRef<HTMLButtonElement>,
) {
let Component;
if (variant === "solid") {
Component = SolidButton;
@@ -35,48 +32,39 @@ function ButtonWithRef({
throw new Error(`Unknown button variant "${variant}"!`);
}
return (
<Component
$isCircle={isCircle}
disabled={disabled}
title={title}
aria-label={title}
ref={ref}
{...props}
/>
);
return <Component $isCircle={isCircle} disabled={disabled} title={title} aria-label={title} ref={ref} {...props} />;
}
const BaseButton = styled.button<{ $isCircle: boolean }>`
--gap: 0;
--focus-ring-color: ${theme.colors["text-primary"]};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
pointer-events: ${(props) => props.disabled && "none"};
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1rem;
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
border-radius: 999px;
gap: var(--gap, 0);
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
opacity: ${(props) => props.disabled && "0.5"};
box-shadow: ${theme.shadows.low};
transition: background-color 250ms;
// Buttons within other buttons should have a special margin and no shadow.
& & {
box-shadow: none;
margin: -8px 8px -8px -16px;
}
&:focus:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-color);
}
@@ -90,7 +78,7 @@ const PrimaryButton = styled(BaseButton)`
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
@@ -99,7 +87,7 @@ const PrimaryButton = styled(BaseButton)`
const WarningButton = styled(BaseButton)`
--focus-ring-color: ${theme.colors["text-warning"]};
background-color: ${theme.colors["solid-warning"]};
color: ${theme.colors["text-on-warning"]};
@@ -107,7 +95,7 @@ const WarningButton = styled(BaseButton)`
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
@@ -121,7 +109,7 @@ const SolidButton = styled(BaseButton)`
${withHover`
color: ${theme.colors["text"]};
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
@@ -153,4 +141,3 @@ const SilentButton = styled(BaseButton)`
`}
}
`;
+3 -1
View File
@@ -6,6 +6,8 @@ import { IconTextButton } from "@/components/button/IconTextButton";
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
<IconTextButton icon={faFilter} collapsible {...props}>
Filter
</IconTextButton>
);
}
+24 -25
View File
@@ -10,11 +10,11 @@ import theme from "@/theme";
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
aspect-ratio: 1 / 1;
padding: 8px;
@@ -28,33 +28,32 @@ const StyledText = styled.span<{ $collapseBreakpoint: string }>`
`;
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
icon: IconDefinition | ReactNode
children?: ReactNode
collapsible?: true | keyof typeof theme.breakpoints
icon: IconDefinition | ReactNode;
children?: ReactNode;
collapsible?: true | keyof typeof theme.breakpoints;
}
export const IconTextButton = forwardRef(
function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) {
let collapseBreakpoint = "0px";
export const IconTextButton = forwardRef(function IconTextButton(
{ icon, children, collapsible, ...props }: IconTextButtonProps,
ref,
) {
let collapseBreakpoint = "0px";
if (collapsible === true) {
collapseBreakpoint = theme.breakpoints.mobileMax;
} else if (collapsible) {
collapseBreakpoint = theme.breakpoints[collapsible];
}
return (
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
{isIconDefinition(icon) ? (
<Icon icon={icon} color="text-disabled"/>
) : icon}
{(children !== null && children !== undefined) ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
if (collapsible === true) {
collapseBreakpoint = theme.breakpoints.mobileMax;
} else if (collapsible) {
collapseBreakpoint = theme.breakpoints[collapsible];
}
);
return (
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
{isIconDefinition(icon) ? <Icon icon={icon} color="text-disabled" /> : icon}
{children !== null && children !== undefined ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
});
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
return !!icon && typeof icon === "object" && "icon" in icon;
+12 -13
View File
@@ -13,32 +13,31 @@ import type {
VideoButtonAnimeFragment,
VideoButtonEntryFragment,
VideoButtonThemeFragment,
VideoButtonVideoFragment
VideoButtonVideoFragment,
} from "@/generated/graphql";
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
anime: VideoButtonAnimeFragment
theme: VideoButtonThemeFragment
entry: VideoButtonEntryFragment
video: VideoButtonVideoFragment
anime: VideoButtonAnimeFragment;
theme: VideoButtonThemeFragment;
entry: VideoButtonEntryFragment;
video: VideoButtonVideoFragment;
}
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentWatchListItem } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentWatchListItem ? getVideoSlugByWatchListItem(currentWatchListItem) === videoSlug : false;
const isPlaying = currentWatchListItem
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
: false;
return (
<Link
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior>
<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}/>
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined} />
</Button>
<VideoTags video={video} hideTextOnMobile/>
<VideoTags video={video} hideTextOnMobile />
</Button>
</Link>
);
@@ -67,7 +66,7 @@ VideoButton.fragments = {
video: gql`
${createVideoSlug.fragments.video}
${VideoTags.fragments.video}
fragment VideoButtonVideo on Video {
...createVideoSlugVideo
...VideoTagsVideo
+25 -22
View File
@@ -19,7 +19,7 @@ import theme from "@/theme";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledThemeContainerInline = styled.div`
@@ -32,7 +32,7 @@ const StyledThemeContainerInline = styled.div`
right: 16px;
opacity: 0;
transition-property: opacity;
user-select: none;
${StyledWrapper}:hover & {
@@ -40,7 +40,7 @@ const StyledThemeContainerInline = styled.div`
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
@@ -60,20 +60,25 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
type AnimeSummaryCardProps = {
anime: AnimeSummaryCardAnimeFragment
expandable?: false
} | {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
expandable: true
};
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 } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniqBy(anime.themes.map((theme) => theme.group), (group) => group?.slug);
const groups = uniqBy(
anime.themes.map((theme) => theme.group),
(group) => group?.slug,
);
const animeLink = `/anime/${anime.slug}`;
@@ -87,11 +92,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
const description = (
<SummaryCard.Description>
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && (
<TextLink href={premiereLink}>
{premiere}
</TextLink>
)}
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
@@ -140,10 +141,12 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
<StyledThemeGroupContainer>
{groups.map((group) => (
<Fragment key={group?.slug}>
{!!group && (
<Text variant="h2">{group.name}</Text>
)}
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group?.slug === group?.slug)}/>
{!!group && <Text variant="h2">{group.name}</Text>}
<ThemeTable
themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter(
(theme) => theme.group?.slug === group?.slug,
)}
/>
</Fragment>
))}
</StyledThemeGroupContainer>
@@ -156,7 +159,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
AnimeSummaryCard.fragments = {
anime: gql`
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
@@ -184,5 +187,5 @@ AnimeSummaryCard.fragments = {
}
}
}
`
`,
};
+1 -3
View File
@@ -8,7 +8,5 @@ interface AnnouncementCardProps {
}
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
return (
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
);
return <Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />;
}
+5 -12
View File
@@ -5,8 +5,8 @@ import type { ArtistSummaryCardArtistFragment } from "@/generated/graphql";
import extractImages from "@/utils/extractImages";
type ArtistSummaryCardProps = {
artist: ArtistSummaryCardArtistFragment
as?: string | null
artist: ArtistSummaryCardArtistFragment;
as?: string | null;
};
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
@@ -15,19 +15,12 @@ export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!as && (
<span>As {as}</span>
)}
{!!as && <span>As {as}</span>}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
);
}
@@ -41,5 +34,5 @@ ArtistSummaryCard.fragments = {
facet
}
}
`
`,
};
+11 -9
View File
@@ -6,12 +6,12 @@ import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Card = styled(Solid)<{
hoverable?: boolean
color?: keyof Colors
hoverable?: boolean;
color?: keyof Colors;
}>`
display: block;
position: relative;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
@@ -22,14 +22,16 @@ export const Card = styled(Solid)<{
background-color: ${theme.colors["solid-on-card"]};
}
${(props) => props.hoverable && css`
cursor: pointer;
${(props) =>
props.hoverable &&
css`
cursor: pointer;
${withHover`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`}
`}
`}
&:before {
content: " ";
display: block;
@@ -38,6 +40,6 @@ export const Card = styled(Solid)<{
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
background-color: ${(props) => (props.color ? theme.colors[props.color] : theme.colors["text-primary"])};
}
`;
+7 -6
View File
@@ -21,7 +21,7 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
`;
interface ErrorCardProps {
error: unknown
error: unknown;
}
export function ErrorCard({ error }: ErrorCardProps) {
@@ -29,14 +29,15 @@ export function ErrorCard({ error }: ErrorCardProps) {
<StyledCard color="text-warning">
<Row style={{ "--gap": "1rem" }}>
<Text color="text-warning">
<Icon 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>
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
</Row>
<pre>
<StyledErrorMessage>
{JSON.stringify(error, null, 2)}
</StyledErrorMessage>
<StyledErrorMessage>{JSON.stringify(error, null, 2)}</StyledErrorMessage>
</pre>
</StyledCard>
);
+25 -21
View File
@@ -10,7 +10,7 @@ import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerF
import theme from "@/theme";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledOverlayButtons = styled.div`
@@ -31,26 +31,32 @@ const StyledOverlayButtons = styled.div`
}
`;
type PlaylistSummaryCardProps = {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
} | {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
type PlaylistSummaryCardProps =
| {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
}
| {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
export default function PlaylistSummaryCard({
playlist,
children,
menu,
showOwner,
...props
}: PropsWithChildren<PlaylistSummaryCardProps>) {
const description = (
<SummaryCard.Description>
<span>Playlist</span>
{showOwner ? (
<Text link>{playlist.user.name}</Text>
) : (
<span>{playlist.visibility}</span>
)}
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
{showOwner ? <Text link>{playlist.user.name}</Text> : <span>{playlist.visibility}</span>}
<span>
{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}
</span>
</SummaryCard.Description>
);
@@ -59,9 +65,7 @@ export default function PlaylistSummaryCard({ playlist, children, menu, showOwne
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
{children}
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
@@ -75,7 +79,7 @@ PlaylistSummaryCard.fragments = {
name
visibility
tracks_count
}
}
`,
showOwner: gql`
fragment PlaylistSummaryCardShowOwner on Playlist {
+3 -3
View File
@@ -33,7 +33,7 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
imageProps={{
objectFit: "contain",
backgroundColor,
onLoad: handleLoad
onLoad: handleLoad,
}}
/>
);
@@ -42,11 +42,11 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
StudioSummaryCard.fragments = {
studio: gql`
${extractImages.fragments.resourceWithImages}
fragment StudioSummaryCardStudio on Studio {
slug
name
...extractImagesResourceWithImages
}
`,
};
};
+41 -48
View File
@@ -24,60 +24,54 @@ const StyledSummaryCard = styled(Card)`
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
loading: "lazy",
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
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)}
${(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
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);
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>}
>
<ConditionalWrapper condition={!!to} wrap={(children) => <Link href={to as string}>{children}</Link>}>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
@@ -102,16 +96,15 @@ export function SummaryCard({
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" && to ?
<TextLink href={to}>{title}</TextLink> : title}
{typeof title === "string" && to ? <TextLink href={to}>{title}</TextLink> : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
<SummaryCard.Description>{[description]}</SummaryCard.Description>
) : (
description
)}
</Text>
)}
</StyledBody>
@@ -121,20 +114,20 @@ export function SummaryCard({
}
type SummaryCardDescriptionProps = {
children: Array<ReactNode>
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>
))}
{children
.filter((child) => child)
.map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && <span> &bull; </span>}
</Text>
))}
</>
);
};
+32 -29
View File
@@ -21,43 +21,44 @@ const StyledSummaryCard = styled(Card)`
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
loading: "lazy",
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
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)}
${(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> & {
children?: ReactNode
children?: ReactNode;
};
export function SummaryCard({ children, ...props }: SummaryCardProps) {
return (
<StyledSummaryCard {...props}>
{children}
</StyledSummaryCard>
);
return <StyledSummaryCard {...props}>{children}</StyledSummaryCard>;
}
SummaryCard.Body = StyledBody;
@@ -75,20 +76,22 @@ SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCar
};
interface SummaryCardDescriptionProps {
children: string | Array<ReactNode>
children: string | Array<ReactNode>;
}
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<Text variant="small" maxLines={1} color="text-muted">
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
<>
{child}
{index < length - 1 && (
<span> &bull; </span>
)}
</>
))}
{typeof children === "string"
? children
: children
.filter((child) => child)
.map((child, index, { length }) => (
<>
{child}
{index < length - 1 && <span> &bull; </span>}
</>
))}
</Text>
);
};
@@ -98,8 +101,8 @@ interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCo
}
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
const [imageNotFound, setImageNotFound] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
return (
<StyledCover
+20 -21
View File
@@ -18,7 +18,7 @@ import { entryVersionComparator } from "@/utils/comparators";
const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
@@ -26,7 +26,7 @@ const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
@@ -39,14 +39,14 @@ const StyledVideoListContainer = styled.div`
const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
interface ThemeDetailCardProps {
theme: ThemeDetailCardThemeFragment
theme: ThemeDetailCardThemeFragment;
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
@@ -59,30 +59,29 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
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 variant="small" color="text">
{theme.type}
{theme.sequence || null}
</Text>
<ThemeMenu theme={theme}/>
<Text>
<SongTitle song={theme.song} />
<Performances song={theme.song} expandable />
</Text>
<ThemeMenu theme={theme} />
</StyledRow>
{theme.entries.sort(entryVersionComparator).map(entry => (
{theme.entries.sort(entryVersionComparator).map((entry) => (
<StyledRow key={entry.version || 0}>
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
<Text variant="small" color="text-muted">
{!!entry.version && `v${entry.version}`}
</Text>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
<ThemeEntryTags entry={entry} />
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={anime}
theme={theme}
entry={entry}
video={video}
/>
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
))}
</StyledVideoList>
)}
@@ -97,7 +96,7 @@ ThemeDetailCard.fragments = {
theme: gql`
${ThemeMenu.fragments.theme}
${VideoTags.fragments.video}
fragment ThemeDetailCardTheme on Theme {
...ThemeMenuTheme
type
@@ -132,5 +131,5 @@ ThemeDetailCard.fragments = {
}
}
}
`
`,
};
+54 -32
View File
@@ -21,7 +21,7 @@ import type {
ThemeSummaryCardArtistFragment,
ThemeSummaryCardQuery,
ThemeSummaryCardThemeExpandableFragment,
ThemeSummaryCardThemeFragment
ThemeSummaryCardThemeFragment,
} from "@/generated/graphql";
import useMediaQuery from "@/hooks/useMediaQuery";
import useToggle from "@/hooks/useToggle";
@@ -31,7 +31,7 @@ import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledOverlayButtons = styled.div`
@@ -67,20 +67,29 @@ const StyledPerformedWith = styled.div`
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
type ThemeSummaryCardProps = {
theme: ThemeSummaryCardThemeFragment
artist?: ThemeSummaryCardArtistFragment
expandable?: false
onPlay?(entryIndex?: number, videoIndex?: number): void
} | {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
artist?: ThemeSummaryCardArtistFragment
expandable: true
onPlay?(entryIndex?: number, videoIndex?: number): void
};
type ThemeSummaryCardProps =
| {
theme: ThemeSummaryCardThemeFragment;
artist?: ThemeSummaryCardArtistFragment;
expandable?: false;
onPlay?(entryIndex?: number, videoIndex?: number): void;
}
| {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment;
artist?: ThemeSummaryCardArtistFragment;
expandable: true;
onPlay?(entryIndex?: number, videoIndex?: number): void;
};
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
export function ThemeSummaryCard({
theme,
artist,
children,
expandable,
onPlay,
...props
}: PropsWithChildren<ThemeSummaryCardProps>) {
const [isExpanded, toggleExpanded] = useToggle();
const isMobile = useIsMobile();
@@ -125,13 +134,17 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
<Performances song={theme.song} artist={artist} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group.name})`}</span>
<span>
{theme.type}
{theme.sequence || null}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
<ThemeMenu theme={theme} />
{expandable && (
<StyledExpandButton
variant="silent"
@@ -151,7 +164,10 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
<ThemeTable
themes={[theme]}
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
/>
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<TableHead>
@@ -166,11 +182,14 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
key={performance.artist.slug}
href={`/artist/${performance.artist.slug}`}
passHref
legacyBehavior>
legacyBehavior
>
<TableRow as="a">
<TableCell>
<Text color="text-primary" weight="600">
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
{performance.as
? `${performance.as} (CV: ${performance.artist.name})`
: performance.artist.name}
</Text>
</TableCell>
</TableRow>
@@ -222,7 +241,7 @@ ThemeSummaryCard.fragments = {
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCardArtist on Artist {
...SongTitleWithArtistsArtist
}
@@ -233,23 +252,26 @@ ThemeSummaryCard.fragments = {
fragment ThemeSummaryCardThemeExpandable on Theme {
...ThemeTableTheme
}
`
`,
};
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
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) {
...ThemeSummaryCardTheme
anime {
year
season
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(
gql`
${ThemeSummaryCard.fragments.theme}
query ThemeSummaryCard($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCardTheme
anime {
year
season
}
}
}
}
`, { themeId: id }).then((result) => result.data?.theme ?? null);
`,
{ themeId: id },
).then((result) => result.data?.theme ?? null);
};
+48 -46
View File
@@ -45,11 +45,11 @@ const StyledCoverLink = styled(Link)`
const StyledCoverOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
`;
@@ -61,52 +61,54 @@ interface VideoSummaryCardProps {
isPlaying?: boolean;
}
export const VideoSummaryCard = forwardRef(
function VideoSummaryCard({ video, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme?.anime;
export const VideoSummaryCard = forwardRef(function VideoSummaryCard(
{ video, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{videoSlug}{theme.group && ` (${theme.group.name})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
{append}
</SummaryCard>
</StyledWrapper>
);
if (!entry || !theme || !anime) {
return null;
}
);
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>
{videoSlug}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
) : null}
{append}
</SummaryCard>
</StyledWrapper>
);
});
export const VideoSummaryCardFragmentVideo = gql`
${SongTitleWithArtists.fragments.song}
+1 -1
View File
@@ -10,7 +10,7 @@ export const SidebarContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
}
// This will prevent columns from overflowing
& > * {
min-width: 0;
@@ -22,27 +22,25 @@ const StyledValue = styled.dd`
`;
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
children: ReactNode
children: ReactNode;
}
export function DescriptionList({ children, ...props }: DescriptionListProps) {
return (
<StyledDescriptionList {...props}>
{children}
</StyledDescriptionList>
);
return <StyledDescriptionList {...props}>{children}</StyledDescriptionList>;
}
interface DescriptionListItemProps {
title: string
children: ReactNode
title: string;
children: ReactNode;
}
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
return (
<>
<StyledKey>
<Text as="span" variant="h2">{title}</Text>
<Text as="span" variant="h2">
{title}
</Text>
</StyledKey>
<StyledValue>{children}</StyledValue>
</>
+23 -22
View File
@@ -38,11 +38,11 @@ const StyledDialogCard = styled(Card)`
width: 100%;
max-width: 450px;
animation: ${contentAnimation} 250ms;
margin: auto;
padding: 24px;
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
&:before {
display: none;
}
@@ -57,26 +57,27 @@ interface DialogContentProps extends RadixDialog.DialogContentProps {
title?: string;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
function DialogContent({ title, children, ...props }, ref) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? (
<StyledHeader>
<Text variant="h2">{title}</Text>
</StyledHeader>
) : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
);
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(function DialogContent(
{ title, children, ...props },
ref,
) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? (
<StyledHeader>
<Text variant="h2">{title}</Text>
</StyledHeader>
) : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
});
export const Dialog = RadixDialog.Root;
+9 -15
View File
@@ -27,16 +27,13 @@ export function ForgotPasswordDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Text variant="small" link color="text-muted">Forgot Password?</Text>
<Text variant="small" link color="text-muted">
Forgot Password?
</Text>
</DialogTrigger>
<DialogContent title="Request a Password Reset">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ForgotPasswordForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
{open ? <ForgotPasswordForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -72,10 +69,7 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
email,
});
dispatchToast(
"forgot-password-sent",
<Toast>We sent you an e-mail with a password reset link.</Toast>
);
dispatchToast("forgot-password-sent", <Toast>We sent you an e-mail with a password reset link.</Toast>);
onSuccess();
} catch (error) {
@@ -103,12 +97,12 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
{errors.email ? <Text color="text-warning">{errors.email}</Text> : null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Submit</Busy>
</Button>
+18 -19
View File
@@ -11,7 +11,7 @@ import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth from "@/hooks/useAuth";
import useAuth, { type LoginErrors } from "@/hooks/useAuth";
const StyledForm = styled.form`
display: flex;
@@ -29,9 +29,7 @@ export function LoginDialog() {
</DialogTrigger>
<DialogContent title="Login">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginForm onCancel={() => setOpen(false)} />
) : null}
{open ? <LoginForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -51,9 +49,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
const isValid = email && password;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
const [errors, setErrors] = useState<LoginErrors>({});
function performLogin(event: SyntheticEvent) {
event.preventDefault();
@@ -65,8 +61,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
email,
password,
remember: isRemember,
})
.finally(() => setBusy(false));
}).finally(() => setBusy(false));
}
return (
@@ -83,9 +78,13 @@ function LoginForm({ onCancel }: LoginFormProps) {
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
@@ -100,15 +99,15 @@ function LoginForm({ onCancel }: LoginFormProps) {
<ForgotPasswordDialog />
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-remember"
isChecked={isRemember}
onCheckedChange={setRemember}
/>
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
<Switch id="input-remember" isChecked={isRemember} onCheckedChange={setRemember} />
<Text as="label" htmlFor="input-remember">
Remember my login on this device.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Login</Busy>
</Button>
+24 -25
View File
@@ -26,12 +26,7 @@ export function PasswordChangeDialog() {
</DialogTrigger>
<DialogContent title="Change Password">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<PasswordChangeForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
{open ? <PasswordChangeForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -70,19 +65,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
setErrors({});
try {
await axios.put(
`${AUTH_PATH}/user/password`,
{
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
}
);
await axios.put(`${AUTH_PATH}/user/password`, {
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
});
dispatchToast(
"password-change",
<Toast>Password changed successfully.</Toast>
);
dispatchToast("password-change", <Toast>Password changed successfully.</Toast>);
onSuccess();
} catch (error) {
@@ -110,9 +99,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
required: true,
}}
/>
{errors.current_password ? errors.current_password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.current_password
? errors.current_password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
@@ -124,9 +117,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
@@ -140,7 +137,9 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Change Password</Busy>
</Button>
+20 -16
View File
@@ -26,10 +26,7 @@ export function PasswordResetDialog() {
return (
<Dialog open={open}>
<DialogContent title="Reset Password">
<PasswordResetForm
onSuccess={() => router.push("/profile")}
onCancel={() => router.push("/")}
/>
<PasswordResetForm onSuccess={() => router.push("/profile")} onCancel={() => router.push("/")} />
</DialogContent>
</Dialog>
);
@@ -75,13 +72,10 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
email,
password: newPassword,
password_confirmation: newPasswordConfirmation,
token,
token: (Array.isArray(token) ? token[0] : token) ?? "",
});
dispatchToast(
"password-reset",
<Toast>Password reset successfully.</Toast>
);
dispatchToast("password-reset", <Toast>Password reset successfully.</Toast>);
onSuccess();
} catch (error) {
@@ -109,9 +103,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
@@ -123,9 +121,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
@@ -139,7 +141,9 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Reset Password</Busy>
</Button>
+12 -11
View File
@@ -29,17 +29,16 @@ export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
<IconTextButton icon={faPlus} collapsible>
New
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Create a new playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistAddForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
<PlaylistAddForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
@@ -95,10 +94,7 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
@@ -109,13 +105,18 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
<Text color="text-warning">
<strong>The playlist could not be created: </strong>
{error}
</Text>
) : null}
</Column>
</StyledForm>
+14 -13
View File
@@ -32,7 +32,9 @@ export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProp
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
<IconTextButton icon={faPen} variant="solid">
Edit Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Edit playlist details">
@@ -91,12 +93,9 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
name: title,
visibility,
});
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
@@ -115,10 +114,7 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
@@ -129,13 +125,18 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
<Text color="text-warning">
<strong>The playlist could not be updated: </strong>
{error}
</Text>
) : null}
</Column>
</StyledForm>
+14 -7
View File
@@ -30,7 +30,9 @@ export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialog
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
<IconTextButton icon={faMinus} variant="solid" collapsible>
Delete playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Delete playlist">
@@ -84,20 +86,25 @@ function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFor
setBusy(false);
}
dispatchToast(
`playlist-remove-${playlist.id}`,
<PlaylistRemoveToast playlist={playlist} />
);
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<Text>
Do you really want to delete{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<PlaylistSummaryCard playlist={playlist} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
<Busy isBusy={isBusy}>Delete playlist</Busy>
</Button>
@@ -23,7 +23,7 @@ import { useToasts } from "@/context/toastContext";
import type {
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery,
PlaylistTrackAddFormPlaylistQueryVariables
PlaylistTrackAddFormPlaylistQueryVariables,
} from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client";
import axios from "@/lib/client/axios";
@@ -40,17 +40,16 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} variant="solid" collapsible>Add to Playlist</IconTextButton>
<IconTextButton icon={faPlus} variant="solid" collapsible>
Add to Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackAddForm
video={video}
onCancel={() => setOpen(false)}
/>
<PlaylistTrackAddForm video={video} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
@@ -62,7 +61,7 @@ PlaylistTrackAddDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
@@ -77,12 +76,14 @@ interface PlaylistTrackAddFormProps {
}
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
const { data: playlists } = useSWR(
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
async () => {
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
const { data: playlists } = useSWR(["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id], async () => {
const { data } = await fetchDataClient<
PlaylistTrackAddFormPlaylistQuery,
PlaylistTrackAddFormPlaylistQueryVariables
>(
gql`
${PlaylistSummaryCard.fragments.playlist}
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
me {
playlistAll {
@@ -98,18 +99,21 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
}
}
}
`, { filterVideoId: video.id });
`,
{ filterVideoId: video.id },
);
const { playlistAll, playlistAllFiltered } = data.me;
const { playlistAll, playlistAllFiltered } = data.me;
return playlistAll?.map((playlist) => {
return (
playlistAll?.map((playlist) => {
return {
...playlist,
...playlistAllFiltered?.find((p) => p.id === playlist.id)
...playlistAllFiltered?.find((p) => p.id === playlist.id),
};
}) ?? [];
},
);
}) ?? []
);
});
if (!playlists) {
return (
@@ -129,32 +133,33 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
<Column style={{ "--gap": "16px" }}>
{playlists?.length ? playlists.map((playlist) => (
<PlaylistTrackAddCard
key={playlist.id}
playlist={playlist}
video={video}
/>
)) : (
{playlists?.length ? (
playlists.map((playlist) => (
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} />
))
) : (
<Text>You have not created a playlist, yet.</Text>
)}
<PlaylistAddDialog trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
} />
<PlaylistAddDialog
trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
}
/>
</Column>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
</Row>
</Column>
);
}
interface PlaylistTrackAddCardProps {
playlist:
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
playlist: NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
video: PlaylistTrackAddDialogVideoFragment;
}
@@ -168,20 +173,17 @@ function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
setBusy(true);
try {
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id, });
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id });
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} video={video} />
<PlaylistTrackAddToast playlist={playlist} video={video} />,
);
}
@@ -196,38 +198,27 @@ function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
try {
await axios.delete(`/playlist/${playlist.id}/track/${track.id}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
<PlaylistTrackRemoveToast playlist={playlist} video={video} />,
);
}
return (
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{!playlist.tracks?.length ? (
<IconTextButton
icon={faPlus}
disabled={isBusy}
onClick={addTrackToPlaylist}
>
<IconTextButton icon={faPlus} disabled={isBusy} onClick={addTrackToPlaylist}>
<Busy isBusy={isBusy}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton
icon={faMinus}
disabled={isBusy}
onClick={removeTrackFromPlaylist}
>
<IconTextButton icon={faMinus} disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove</Busy>
</IconTextButton>
)}
@@ -17,7 +17,7 @@ import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import type {
PlaylistTrackRemoveDialogPlaylistFragment,
PlaylistTrackRemoveDialogVideoFragment
PlaylistTrackRemoveDialogVideoFragment,
} from "@/generated/graphql";
import axios from "@/lib/client/axios";
@@ -35,7 +35,9 @@ export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }:
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
<IconTextButton icon={faMinus} variant="solid" collapsible>
Remove from Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Remove from playlist">
@@ -69,7 +71,7 @@ PlaylistTrackRemoveDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
@@ -95,30 +97,35 @@ function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel
try {
await axios.delete(`/playlist/${playlist.id}/track/${trackId}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
<PlaylistTrackRemoveToast playlist={playlist} video={video} />,
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<Text>
Do you really want to remove this video from{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<VideoSummaryCard video={video} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove from playlist</Busy>
</Button>
+40 -30
View File
@@ -11,7 +11,7 @@ import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth from "@/hooks/useAuth";
import useAuth, { type RegisterErrors } from "@/hooks/useAuth";
export function RegisterDialog() {
const [open, setOpen] = useState(false);
@@ -23,9 +23,7 @@ export function RegisterDialog() {
</DialogTrigger>
<DialogContent title="Create a new account">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<RegisterForm onCancel={() => setOpen(false)} />
) : null}
{open ? <RegisterForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -53,11 +51,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
password?: string[]
}>({});
const [errors, setErrors] = useState<RegisterErrors>({});
function performRegister(event: SyntheticEvent) {
event.preventDefault();
@@ -71,8 +65,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
password,
password_confirmation: passwordConfirmation,
terms: isTermsAccepted,
})
.finally(() => setBusy(false));
}).finally(() => setBusy(false));
}
return (
@@ -88,9 +81,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.name ? errors.name.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail</Text>
@@ -102,9 +99,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
@@ -116,9 +117,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm Password</Text>
@@ -132,18 +137,23 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
/>
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-terms"
isChecked={isTermsAccepted}
onCheckedChange={setTermsAccepted}
/>
<Text as="label" htmlFor="input-terms">I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
<Switch id="input-terms" isChecked={isTermsAccepted} onCheckedChange={setTermsAccepted} />
<Text as="label" htmlFor="input-terms">
I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>
Terms of Service
</Text>{" "}
and{" "}
<Text as={Link} href="/about/privacy-policy" link>
Privacy Policy
</Text>
.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Account</Busy>
</Button>
@@ -152,4 +162,4 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
</StyledForm>
</>
);
}
}
+16 -13
View File
@@ -21,17 +21,10 @@ export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger}
</DialogTrigger>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent title="Shuffle">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ShuffleForm
onCancel={() => setOpen(false)}
onSuccess={() => setOpen(false)}
/>
) : null}
{open ? <ShuffleForm onCancel={() => setOpen(false)} onSuccess={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -39,7 +32,7 @@ export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
const StyledForm = styled.form`
position: relative;
display: flex;
flex-direction: column;
gap: 32px;
@@ -89,15 +82,25 @@ function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered After</Text>
<Input value={filterAnimeYearMin} onChange={setFilterAnimeYearMin} inputProps={{ type: "number", placeholder: "1900" }} />
<Input
value={filterAnimeYearMin}
onChange={setFilterAnimeYearMin}
inputProps={{ type: "number", placeholder: "1900" }}
/>
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered Before</Text>
<Input value={filterAnimeYearMax} onChange={setFilterAnimeYearMax} inputProps={{ type: "number", placeholder: "2100" }} />
<Input
value={filterAnimeYearMax}
onChange={setFilterAnimeYearMax}
inputProps={{ type: "number", placeholder: "2100" }}
/>
</SearchFilter>
</SearchFilterGroup>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isBusy}>
<Busy isBusy={isBusy}>Start Shuffle</Busy>
</Button>
+24 -27
View File
@@ -28,12 +28,7 @@ export function UserInformationDialog() {
</DialogTrigger>
<DialogContent title="Change User Information">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<UserInformationForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
{open ? <UserInformationForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -72,21 +67,13 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
setErrors({});
try {
await axios.put(
`${AUTH_PATH}/user/profile-information`,
{
name: username,
email: email,
}
);
await mutate((key) => (
[key].flat().some((key) => key === "/api/me")
));
await axios.put(`${AUTH_PATH}/user/profile-information`, {
name: username,
email: email,
});
await mutate((key) => [key].flat().some((key) => key === "/api/me"));
dispatchToast(
"email-change",
<Toast>User information changed successfully.</Toast>
);
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
onSuccess();
} catch (error) {
@@ -113,9 +100,13 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
required: true,
}}
/>
{errors.name ? errors.name.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail Address</Text>
@@ -127,12 +118,18 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update</Busy>
</Button>
@@ -10,7 +10,7 @@ import theme from "@/theme";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
@@ -26,7 +26,7 @@ const EventButton = styled(Button)`
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
@@ -39,15 +39,15 @@ const GradientBorder = styled.div`
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: '<angle>';
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
@@ -57,10 +57,10 @@ const GradientBorder = styled.div`
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
@@ -77,10 +77,12 @@ export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps)
<Link href="/event/anime-awards" passHref legacyBehavior>
<EventButton forwardedAs="a">
<EventIcon icon={faTrophy} />
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
<Text>
<Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!
</Text>
<Icon icon={faArrowRight} color="text-primary" />
</EventButton>
</Link>
</EventButtonContainer>
);
}
}
@@ -6,8 +6,8 @@ import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
href?: string | null
children: ReactNode
href?: string | null;
children: ReactNode;
}
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
@@ -16,7 +16,7 @@ export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
<Text>{children}</Text>
<Text noWrap>
&nbsp;
<Icon icon={faChevronCircleRight}/>
<Icon icon={faChevronCircleRight} />
</Text>
</Text>
);
+25 -39
View File
@@ -38,11 +38,11 @@ const StyledWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
position: relative;
@media (max-width: ${theme.breakpoints.mobileMax}) {
margin-inline-start: -16px;
margin-inline-end: -16px;
@@ -63,7 +63,7 @@ const StyledOverflowHidden = styled(Link)`
const StyledCenter = styled.div`
position: absolute;
width: 400px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
@@ -91,12 +91,12 @@ const StyledCover = styled.img`
const StyledGrillContainer = styled.div`
position: absolute;
height: 130%;
bottom: 0;
right: 32px;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
right: 0;
}
@@ -108,10 +108,10 @@ const StyledGrill = styled.img`
object-fit: contain;
object-position: bottom;
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 1s;
transform: translateY(10%);
&:hover {
transform: none;
}
@@ -127,8 +127,8 @@ interface FeaturedThemeProps {
}
export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
const [ grill, setGrill ] = useState<string | null>(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const [grill, setGrill] = useState<string | null>(null);
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
useEffect(() => {
if (hasGrill) {
@@ -136,26 +136,21 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
}
}, [hasGrill]);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
? StyledWrapper
: Box;
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED ? StyledWrapper : Box;
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
? (
<StyledCenter>
{card ?? <ThemeSummaryCard theme={theme}/>}
</StyledCenter>
)
: (
card ?? <ThemeSummaryCard theme={theme}/>
const featuredThemeSummaryCard =
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
<StyledCenter>{card ?? <ThemeSummaryCard theme={theme} />}</StyledCenter>
) : (
card ?? <ThemeSummaryCard theme={theme} />
);
return (
<FeaturedThemeWrapper>
<FeaturedThemeBackground theme={theme} onPlay={onPlay}/>
<FeaturedThemeBackground theme={theme} onPlay={onPlay} />
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
<StyledGrillContainer>
<StyledGrill src={grill}/>
<StyledGrill src={grill} />
</StyledGrillContainer>
)}
{featuredThemeSummaryCard}
@@ -164,9 +159,9 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
}
function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability();
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const [fallbackToCover, setFallbackToCover] = useState(false);
const { smallCover: featuredCover } = extractImages(theme.anime);
if (!theme.anime || !theme.entries.length) {
@@ -187,24 +182,15 @@ function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledVideo
key={video.basename}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
<StyledVideo key={video.basename} autoPlay muted loop onError={() => setFallbackToCover(true)}>
<source src={`${VIDEO_URL}/${video.basename}`} type={`video/webm; codecs="vp8, vp9, opus`} />
</StyledVideo>
</StyledOverflowHidden>
);
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledCover src={featuredCover}/>
<StyledCover src={featuredCover} />
</StyledOverflowHidden>
);
}
@@ -216,7 +202,7 @@ FeaturedTheme.fragments = {
theme: gql`
${ThemeSummaryCard.fragments.theme}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeTheme on Theme {
...ThemeSummaryCardTheme
anime {
@@ -228,5 +214,5 @@ FeaturedTheme.fragments = {
}
}
}
`
`,
};
+51 -34
View File
@@ -11,27 +11,26 @@ import type { AnimeThemeFilterThemeFragment } from "@/generated/graphql";
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>
themes: Array<AnimeThemeFilterThemeFragment>;
}
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
const hasMultipleTypes = (
themes.some((theme) => theme.type === "OP") &&
themes.some((theme) => theme.type === "ED")
);
const [ filterType, setFilterType ] = useState<string | null>(null);
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
const [filterType, setFilterType] = useState<string | null>(null);
const filteredThemes = themes
.filter((theme) => !filterType || theme.type === filterType)
.sort(either(themeGroupComparator).or(themeTypeComparator).or(themeIndexComparator).chain());
const groups = useMemo(
() => filteredThemes.reduce<{
name: string,
slug: string,
themes: typeof themes
}[]>(
(groups, theme) => {
() =>
filteredThemes.reduce<
{
name: string;
slug: string;
themes: typeof themes;
}[]
>((groups, theme) => {
const groupName = theme.group?.name || "Original";
const groupSlug = theme.group?.slug || "original";
const group = groups.find((group) => group.name === groupName);
@@ -45,13 +44,11 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
group.themes.push(theme);
}
return groups;
},
[]
),
[ filteredThemes ]
}, []),
[filteredThemes],
);
const [ activeGroup, setActiveGroup ] = useState<string | null>(null);
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const activeGroupThemes = groups.find((group) => group.slug === activeGroup)?.themes;
return (
@@ -60,16 +57,36 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
<HorizontalScroll fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onValueChange={setActiveGroup} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>All Groups</ListboxOption>
<Listbox
value={activeGroup}
onValueChange={setActiveGroup}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
All Groups
</ListboxOption>
{groups.map((group) => (
<ListboxOption key={group.slug} value={group.slug}>{group.name}</ListboxOption>
<ListboxOption key={group.slug} value={group.slug}>
{group.name}
</ListboxOption>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
<Listbox
value={filterType}
onValueChange={setFilterType}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
OP & ED
</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
@@ -84,16 +101,16 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
) : groups.map((group) => (
<Column key={group.slug} style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Text variant="h3">{group.name}</Text>
)}
{group.themes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
))}
) : (
groups.map((group) => (
<Column key={group.slug} style={{ "--gap": "16px" }}>
{groups.length > 1 && <Text variant="h3">{group.name}</Text>}
{group.themes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
))
)}
</Column>
</Column>
);
@@ -102,7 +119,7 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
AnimeThemeFilterInternal.fragments = {
theme: gql`
${ThemeDetailCard.fragments.theme}
fragment AnimeThemeFilterTheme on Theme {
...ThemeDetailCardTheme
type
@@ -111,7 +128,7 @@ AnimeThemeFilterInternal.fragments = {
slug
}
}
`
`,
};
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
+13 -28
View File
@@ -32,17 +32,16 @@ const StyledSocialList = styled(Row)`
justify-content: flex-end;
align-items: flex-end;
gap: 4px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-basis: 100%;
justify-content: flex-start;
}
// To avoid overlap with scroll back to top button as window width gets smaller
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width: ${theme.breakpoints.mobileMax}) {
margin-right: 64px;
}
`;
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
@@ -54,23 +53,13 @@ export function Footer() {
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="/about/transparency">
Transparency
</FooterTextLink>
<FooterTextLink href="/about/donate">
Donate
</FooterTextLink>
<FooterTextLink href="/about/faq">
FAQ
</FooterTextLink>
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="/about/terms-of-service">
Terms of Service
</FooterTextLink>
<FooterTextLink href="/about/privacy-policy">
Privacy Policy
</FooterTextLink>
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
<FooterTextLink href="/about/privacy-policy">Privacy Policy</FooterTextLink>
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
Contact
</FooterTextLink>
@@ -78,22 +67,22 @@ export function Footer() {
<StyledSocialList>
<FooterLink href="https://reddit.com/r/AnimeThemes">
<StyledSocialButton title="Reddit">
<Icon icon={faReddit}/>
<Icon icon={faReddit} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://discordapp.com/invite/m9zbVyQ">
<StyledSocialButton title="Discord">
<Icon icon={faDiscord}/>
<Icon icon={faDiscord} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://twitter.com/AnimeThemesMoe">
<StyledSocialButton title="Twitter">
<Icon icon={faTwitter}/>
<Icon icon={faTwitter} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://github.com/AnimeThemes">
<StyledSocialButton title="GitHub">
<Icon icon={faGithub}/>
<Icon icon={faGithub} />
</StyledSocialButton>
</FooterLink>
</StyledSocialList>
@@ -103,13 +92,9 @@ export function Footer() {
}
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
return (
<Text as={Link} {...props}/>
);
return <Text as={Link} {...props} />;
}
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
return (
<FooterLink link block color="text-muted" noWrap {...props}/>
);
return <FooterLink link block color="text-muted" noWrap {...props} />;
}
+11 -20
View File
@@ -17,10 +17,10 @@ const StyledSearchInput = styled.div`
padding: 0.5rem 1rem;
border-radius: 2rem;
gap: 8px;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
&:focus-within {
box-shadow: ${theme.shadows.low};
@@ -34,7 +34,7 @@ const StyledInput = styled.input`
`;
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
margin: -8px;
&:hover {
background-color: transparent;
box-shadow: none;
@@ -42,26 +42,17 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
`;
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
value: string
onChange: (value: string) => void
resettable?: boolean
icon?: IconDefinition
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
value: string;
onChange: (value: string) => void;
resettable?: boolean;
icon?: IconDefinition;
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>;
}
export function Input({
value,
onChange,
resettable = false,
icon,
inputProps = {},
...props
}: InputProps) {
export function Input({ value, onChange, resettable = false, icon, inputProps = {}, ...props }: InputProps) {
return (
<StyledSearchInput {...props}>
{icon && (
<Icon icon={icon} color="text-disabled"/>
)}
{icon && <Icon icon={icon} color="text-disabled" />}
<StyledInput
type="text"
value={value}
@@ -70,7 +61,7 @@ export function Input({
/>
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
<Icon icon={faTimes} onClick={() => onChange && onChange("")} />
</StyledResetButton>
)}
</StyledSearchInput>
+5 -3
View File
@@ -29,10 +29,12 @@ const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
height: 25px;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
transition: transform 250ms, background-color 250ms;
transition:
transform 250ms,
background-color 250ms;
will-change: transform;
&[data-state='checked'] {
&[data-state="checked"] {
transform: translateX(25px);
background-color: ${theme.colors["text-primary"]};
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const TextArea = styled.textarea`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
scrollbar-color: ${theme.colors["gray-800"]} transparent;
&:focus-within {
box-shadow: ${theme.shadows.low};
+7 -9
View File
@@ -3,9 +3,9 @@ import type { SVGProps } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
icon: IconDefinition
title?: string
}
icon: IconDefinition;
title?: string;
};
const xmlns = "http://www.w3.org/2000/svg";
@@ -27,17 +27,15 @@ export function FontAwesomeIcon(props: FontAwesomeIconProps) {
className={`${className} svg-inline--fa fa-fw`}
{...rest}
>
{title ? (
<title>{title}</title>
) : null}
{title ? <title>{title}</title> : null}
{children}
{Array.isArray(svgPathData) ? (
<g>
<path d={svgPathData[0]}/>
<path d={svgPathData[1]}/>
<path d={svgPathData[0]} />
<path d={svgPathData[1]} />
</g>
) : (
<path fill="currentColor" d={svgPathData}/>
<path fill="currentColor" d={svgPathData} />
)}
</svg>
);
+2 -2
View File
@@ -6,8 +6,8 @@ import { FontAwesomeIcon } from "@/components/icon/FontAwesomeIcon";
import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors, transition?: Property.Transition }>`
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors; transition?: Property.Transition }>`
color: ${(props) => props.color && theme.colors[props.color]};
transition: ${(props) => props.transition}
transition: ${(props) => props.transition};
`;
+8 -3
View File
@@ -8,7 +8,7 @@ import type { CoverImageResourceWithImagesFragment } from "@/generated/graphql";
import extractImages from "@/utils/extractImages";
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
resourceWithImages: CoverImageResourceWithImagesFragment
resourceWithImages: CoverImageResourceWithImagesFragment;
}
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
@@ -16,7 +16,12 @@ export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
<FullWidthImage
key={largeCover}
src={largeCover}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</AspectRatio>
);
}
@@ -24,7 +29,7 @@ export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
CoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment CoverImageResourceWithImages on ResourceWithImages {
...extractImagesResourceWithImages
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const FullWidthImage = styled.img`
object-fit: cover;
border-radius: 0.5rem;
box-shadow: ${theme.shadows.medium};
background-size: cover;
background-position: center;
`;
+2 -2
View File
@@ -3,8 +3,8 @@ 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"/>
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
<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" />
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48" />
</svg>
);
}
+3 -3
View File
@@ -8,10 +8,10 @@ const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
@@ -22,7 +22,7 @@ const StyledPlaceholder = styled.div`
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
return (
<StyledPlaceholder {...props}>
<Logo/>
<Logo />
</StyledPlaceholder>
);
}
+73 -72
View File
@@ -12,17 +12,24 @@ function getTranslationX(item: number, itemCount: number) {
switch (itemCount) {
case 4:
switch (item) {
case 1: return -33;
case 2: return -16.5;
case 3: return 16.5;
case 4: return 33;
case 1:
return -33;
case 2:
return -16.5;
case 3:
return 16.5;
case 4:
return 33;
}
break;
case 3:
switch (item) {
case 1: return -25;
case 2: return 0;
case 3: return 25;
case 1:
return -25;
case 2:
return 0;
case 3:
return 25;
}
break;
}
@@ -43,58 +50,52 @@ const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
width: 100%;
height: 100%;
${(props) => props.$itemCount > 1 && css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(
0 0,
calc(100% / (${props.$itemCount} - 1)) 0,
0 100%,
0 100%
);
}
${(props) =>
props.$itemCount > 1 &&
css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(0 0, calc(100% / (${props.$itemCount} - 1)) 0, 0 100%, 0 100%);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(
100% 0,
100% 0,
100% 100%,
calc(100% / (${props.$itemCount} - 1) * 2) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(100% 0, 100% 0, 100% 100%, calc(100% / (${props.$itemCount} - 1) * 2) 100%);
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:not(:hover) {
transition: clip-path 500ms, z-index 1000ms;
z-index: 0;
}
`}
&:not(:hover) {
transition:
clip-path 500ms,
z-index 1000ms;
z-index: 0;
}
`}
`;
const StyledCover = styled.img`
width: 100%;
@@ -105,7 +106,7 @@ const StyledCover = styled.img`
background-position: center;
transition: transform 500ms;
transform: translateX(var(--translate-x));
&:hover {
transition: transform 250ms;
transform: scale(1.1);
@@ -113,14 +114,12 @@ const StyledCover = styled.img`
`;
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>;
}
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
const images = resourcesWithImages
.filter((resource, index, list) =>
list.findIndex((r) => r.name === resource.name) === index
)
.filter((resource, index, list) => list.findIndex((r) => r.name === resource.name) === index)
.map((resource) => {
const { largeCover, smallCover } = extractImages(resource);
@@ -136,19 +135,21 @@ export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverIma
return (
<AspectRatio ratio={2 / 3}>
<StyledCoverContainer>
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
<StyledCover
loading="lazy"
src={largeCover}
alt={`Cover image of ${resource.name}`}
title={resource.name}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</StyledCoverItemContainer>
)) : (
<LogoPlaceholder {...props}/>
{images.length ? (
images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
<StyledCover
loading="lazy"
src={largeCover}
alt={`Cover image of ${resource.name}`}
title={resource.name}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</StyledCoverItemContainer>
))
) : (
<LogoPlaceholder {...props} />
)}
</StyledCoverContainer>
</AspectRatio>
+4 -4
View File
@@ -16,13 +16,13 @@ const StyledImage = styled(FullWidthImage)`
`;
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
studio: StudioCoverImageStudioFragment
studio: StudioCoverImageStudioFragment;
}
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
const { largeCover } = extractImages(studio);
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
const [imageNotFound, setImageNotFound] = useState(!largeCover);
return (
<AspectRatio ratio={2 / 3}>
@@ -35,7 +35,7 @@ export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
{...props}
/>
) : (
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
<MultiCoverImage resourcesWithImages={studio.anime} {...props} />
)}
</AspectRatio>
);
@@ -56,5 +56,5 @@ StudioCoverImage.fragments = {
}
}
}
`
`,
};
+25 -26
View File
@@ -25,8 +25,8 @@ const StyledItemGrid = styled.div`
type AlphabeticalIndexItem = { name: string };
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
items: Array<T>
children: (item: T) => ReactNode
items: Array<T>;
children: (item: T) => ReactNode;
};
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
@@ -39,30 +39,29 @@ export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, chil
return firstLetter;
}
return "0-9";
}
)
)
.sort(([ a ], [ b ]) => a.localeCompare(b));
},
),
).sort(([a], [b]) => a.localeCompare(b));
return <>
<StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter ]) => (
<Link
key={firstLetter}
href={`#${firstLetter}`}
passHref
legacyBehavior>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
return (
<>
<StyledLetterList>
{itemsByFirstLetter.map(([firstLetter]) => (
<Link key={firstLetter} href={`#${firstLetter}`} passHref 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>
))}
</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>
))}
</>;
</>
);
}
+70 -68
View File
@@ -37,7 +37,9 @@ const StyledListboxPopover = styled(RadixSelect.Portal)`
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
box-shadow:
0 0 0 2px ${theme.colors["text-primary"]},
${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@@ -57,7 +59,7 @@ const StyledListboxList = styled(RadixSelect.Content)`
padding: 8px 0;
`;
const StyledListboxOption = styled(RadixSelect.Item)`
display: ${(props) => props.hidden ? "none" : "flex"};
display: ${(props) => (props.hidden ? "none" : "flex")};
align-items: center;
justify-content: space-between;
@@ -72,16 +74,14 @@ const StyledListboxOption = styled(RadixSelect.Item)`
color: ${theme.colors["text"]};
outline: none;
}
&[data-state="checked"] {
color: ${theme.colors["text-primary"]};
}
`;
type ListboxProps =
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
& (PropsNullable | PropsNotNullable)
& {
type ListboxProps = Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange"> &
(PropsNullable | PropsNotNullable) & {
resettable?: boolean;
highlightNonDefault?: boolean;
};
@@ -100,71 +100,73 @@ interface PropsNotNullable {
defaultValue?: string;
}
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(
function Listbox({
value,
onValueChange,
defaultValue,
nullable,
resettable,
highlightNonDefault,
children,
...props
}, ref) {
const radixValue = value === null ? NULL_VALUE : value;
const radixOnValueChange = (newValue: string) => {
if (nullable) {
onValueChange(newValue === NULL_VALUE ? null : newValue);
} else {
onValueChange(newValue);
}
};
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(function Listbox(
{ value, onValueChange, defaultValue, nullable, resettable, highlightNonDefault, children, ...props },
ref,
) {
const radixValue = value === null ? NULL_VALUE : value;
const radixOnValueChange = (newValue: string) => {
if (nullable) {
onValueChange(newValue === NULL_VALUE ? null : newValue);
} else {
onValueChange(newValue);
}
};
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
return (
<RadixSelect.Root value={radixValue} onValueChange={radixOnValueChange} defaultValue={radixDefaultValue} {...props}>
<RadixSelect.Trigger asChild ref={ref}>
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
<RadixSelect.Value />
<RadixSelect.Icon>
{(resettable && radixDefaultValue && value !== defaultValue) ? (
<StyledListboxReset
onClick={() => radixOnValueChange(radixDefaultValue)}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort} />
)}
</RadixSelect.Icon>
</StyledListboxButton>
</RadixSelect.Trigger>
<StyledListboxPopover>
<StyledListboxList position="popper" sideOffset={8} collisionBoundary={typeof document !== "undefined" ? document.body : []}>
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
</StyledListboxList>
</StyledListboxPopover>
</RadixSelect.Root>
);
}
);
return (
<RadixSelect.Root
value={radixValue}
onValueChange={radixOnValueChange}
defaultValue={radixDefaultValue}
{...props}
>
<RadixSelect.Trigger asChild ref={ref}>
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
<RadixSelect.Value />
<RadixSelect.Icon>
{resettable && radixDefaultValue && value !== defaultValue ? (
<StyledListboxReset
onClick={() => radixOnValueChange(radixDefaultValue)}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon icon={faTimes} />
</StyledListboxReset>
) : (
<Icon icon={faSort} />
)}
</RadixSelect.Icon>
</StyledListboxButton>
</RadixSelect.Trigger>
<StyledListboxPopover>
<StyledListboxList
position="popper"
sideOffset={8}
collisionBoundary={typeof document !== "undefined" ? document.body : []}
>
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
</StyledListboxList>
</StyledListboxPopover>
</RadixSelect.Root>
);
});
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
value: string | null;
}
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
function ListboxOption({ value, children, ...props }, ref) {
const radixValue = value === null ? NULL_VALUE : value;
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(function ListboxOption(
{ value, children, ...props },
ref,
) {
const radixValue = value === null ? NULL_VALUE : value;
return (
<StyledListboxOption value={radixValue} {...props} ref={ref}>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator>
<Icon icon={faCheck} />
</RadixSelect.ItemIndicator>
</StyledListboxOption>
);
}
);
return (
<StyledListboxOption value={radixValue} {...props} ref={ref}>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator>
<Icon icon={faCheck} />
</RadixSelect.ItemIndicator>
</StyledListboxOption>
);
});
+31 -18
View File
@@ -13,7 +13,7 @@ import theme from "@/theme";
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
@@ -26,21 +26,34 @@ const StyledMarkdown = styled.div`
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
& p + h2,
& ul + h2,
& ol + h2,
& ${Card} + h2,
& pre + h2,
& table + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
& p + h3,
& ul + h3,
& ol + h3,
& ${Card} + h3,
& pre + h3,
& table + h3 {
margin-top: 32px;
}
& p, & ul, & ol {
& p,
& ul,
& ol {
margin-top: 0;
margin-bottom: 16px;
}
& ul ul, & ol ol {
& ul ul,
& ol ol {
margin-bottom: 0;
}
@@ -91,23 +104,23 @@ const StyledMarkdown = styled.div`
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
& ${Card} {
margin-bottom: 16px;
}
& img {
border-radius: ${theme.scalars.borderRadiusCard};
}
@@ -128,19 +141,19 @@ export function Markdown({ source, components = {} }: MarkdownProps) {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
return <TextLink href={href} {...props} />;
}
return <Text as="a" link href={href} {...props}/>;
return <Text as="a" link href={href} {...props} />;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props} />,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props} />,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props} />,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props} />,
Card,
...components,
}}
/>
</StyledMarkdown>
);
}
}
+8 -8
View File
@@ -12,7 +12,7 @@ const StyledTableOfContents = styled.ul`
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
@@ -20,13 +20,13 @@ const StyledTableOfContents = styled.ul`
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
@@ -75,10 +75,10 @@ export function TableOfContents({ headings }: { headings: Array<Heading> }) {
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
{slug === currentSlug && <StyledDot layoutId="dot" />}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>
{text}
</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
+24 -21
View File
@@ -15,7 +15,9 @@ const StyledMenuContent = styled(RadixMenu.Content)`
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
box-shadow:
0 0 0 2px ${theme.colors["text-primary"]},
${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@@ -24,24 +26,25 @@ const StyledMenuContent = styled(RadixMenu.Content)`
export const Menu = RadixMenu.Root;
export const MenuTrigger = RadixMenu.Trigger;
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
function MenuContent({ children, ...props }, forwardedRef) {
return (
<RadixMenu.Portal>
<StyledMenuContent
align="start"
sideOffset={8}
collisionPadding={8}
collisionBoundary={typeof document !== "undefined" ? document.body : []}
{...props}
ref={forwardedRef}
>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
}
);
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(function MenuContent(
{ children, ...props },
forwardedRef,
) {
return (
<RadixMenu.Portal>
<StyledMenuContent
align="start"
sideOffset={8}
collisionPadding={8}
collisionBoundary={typeof document !== "undefined" ? document.body : []}
{...props}
ref={forwardedRef}
>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
});
export const MenuItem = styled(RadixMenu.Item)`
display: flex;
@@ -58,7 +61,7 @@ export const MenuItem = styled(RadixMenu.Item)`
color: ${theme.colors["text"]};
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: revert;
@@ -69,7 +72,7 @@ export const MenuLabel = styled(RadixMenu.Label)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
color: ${theme.colors["text-muted"]};
+10 -5
View File
@@ -22,17 +22,22 @@ export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuPr
const [audioMode] = useSetting(AudioMode, { storageSync: false });
function saveToClipboard(url: string) {
navigator.clipboard.writeText(url)
.then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
navigator.clipboard.writeText(url).then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
}
return (
<Menu modal={false}>
<MenuTrigger asChild>
{trigger ?? <IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">Share</IconTextButton>}
{trigger ?? (
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">
Share
</IconTextButton>
)}
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>Copy URL to this Page</MenuItem>
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>
Copy URL to this Page
</MenuItem>
{audioMode === AudioMode.ENABLED ? (
<>
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
@@ -51,4 +56,4 @@ export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuPr
</MenuContent>
</Menu>
);
}
}
+7 -5
View File
@@ -31,10 +31,12 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
// Flip the structure on it's head, because we need video as the root object here.
const videoFlipped = {
...video,
entries: [{
...entry,
theme,
}],
entries: [
{
...entry,
theme,
},
],
};
return (
@@ -79,7 +81,7 @@ ThemeMenu.fragments = {
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment ThemeMenuTheme on Theme {
...createVideoSlugTheme
id
+10 -8
View File
@@ -14,13 +14,15 @@ export const StyledNavigation = styled.nav<{ $floating: boolean }>`
transition: 100ms ease;
transition-property: background-color, box-shadow;
${(props) => props.$floating && css`
transition: 500ms ease;
background-color: transparent;
box-shadow: none;
`}
${(props) =>
props.$floating &&
css`
transition: 500ms ease;
background-color: transparent;
box-shadow: none;
`}
[data-fullscreen] & {
display: none;
}
@@ -32,7 +34,7 @@ export const StyledNavigationContainer = styled(Container)`
justify-content: space-between;
align-items: stretch;
gap: 16px;
padding: 8px 16px;
`;
+68 -47
View File
@@ -13,7 +13,7 @@ import {
StyledNavigationContainer,
StyledNavigationLinks,
StyledProfileImage,
StyledProfileImageIcon
StyledProfileImageIcon,
} from "@/components/navigation/Navigation.style";
import useAuth from "@/hooks/useAuth";
import useCurrentSeason from "@/hooks/useCurrentSeason";
@@ -24,9 +24,9 @@ export function Navigation() {
const { currentYear, currentSeason } = useCurrentSeason();
const router = useRouter();
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
const [prevPathname, setPrevPathname] = useState(router.pathname);
const [ isFloating, setFloating ] = useState(true);
const [isFloating, setFloating] = useState(true);
useEffect(() => {
function onScroll() {
@@ -45,50 +45,71 @@ export function Navigation() {
return null;
}
return <>
<StyledNavigation $floating={isFloating}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref legacyBehavior>
<StyledLogoContainer>
<StyledLogo width="277" height="150"/>
</StyledLogoContainer>
</Link>
<StyledNavigationLinks>
<Link href="/search" passHref legacyBehavior>
<IconTextButton forwardedAs="a" icon={faSearch} variant="silent" collapsible style={{ "--gap": "8px" }}>
Search
</IconTextButton>
return (
<>
<StyledNavigation $floating={isFloating}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref legacyBehavior>
<StyledLogoContainer>
<StyledLogo width="277" height="150" />
</StyledLogoContainer>
</Link>
<ShuffleDialog trigger={
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }}>
Shuffle
</IconTextButton>
} />
<Link
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
passHref
legacyBehavior>
<IconTextButton forwardedAs="a" variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
Current Season
</IconTextButton>
</Link>
<Link href="/profile" passHref legacyBehavior>
<IconTextButton
forwardedAs="a"
variant="silent"
icon={me.user ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me.user} />
</StyledProfileImageIcon>
) : faUser}
title="My Profile"
collapsible
<StyledNavigationLinks>
<Link href="/search" passHref legacyBehavior>
<IconTextButton
forwardedAs="a"
icon={faSearch}
variant="silent"
collapsible
style={{ "--gap": "8px" }}
>
Search
</IconTextButton>
</Link>
<ShuffleDialog
trigger={
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }}>
Shuffle
</IconTextButton>
}
/>
<Link
href={currentYear && currentSeason ? `/year/${currentYear}/${currentSeason}` : "/"}
passHref
legacyBehavior
>
My Profile
</IconTextButton>
</Link>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
</>;
<IconTextButton
forwardedAs="a"
variant="silent"
icon={faTv}
collapsible
style={{ "--gap": "8px" }}
>
Current Season
</IconTextButton>
</Link>
<Link href="/profile" passHref legacyBehavior>
<IconTextButton
forwardedAs="a"
variant="silent"
icon={
me.user ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me.user} />
</StyledProfileImageIcon>
) : (
faUser
)
}
title="My Profile"
collapsible
>
My Profile
</IconTextButton>
</Link>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
</>
);
}
+89 -38
View File
@@ -17,7 +17,7 @@ const StyledSearchOptions = styled.div`
grid-template-columns: 1fr auto;
align-items: center;
grid-gap: 1rem;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
align-items: stretch;
@@ -28,19 +28,23 @@ const updateSearchQuery = debounce((router, newSearchQuery) => {
// Update URL to maintain the searchQuery on page navigation.
const newUrlParams = {
...router.query,
q: newSearchQuery
q: newSearchQuery,
};
if (!newUrlParams.q) {
delete newUrlParams.q;
}
router.replace({
pathname: router.pathname,
query: newUrlParams
}, null, {
shallow: true
});
router.replace(
{
pathname: router.pathname,
query: newUrlParams,
},
null,
{
shallow: true,
},
);
}, 500);
export function SearchNavigation() {
@@ -62,7 +66,7 @@ export function SearchNavigation() {
// Only focus the input on desktop devices
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
input?.focus({
preventScroll: true
preventScroll: true,
});
}
@@ -71,10 +75,13 @@ export function SearchNavigation() {
useEffect(() => {
const hotkeyListener = (event: KeyboardEvent) => {
if (inputRef.current !== document.activeElement && ((event.key === "s" && event.ctrlKey) || (event.key === "/"))) {
if (
inputRef.current !== document.activeElement &&
((event.key === "s" && event.ctrlKey) || event.key === "/")
) {
event.preventDefault();
inputRef.current?.focus({
preventScroll: true
preventScroll: true,
});
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -91,31 +98,75 @@ export function SearchNavigation() {
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} prefetch={false} href={{ pathname: "/search", query }}/>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/anime", query }} value="anime">Anime</SwitcherOption>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/theme", query }} value="theme">Theme</SwitcherOption>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/playlist", query }} value="playlist">Playlist</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>;
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} prefetch={false} href={{ pathname: "/search", query }} />
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/anime", query }}
value="anime"
>
Anime
</SwitcherOption>
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/theme", query }}
value="theme"
>
Theme
</SwitcherOption>
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/artist", query }}
value="artist"
>
Artist
</SwitcherOption>
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/series", query }}
value="series"
>
Series
</SwitcherOption>
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/studio", query }}
value="studio"
>
Studio
</SwitcherOption>
<SwitcherOption
as={Link}
prefetch={false}
href={{ pathname: "/search/playlist", query }}
value="playlist"
>
Playlist
</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>
);
}
+6 -2
View File
@@ -31,7 +31,9 @@ export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
<StyledYearPrevious>
{previousYear && (
<Link href={`/year/${previousYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{previousYear}</Button>
<Button as="a" variant="silent">
{previousYear}
</Button>
</Link>
)}
</StyledYearPrevious>
@@ -43,7 +45,9 @@ export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
<StyledYearNext>
{nextYear && (
<Link href={`/year/${nextYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{nextYear}</Button>
<Button as="a" variant="silent">
{nextYear}
</Button>
</Link>
)}
</StyledYearNext>
@@ -5,8 +5,8 @@ import { Text } from "@/components/text/Text";
const letters = createLetters();
interface SearchFilterFirstLetterProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLetterProps) {
@@ -14,9 +14,13 @@ export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLe
<SearchFilter>
<Text variant="h2">First Letter</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
{letters.map((letter) => (
<ListboxOption key={letter} value={letter}>{letter}</ListboxOption>
<ListboxOption key={letter} value={letter}>
{letter}
</ListboxOption>
))}
</Listbox>
</SearchFilter>
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
interface SearchFilterMediaFormatProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
@@ -12,7 +12,9 @@ export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFo
<SearchFilter>
<Text variant="h2">Format</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
<ListboxOption value="TV">TV</ListboxOption>
<ListboxOption value="TV Short">TV Short</ListboxOption>
<ListboxOption value="Movie">Movie</ListboxOption>
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
interface SearchFilterSeasonProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps) {
@@ -12,7 +12,9 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
<SearchFilter>
<Text variant="h2">Season</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
<ListboxOption value="Winter">Winter</ListboxOption>
<ListboxOption value="Spring">Spring</ListboxOption>
<ListboxOption value="Summer">Summer</ListboxOption>
@@ -6,9 +6,9 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
interface SearchFilterSortByProps<T extends string | null> {
children: ReactNode
value: T
setValue: (newValue: T) => void
children: ReactNode;
value: T;
setValue: (newValue: T) => void;
}
export function SearchFilterSortBy<T extends string | null>({ children, value, setValue }: SearchFilterSortByProps<T>) {
@@ -23,5 +23,5 @@ export function SearchFilterSortBy<T extends string | null>({ children, value, s
}
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ListboxOptionProps) {
return <ListboxOption {...props}/>;
return <ListboxOption {...props} />;
};
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
interface SearchFilterThemeTypeProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeTypeProps) {
@@ -12,7 +12,9 @@ export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeType
<SearchFilter>
<Text variant="h2">Type</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
@@ -4,8 +4,8 @@ import { Text } from "@/components/text/Text";
import useYearList from "@/hooks/useYearList";
interface SearchFilterYearProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
@@ -14,10 +14,21 @@ export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
return (
<SearchFilter>
<Text variant="h2">Year</Text>
<Listbox value={value ? String(value) : null} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<Listbox
value={value ? String(value) : null}
onValueChange={setValue}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
{yearList.map((year) => (
<ListboxOption key={year} value={year ? String(year) : null}>{String(year)}</ListboxOption>
<ListboxOption key={year} value={year ? String(year) : null}>
{String(year)}
</ListboxOption>
))}
</Listbox>
</SearchFilter>
+23 -23
View File
@@ -22,7 +22,7 @@ const initialFilter = {
};
interface SearchAnimeProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchAnime({ searchQuery }: SearchAnimeProps) {
@@ -30,7 +30,7 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -47,8 +47,7 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
}
return (
<SearchEntity
<SearchAnimeQuery["searchAnime"]["data"][number]>
<SearchEntity<SearchAnimeQuery["searchAnime"]["data"][number]>
entity="anime"
searchArgs={{
query: searchQuery,
@@ -61,32 +60,33 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
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
const { data } = await fetchDataClient<SearchAnimeQuery, SearchAnimeQueryVariables>(
gql`
${AnimeSummaryCard.fragments.anime}
${AnimeSummaryCard.fragments.expandable}
query SearchAnime($args: SearchArgs!) {
searchAnime(args: $args) {
data {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchAnime;
}}
renderResult={(anime) => (
<AnimeSummaryCard key={anime.slug} anime={anime} expandable/>
)}
renderResult={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable />}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")}/>
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")}/>
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")}/>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")} />
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")} />
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")} />
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
+19 -19
View File
@@ -16,7 +16,7 @@ const initialFilter = {
};
interface SearchArtistProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchArtist({ searchQuery }: SearchArtistProps) {
@@ -24,7 +24,7 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -41,38 +41,38 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
}
return (
<SearchEntity
<SearchArtistQuery["searchArtist"]["data"][number]>
<SearchEntity<SearchArtistQuery["searchArtist"]["data"][number]>
entity="artist"
searchArgs={{
query: searchQuery,
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: filter.sortBy
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
const { data } = await fetchDataClient<SearchArtistQuery, SearchArtistQueryVariables>(
gql`
${ArtistSummaryCard.fragments.artist}
query SearchArtist($args: SearchArgs!) {
searchArtist(args: $args) {
data {
...ArtistSummaryCardArtist
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchArtist;
}}
renderResult={(artist) => (
<ArtistSummaryCard key={artist.slug} artist={artist}/>
)}
renderResult={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
+27 -41
View File
@@ -13,50 +13,30 @@ import useEntitySearch from "@/hooks/useEntitySearch";
import type { SimpleSearchArgs } from "@/lib/client/search";
interface SearchEntityProps<T> {
entity: string
entity: string;
fetchResults: (searchArgs: SearchArgs) => Promise<{
data: Array<T>
nextPage: number | null
}>
searchArgs: SimpleSearchArgs
filters: ReactNode
renderResult: (result: T) => ReactNode
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,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isLoading,
isPlaceholderData,
} = useEntitySearch<T>(
entity,
fetchResults,
searchArgs
);
const { data, error, fetchNextPage, hasNextPage, isError, isFetchingNextPage, isLoading, isPlaceholderData } =
useEntitySearch<T>(entity, fetchResults, searchArgs);
return (
<>
{!!filters && (
<SearchFilterGroup>
{filters}
</SearchFilterGroup>
)}
{!!filters && <SearchFilterGroup>{filters}</SearchFilterGroup>}
{(() => {
if (isError) {
return (
<ErrorCard error={error}/>
);
return <ErrorCard error={error} />;
}
if (isLoading) {
return (
<Text block>Searching...</Text>
);
return <Text block>Searching...</Text>;
}
const results = data?.pages.flatMap((page) => page.data) ?? [];
@@ -64,12 +44,12 @@ export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, ren
if (!results.length) {
if (searchArgs.query) {
return (
<Text block>No results found for query &quot;{searchArgs.query}&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 (
<Text block>No results found for your current filter settings.</Text>
);
return <Text block>No results found for your current filter settings.</Text>;
}
}
@@ -77,13 +57,19 @@ export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, ren
return (
<>
<Column style={{ "--gap": "16px" }}>
{results.map(renderResult)}
</Column>
<Column style={{ "--gap": "16px" }}>{results.map(renderResult)}</Column>
{(hasNextPage || isPlaceholderData) && (
<Row style={{ "--justify-content": "center" }}>
<Button variant="silent" isCircle onClick={() => !isLoadingMore && fetchNextPage()} title="Load more">
<Icon icon={isLoadingMore ? faSpinner : faChevronDown} className={isLoadingMore ? "fa-spin" : undefined}/>
<Button
variant="silent"
isCircle
onClick={() => !isLoadingMore && fetchNextPage()}
title="Load more"
>
<Icon
icon={isLoadingMore ? faSpinner : faChevronDown}
className={isLoadingMore ? "fa-spin" : undefined}
/>
</Button>
</Row>
)}
+87 -89
View File
@@ -20,71 +20,62 @@ import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "@/generated/
import { fetchDataClient } from "@/lib/client";
interface SearchGlobalProps {
searchQuery?: string
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}
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query SearchGlobal($args: SearchArgs!) {
search(args: $args) {
anime {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
themes {
...ThemeSummaryCardTheme
...ThemeSummaryCardThemeExpandable
}
artists {
...ArtistSummaryCardArtist
}
series {
slug
name
}
studios {
slug
name
}
playlists {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
}
}
`, { args: { query: searchQuery ?? null } });
const fetchSearchResults = () =>
fetchDataClient<SearchGlobalQuery, SearchGlobalQueryVariables>(
gql`
${AnimeSummaryCard.fragments.anime}
${AnimeSummaryCard.fragments.expandable}
${ThemeSummaryCard.fragments.theme}
${ThemeSummaryCard.fragments.expandable}
${ArtistSummaryCard.fragments.artist}
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
const {
data,
error,
isLoading,
isError
} = useQuery(
["searchGlobal", searchQuery],
fetchSearchResults,
{
keepPreviousData: true
}
);
query SearchGlobal($args: SearchArgs!) {
search(args: $args) {
anime {
...AnimeSummaryCardAnime
...AnimeSummaryCardAnimeExpandable
}
themes {
...ThemeSummaryCardTheme
...ThemeSummaryCardThemeExpandable
}
artists {
...ArtistSummaryCardArtist
}
series {
slug
name
}
studios {
slug
name
}
playlists {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
}
}
`,
{ args: { query: searchQuery ?? null } },
);
const { data, error, isLoading, isError } = useQuery(["searchGlobal", searchQuery], fetchSearchResults, {
keepPreviousData: true,
});
if (isError) {
return (
<ErrorCard error={error}/>
);
return <ErrorCard error={error} />;
}
if (isLoading || !data) {
return (
<Text block>Searching...</Text>
);
return <Text block>Searching...</Text>;
}
const {
@@ -105,9 +96,7 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
playlistResults.length;
if (!totalResults) {
return (
<Text block>No results found for query &quot;{searchQuery}&quot;. Did you spell it correctly?</Text>
);
return <Text block>No results found for query &quot;{searchQuery}&quot;. Did you spell it correctly?</Text>;
}
return (
@@ -116,27 +105,33 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
entity="anime"
title="Anime"
results={animeResults}
renderSummaryCard={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable/>}
renderSummaryCard={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable />}
/>
<GlobalSearchSection
entity="theme"
title="Themes"
results={themeResults}
renderSummaryCard={(theme) => <ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable/>}
renderSummaryCard={(theme) => (
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable />
)}
/>
<GlobalSearchSection
entity="artist"
title="Artist"
results={artistResults}
renderSummaryCard={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist}/>}
renderSummaryCard={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />}
/>
<GlobalSearchSection
entity="series"
title="Series"
results={seriesResults}
renderSummaryCard={(series) => (
<SummaryCard key={series.slug} title={series.name} description="Series"
to={`/series/${series.slug}`}/>
<SummaryCard
key={series.slug}
title={series.name}
description="Series"
to={`/series/${series.slug}`}
/>
)}
/>
<GlobalSearchSection
@@ -144,25 +139,31 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
title="Studios"
results={studioResults}
renderSummaryCard={(studio) => (
<SummaryCard key={studio.slug} title={studio.name} description="Studio"
to={`/studio/${studio.slug}`}/>
<SummaryCard
key={studio.slug}
title={studio.name}
description="Studio"
to={`/studio/${studio.slug}`}
/>
)}
/>
<GlobalSearchSection
entity="playlist"
title="Playlists"
results={playlistResults}
renderSummaryCard={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
renderSummaryCard={(playlist) => (
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
)}
/>
</>
);
}
interface GlobalSearchSectionProps<T> {
entity: string
title: string
results: Array<T>
renderSummaryCard: (result: T) => ReactNode
entity: string;
title: string;
results: Array<T>;
renderSummaryCard: (result: T) => ReactNode;
}
function GlobalSearchSection<T>({ entity, title, results, renderSummaryCard }: GlobalSearchSectionProps<T>) {
@@ -176,22 +177,19 @@ function GlobalSearchSection<T>({ entity, title, results, renderSummaryCard }: G
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
legacyBehavior>
<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>
)}
</>
);
}
+19 -19
View File
@@ -14,7 +14,7 @@ const initialFilter = {
};
interface SearchPlaylistProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
@@ -22,7 +22,7 @@ export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -39,34 +39,34 @@ export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
}
return (
<SearchEntity
<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
<SearchEntity<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
entity="playlist"
searchArgs={{
query: searchQuery,
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query SearchPlaylist($args: SearchArgs!) {
searchPlaylist(args: $args) {
data {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(
gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query SearchPlaylist($args: SearchArgs!) {
searchPlaylist(args: $args) {
data {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchPlaylist;
}}
renderResult={(playlist) => (
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
)}
renderResult={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
filters={
<>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
+16 -14
View File
@@ -16,7 +16,7 @@ const initialFilter = {
};
interface SearchSeriesProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchSeries({ searchQuery }: SearchSeriesProps) {
@@ -24,7 +24,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -41,8 +41,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
}
return (
<SearchEntity
<SearchSeriesQuery["searchSeries"]["data"][number]>
<SearchEntity<SearchSeriesQuery["searchSeries"]["data"][number]>
entity="series"
searchArgs={{
query: searchQuery,
@@ -52,17 +51,20 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(gql`
query SearchSeries($args: SearchArgs!) {
searchSeries(args: $args) {
data {
slug
name
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(
gql`
query SearchSeries($args: SearchArgs!) {
searchSeries(args: $args) {
data {
slug
name
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchSeries;
}}
@@ -71,7 +73,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
+20 -20
View File
@@ -17,7 +17,7 @@ const initialFilter = {
};
interface SearchStudioProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchStudio({ searchQuery }: SearchStudioProps) {
@@ -25,7 +25,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -42,8 +42,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
}
return (
<SearchEntity
<SearchStudioQuery["searchStudio"]["data"][number]>
<SearchEntity<SearchStudioQuery["searchStudio"]["data"][number]>
entity="studio"
searchArgs={{
query: searchQuery,
@@ -53,29 +52,30 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
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
const { data } = await fetchDataClient<SearchStudioQuery, SearchStudioQueryVariables>(
gql`
${StudioCoverImage.fragments.studio}
query SearchStudio($args: SearchArgs!) {
searchStudio(args: $args) {
data {
...StudioCoverImageStudio
slug
name
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchStudio;
}}
renderResult={(studio) => (
<StudioSummaryCard key={studio.slug} studio={studio}/>
)}
renderResult={(studio) => <StudioSummaryCard key={studio.slug} studio={studio} />}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
+28 -22
View File
@@ -18,7 +18,7 @@ const initialFilter = {
};
interface SearchThemeProps {
searchQuery?: string
searchQuery?: string;
}
export function SearchTheme({ searchQuery }: SearchThemeProps) {
@@ -26,7 +26,7 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
@@ -43,51 +43,57 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
}
return (
<SearchEntity
<SearchThemeQuery["searchTheme"]["data"][number]>
<SearchEntity<SearchThemeQuery["searchTheme"]["data"][number]>
entity="theme"
searchArgs={{
query: searchQuery,
filters: {
has: "song",
"song][title-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
type: filter.type
type: filter.type,
},
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
const { data } = await fetchDataClient<SearchThemeQuery, SearchThemeQueryVariables>(
gql`
${ThemeSummaryCard.fragments.theme}
${ThemeSummaryCard.fragments.expandable}
query SearchTheme($args: SearchArgs!) {
searchTheme(args: $args) {
data {
...ThemeSummaryCardTheme
...ThemeSummaryCardThemeExpandable
}
nextPage
}
nextPage
}
}
`, { args: searchArgs });
`,
{ args: searchArgs },
);
return data.searchTheme;
}}
renderResult={(theme) => (
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable/>
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable />
)}
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")}/>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")} />
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="song.title">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-song.title">Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="anime.year,anime.season,song.title">Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-anime.year,-anime.season,song.title">New Old</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="anime.year,anime.season,song.title">
Old New
</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-anime.year,-anime.season,song.title">
New Old
</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</>
+9 -9
View File
@@ -4,27 +4,27 @@ import Head from "next/head";
import withBasePath from "@/utils/withBasePath";
interface SEOProps {
title?: string
description?: string
image?: string
title?: string;
description?: string;
image?: string;
}
export function SEO({
title,
description = "AnimeThemes is a simple and consistent repository of anime opening and ending themes.",
image = withBasePath("/img/logo.svg"),
children
children,
}: PropsWithChildren<SEOProps>) {
const titleWithSuffix = title ? `${title} · AnimeThemes` : "AnimeThemes";
return (
<Head>
<title key="title">{titleWithSuffix}</title>
<meta key="description" name="description" content={description}/>
<meta key="og:title" name="og:title" content={title}/>
<meta key="og:description" name="og:description" content={description}/>
<meta key="og:image" name="og:image" content={image}/>
<meta key="og:site_name" name="og:site_name" content="AnimeThemes"/>
<meta key="description" name="description" content={description} />
<meta key="og:title" name="og:title" content={title} />
<meta key="og:description" name="og:description" content={description} />
<meta key="og:image" name="og:image" content={image} />
<meta key="og:site_name" name="og:site_name" content="AnimeThemes" />
{children}
</Head>
);
+6 -10
View File
@@ -7,7 +7,7 @@ import { fadeIn } from "@/styles/animations";
import { loadingAnimation } from "@/styles/mixins";
import theme from "@/theme";
const StyledSkeleton = styled.div`
const StyledSkeleton = styled.div`
${loadingAnimation}
`;
@@ -21,22 +21,18 @@ const StyledContent = styled.div<{ style: { "--delay": Property.AnimationDelay }
`;
interface SkeletonProps {
children?: ReactNode
variant?: "summary-card"
delay?: number
children?: ReactNode;
variant?: "summary-card";
delay?: number;
}
export function Skeleton({ children, variant, delay = 0 }: SkeletonProps) {
if (!children) {
switch (variant) {
case "summary-card":
return <StyledSkeletonSummaryCard/>;
return <StyledSkeletonSummaryCard />;
}
}
return (
<StyledContent style={{ "--delay": `${delay}ms` }}>
{children}
</StyledContent>
);
return <StyledContent style={{ "--delay": `${delay}ms` }}>{children}</StyledContent>;
}
+15 -17
View File
@@ -35,30 +35,28 @@ const StyledThumb = styled(RadixSlider.Thumb)`
box-shadow: ${theme.shadows.low};
border-radius: 10px;
cursor: pointer;
&:hover {
background-color: ${theme.colors["text-muted"]};
}
&:focus:focus-within {
outline: none;
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(
function Slider(props, ref) {
const value = (props.value || props.defaultValue) ?? [];
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(function Slider(props, ref) {
const value = (props.value || props.defaultValue) ?? [];
return (
<StyledSlider {...props} ref={ref}>
<StyledTrack>
<StyledRange />
</StyledTrack>
{value.map((_, i) => (
<StyledThumb key={i} />
))}
</StyledSlider>
);
}
);
return (
<StyledSlider {...props} ref={ref}>
<StyledTrack>
<StyledRange />
</StyledTrack>
{value.map((_, i) => (
<StyledThumb key={i} />
))}
</StyledSlider>
);
});
+32 -34
View File
@@ -12,23 +12,25 @@ import { withHover } from "@/styles/mixins";
import theme from "@/theme";
interface SwitcherContextValue {
selectedItem: string | null
select?: (value: string | null) => void
selectedItem: string | null;
select?: (value: string | null) => void;
}
const SwitcherContext = createContext<SwitcherContextValue>({
selectedItem: null,
select: () => { /* Do nothing. */ }
select: () => {
/* Do nothing. */
},
});
const StyledSwitcher = styled.div`
display: flex;
align-items: stretch;
width: fit-content;
border-radius: 2rem;
white-space: nowrap;
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.low};
@@ -38,31 +40,31 @@ const StyledSwitcher = styled.div`
`;
interface StyledButtonProps {
$isCircle?: boolean
$isSelected?: boolean
$isCircle?: boolean;
$isSelected?: boolean;
}
const StyledButton = styled.button<StyledButtonProps>`
const StyledButton = styled.button<StyledButtonProps>`
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1rem;
cursor: pointer;
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text-muted"]};
color: ${(props) => (props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text-muted"])};
transition: color 500ms;
${withHover`
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
color: ${(props) => (props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"])};;
transition-duration: 250ms;
`}
`;
@@ -82,33 +84,34 @@ const StyledTop = styled.span`
`;
type SwitcherProps<T extends string | null> = ComponentPropsWithoutRef<typeof StyledSwitcher> & {
selectedItem: T
onChange?: (value: T) => void
children: ReactNode
selectedItem: T;
onChange?: (value: T) => void;
children: ReactNode;
};
export function Switcher<T extends string | null>({ selectedItem, onChange, children, ...props }: SwitcherProps<T>) {
const uniqueId = useMemo(createUniqueId, []);
const context = useMemo(() => ({
selectedItem,
select: onChange as (value: string | null) => void
}), [onChange, selectedItem]);
const context = useMemo(
() => ({
selectedItem,
select: onChange as (value: string | null) => void,
}),
[onChange, selectedItem],
);
return (
<StyledSwitcher {...props}>
<SwitcherContext.Provider value={context}>
<LayoutGroup id={uniqueId}>
{children}
</LayoutGroup>
<LayoutGroup id={uniqueId}>{children}</LayoutGroup>
</SwitcherContext.Provider>
</StyledSwitcher>
);
}
interface SwitcherOptionProps extends ComponentPropsWithRef<typeof StyledButton> {
children: ReactNode
value: string
children: ReactNode;
value: string;
}
export function SwitcherOption({ children, value, ...props }: SwitcherOptionProps) {
@@ -116,18 +119,13 @@ export function SwitcherOption({ children, value, ...props }: SwitcherOptionProp
const isSelected = context.selectedItem === value;
return (
<StyledButton
type="button"
$isSelected={isSelected}
onClick={() => context.select?.(value)}
{...props}
>
<StyledButton type="button" $isSelected={isSelected} onClick={() => context.select?.(value)} {...props}>
{isSelected && (
<StyledButtonBackground
layout
layoutId="button-bg"
layoutDependency={value}
transition={{ duration: 0.250 }}
transition={{ duration: 0.25 }}
/>
)}
<StyledTop>{children}</StyledTop>
@@ -152,7 +150,7 @@ export function SwitcherReset(props: SwitcherResetProps) {
onClick={() => context.select?.(null)}
{...props}
>
<Icon icon={faTimes}/>
<Icon icon={faTimes} />
</StyledButton>
);
}
+2 -2
View File
@@ -21,11 +21,11 @@ export const TableRow = styled.div`
grid-gap: 16px;
align-items: baseline;
padding: 8px;
&:not(:last-of-type) {
border-bottom: 2px solid ${theme.colors["solid-on-card"]};
}
${withHover`
background-color: ${theme.colors["solid"]};
`}
+54 -51
View File
@@ -15,58 +15,63 @@ import { either, themeIndexComparator, themeTypeComparator } from "@/utils/compa
import createVideoSlug from "@/utils/createVideoSlug";
export interface ThemeTableProps {
themes: Array<ThemeTableThemeFragment>
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void
themes: Array<ThemeTableThemeFragment>;
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void;
}
export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
const rows = themes
.filter((theme) => theme.anime && theme.entries.length && theme.entries[0]?.videos.length)
.sort(either(themeTypeComparator).or(themeIndexComparator).chain())
.map((theme) => theme.entries.map((entry, entryIndex) => entry.videos.map((video, videoIndex) => {
const anime = theme.anime as NonNullable<typeof theme["anime"]>;
const videoSlug = createVideoSlug(theme, entry, video);
return (
<Link
key={anime.slug + videoSlug}
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior
>
<TableRow as="a" onClick={()=> onPlay?.(theme.id, entryIndex, videoIndex)}>
<TableCell style={{ "--span": (entryIndex || videoIndex) ? 2 : undefined }}>
{!videoIndex && (
(entry.version ?? 1) > 1 ? (
<Text variant="small" color="text-muted">{theme.type}{theme.sequence || null} v{entry.version}</Text>
) : (
<Text variant="small">{theme.type}{theme.sequence || null}</Text>
)
)}
</TableCell>
{(!entryIndex && !videoIndex) && (
<TableCell>
<SongTitle song={theme.song}/>
</TableCell>
)}
<TableCell>
{!videoIndex && (
<EpisodeTag entry={entry}/>
)}
</TableCell>
<TableCell>
{!videoIndex && (
<Row $wrap style={{ "--gap": "8px", "--align-items": "baseline" }}>
<ContentWarningTags entry={entry}/>
</Row>
)}
</TableCell>
<TableCell>
<VideoTags video={video}/>
</TableCell>
</TableRow>
</Link>
);
})));
.map((theme) =>
theme.entries.map((entry, entryIndex) =>
entry.videos.map((video, videoIndex) => {
const anime = theme.anime as NonNullable<(typeof theme)["anime"]>;
const videoSlug = createVideoSlug(theme, entry, video);
return (
<Link
key={anime.slug + videoSlug}
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior
>
<TableRow as="a" onClick={() => onPlay?.(theme.id, entryIndex, videoIndex)}>
<TableCell style={{ "--span": entryIndex || videoIndex ? 2 : undefined }}>
{!videoIndex &&
((entry.version ?? 1) > 1 ? (
<Text variant="small" color="text-muted">
{theme.type}
{theme.sequence || null} v{entry.version}
</Text>
) : (
<Text variant="small">
{theme.type}
{theme.sequence || null}
</Text>
))}
</TableCell>
{!entryIndex && !videoIndex && (
<TableCell>
<SongTitle song={theme.song} />
</TableCell>
)}
<TableCell>{!videoIndex && <EpisodeTag entry={entry} />}</TableCell>
<TableCell>
{!videoIndex && (
<Row $wrap style={{ "--gap": "8px", "--align-items": "baseline" }}>
<ContentWarningTags entry={entry} />
</Row>
)}
</TableCell>
<TableCell>
<VideoTags video={video} />
</TableCell>
</TableRow>
</Link>
);
}),
),
);
return (
<Table style={{ "--columns": "42px 3fr 2fr 2fr 2fr" }}>
@@ -77,9 +82,7 @@ export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
<TableHeadCell>Content Warning</TableHeadCell>
<TableHeadCell>Notes</TableHeadCell>
</TableHead>
<TableBody>
{rows}
</TableBody>
<TableBody>{rows}</TableBody>
</Table>
);
}
@@ -92,7 +95,7 @@ ThemeTable.fragments = {
${EpisodeTag.fragments.entry}
${ContentWarningTags.fragments.entry}
${VideoTags.fragments.video}
fragment ThemeTableTheme on Theme {
...createVideoSlugTheme
id
@@ -114,5 +117,5 @@ ThemeTable.fragments = {
title
}
}
`
`,
};
+3 -3
View File
@@ -6,19 +6,19 @@ import { Tag } from "@/components/tag/Tag";
import type { ContentWarningTagsEntryFragment } from "@/generated/graphql";
interface ContentWarningTagsProps {
entry: ContentWarningTagsEntryFragment
entry: ContentWarningTagsEntryFragment;
}
export function ContentWarningTags({ entry }: ContentWarningTagsProps) {
return (
<>
{entry.spoiler && (
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning"/>} textColor="text-warning-muted">
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning" />} textColor="text-warning-muted">
SPOILER
</Tag>
)}
{entry.nsfw && (
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning"/>} textColor="text-warning-muted">
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning" />} textColor="text-warning-muted">
NSFW
</Tag>
)}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Tag } from "@/components/tag/Tag";
import type { EpisodeTagEntryFragment } from "@/generated/graphql";
interface EpisodeTagProps {
entry: EpisodeTagEntryFragment
entry: EpisodeTagEntryFragment;
}
export function EpisodeTag({ entry }: EpisodeTagProps) {
+17 -13
View File
@@ -13,7 +13,7 @@ const StyledTag = styled.span`
flex-direction: row;
align-items: baseline;
gap: 4px;
& ${Icon} {
transform: translateY(0.2em);
}
@@ -21,25 +21,27 @@ const StyledTag = styled.span`
const StyledText = styled(Text)`
letter-spacing: 0.05rem;
${(props) => props.hideTextOnMobile && css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`}
${(props) =>
props.hideTextOnMobile &&
css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`}
`;
interface TagProps extends HTMLAttributes<HTMLSpanElement> {
icon?: ReactComponentElement<typeof Icon> | IconDefinition
children?: ReactNode
hideTextOnMobile?: boolean
textColor?: keyof Colors
icon?: ReactComponentElement<typeof Icon> | IconDefinition;
children?: ReactNode;
hideTextOnMobile?: boolean;
textColor?: keyof Colors;
}
export function Tag({ icon, children, hideTextOnMobile = false, textColor, ...props }: TagProps) {
return (
<StyledTag {...props}>
{!!icon && (isIcon(icon) ? icon : <Icon icon={icon} color="text-disabled"/>)}
{!!icon && (isIcon(icon) ? icon : <Icon icon={icon} color="text-disabled" />)}
{!!children && (
<StyledText variant="small" hideTextOnMobile={hideTextOnMobile} color={textColor}>
{children}
@@ -49,6 +51,8 @@ export function Tag({ icon, children, hideTextOnMobile = false, textColor, ...pr
);
}
function isIcon(value: ReactComponentElement<typeof Icon> | IconDefinition): value is ReactComponentElement<typeof Icon> {
function isIcon(
value: ReactComponentElement<typeof Icon> | IconDefinition,
): value is ReactComponentElement<typeof Icon> {
return typeof value === "object" && "type" in value && (value as ReactElement).type === Icon;
}
+5 -5
View File
@@ -6,14 +6,14 @@ import { EpisodeTag } from "@/components/tag/EpisodeTag";
import type { ThemeEntryTagsEntryFragment } from "@/generated/graphql";
type ThemeEntryTagsProps = {
entry: ThemeEntryTagsEntryFragment
entry: ThemeEntryTagsEntryFragment;
};
export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
return (
<Row style={{ "--gap": "8px", "--align-items": "baseline" }}>
<EpisodeTag entry={entry}/>
<ContentWarningTags entry={entry}/>
<EpisodeTag entry={entry} />
<ContentWarningTags entry={entry} />
</Row>
);
}
@@ -22,10 +22,10 @@ ThemeEntryTags.fragments = {
entry: gql`
${EpisodeTag.fragments.entry}
${ContentWarningTags.fragments.entry}
fragment ThemeEntryTagsEntry on Entry {
...EpisodeTagEntry
...ContentWarningTagsEntry
}
`
`,
};
+9 -19
View File
@@ -6,7 +6,7 @@ import {
faCommentLines,
faCommentMusic,
faCompactDisc,
faEyes
faEyes,
} from "@fortawesome/pro-solid-svg-icons";
import gql from "graphql-tag";
@@ -21,32 +21,22 @@ const StyledVideoTags = styled(Row)`
`;
interface VideoTagsProps {
video: VideoTagsVideoFragment
hideTextOnMobile?: boolean
video: VideoTagsVideoFragment;
hideTextOnMobile?: boolean;
}
export function VideoTags({ video, hideTextOnMobile = false }: VideoTagsProps) {
return (
<StyledVideoTags>
<Tag title="Resolution">
{video.resolution}p
</Tag>
<Tag title="Resolution">{video.resolution}p</Tag>
{video.nc && (
<Tag icon={faAlignSlash} title="No Credits"/>
)}
{video.nc && <Tag icon={faAlignSlash} title="No Credits" />}
{video.subbed && (
<Tag icon={faCommentLines} title="With Subtitles"/>
)}
{video.subbed && <Tag icon={faCommentLines} title="With Subtitles" />}
{video.lyrics && (
<Tag icon={faCommentMusic} title="With Lyrics"/>
)}
{video.lyrics && <Tag icon={faCommentMusic} title="With Lyrics" />}
{video.uncen && (
<Tag icon={faEyes} title="Uncensored"/>
)}
{video.uncen && <Tag icon={faEyes} title="Uncensored" />}
{!!video.source && (
<Tag icon={faCompactDisc} title="Source">
@@ -74,5 +64,5 @@ VideoTags.fragments = {
source
overlap
}
`
`,
};
+92 -74
View File
@@ -8,92 +8,110 @@ import type { Colors } from "@/theme/colors";
interface TextProps {
variant?: "h1" | "h2" | "h3" | "small" | "code";
link?: boolean
maxLines?: number
noWrap?: boolean | "ellipsis"
block?: boolean
italics?: boolean
weight?: Property.FontWeight
color?: keyof Colors
as?: string | ReactElement
wrapAnywhere?: boolean
link?: boolean;
maxLines?: number;
noWrap?: boolean | "ellipsis";
block?: boolean;
italics?: boolean;
weight?: Property.FontWeight;
color?: keyof Colors;
as?: string | ReactElement;
wrapAnywhere?: boolean;
}
export const Text = styled.span.attrs(getAttributes)<TextProps>`
export const Text = styled.span.attrs(getAttributes)<TextProps>`
// Reset margin for elements like <p>
margin: 0;
scroll-margin-top: 4rem;
${(props) => props.variant === "h1" && css`
font-size: 2rem;
font-weight: 700;
color: ${theme.colors["text"]};
`}
${(props) => props.variant === "h2" && css`
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: ${theme.colors["text-muted"]};
`}
${(props) => props.variant === "h3" && css`
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: ${theme.colors["text-disabled"]};
`}
${(props) => props.variant === "small" && css`
font-size: 0.8rem;
font-weight: 700;
`}
${(props) => props.variant === "code" && css`
font-family: monospace;
line-height: 1.5;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.low};
box-decoration-break: clone;
`}
${(props) => props.link && css`
cursor: pointer;
color: ${theme.colors["text-primary"]};
font-weight: 600;
&:hover {
text-decoration: underline;
}
`}
${(props) => props.maxLines && css`
display: -webkit-box;
-webkit-line-clamp: ${props.maxLines};
-webkit-box-orient: vertical;
overflow: hidden;
`}
${(props) => props.noWrap && css<TextProps>`
white-space: nowrap;
${(props) => props.noWrap === "ellipsis" && css`
overflow: hidden;
text-overflow: ellipsis;
${(props) =>
props.variant === "h1" &&
css`
font-size: 2rem;
font-weight: 700;
color: ${theme.colors["text"]};
`}
${(props) =>
props.variant === "h2" &&
css`
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: ${theme.colors["text-muted"]};
`}
${(props) =>
props.variant === "h3" &&
css`
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: ${theme.colors["text-disabled"]};
`}
${(props) =>
props.variant === "small" &&
css`
font-size: 0.8rem;
font-weight: 700;
`}
${(props) =>
props.variant === "code" &&
css`
font-family: monospace;
line-height: 1.5;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.low};
box-decoration-break: clone;
`}
${(props) =>
props.link &&
css`
cursor: pointer;
color: ${theme.colors["text-primary"]};
font-weight: 600;
&:hover {
text-decoration: underline;
}
`}
${(props) =>
props.maxLines &&
css`
display: -webkit-box;
-webkit-line-clamp: ${props.maxLines};
-webkit-box-orient: vertical;
overflow: hidden;
`}
${(props) =>
props.noWrap &&
css<TextProps>`
white-space: nowrap;
${(props) =>
props.noWrap === "ellipsis" &&
css`
overflow: hidden;
text-overflow: ellipsis;
`}
`}
`}
// Apply these styles last, so that props can override variant styles.
display: ${(props) => props.block && "block"};
font-style: ${(props) => props.italics && "italic"};
font-weight: ${(props) => props.weight};
color: ${(props) => props.color && theme.colors[props.color]};
overflow-wrap: ${(props) => props.wrapAnywhere ? "anywhere" : "break-word"};
overflow-wrap: ${(props) => (props.wrapAnywhere ? "anywhere" : "break-word")};
`;
function getAttributes(props: TextProps) {
@@ -102,7 +120,7 @@ function getAttributes(props: TextProps) {
}
return {
as: getAs(props.variant)
as: getAs(props.variant),
};
}
+12 -11
View File
@@ -13,7 +13,7 @@ import { ShowAnnouncements } from "@/utils/settings";
const StyledBody = styled.div`
display: flex;
gap: 8px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-direction: column;
}
@@ -25,22 +25,23 @@ const StyledAnnouncements = styled.div`
export function AnnouncementToast() {
const { closeToast } = useToasts();
const [ announcements, setAnnouncements ] = useState<Array<Announcement>>([]);
const [ showAnnouncements ] = useSetting(ShowAnnouncements);
const [announcements, setAnnouncements] = useState<Array<Announcement>>([]);
const [showAnnouncements] = useSetting(ShowAnnouncements);
useEffect(() => {
let cancelled = false;
if (showAnnouncements !== ShowAnnouncements.DISABLED) {
fetchAnnouncements()
.then((announcements) => {
if (!cancelled) {
setAnnouncements(announcements);
}
});
fetchAnnouncements().then((announcements) => {
if (!cancelled) {
setAnnouncements(announcements);
}
});
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [showAnnouncements]);
if (!announcements.length) {
@@ -52,7 +53,7 @@ export function AnnouncementToast() {
<StyledBody>
<StyledAnnouncements>
{announcements.map((announcement) => (
<Text key={announcement.id} as="p" dangerouslySetInnerHTML={{ __html: announcement.content }}/>
<Text key={announcement.id} as="p" dangerouslySetInnerHTML={{ __html: announcement.content }} />
))}
</StyledAnnouncements>
<Text color="text-disabled">(Click to dismiss.)</Text>
+2 -2
View File
@@ -7,7 +7,7 @@ import { Toast } from "@/components/toast/Toast";
import { SongTitle } from "@/components/utils/SongTitle";
interface PlaylistAddToastProps {
theme: Exclude<FetchThemeSummaryCardData, null>
theme: Exclude<FetchThemeSummaryCardData, null>;
}
export function PlaylistAddToast({ theme }: PlaylistAddToastProps) {
@@ -16,7 +16,7 @@ export function PlaylistAddToast({ theme }: PlaylistAddToastProps) {
<Toast as="a" hoverable>
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
<span>
<SongTitle song={theme.song}/> was added to the playlist!
<SongTitle song={theme.song} /> was added to the playlist!
</span>
<Text color="text-disabled">(Click to view playlist.)</Text>
</Row>

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