mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 09:34:35 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b79b8811f0 | |||
| 37a512ad05 | |||
| 77c7a8d82b | |||
| 732315b40b | |||
| f86b88bb01 | |||
| 17392046ac | |||
| be322072cf | |||
| 505347face | |||
| cdb5f41404 | |||
| 6c3346897f | |||
| a58a9fb134 | |||
| 74a9affcfb | |||
| 3ab709de25 | |||
| 937d05053a | |||
| 735704377c |
@@ -30,10 +30,6 @@ This is a list of all available options:
|
||||
; The URL to the AnimeThemes API which will be used on the server.
|
||||
ANIMETHEMES_API_URL=http://localhost
|
||||
|
||||
; If specified, this API key will be used to make requests to the AnimeThemes API.
|
||||
; This is used to by-pass rate limiting.
|
||||
ANIMETHEMES_API_KEY=...
|
||||
|
||||
; Set to any truthy value to activate minimal build mode.
|
||||
; In minimal build mode, only a small subset of pages get prerendered at build time.
|
||||
MINIMAL_BUILD=true
|
||||
|
||||
+8
-53
@@ -1,19 +1,18 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
schema: [
|
||||
"src/lib/common/animethemes/type-defs.ts",
|
||||
"src/lib/server/animebracket/type-defs.ts",
|
||||
"src/lib/client/search.ts",
|
||||
],
|
||||
schema: "http://graphql.animethemes.test",
|
||||
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
|
||||
ignoreNoDocuments: true,
|
||||
verbose: true,
|
||||
generates: {
|
||||
"src/generated/graphql.ts": {
|
||||
plugins: ["typescript", "typescript-operations"],
|
||||
"src/graphql/generated/": {
|
||||
preset: "client",
|
||||
presetConfig: {
|
||||
fragmentMasking: { unmaskFunctionName: "getFragmentData" },
|
||||
},
|
||||
config: {
|
||||
avoidOptionals: {
|
||||
object: true,
|
||||
field: true,
|
||||
inputValue: false,
|
||||
},
|
||||
@@ -22,50 +21,6 @@ const config: CodegenConfig = {
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
schema: http://graphql.animethemes.test
|
||||
+1
-1
@@ -24,7 +24,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
staticPageGenerationTimeout: 3600,
|
||||
experimental: {
|
||||
turbo: {},
|
||||
// We don't want to multi-thread page building
|
||||
// to make use of caching between page builds.
|
||||
workerThreads: false,
|
||||
@@ -78,6 +77,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
allowedDevOrigins: ["animethemes.test", "*.animethemes.test"],
|
||||
};
|
||||
|
||||
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
|
||||
|
||||
Generated
+924
-1172
File diff suppressed because it is too large
Load Diff
+21
-29
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"predev": "npm run compile-config",
|
||||
"prebuild": "npm run compile-config",
|
||||
@@ -13,17 +13,15 @@
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.ts",
|
||||
"prepare": "husky",
|
||||
"postinstall": "patch-package"
|
||||
"graphql-codegen-watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@graphql-tools/merge": "^9.0.16",
|
||||
"@graphql-tools/schema": "^10.0.15",
|
||||
"@graphql-tools/utils": "^10.7.1",
|
||||
"@next/bundle-analyzer": "^15.1.3",
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@next/bundle-analyzer": "15.3.0",
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
@@ -33,51 +31,44 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.62.15",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.2.2",
|
||||
"axios": "^1.13.5",
|
||||
"common-tags": "^1.8.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-parse-resolve-info": "^4.13.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"hast-util-has-property": "^3.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.4",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"next": "^15.5.12",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.43.4",
|
||||
"shiki": "^1.26.2",
|
||||
"styled-components": "^6.1.14",
|
||||
"swr": "^2.3.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-local-storage-state": "^19.5.0"
|
||||
},
|
||||
"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",
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react": "19.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.1.3",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"husky": "^9.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2"
|
||||
@@ -86,6 +77,7 @@
|
||||
"ahooks": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
},
|
||||
"@types/react": "19.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
diff --git a/node_modules/graphql-parse-resolve-info/build-turbo/index.js b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
index b10297b..6a56538 100644
|
||||
--- a/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
+++ b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
|
||||
const assert = require("assert");
|
||||
const graphql_1 = require("graphql");
|
||||
-const values_1 = require("graphql/execution/values");
|
||||
+const values_1 = require("graphql");
|
||||
const debugFactory = require("debug");
|
||||
const debug = debugFactory("graphql-parse-resolve-info");
|
||||
const DEBUG_ENABLED = debug.enabled;
|
||||
diff --git a/node_modules/graphql-parse-resolve-info/node8plus/index.js b/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
index 4c3d55d..0e2feb3 100644
|
||||
--- a/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
+++ b/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
|
||||
const assert = require("assert");
|
||||
const graphql_1 = require("graphql");
|
||||
-const values_1 = require("graphql/execution/values");
|
||||
+const values_1 = require("graphql");
|
||||
const debugFactory = require("debug");
|
||||
const debug = debugFactory("graphql-parse-resolve-info");
|
||||
const DEBUG_ENABLED = debug.enabled;
|
||||
@@ -13,7 +13,7 @@ interface LoginGateProps {
|
||||
export function LoginGate({ children }: LoginGateProps) {
|
||||
const { me } = useAuth();
|
||||
|
||||
if (me.user) {
|
||||
if (me) {
|
||||
// User is already logged in, so we can safely show the content.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
|
||||
const Flex = styled.div<{
|
||||
$wrap?: boolean;
|
||||
style?: {
|
||||
style?: CSSProperties & {
|
||||
"--justify-content"?: Property.JustifyContent;
|
||||
"--align-items"?: Property.AlignItems;
|
||||
"--gap"?: Property.Gap;
|
||||
|
||||
@@ -5,13 +5,17 @@ import styled from "styled-components";
|
||||
import { faDiagramProject } from "@fortawesome/free-solid-svg-icons";
|
||||
import { m } from "motion/react";
|
||||
|
||||
import { BracketThemeSummaryCard } from "@/components/bracket/BracketThemeSummaryCard";
|
||||
import {
|
||||
type BracketCharacterWithTheme,
|
||||
type BracketPairingWithThemes,
|
||||
type BracketRoundWithThemes,
|
||||
BracketThemeSummaryCard,
|
||||
type BracketWithThemes,
|
||||
} from "@/components/bracket/BracketThemeSummaryCard";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { BracketPageQuery } from "@/generated/graphql";
|
||||
import theme from "@/theme";
|
||||
import type { RequiredNonNullable } from "@/utils/types";
|
||||
|
||||
const StyledBracketContainer = styled.div`
|
||||
flex: 1 1 0;
|
||||
@@ -66,7 +70,9 @@ const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
|
||||
width: 384px;
|
||||
`;
|
||||
|
||||
type BracketChartProps = RequiredNonNullable<BracketPageQuery>;
|
||||
interface BracketChartProps {
|
||||
bracket: BracketWithThemes;
|
||||
}
|
||||
|
||||
export function BracketChart({ bracket }: BracketChartProps) {
|
||||
const [showBracketChart, setShowBracketChart] = useState(false);
|
||||
@@ -161,7 +167,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
}
|
||||
|
||||
interface BracketRoundProps {
|
||||
round: BracketChartProps["bracket"]["rounds"][number];
|
||||
round: BracketRoundWithThemes;
|
||||
}
|
||||
|
||||
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
@@ -178,7 +184,7 @@ const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
});
|
||||
|
||||
interface BracketPairingsProps {
|
||||
pairings: BracketRoundProps["round"]["pairings"];
|
||||
pairings: Array<BracketPairingWithThemes>;
|
||||
}
|
||||
|
||||
function BracketPairings({ pairings }: BracketPairingsProps) {
|
||||
@@ -208,12 +214,8 @@ function BracketPairings({ pairings }: BracketPairingsProps) {
|
||||
}
|
||||
|
||||
interface ContestantCardProps {
|
||||
contestant:
|
||||
| BracketPairingsProps["pairings"][number]["characterA"]
|
||||
| BracketPairingsProps["pairings"][number]["characterB"];
|
||||
opponent:
|
||||
| BracketPairingsProps["pairings"][number]["characterA"]
|
||||
| BracketPairingsProps["pairings"][number]["characterB"];
|
||||
contestant: BracketCharacterWithTheme;
|
||||
opponent: BracketCharacterWithTheme;
|
||||
contestantVotes: number | null;
|
||||
opponentVotes: number | null;
|
||||
}
|
||||
@@ -226,7 +228,7 @@ function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }
|
||||
|
||||
return (
|
||||
<StyledBracketThemeSummaryCard
|
||||
contestant={contestant}
|
||||
character={contestant}
|
||||
isVoted={isVoted}
|
||||
isWinner={isWinner}
|
||||
seed={contestant.seed}
|
||||
|
||||
@@ -2,17 +2,16 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faAward, faSeedling, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import { CornerIcon } from "@/components/icon/CornerIcon";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type { BracketThemeSummaryCardConstestantFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import type { Bracket, BracketCharacter, BracketPairing, BracketRound } from "@/lib/server/animebracket";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
@@ -33,8 +32,56 @@ const StyledRank = styled(Text)`
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
export const BRACKET_THEME_SUMMARY_CARD_THEME = graphql(`
|
||||
fragment BracketThemeSummaryCardTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export interface BracketWithThemes extends Bracket {
|
||||
currentRound: BracketRoundWithThemes | null;
|
||||
rounds: Array<BracketRoundWithThemes>;
|
||||
}
|
||||
export interface BracketRoundWithThemes extends BracketRound {
|
||||
pairings: Array<BracketPairingWithThemes>;
|
||||
}
|
||||
export interface BracketPairingWithThemes extends BracketPairing {
|
||||
characterA: BracketCharacterWithTheme;
|
||||
characterB: BracketCharacterWithTheme;
|
||||
}
|
||||
export interface BracketCharacterWithTheme extends BracketCharacter {
|
||||
theme: FragmentType<typeof BRACKET_THEME_SUMMARY_CARD_THEME> | null;
|
||||
}
|
||||
|
||||
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
|
||||
contestant: BracketThemeSummaryCardConstestantFragment;
|
||||
character: BracketCharacterWithTheme;
|
||||
isVoted: boolean;
|
||||
isWinner: boolean;
|
||||
seed: number | null;
|
||||
@@ -42,22 +89,22 @@ interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof S
|
||||
}
|
||||
|
||||
export function BracketThemeSummaryCard({
|
||||
contestant,
|
||||
character,
|
||||
isVoted,
|
||||
isWinner,
|
||||
seed,
|
||||
votes,
|
||||
...props
|
||||
}: BracketThemeSummaryCardProps) {
|
||||
const theme = contestant.theme;
|
||||
const { smallCover } = extractImages(theme?.anime);
|
||||
const theme = getFragmentData(BRACKET_THEME_SUMMARY_CARD_THEME, character.theme);
|
||||
const { smallCover } = extractImages(theme?.anime.images.nodes ?? []);
|
||||
|
||||
let to;
|
||||
let description: ReactNode = contestant.source;
|
||||
let description: ReactNode = character.source;
|
||||
|
||||
if (theme?.anime) {
|
||||
const entry = theme.entries?.[0];
|
||||
const video = entry?.videos?.[0];
|
||||
const entry = theme.animethemeentries?.[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
to = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
@@ -73,9 +120,9 @@ export function BracketThemeSummaryCard({
|
||||
return (
|
||||
<StyledSummaryCardWrapper {...props}>
|
||||
<StyledSummaryCard
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : contestant.name}
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : character.name}
|
||||
description={description}
|
||||
image={smallCover ?? contestant.image}
|
||||
image={smallCover ?? character.image}
|
||||
to={to}
|
||||
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
|
||||
>
|
||||
@@ -96,18 +143,3 @@ export function BracketThemeSummaryCard({
|
||||
</StyledSummaryCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
BracketThemeSummaryCard.fragments = {
|
||||
contestant: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
|
||||
name
|
||||
source
|
||||
image
|
||||
theme {
|
||||
...ThemeSummaryCardTheme
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -3,28 +3,59 @@ import type { ComponentPropsWithoutRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { VideoTags } from "@/components/tag/VideoTags";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import type {
|
||||
VideoButtonAnimeFragment,
|
||||
VideoButtonEntryFragment,
|
||||
VideoButtonThemeFragment,
|
||||
VideoButtonVideoFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
|
||||
|
||||
export const VIDEO_BUTTON_ANIME = graphql(`
|
||||
fragment VideoButtonAnime on Anime {
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_THEME = graphql(`
|
||||
fragment VideoButtonTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_ENTRY = graphql(`
|
||||
fragment VideoButtonEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_VIDEO = graphql(`
|
||||
fragment VideoButtonVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
|
||||
anime: VideoButtonAnimeFragment;
|
||||
theme: VideoButtonThemeFragment;
|
||||
entry: VideoButtonEntryFragment;
|
||||
video: VideoButtonVideoFragment;
|
||||
anime: FragmentType<typeof VIDEO_BUTTON_ANIME>;
|
||||
theme: FragmentType<typeof VIDEO_BUTTON_THEME>;
|
||||
entry: FragmentType<typeof VIDEO_BUTTON_ENTRY>;
|
||||
video: FragmentType<typeof VIDEO_BUTTON_VIDEO>;
|
||||
}
|
||||
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
|
||||
export function VideoButton({
|
||||
anime: animeFragment,
|
||||
theme: themeFragment,
|
||||
entry: entryFragment,
|
||||
video: videoFragment,
|
||||
...props
|
||||
}: VideoButtonProps) {
|
||||
const anime = getFragmentData(VIDEO_BUTTON_ANIME, animeFragment);
|
||||
const theme = getFragmentData(VIDEO_BUTTON_THEME, themeFragment);
|
||||
const entry = getFragmentData(VIDEO_BUTTON_ENTRY, entryFragment);
|
||||
const video = getFragmentData(VIDEO_BUTTON_VIDEO, videoFragment);
|
||||
|
||||
const { currentWatchListItem } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentWatchListItem
|
||||
@@ -44,35 +75,3 @@ export function VideoButton({ anime, theme, entry, video, ...props }: VideoButto
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
VideoButton.fragments = {
|
||||
anime: gql`
|
||||
fragment VideoButtonAnime on Anime {
|
||||
slug
|
||||
}
|
||||
`,
|
||||
theme: gql`
|
||||
${createVideoSlug.fragments.theme}
|
||||
|
||||
fragment VideoButtonTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${createVideoSlug.fragments.entry}
|
||||
|
||||
fragment VideoButtonEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment VideoButtonVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
id
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, type MouseEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
import { uniqBy } from "lodash-es";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
@@ -12,7 +12,7 @@ import { ThemeTable } from "@/components/table/ThemeTable";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Collapse } from "@/components/utils/Collapse";
|
||||
import type { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import theme from "@/theme";
|
||||
@@ -60,40 +60,65 @@ const StyledThemeGroupContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type AnimeSummaryCardProps =
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment;
|
||||
expandable?: false;
|
||||
}
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
|
||||
expandable: true;
|
||||
};
|
||||
export const ANIME_SUMMARY_CARD_ANIME = graphql(`
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
slug
|
||||
name
|
||||
year
|
||||
season
|
||||
seasonLocalized
|
||||
mediaFormatLocalized
|
||||
animethemes {
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
|
||||
export const ANIME_SUMMARY_CARD_ANIME_EXPANDABLE = graphql(`
|
||||
fragment AnimeSummaryCardAnimeExpandable on Anime {
|
||||
animethemes {
|
||||
...ThemeTableTheme
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface AnimeSummaryCardProps {
|
||||
anime: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME>;
|
||||
expandable?: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
|
||||
}
|
||||
|
||||
export function AnimeSummaryCard({ anime: animeFragment, expandable, ...props }: AnimeSummaryCardProps) {
|
||||
const anime = getFragmentData(ANIME_SUMMARY_CARD_ANIME, animeFragment);
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { smallCover } = extractImages(anime);
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
const groups = uniqBy(
|
||||
anime.themes.map((theme) => theme.group),
|
||||
(group) => group?.slug,
|
||||
);
|
||||
|
||||
const animeLink = `/anime/${anime.slug}`;
|
||||
|
||||
let premiere = String(anime.year);
|
||||
let premiereLink = `/year/${anime.year}`;
|
||||
if (anime.season) {
|
||||
premiere = anime.season + " " + premiere;
|
||||
if (anime.seasonLocalized && anime.season) {
|
||||
premiere = anime.seasonLocalized + " " + premiere;
|
||||
premiereLink += `/${anime.season.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>{anime.media_format ?? "Anime"}</span>
|
||||
<span>{anime.mediaFormatLocalized ?? "Anime"}</span>
|
||||
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
|
||||
<span>{anime.themes.length} themes</span>
|
||||
<span>{anime.animethemes.length} themes</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
@@ -137,55 +162,36 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
)}
|
||||
</SummaryCard>
|
||||
{expandable ? (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<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,
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
</Collapse>
|
||||
<AnimeSummaryCardCollapse
|
||||
anime={getFragmentData(ANIME_SUMMARY_CARD_ANIME_EXPANDABLE, expandable)}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
AnimeSummaryCard.fragments = {
|
||||
anime: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
interface AnimeSummaryCardCollapseProps {
|
||||
anime: ResultOf<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
year
|
||||
season
|
||||
media_format
|
||||
themes {
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expandable: gql`
|
||||
${ThemeTable.fragments.theme}
|
||||
function AnimeSummaryCardCollapse({ anime, isExpanded }: AnimeSummaryCardCollapseProps) {
|
||||
const groups = uniqBy(
|
||||
anime.animethemes.map((theme) => theme.group),
|
||||
(group) => group?.slug,
|
||||
);
|
||||
|
||||
fragment AnimeSummaryCardAnimeExpandable on Anime {
|
||||
themes {
|
||||
...ThemeTableTheme
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
return (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledThemeGroupContainer>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group?.slug}>
|
||||
{!!group && <Text variant="h2">{group.name}</Text>}
|
||||
<ThemeTable themes={anime.animethemes.filter((theme) => theme.group?.slug === group?.slug)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import type { ArtistSummaryCardArtistFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
export const ARTIST_SUMMARY_CARD_ARTIST = graphql(`
|
||||
fragment ArtistSummaryCardArtist on Artist {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtistSummaryCardProps = {
|
||||
artist: ArtistSummaryCardArtistFragment;
|
||||
artist: FragmentType<typeof ARTIST_SUMMARY_CARD_ARTIST>;
|
||||
as?: string | null;
|
||||
};
|
||||
|
||||
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
const { smallCover } = extractImages(artist);
|
||||
export function ArtistSummaryCard({ artist: artistFragment, as }: ArtistSummaryCardProps) {
|
||||
const artist = getFragmentData(ARTIST_SUMMARY_CARD_ARTIST, artistFragment);
|
||||
|
||||
const { smallCover } = extractImages(artist.images.nodes);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
@@ -23,16 +35,3 @@ export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
|
||||
);
|
||||
}
|
||||
|
||||
ArtistSummaryCard.fragments = {
|
||||
artist: gql`
|
||||
fragment ArtistSummaryCardArtist on Artist {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
@@ -19,7 +16,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -31,31 +28,49 @@ const StyledOverlayButtons = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
type PlaylistSummaryCardProps =
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner?: false;
|
||||
}
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner: true;
|
||||
};
|
||||
export const PLAYLIST_SUMMARY_CARD_PLAYLIST = graphql(`
|
||||
fragment PlaylistSummaryCardPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibilityLocalized
|
||||
tracksCount
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER = graphql(`
|
||||
fragment PlaylistSummaryCardPlaylistWithOwner on Playlist {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistSummaryCardProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST>;
|
||||
playlistWithOwner?: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER>;
|
||||
menu?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PlaylistSummaryCard({
|
||||
playlist,
|
||||
playlist: playlistFragment,
|
||||
playlistWithOwner: playlistWithOwnerFragment,
|
||||
children,
|
||||
menu,
|
||||
showOwner,
|
||||
...props
|
||||
}: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
const playlist = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST, playlistFragment);
|
||||
const playlistWithOwner = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER, playlistWithOwnerFragment);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Playlist</span>
|
||||
{showOwner ? <Text link>{playlist.user.name}</Text> : <span>{playlist.visibility}</span>}
|
||||
{playlistWithOwner ? (
|
||||
<Text link>{playlistWithOwner.user?.name}</Text>
|
||||
) : (
|
||||
<span>{playlist.visibilityLocalized}</span>
|
||||
)}
|
||||
<span>
|
||||
{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}
|
||||
{playlist.tracksCount} theme{playlist.tracksCount !== 1 ? "s" : null}
|
||||
</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
@@ -71,21 +86,3 @@ export default function PlaylistSummaryCard({
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistSummaryCard.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistSummaryCardPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
tracks_count
|
||||
}
|
||||
`,
|
||||
showOwner: gql`
|
||||
fragment PlaylistSummaryCardShowOwner on Playlist {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import type { StudioSummaryCardStudioFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractBackgroundColor from "@/utils/extractBackgroundColor";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
export const STUDIO_SUMMARY_CARD_STUDIO = graphql(`
|
||||
fragment StudioSummaryCardStudio on Studio {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface StudioSummaryCardProps {
|
||||
studio: StudioSummaryCardStudioFragment;
|
||||
studio: FragmentType<typeof STUDIO_SUMMARY_CARD_STUDIO>;
|
||||
}
|
||||
|
||||
export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
export function StudioSummaryCard({ studio: studioFragment }: StudioSummaryCardProps) {
|
||||
const studio = getFragmentData(STUDIO_SUMMARY_CARD_STUDIO, studioFragment);
|
||||
|
||||
const [backgroundColor, setBackgroundColor] = useState<Property.Background>();
|
||||
|
||||
function handleLoad(event: SyntheticEvent<HTMLImageElement>) {
|
||||
@@ -29,7 +42,7 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
title={studio.name}
|
||||
description="Studio"
|
||||
to={`/studio/${studio.slug}`}
|
||||
image={extractImages(studio).largeCover}
|
||||
image={extractImages(studio.images.nodes).largeCover}
|
||||
imageProps={{
|
||||
$objectFit: "contain",
|
||||
$backgroundColor: backgroundColor,
|
||||
@@ -38,15 +51,3 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
StudioSummaryCard.fragments = {
|
||||
studio: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment StudioSummaryCardStudio on Studio {
|
||||
slug
|
||||
name
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { VideoButton } from "@/components/button/VideoButton";
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { ThemeMenu } from "@/components/menu/ThemeMenu";
|
||||
import { ThemeEntryTags } from "@/components/tag/ThemeEntryTags";
|
||||
import { VideoTags } from "@/components/tag/VideoTags";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type { ThemeDetailCardThemeFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
import { entryVersionComparator } from "@/utils/comparators";
|
||||
|
||||
@@ -45,11 +42,46 @@ const StyledVideoList = styled(Row)`
|
||||
}
|
||||
`;
|
||||
|
||||
export const THEME_DETAIL_CARD_THEME = graphql(`
|
||||
fragment ThemeDetailCardTheme on AnimeTheme {
|
||||
...ThemeMenuTheme
|
||||
...VideoButtonTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...VideoButtonAnime
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
animethemeentries {
|
||||
...ThemeEntryTagsEntry
|
||||
...VideoButtonEntry
|
||||
version
|
||||
videos {
|
||||
nodes {
|
||||
...VideoButtonVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface ThemeDetailCardProps {
|
||||
theme: ThemeDetailCardThemeFragment;
|
||||
theme: FragmentType<typeof THEME_DETAIL_CARD_THEME>;
|
||||
}
|
||||
|
||||
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
export function ThemeDetailCard({ theme: themeFragment }: ThemeDetailCardProps) {
|
||||
const theme = getFragmentData(THEME_DETAIL_CARD_THEME, themeFragment);
|
||||
const { anime } = theme;
|
||||
|
||||
if (!anime) {
|
||||
@@ -69,10 +101,10 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
</Text>
|
||||
<ThemeMenu theme={theme} />
|
||||
</StyledRow>
|
||||
{theme.entries.sort(entryVersionComparator).map((entry) => (
|
||||
<StyledRow key={entry.version || 0}>
|
||||
{[...theme.animethemeentries].sort(entryVersionComparator).map((entry) => (
|
||||
<StyledRow key={entry.version ?? 0}>
|
||||
<Text variant="small" color="text-muted">
|
||||
{!!entry.version && `v${entry.version}`}
|
||||
v{entry.version ?? 1}
|
||||
</Text>
|
||||
<Text color="text-muted">
|
||||
<ThemeEntryTags entry={entry} />
|
||||
@@ -80,7 +112,7 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
<StyledVideoListContainer>
|
||||
{!!entry.videos && (
|
||||
<StyledVideoList>
|
||||
{entry.videos.map((video, index) => (
|
||||
{entry.videos.nodes.map((video, index) => (
|
||||
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
|
||||
))}
|
||||
</StyledVideoList>
|
||||
@@ -91,46 +123,3 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
</StyledThemeCard>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeDetailCard.fragments = {
|
||||
theme: gql`
|
||||
${ThemeMenu.fragments.theme}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment ThemeDetailCardTheme on Theme {
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
title
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
entries {
|
||||
version
|
||||
episodes
|
||||
spoiler
|
||||
nsfw
|
||||
videos {
|
||||
...VideoTagsVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
@@ -16,16 +15,9 @@ import { TextLink } from "@/components/text/TextLink";
|
||||
import { Collapse } from "@/components/utils/Collapse";
|
||||
import { getDisplayedArtistName, Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type {
|
||||
ThemeSummaryCardArtistFragment,
|
||||
ThemeSummaryCardQuery,
|
||||
ThemeSummaryCardThemeExpandableFragment,
|
||||
ThemeSummaryCardThemeFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
@@ -40,7 +32,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -65,49 +57,112 @@ const StyledPerformedWith = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
export const THEME_SUMMARY_CARD_THEME = graphql(`
|
||||
fragment ThemeSummaryCardTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
__typename
|
||||
... on Artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
... on Membership {
|
||||
group {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const THEME_SUMMARY_CARD_ARTIST = graphql(`
|
||||
fragment ThemeSummaryCardArtist on Artist {
|
||||
...PerformancesArtist
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
export const THEME_SUMMARY_CARD_THEME_EXPANDABLE = graphql(`
|
||||
fragment ThemeSummaryCardThemeExpandable on AnimeTheme {
|
||||
...ThemeTableTheme
|
||||
}
|
||||
`);
|
||||
|
||||
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;
|
||||
};
|
||||
interface ThemeSummaryCardProps {
|
||||
theme: FragmentType<typeof THEME_SUMMARY_CARD_THEME>;
|
||||
artist?: FragmentType<typeof THEME_SUMMARY_CARD_ARTIST>;
|
||||
expandable?: FragmentType<typeof THEME_SUMMARY_CARD_THEME_EXPANDABLE>;
|
||||
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,
|
||||
theme: themeFragment,
|
||||
artist: artistFragment,
|
||||
children,
|
||||
expandable,
|
||||
expandable: expandableFragment,
|
||||
onPlay,
|
||||
...props
|
||||
}: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
const theme = getFragmentData(THEME_SUMMARY_CARD_THEME, themeFragment);
|
||||
const ownerArtist = artistFragment ? getFragmentData(THEME_SUMMARY_CARD_ARTIST, artistFragment) : undefined;
|
||||
const expandable = expandableFragment
|
||||
? getFragmentData(THEME_SUMMARY_CARD_THEME_EXPANDABLE, expandableFragment)
|
||||
: undefined;
|
||||
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const anime = theme.anime;
|
||||
const entry = theme.entries[0];
|
||||
const video = entry?.videos[0];
|
||||
const entry = theme.animethemeentries[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
|
||||
if (!anime || !entry || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
function handleToggleExpand(event: MouseEvent) {
|
||||
if (isLink(event.target)) {
|
||||
event.stopPropagation();
|
||||
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
|
||||
if (filterLinks && isLink(event.target)) {
|
||||
return;
|
||||
} else if (expandable && !isMobile) {
|
||||
toggleExpanded();
|
||||
}
|
||||
@@ -122,16 +177,24 @@ export function ThemeSummaryCard({
|
||||
return isLink(element.parentElement);
|
||||
}
|
||||
|
||||
const performances = (theme.song?.performances ?? [])
|
||||
.map((performance) => ({
|
||||
artist: performance.artist.__typename === "Artist" ? performance.artist : performance.artist.group,
|
||||
performance,
|
||||
}))
|
||||
.filter(({ artist }) => artist.slug !== ownerArtist?.slug)
|
||||
.sort(({ artist: a }, { artist: b }) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard onClick={handleToggleExpand} {...props}>
|
||||
<SummaryCard {...props}>
|
||||
<Link href={href} onClick={() => onPlay?.()}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
</Link>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
|
||||
<Performances song={theme.song} artist={artist} />
|
||||
<Performances song={theme.song} artist={ownerArtist} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>
|
||||
@@ -143,7 +206,7 @@ export function ThemeSummaryCard({
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{children}
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
<StyledOverlayButtons>
|
||||
<ThemeMenu theme={theme} />
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
@@ -165,31 +228,24 @@ export function ThemeSummaryCard({
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledPerformedWith>
|
||||
<ThemeTable
|
||||
themes={[theme]}
|
||||
themes={[expandable]}
|
||||
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
|
||||
/>
|
||||
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
|
||||
{performances.length > 0 && (
|
||||
<Table style={{ "--columns": "1fr" }}>
|
||||
<TableHead>
|
||||
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
|
||||
<TableHeadCell>Performed {ownerArtist ? "With" : "By"}</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{theme.song?.performances
|
||||
?.filter((performance) => performance.artist.slug !== artist?.slug)
|
||||
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
|
||||
.map((performance) => (
|
||||
<TableRow
|
||||
key={performance.artist.slug}
|
||||
as={Link}
|
||||
href={`/artist/${performance.artist.slug}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{getDisplayedArtistName(performance)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{performances.map(({ artist, performance }) => (
|
||||
<TableRow key={artist.slug} as={Link} href={`/artist/${artist.slug}`}>
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{getDisplayedArtistName({ ...performance, artist })}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
@@ -199,74 +255,3 @@ export function ThemeSummaryCard({
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeSummaryCard.fragments = {
|
||||
theme: gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
${ThemeMenu.fragments.theme}
|
||||
|
||||
fragment ThemeSummaryCardTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
artist: gql`
|
||||
${SongTitleWithArtists.fragments.artist}
|
||||
|
||||
fragment ThemeSummaryCardArtist on Artist {
|
||||
...SongTitleWithArtistsArtist
|
||||
}
|
||||
`,
|
||||
expandable: gql`
|
||||
${ThemeTable.fragments.theme}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ themeId: id },
|
||||
).then((result) => result.data?.theme ?? null);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,13 @@ import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
@@ -26,7 +24,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -53,10 +51,48 @@ const StyledCoverOverlay = styled.div`
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
export const VIDEO_SUMMARY_CARD_VIDEO = graphql(`
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_SUMMARY_CARD_ENTRY = graphql(`
|
||||
fragment VideoSummaryCardEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoSummaryCardProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
entry: VideoSummaryCardEntryFragment;
|
||||
video: FragmentType<typeof VIDEO_SUMMARY_CARD_VIDEO>;
|
||||
entry: FragmentType<typeof VIDEO_SUMMARY_CARD_ENTRY>;
|
||||
menu?: ReactNode;
|
||||
append?: ReactNode;
|
||||
onPlay?(): void;
|
||||
@@ -65,22 +101,24 @@ interface VideoSummaryCardProps {
|
||||
|
||||
export function VideoSummaryCard({
|
||||
ref,
|
||||
video,
|
||||
entry,
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
menu,
|
||||
append,
|
||||
onPlay,
|
||||
isPlaying,
|
||||
...props
|
||||
}: VideoSummaryCardProps) {
|
||||
const theme = entry.theme;
|
||||
const video = getFragmentData(VIDEO_SUMMARY_CARD_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(VIDEO_SUMMARY_CARD_ENTRY, entryFragment);
|
||||
const theme = entry.animetheme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
@@ -108,54 +146,9 @@ export function VideoSummaryCard({
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
|
||||
) : null}
|
||||
{menu !== null ? <StyledOverlayButtons>{menu}</StyledOverlayButtons> : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const VideoSummaryCardFragmentVideo = gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const VideoSummaryCardFragmentEntry = gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
|
||||
fragment VideoSummaryCardEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { isAxiosError } from "axios";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
@@ -16,7 +15,9 @@ import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
interface PlaylistAddDialogProps {
|
||||
trigger?: ReactNode;
|
||||
@@ -57,36 +58,36 @@ interface PlaylistAddFormProps {
|
||||
|
||||
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [visibility, setVisibility] = useState("PUBLIC");
|
||||
const [visibility, setVisibility] = useState<PlaylistVisibility>("PUBLIC");
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistAdd($name: String!, $visibility: PlaylistVisibility!) {
|
||||
CreatePlaylist(name: $name, visibility: $visibility) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => onSuccess(),
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await axios.post("/playlist", {
|
||||
await mutate({
|
||||
variables: {
|
||||
name: title,
|
||||
visibility,
|
||||
});
|
||||
await mutate((key) => [key].flat().includes("/api/me/playlist"));
|
||||
} catch (error: unknown) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
setError(error.response.data.message ?? "An unknown error occured!");
|
||||
}
|
||||
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,7 +99,10 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
<Listbox value={visibility} onValueChange={setVisibility}>
|
||||
<Listbox
|
||||
value={visibility}
|
||||
onValueChange={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
|
||||
>
|
||||
<ListboxOption value="PUBLIC">Public</ListboxOption>
|
||||
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
|
||||
<ListboxOption value="PRIVATE">Private</ListboxOption>
|
||||
@@ -108,14 +112,14 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Create Playlist</Busy>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || loading}>
|
||||
<Busy isBusy={loading}>Create Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be created: </strong>
|
||||
{error}
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { isAxiosError } from "axios";
|
||||
import gql from "graphql-tag";
|
||||
import { mutate } from "swr";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
@@ -17,15 +16,27 @@ import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import type { PlaylistEditDialogPlaylistFragment } from "@/generated/graphql";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_EDIT_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistEditDialogPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistEditDialogProps {
|
||||
playlist: PlaylistEditDialogPlaylistFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
|
||||
export function PlaylistEditDialog({ playlist: playlistFragment, trigger }: PlaylistEditDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_EDIT_DIALOG_PLAYLIST, playlistFragment);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -53,60 +64,51 @@ export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProp
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistEditDialog.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistEditDialogPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistEditDialogPlaylistFragment;
|
||||
playlist: ResultOf<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
|
||||
const [title, setTitle] = useState(playlist.name);
|
||||
const [visibility, setVisibility] = useState<string>(playlist.visibility);
|
||||
const [visibility, setVisibility] = useState<PlaylistVisibility>(playlist.visibility);
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistEdit($id: String!, $name: String, $visibility: PlaylistVisibility) {
|
||||
UpdatePlaylist(id: $id, name: $name, visibility: $visibility) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => onSuccess(),
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await axios.put(`/playlist/${playlist.id}`, {
|
||||
await mutate({
|
||||
variables: {
|
||||
id: playlist.id,
|
||||
name: title,
|
||||
visibility,
|
||||
});
|
||||
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!");
|
||||
}
|
||||
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -118,24 +120,27 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
<Listbox value={visibility} onValueChange={setVisibility}>
|
||||
<ListboxOption value="Public">Public</ListboxOption>
|
||||
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
|
||||
<ListboxOption value="Private">Private</ListboxOption>
|
||||
<Listbox
|
||||
value={visibility}
|
||||
onValueChange={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
|
||||
>
|
||||
<ListboxOption value="PUBLIC">Public</ListboxOption>
|
||||
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
|
||||
<ListboxOption value="PRIVATE">Private</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Update Playlist</Busy>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || loading}>
|
||||
<Busy isBusy={loading}>Update Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be updated: </strong>
|
||||
{error}
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
import { mutate } from "swr";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
@@ -15,15 +15,26 @@ import { Text } from "@/components/text/Text";
|
||||
import { PlaylistRemoveToast } from "@/components/toast/PlaylistRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import type { PlaylistRemoveDialogPlaylistFragment } from "@/generated/graphql";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_REMOVE_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistRemoveDialogProps {
|
||||
playlist: PlaylistRemoveDialogPlaylistFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
|
||||
export function PlaylistRemoveDialog({ playlist: playlistFragment, trigger }: PlaylistRemoveDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_REMOVE_DIALOG_PLAYLIST, playlistFragment);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -51,22 +62,8 @@ export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialog
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistRemoveDialog.fragments = {
|
||||
playlist: gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistRemoveToast.fragments.playlist}
|
||||
|
||||
fragment PlaylistRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistRemoveFormProps {
|
||||
playlist: PlaylistRemoveDialogPlaylistFragment;
|
||||
playlist: ResultOf<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
@@ -74,21 +71,33 @@ interface PlaylistRemoveFormProps {
|
||||
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistRemove($id: String!) {
|
||||
DeletePlaylist(id: $id) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function removePlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/playlist/${playlist.id}`);
|
||||
await mutate((key) => [key].flat().includes("/api/me/playlist"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
|
||||
|
||||
onSuccess();
|
||||
await mutate({
|
||||
variables: {
|
||||
id: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -105,10 +114,16 @@ function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFor
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
|
||||
<Busy isBusy={isBusy}>Delete playlist</Busy>
|
||||
<Button variant="warning" disabled={loading} onClick={removePlaylist}>
|
||||
<Busy isBusy={loading}>Delete playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be removed: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { faArrowDown, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -24,22 +21,39 @@ import { PlaylistTrackAddToast } from "@/components/toast/PlaylistTrackAddToast"
|
||||
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackAddDialogEntryFragment,
|
||||
PlaylistTrackAddDialogVideoFragment,
|
||||
PlaylistTrackAddFormPlaylistQuery,
|
||||
PlaylistTrackAddFormPlaylistQueryVariables,
|
||||
} from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_DIALOG_VIDEO = graphql(`
|
||||
fragment PlaylistTrackAddDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_DIALOG_ENTRY = graphql(`
|
||||
fragment PlaylistTrackAddDialogEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackAddToastEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddDialogProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
video: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddDialog({ video, entry, trigger }: PlaylistTrackAddDialogProps) {
|
||||
export function PlaylistTrackAddDialog({
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
trigger,
|
||||
}: PlaylistTrackAddDialogProps) {
|
||||
const video = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_ENTRY, entryFragment);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -63,74 +77,35 @@ export function PlaylistTrackAddDialog({ video, entry, trigger }: PlaylistTrackA
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackAddDialog.fragments = {
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
|
||||
fragment PlaylistTrackAddDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
id
|
||||
const PLAYLIST_TRACK_ADD_FORM_PLAYLIST = graphql(`
|
||||
query PlaylistTrackAddFormPlaylist($entryId: Mixed!, $videoId: Mixed!) {
|
||||
me {
|
||||
playlists {
|
||||
...PlaylistTrackAddCardPlaylist
|
||||
id
|
||||
tracks(where: [{ field: ENTRY_ID, value: $entryId }, { field: VIDEO_ID, value: $videoId }]) {
|
||||
...PlaylistTrackAddCardTrack
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
${PlaylistTrackRemoveToast.fragments.entry}
|
||||
|
||||
fragment PlaylistTrackAddDialogEntry on Entry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
id
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddFormProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormProps) {
|
||||
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 {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
id
|
||||
tracks_count
|
||||
}
|
||||
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
|
||||
id
|
||||
tracks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ filterVideoId: video.id },
|
||||
);
|
||||
|
||||
const { playlistAll, playlistAllFiltered } = data.me;
|
||||
|
||||
return (
|
||||
playlistAll?.map((playlist) => {
|
||||
return {
|
||||
...playlist,
|
||||
...playlistAllFiltered?.find((p) => p.id === playlist.id),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
const { data } = useQuery(PLAYLIST_TRACK_ADD_FORM_PLAYLIST, {
|
||||
variables: {
|
||||
entryId: entry.id,
|
||||
videoId: video.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!playlists) {
|
||||
if (!data) {
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Skeleton variant="summary-card" />
|
||||
@@ -141,16 +116,24 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
|
||||
);
|
||||
}
|
||||
|
||||
const playlists = data.me?.playlists ?? [];
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<VideoSummaryCard video={video} entry={entry} />
|
||||
<VideoSummaryCard video={video} entry={entry} menu={null} />
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Icon icon={faArrowDown} color="text-disabled" />
|
||||
</Row>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{playlists?.length ? (
|
||||
playlists.map((playlist) => (
|
||||
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} entry={entry} />
|
||||
<PlaylistTrackAddCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
track={playlist.tracks[0] ?? null}
|
||||
video={video}
|
||||
entry={entry}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text>You have not created a playlist, yet.</Text>
|
||||
@@ -173,71 +156,143 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYLIST_TRACK_ADD_CARD_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackAddCardPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistTrackAddToastPlaylist
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
const PLAYLIST_TRACK_ADD_CARD_TRACK = graphql(`
|
||||
fragment PlaylistTrackAddCardTrack on PlaylistTrack {
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddCardProps {
|
||||
playlist: NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
|
||||
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
entry: PlaylistTrackAddDialogEntryFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_PLAYLIST>;
|
||||
track: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_TRACK> | null;
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddCard({ playlist, video, entry }: PlaylistTrackAddCardProps) {
|
||||
function PlaylistTrackAddCard({
|
||||
playlist: playlistFragment,
|
||||
track: trackFragment,
|
||||
video,
|
||||
entry,
|
||||
}: PlaylistTrackAddCardProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_ADD_CARD_PLAYLIST, playlistFragment);
|
||||
const track = getFragmentData(PLAYLIST_TRACK_ADD_CARD_TRACK, trackFragment);
|
||||
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [mutateAddTrack, { loading: loadingAddTrack, error: errorAddTrack }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackAdd($playlistId: String!, $entryId: Int!, $videoId: Int!) {
|
||||
CreatePlaylistTrack(playlist: $playlistId, entryId: $entryId, videoId: $videoId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(
|
||||
`playlist-add-track-${playlist.id}-${video.id}`,
|
||||
<PlaylistTrackAddToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the list in the dialog above
|
||||
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [mutateRemoveTrack, { loading: loadingRemoveTrack, error: errorRemoveTrack }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
|
||||
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
if (track === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${track.id}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the list in the dialog above
|
||||
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const loading = loadingAddTrack || loadingRemoveTrack;
|
||||
|
||||
async function addTrackToPlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id, entry_id: entry.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} entry={entry} />,
|
||||
);
|
||||
await mutateAddTrack({
|
||||
variables: {
|
||||
playlistId: playlist.id,
|
||||
videoId: video.id,
|
||||
entryId: entry.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
const track = playlist.tracks?.[0];
|
||||
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
|
||||
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"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${track.id}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
await mutateRemoveTrack({
|
||||
variables: {
|
||||
id: track.id,
|
||||
playlistId: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
|
||||
{!playlist.tracks?.length ? (
|
||||
<IconTextButton icon={faPlus} disabled={isBusy} onClick={addTrackToPlaylist}>
|
||||
<Busy isBusy={isBusy}>Add</Busy>
|
||||
</IconTextButton>
|
||||
) : (
|
||||
<IconTextButton icon={faMinus} disabled={isBusy} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={isBusy}>Remove</Busy>
|
||||
</IconTextButton>
|
||||
)}
|
||||
</PlaylistSummaryCard>
|
||||
<>
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
|
||||
{track === null ? (
|
||||
<IconTextButton icon={faPlus} disabled={loading} onClick={addTrackToPlaylist}>
|
||||
<Busy isBusy={loading}>Add</Busy>
|
||||
</IconTextButton>
|
||||
) : (
|
||||
<IconTextButton icon={faMinus} disabled={loading} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={loading}>Remove</Busy>
|
||||
</IconTextButton>
|
||||
)}
|
||||
</PlaylistSummaryCard>
|
||||
{errorAddTrack ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be added to the playlist: </strong>
|
||||
{errorAddTrack.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{errorRemoveTrack ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be removed from the playlist: </strong>
|
||||
{errorRemoveTrack.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
import { mutate } from "swr";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackRemoveDialogEntryFragment,
|
||||
PlaylistTrackRemoveDialogPlaylistFragment,
|
||||
PlaylistTrackRemoveDialogVideoFragment,
|
||||
} from "@/generated/graphql";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackRemoveDialogProps {
|
||||
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
|
||||
trackId: string;
|
||||
video: PlaylistTrackRemoveDialogVideoFragment;
|
||||
entry: PlaylistTrackRemoveDialogEntryFragment;
|
||||
video: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackRemoveDialog({
|
||||
playlist,
|
||||
playlist: playlistFragment,
|
||||
trackId,
|
||||
video,
|
||||
entry,
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
trigger,
|
||||
}: PlaylistTrackRemoveDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST, playlistFragment);
|
||||
const video = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY, entryFragment);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -71,39 +88,11 @@ export function PlaylistTrackRemoveDialog({
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackRemoveDialog.fragments = {
|
||||
playlist: gql`
|
||||
${PlaylistTrackRemoveToast.fragments.playlist}
|
||||
|
||||
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
|
||||
fragment PlaylistTrackRemoveDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
${PlaylistTrackRemoveToast.fragments.entry}
|
||||
|
||||
fragment PlaylistTrackRemoveDialogEntry on Entry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistTrackRemoveFormProps {
|
||||
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
|
||||
playlist: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
|
||||
trackId: string;
|
||||
video: PlaylistTrackRemoveDialogVideoFragment;
|
||||
entry: PlaylistTrackRemoveDialogEntryFragment;
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
@@ -118,26 +107,37 @@ function PlaylistTrackRemoveForm({
|
||||
}: PlaylistTrackRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
|
||||
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${trackId}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
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"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${trackId}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
await mutate({
|
||||
variables: {
|
||||
id: trackId,
|
||||
playlistId: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -149,15 +149,21 @@ function PlaylistTrackRemoveForm({
|
||||
</Text>
|
||||
?
|
||||
</Text>
|
||||
<VideoSummaryCard video={video} entry={entry} />
|
||||
<VideoSummaryCard video={video} entry={entry} menu={null} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={isBusy}>Remove from playlist</Busy>
|
||||
<Button variant="warning" disabled={loading} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={loading}>Remove from playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be removed from the playlist: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilter
|
||||
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import type { AnimeMediaFormat, ThemeType } from "@/graphql/generated/graphql";
|
||||
import useRandomThemes from "@/hooks/useRandomThemes";
|
||||
|
||||
interface ShuffleDialogProps {
|
||||
@@ -46,8 +47,8 @@ interface ShuffleFormProps {
|
||||
|
||||
function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [filterThemeType, setFilterThemeType] = useState("");
|
||||
const [filterMediaFormat, setFilterMediaFormat] = useState<string | null>(null);
|
||||
const [filterThemeType, setFilterThemeType] = useState<ThemeType | null>(null);
|
||||
const [filterMediaFormat, setFilterMediaFormat] = useState<AnimeMediaFormat | null>(null);
|
||||
const [filterAnimeYearMin, setFilterAnimeYearMin] = useState("");
|
||||
const [filterAnimeYearMax, setFilterAnimeYearMax] = useState("");
|
||||
|
||||
@@ -60,7 +61,7 @@ function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
|
||||
try {
|
||||
await playRandomThemes({
|
||||
themeType: filterThemeType,
|
||||
themeType: filterThemeType ?? undefined,
|
||||
mediaFormat: filterMediaFormat ?? undefined,
|
||||
animeYearMin: parseInt(filterAnimeYearMin),
|
||||
animeYearMax: parseInt(filterAnimeYearMax),
|
||||
|
||||
@@ -2,9 +2,6 @@ import { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
@@ -15,8 +12,6 @@ import { Toast } from "@/components/toast/Toast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { AUTH_PATH } from "@/utils/config";
|
||||
|
||||
export function UserInformationDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -46,11 +41,11 @@ interface UserInformationFormProps {
|
||||
}
|
||||
|
||||
function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps) {
|
||||
const { me } = useAuth();
|
||||
const { me, updateUserInformation } = useAuth();
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [username, setUsername] = useState(me.user?.name ?? "");
|
||||
const [email, setEmail] = useState(me.user?.email ?? "");
|
||||
const [username, setUsername] = useState(me?.name ?? "");
|
||||
const [email, setEmail] = useState(me?.email ?? "");
|
||||
|
||||
const isValid = username && email;
|
||||
|
||||
@@ -67,21 +62,11 @@ 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 updateUserInformation({ setErrors, name: username, email });
|
||||
|
||||
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setErrors(error.response.data.errors);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { ReactNode } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import type { FeaturedThemeThemeFragment } from "@/generated/graphql";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useCompatability from "@/hooks/useCompatability";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { fetchRandomGrill } from "@/lib/client/randomGrill";
|
||||
@@ -119,14 +119,50 @@ const StyledGrill = styled.img`
|
||||
|
||||
const Box = styled.div``;
|
||||
|
||||
export const FEATURED_THEME_ENTRY = graphql(`
|
||||
fragment FeaturedThemeEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...createVideoSlugEntry
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
anime {
|
||||
slug
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const FEATURED_THEME_VIDEO = graphql(`
|
||||
fragment FeaturedThemeVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...createVideoSlugVideo
|
||||
basename
|
||||
}
|
||||
`);
|
||||
|
||||
interface FeaturedThemeProps {
|
||||
theme: FeaturedThemeThemeFragment;
|
||||
entry: FragmentType<typeof FEATURED_THEME_ENTRY>;
|
||||
video: FragmentType<typeof FEATURED_THEME_VIDEO>;
|
||||
hasGrill?: boolean;
|
||||
card?: ReactNode;
|
||||
onPlay?(): void;
|
||||
}
|
||||
|
||||
export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
|
||||
export function FeaturedTheme({
|
||||
entry: entryFragment,
|
||||
video: videoFragment,
|
||||
hasGrill = true,
|
||||
card,
|
||||
onPlay,
|
||||
}: FeaturedThemeProps) {
|
||||
const entry = getFragmentData(FEATURED_THEME_ENTRY, entryFragment);
|
||||
const video = getFragmentData(FEATURED_THEME_VIDEO, videoFragment);
|
||||
|
||||
const [grill, setGrill] = useState<string | null>(null);
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
|
||||
@@ -140,14 +176,14 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
|
||||
|
||||
const featuredThemeSummaryCard =
|
||||
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
|
||||
<StyledCenter>{card ?? <ThemeSummaryCard theme={theme} />}</StyledCenter>
|
||||
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
|
||||
) : (
|
||||
card ?? <ThemeSummaryCard theme={theme} />
|
||||
(card ?? <VideoSummaryCard entry={entry} video={video} />)
|
||||
);
|
||||
|
||||
return (
|
||||
<FeaturedThemeWrapper>
|
||||
<FeaturedThemeBackground theme={theme} onPlay={onPlay} />
|
||||
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
|
||||
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
|
||||
<StyledGrillContainer>
|
||||
<StyledGrill src={grill} />
|
||||
@@ -158,26 +194,21 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
|
||||
interface FeaturedThemeBackgroundProps {
|
||||
entry: ResultOf<typeof FEATURED_THEME_ENTRY>;
|
||||
video: ResultOf<typeof FEATURED_THEME_VIDEO>;
|
||||
onPlay?(): void;
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeBackgroundProps) {
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
const { canPlayVideo } = useCompatability();
|
||||
const [fallbackToCover, setFallbackToCover] = useState(false);
|
||||
const { smallCover: featuredCover } = extractImages(theme.anime);
|
||||
const { smallCover: featuredCover } = extractImages(entry.animetheme.anime.images.nodes);
|
||||
|
||||
if (!theme.anime || !theme.entries.length) {
|
||||
return null;
|
||||
}
|
||||
const videoSlug = createVideoSlug(entry.animetheme, entry, video);
|
||||
|
||||
const entry = theme.entries[0];
|
||||
|
||||
if (!entry.videos.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
const href = `/anime/${entry.animetheme.anime.slug}/${videoSlug}`;
|
||||
|
||||
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
|
||||
return (
|
||||
@@ -197,22 +228,3 @@ function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
FeaturedTheme.fragments = {
|
||||
theme: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment FeaturedThemeTheme on Theme {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
entries {
|
||||
videos {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { ThemeDetailCard } from "@/components/card/ThemeDetailCard";
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
|
||||
import type { AnimeThemeFilterThemeFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
|
||||
|
||||
export const ANIME_THEME_FILTER_THEME = graphql(`
|
||||
fragment AnimeThemeFilterTheme on AnimeTheme {
|
||||
...ThemeDetailCardTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface AnimeThemeFilterProps {
|
||||
themes: Array<AnimeThemeFilterThemeFragment>;
|
||||
themes: Array<FragmentType<typeof ANIME_THEME_FILTER_THEME>>;
|
||||
}
|
||||
|
||||
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
function AnimeThemeFilterInternal({ themes: themesFragment }: AnimeThemeFilterProps) {
|
||||
const themes = getFragmentData(ANIME_THEME_FILTER_THEME, themesFragment);
|
||||
|
||||
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
|
||||
@@ -116,19 +129,4 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
AnimeThemeFilterInternal.fragments = {
|
||||
theme: gql`
|
||||
${ThemeDetailCard.fragments.theme}
|
||||
|
||||
fragment AnimeThemeFilterTheme on Theme {
|
||||
...ThemeDetailCardTheme
|
||||
type
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function MostPopularEntries() {
|
||||
const { data: mostPopular = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageTrending"],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageMostPopular {
|
||||
animethemeentryPagination(sort: [TRACKS_COUNT_DESC], first: 10) {
|
||||
data {
|
||||
...VideoSummaryCardEntry
|
||||
videos(first: 1) {
|
||||
nodes {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
return data.animethemeentryPagination.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{mostPopular.map((entry, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{entry ? <VideoSummaryCard video={entry.videos.nodes[0]} entry={entry} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import gql from "graphql-tag";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import type { HomePageMostViewedQuery } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client/index";
|
||||
|
||||
export function MostViewedVideos() {
|
||||
const { data: mostViewed } = useQuery<HomePageMostViewedQuery["videoAll"] | Array<null>>({
|
||||
queryKey: ["HomePageTrending"],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchDataClient<HomePageMostViewedQuery>(gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
|
||||
query HomePageMostViewed {
|
||||
videoAll(orderBy: "views_count", orderDesc: true, limit: 10) {
|
||||
...VideoSummaryCardVideo
|
||||
entries {
|
||||
...VideoSummaryCardEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.videoAll;
|
||||
},
|
||||
placeholderData: range(10).map(() => null),
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{mostViewed?.map((video, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{video ? <VideoSummaryCard video={video} entry={video.entries[0]} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,38 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import gql from "graphql-tag";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import type { HomePageRecentlyAddedPlaylistsQuery } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client/index";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function RecentlyAddedPlaylists() {
|
||||
const { data: recentlyAddedPlaylists } = useQuery<HomePageRecentlyAddedPlaylistsQuery["playlistAll"] | null[]>({
|
||||
const { data: recentlyAddedPlaylists = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageRecentlyAddedPlaylists"],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchDataClient<HomePageRecentlyAddedPlaylistsQuery>(gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query HomePageRecentlyAddedPlaylists {
|
||||
playlistAll(orderBy: "created_at", orderDesc: true, limit: 10, onlyNonEmpty: true) {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageRecentlyAddedPlaylists {
|
||||
playlistPagination(sort: [CREATED_AT_DESC], first: 10) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardPlaylistWithOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
`),
|
||||
});
|
||||
|
||||
return data.playlistAll;
|
||||
return data.playlistPagination.data;
|
||||
},
|
||||
placeholderData: range(10).map(() => null),
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{recentlyAddedPlaylists?.map((playlist, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{playlist ? <PlaylistSummaryCard playlist={playlist} showOwner /> : null}
|
||||
{playlist ? <PlaylistSummaryCard playlist={playlist} playlistWithOwner={playlist} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import gql from "graphql-tag";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import {
|
||||
VideoSummaryCard,
|
||||
VideoSummaryCardFragmentEntry,
|
||||
VideoSummaryCardFragmentVideo,
|
||||
} from "@/components/card/VideoSummaryCard";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import type { HomePageRecentlyAddedQuery } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function RecentlyAddedVideos() {
|
||||
const { data: recentlyAdded } = useQuery<HomePageRecentlyAddedQuery["videoAll"] | Array<null>>({
|
||||
const { data: recentlyAdded = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageRecentlyAdded"],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchDataClient<HomePageRecentlyAddedQuery>(gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${VideoSummaryCardFragmentEntry}
|
||||
|
||||
query HomePageRecentlyAdded {
|
||||
videoAll(orderBy: "id", orderDesc: true, limit: 10) {
|
||||
...VideoSummaryCardVideo
|
||||
entries {
|
||||
...VideoSummaryCardEntry
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageRecentlyAdded {
|
||||
videoPagination(sort: [ID_DESC], first: 10) {
|
||||
data {
|
||||
...VideoSummaryCardVideo
|
||||
animethemeentries(first: 1) {
|
||||
nodes {
|
||||
...VideoSummaryCardEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
`),
|
||||
});
|
||||
|
||||
return data.videoAll;
|
||||
return data.videoPagination.data;
|
||||
},
|
||||
placeholderData: range(10).map(() => null),
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{recentlyAdded?.map((video, index) => (
|
||||
{recentlyAdded.map((video, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{video ? <VideoSummaryCard video={video} entry={video.entries[0]} /> : null}
|
||||
{video ? <VideoSummaryCard video={video} entry={video.animethemeentries.nodes[0]} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { FullWidthImage } from "@/components/image/FullWidthImage";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
import type { CoverImageResourceWithImagesFragment } from "@/generated/graphql";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
resourceWithImages: CoverImageResourceWithImagesFragment;
|
||||
smallCover: string | undefined;
|
||||
largeCover: string | undefined;
|
||||
}
|
||||
|
||||
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
const { smallCover, largeCover } = extractImages(resourceWithImages);
|
||||
|
||||
export function CoverImage({ smallCover, largeCover, ...props }: CoverImageProps) {
|
||||
return (
|
||||
<AspectRatio $ratio={2 / 3}>
|
||||
<FullWidthImage
|
||||
@@ -25,13 +20,3 @@ export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
CoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment CoverImageResourceWithImages on ResourceWithImages {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { LogoPlaceholder } from "@/components/image/LogoPlaceholder";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
import type {
|
||||
MultiCoverImageResourceWithImagesFragment,
|
||||
MultiLargeCoverImageResourceWithImagesFragment,
|
||||
} from "@/generated/graphql";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import extractMultipleImages from "@/utils/extractMultipleImages";
|
||||
|
||||
function getTranslationX(item: number, itemCount: number) {
|
||||
switch (itemCount) {
|
||||
@@ -118,40 +110,33 @@ const StyledCover = styled.img`
|
||||
`;
|
||||
|
||||
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
|
||||
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>;
|
||||
items: Array<{
|
||||
largeCover?: string;
|
||||
smallCover?: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
|
||||
const images = resourcesWithImages
|
||||
.filter((resource, index, list) => list.findIndex((r) => r.name === resource.name) === index)
|
||||
.map((resource) => {
|
||||
const { largeCover, smallCover } = extractImages(resource);
|
||||
|
||||
return {
|
||||
largeCover,
|
||||
smallCover,
|
||||
resource,
|
||||
};
|
||||
})
|
||||
.filter(({ largeCover }) => !!largeCover)
|
||||
.slice(0, 4);
|
||||
|
||||
export function MultiCoverImage({ items, ...props }: MultiCoverImageProps) {
|
||||
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>
|
||||
))
|
||||
{items.length ? (
|
||||
items
|
||||
.filter(({ largeCover }) => !!largeCover)
|
||||
.slice(0, 4)
|
||||
.map(({ largeCover, smallCover, name }, _, slicedItems) => (
|
||||
<StyledCoverItemContainer key={largeCover} $itemCount={slicedItems.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={largeCover}
|
||||
alt={`Cover image of ${name}`}
|
||||
title={name}
|
||||
style={smallCover ? { backgroundImage: `url(${smallCover})` } : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
))
|
||||
) : (
|
||||
<LogoPlaceholder {...props} />
|
||||
)}
|
||||
@@ -159,72 +144,3 @@ export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverIma
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
MultiCoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment MultiCoverImageResourceWithImages on ResourceWithImages {
|
||||
... on Anime {
|
||||
name
|
||||
}
|
||||
... on Artist {
|
||||
name
|
||||
}
|
||||
... on Studio {
|
||||
name
|
||||
}
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface MultiLargeCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
|
||||
resourceWithImages: MultiLargeCoverImageResourceWithImagesFragment;
|
||||
}
|
||||
|
||||
export function MultiLargeCoverImage({ resourceWithImages, ...props }: MultiLargeCoverImageProps) {
|
||||
const images = extractMultipleImages(resourceWithImages).slice(0, 4);
|
||||
|
||||
return (
|
||||
<AspectRatio $ratio={2 / 3}>
|
||||
<StyledCoverContainer>
|
||||
{images.length ? (
|
||||
images.map(({ link }) => (
|
||||
<StyledCoverItemContainer key={link} $itemCount={images.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={link}
|
||||
alt={`Cover image of ${resourceWithImages.name}`}
|
||||
title={resourceWithImages.name}
|
||||
style={{ backgroundImage: `url(${link})` }}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
))
|
||||
) : (
|
||||
<LogoPlaceholder {...props} />
|
||||
)}
|
||||
</StyledCoverContainer>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
MultiLargeCoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractMultipleImages.fragments.resourceWithImages}
|
||||
|
||||
fragment MultiLargeCoverImageResourceWithImages on ResourceWithImages {
|
||||
... on Anime {
|
||||
name
|
||||
}
|
||||
... on Artist {
|
||||
name
|
||||
}
|
||||
... on Studio {
|
||||
name
|
||||
}
|
||||
...extractMultipleImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import gql from "graphql-tag";
|
||||
import md5 from "md5";
|
||||
|
||||
import type { ProfileImageUserFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const PROFILE_IMAGE_USER = graphql(`
|
||||
fragment ProfileImageUser on Me {
|
||||
name
|
||||
email
|
||||
}
|
||||
`);
|
||||
|
||||
interface ProfileImageProps {
|
||||
user: ProfileImageUserFragment;
|
||||
user: FragmentType<typeof PROFILE_IMAGE_USER>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
|
||||
export function ProfileImage({ user: userFragment, size = 80, ...props }: ProfileImageProps) {
|
||||
const user = getFragmentData(PROFILE_IMAGE_USER, userFragment);
|
||||
|
||||
const hash = md5(user.email.trim().toLowerCase());
|
||||
|
||||
return (
|
||||
@@ -21,12 +29,3 @@ export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProfileImage.fragments = {
|
||||
user: gql`
|
||||
fragment ProfileImageUser on UserAuth {
|
||||
name
|
||||
email
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useState } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { FullWidthImage } from "@/components/image/FullWidthImage";
|
||||
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
import type { StudioCoverImageStudioFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledImage = styled(FullWidthImage)`
|
||||
@@ -15,12 +13,33 @@ const StyledImage = styled(FullWidthImage)`
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
export const STUDIO_COVER_IMAGE_STUDIO = graphql(`
|
||||
fragment StudioCoverImageStudio on Studio {
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
anime {
|
||||
nodes {
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
studio: StudioCoverImageStudioFragment;
|
||||
studio: FragmentType<typeof STUDIO_COVER_IMAGE_STUDIO>;
|
||||
}
|
||||
|
||||
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
const { largeCover } = extractImages(studio);
|
||||
export function StudioCoverImage({ studio: studioFragment, ...props }: StudioCoverImageProps) {
|
||||
const studio = getFragmentData(STUDIO_COVER_IMAGE_STUDIO, studioFragment);
|
||||
const { largeCover } = extractImages(studio.images.nodes);
|
||||
|
||||
const [imageNotFound, setImageNotFound] = useState(!largeCover);
|
||||
|
||||
@@ -35,26 +54,14 @@ export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<MultiCoverImage resourcesWithImages={studio.anime} {...props} />
|
||||
<MultiCoverImage
|
||||
items={studio.anime.nodes.map((anime) => ({
|
||||
...extractImages(anime.images.nodes),
|
||||
name: anime.name,
|
||||
}))}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
StudioCoverImage.fragments = {
|
||||
studio: gql`
|
||||
fragment StudioCoverImageStudio on Studio {
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
anime {
|
||||
name
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
|
||||
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
|
||||
const itemsByFirstLetter = Object.entries(
|
||||
groupBy(
|
||||
items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
(item) => {
|
||||
const firstLetter = item.name[0].toLowerCase();
|
||||
if (firstLetter.match(/[a-z]/)) {
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import type { ThemeMenuThemeFragment } from "@/generated/graphql";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const THEME_MENU_THEME = graphql(`
|
||||
fragment ThemeMenuTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface ThemeMenuProps {
|
||||
theme: ThemeMenuThemeFragment;
|
||||
theme: FragmentType<typeof THEME_MENU_THEME>;
|
||||
}
|
||||
|
||||
export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
export function ThemeMenu({ theme: themeFragment }: ThemeMenuProps) {
|
||||
const theme = getFragmentData(THEME_MENU_THEME, themeFragment);
|
||||
|
||||
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
|
||||
|
||||
const entry = theme.entries[0];
|
||||
const video = entry?.videos[0];
|
||||
const entry = theme.animethemeentries[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
|
||||
if (!entry || !video) {
|
||||
return null;
|
||||
@@ -52,61 +89,20 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
{watchList.length ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={() => addWatchListItem(video, entryFlipped)}>
|
||||
<Icon icon={faArrowTurnDown} color="text-disabled" />
|
||||
<Text>Add to Watch List</Text>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => addWatchListItemNext(video, entryFlipped)}>
|
||||
<Icon icon={faArrowTurnUp} color="text-disabled" />
|
||||
<Text>Play Next</Text>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
{/*{watchList.length ? (*/}
|
||||
{/* <>*/}
|
||||
{/* <MenuSeparator />*/}
|
||||
{/* <MenuItem onSelect={() => addWatchListItem(video, entryFlipped)}>*/}
|
||||
{/* <Icon icon={faArrowTurnDown} color="text-disabled" />*/}
|
||||
{/* <Text>Add to Watch List</Text>*/}
|
||||
{/* </MenuItem>*/}
|
||||
{/* <MenuItem onSelect={() => addWatchListItemNext(video, entryFlipped)}>*/}
|
||||
{/* <Icon icon={faArrowTurnUp} color="text-disabled" />*/}
|
||||
{/* <Text>Play Next</Text>*/}
|
||||
{/* </MenuItem>*/}
|
||||
{/* </>*/}
|
||||
{/*) : null}*/}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMenu.fragments = {
|
||||
theme: gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
fragment ThemeMenuTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
videos {
|
||||
...createVideoSlugVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const VIDEO_MENU_ENTRY = graphql(`
|
||||
fragment VideoMenuEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
...PlaylistTrackAddDialogEntry
|
||||
id
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_MENU_VIDEO = graphql(`
|
||||
fragment VideoMenuVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...PlaylistTrackAddDialogVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoMenuProps {
|
||||
entry: FragmentType<typeof VIDEO_MENU_ENTRY>;
|
||||
video: FragmentType<typeof VIDEO_MENU_VIDEO>;
|
||||
}
|
||||
|
||||
export function VideoMenu({ entry: entryFragment, video: videoFragment }: VideoMenuProps) {
|
||||
const entry = getFragmentData(VIDEO_MENU_ENTRY, entryFragment);
|
||||
const video = getFragmentData(VIDEO_MENU_VIDEO, videoFragment);
|
||||
|
||||
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
|
||||
|
||||
return (
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Add to Playlist</Text>
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
{watchList.length ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={() => addWatchListItem(video, entry)}>
|
||||
<Icon icon={faArrowTurnDown} color="text-disabled" />
|
||||
<Text>Add to Watch List</Text>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => addWatchListItemNext(video, entry)}>
|
||||
<Icon icon={faArrowTurnUp} color="text-disabled" />
|
||||
<Text>Play Next</Text>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -85,9 +85,9 @@ export function Navigation() {
|
||||
asChild
|
||||
variant="silent"
|
||||
icon={
|
||||
me.user ? (
|
||||
me ? (
|
||||
<StyledProfileImageIcon>
|
||||
<StyledProfileImage user={me.user} />
|
||||
<StyledProfileImage user={me} />
|
||||
</StyledProfileImageIcon>
|
||||
) : (
|
||||
faUser
|
||||
|
||||
@@ -3,19 +3,42 @@ import Link from "next/link";
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
|
||||
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
|
||||
import type { YearDetailPageProps } from "@/pages/year/[year]";
|
||||
import type { SeasonDetailPageProps } from "@/pages/year/[year]/[season]";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export function SeasonNavigation(props: YearDetailPageProps | SeasonDetailPageProps) {
|
||||
const { year } = props;
|
||||
export const SEASON_NAVIGATION_YEAR = graphql(`
|
||||
fragment SeasonNavigationYear on AnimeYear {
|
||||
year
|
||||
seasons {
|
||||
season
|
||||
seasonLocalized
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const SEASON_NAVIGATION_SEASON = graphql(`
|
||||
fragment SeasonNavigationSeason on AnimeYearSeason {
|
||||
season
|
||||
}
|
||||
`);
|
||||
|
||||
interface SeasonNavigationProps {
|
||||
year: FragmentType<typeof SEASON_NAVIGATION_YEAR>;
|
||||
season?: FragmentType<typeof SEASON_NAVIGATION_SEASON>;
|
||||
}
|
||||
|
||||
export function SeasonNavigation({ year: yearFragment, season: seasonFragment }: SeasonNavigationProps) {
|
||||
const year = getFragmentData(SEASON_NAVIGATION_YEAR, yearFragment);
|
||||
const season = seasonFragment ? getFragmentData(SEASON_NAVIGATION_SEASON, seasonFragment) : undefined;
|
||||
|
||||
return (
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<HorizontalScroll $fixShadows>
|
||||
<Switcher selectedItem={"season" in props ? props.season.value.toLowerCase() : null}>
|
||||
{year.seasons.map((season) => (
|
||||
<SwitcherOption key={season.value} asChild value={season.value.toLowerCase()}>
|
||||
<Link href={`/year/${year.value}/${season.value.toLowerCase()}`}>{season.value}</Link>
|
||||
<Switcher selectedItem={season ? season.season : null}>
|
||||
{year.seasons?.map((season) => (
|
||||
<SwitcherOption key={season.season} asChild value={season.season}>
|
||||
<Link href={`/year/${year.year}/${season.season.toLowerCase()}`}>
|
||||
{season.seasonLocalized}
|
||||
</Link>
|
||||
</SwitcherOption>
|
||||
))}
|
||||
</Switcher>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { YearDetailPageProps } from "@/pages/year/[year]";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
const StyledYear = styled.div`
|
||||
flex: 1;
|
||||
@@ -22,9 +22,33 @@ const StyledYearNext = styled(StyledYear)`
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
|
||||
const previousYear = yearAll.find((y) => y.value === year.value - 1)?.value ?? null;
|
||||
const nextYear = yearAll.find((y) => y.value === year.value + 1)?.value ?? null;
|
||||
export const YEAR_NAVIGATION_YEAR = graphql(`
|
||||
fragment YearNavigationYear on AnimeYear {
|
||||
year
|
||||
seasons {
|
||||
season
|
||||
seasonLocalized
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const YEAR_NAVIGATION_YEARS = graphql(`
|
||||
fragment YearNavigationYears on AnimeYear {
|
||||
year
|
||||
}
|
||||
`);
|
||||
|
||||
interface YearNavigationProps {
|
||||
year: FragmentType<typeof YEAR_NAVIGATION_YEAR>;
|
||||
years: Array<FragmentType<typeof YEAR_NAVIGATION_YEARS>>;
|
||||
}
|
||||
|
||||
export function YearNavigation({ year: yearFragment, years: yearsFragment }: YearNavigationProps) {
|
||||
const year = getFragmentData(YEAR_NAVIGATION_YEAR, yearFragment);
|
||||
const years = getFragmentData(YEAR_NAVIGATION_YEARs, yearsFragment);
|
||||
|
||||
const previousYear = years.find((y) => y.year === year.year - 1)?.year ?? null;
|
||||
const nextYear = years.find((y) => y.year === year.year + 1)?.year ?? null;
|
||||
|
||||
return (
|
||||
<Row style={{ "--align-items": "center" }}>
|
||||
@@ -37,7 +61,7 @@ export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
|
||||
</StyledYearPrevious>
|
||||
<Button asChild variant="silent">
|
||||
<Link href={`/year`}>
|
||||
<Text variant="h1">{year.value}</Text>
|
||||
<Text variant="h1">{year.year}</Text>
|
||||
</Link>
|
||||
</Button>
|
||||
<StyledYearNext>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { AnimeMediaFormat } from "@/graphql/generated/graphql";
|
||||
|
||||
interface SearchFilterMediaFormatProps {
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
value: AnimeMediaFormat | null;
|
||||
setValue: (newValue: AnimeMediaFormat | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
|
||||
@@ -16,11 +17,11 @@ export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFo
|
||||
Any
|
||||
</ListboxOption>
|
||||
<ListboxOption value="TV">TV</ListboxOption>
|
||||
<ListboxOption value="TV Short">TV Short</ListboxOption>
|
||||
<ListboxOption value="Movie">Movie</ListboxOption>
|
||||
<ListboxOption value="TV_SHORT">TV Short</ListboxOption>
|
||||
<ListboxOption value="MOVIE">Movie</ListboxOption>
|
||||
<ListboxOption value="OVA">OVA</ListboxOption>
|
||||
<ListboxOption value="ONA">ONA</ListboxOption>
|
||||
<ListboxOption value="Special">Special</ListboxOption>
|
||||
<ListboxOption value="SPECIAL">Special</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { AnimeSeason } from "@/graphql/generated/graphql";
|
||||
|
||||
interface SearchFilterSeasonProps {
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
value: AnimeSeason | null;
|
||||
setValue: (newValue: AnimeSeason | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps) {
|
||||
@@ -15,10 +16,10 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
<ListboxOption value="Winter">Winter</ListboxOption>
|
||||
<ListboxOption value="Spring">Spring</ListboxOption>
|
||||
<ListboxOption value="Summer">Summer</ListboxOption>
|
||||
<ListboxOption value="Fall">Fall</ListboxOption>
|
||||
<ListboxOption value="WINTER">Winter</ListboxOption>
|
||||
<ListboxOption value="SPRING">Spring</ListboxOption>
|
||||
<ListboxOption value="SUMMER">Summer</ListboxOption>
|
||||
<ListboxOption value="FALL">Fall</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,117 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { AnimeSummaryCard } from "@/components/card/AnimeSummaryCard";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterFirstLetter } from "@/components/search-filter/SearchFilterFirstLetter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilterMediaFormat";
|
||||
import { SearchFilterSeason } from "@/components/search-filter/SearchFilterSeason";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import { SearchFilterYear } from "@/components/search-filter/SearchFilterYear";
|
||||
import type { SearchAnimeQuery, SearchAnimeQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { AnimeMediaFormat, AnimeSeason, AnimeSortableColumns } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
interface Filter {
|
||||
firstLetter: string | null;
|
||||
season: AnimeSeason | null;
|
||||
year: string | null;
|
||||
mediaFormat: AnimeMediaFormat | null;
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
firstLetter: null,
|
||||
season: null,
|
||||
year: null,
|
||||
mediaFormat: null,
|
||||
sortBy: "name",
|
||||
sortBy: "NAME",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchAnime(
|
||||
$query: String
|
||||
$name_like: String
|
||||
$season: AnimeSeason
|
||||
$year: Int
|
||||
$media_format: AnimeMediaFormat
|
||||
$sort: [AnimeSortableColumns!]
|
||||
$page: Int!
|
||||
) {
|
||||
animePagination(
|
||||
search: $query
|
||||
name_like: $name_like
|
||||
season: $season
|
||||
year: $year
|
||||
mediaFormat: $media_format
|
||||
sort: $sort
|
||||
first: 15
|
||||
page: $page
|
||||
) {
|
||||
data {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
slug
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchAnimeProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-anime", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-anime-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.firstLetter ? { name_like: `${filter.firstLetter}%` } : {}),
|
||||
...(filter.season ? { season: filter.season } : {}),
|
||||
...(filter.year ? { year: parseInt(filter.year) } : {}),
|
||||
...(filter.mediaFormat ? { media_format: filter.mediaFormat } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<AnimeSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "anime", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.animePagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -48,58 +128,37 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity<SearchAnimeQuery["searchAnime"]["data"][number]>
|
||||
entity="anime"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
season: filter.season,
|
||||
year: filter.year,
|
||||
media_format: filter.mediaFormat,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchAnimeQuery, SearchAnimeQueryVariables>(
|
||||
gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
|
||||
query SearchAnime($args: SearchArgs!) {
|
||||
searchAnime(args: $args) {
|
||||
data {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchAnime;
|
||||
}}
|
||||
renderResult={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={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>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="year,season,name">Old ➜ New</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-year,-season,name">New ➜ Old</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<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> : null}
|
||||
<SearchFilterSortBy.Option value="NAME">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="NAME_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="YEAR,SEASON,NAME">Old ➜ New</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="YEAR_DESC,SEASON_DESC,NAME">New ➜ Old</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable={anime} />)}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,87 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ArtistSummaryCard } from "@/components/card/ArtistSummaryCard";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterFirstLetter } from "@/components/search-filter/SearchFilterFirstLetter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import type { SearchArtistQuery, SearchArtistQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { ArtistSortableColumns } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
interface Filter {
|
||||
firstLetter: string | null;
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
firstLetter: null,
|
||||
sortBy: "name",
|
||||
sortBy: "NAME",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchArtist($query: String, $name_like: String, $sort: [ArtistSortableColumns!], $page: Int!) {
|
||||
artistPagination(search: $query, name_like: $name_like, sort: $sort, first: 15, page: $page) {
|
||||
data {
|
||||
...ArtistSummaryCardArtist
|
||||
slug
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchArtistProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-artist", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-artist-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.firstLetter ? { name_like: `${filter.firstLetter}%` } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<ArtistSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "artist", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.artistPagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -42,48 +98,33 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity<SearchArtistQuery["searchArtist"]["data"][number]>
|
||||
entity="artist"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchArtistQuery, SearchArtistQueryVariables>(
|
||||
gql`
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? <SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option> : null}
|
||||
<SearchFilterSortBy.Option value="NAME">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="NAME_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
|
||||
query SearchArtist($args: SearchArgs!) {
|
||||
searchArtist(args: $args) {
|
||||
data {
|
||||
...ArtistSummaryCardArtist
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchArtist;
|
||||
}}
|
||||
renderResult={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />)}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,76 +6,77 @@ import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { ErrorCard } from "@/components/card/ErrorCard";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { SearchArgs } from "@/generated/graphql";
|
||||
import useEntitySearch from "@/hooks/useEntitySearch";
|
||||
import type { SimpleSearchArgs } from "@/lib/client/search";
|
||||
|
||||
interface SearchEntityProps<T> {
|
||||
entity: string;
|
||||
fetchResults: (searchArgs: SearchArgs) => Promise<{
|
||||
data: Array<T>;
|
||||
nextPage: number | null;
|
||||
}>;
|
||||
searchArgs: SimpleSearchArgs;
|
||||
filters: ReactNode;
|
||||
renderResult: (result: T) => ReactNode;
|
||||
interface SearchEntityProps {
|
||||
children: ReactNode;
|
||||
error: unknown;
|
||||
searchQuery?: string;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
hasResults: boolean;
|
||||
hasNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
export function SearchEntity({
|
||||
children,
|
||||
error,
|
||||
searchQuery,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasResults,
|
||||
hasNextPage,
|
||||
onLoadMore,
|
||||
}: SearchEntityProps) {
|
||||
if (isError) {
|
||||
return <ErrorCard error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Text block>Searching...</Text>;
|
||||
}
|
||||
|
||||
if (!hasResults) {
|
||||
if (searchQuery) {
|
||||
return <Text block>No results found for query "{searchQuery}". Did you spell it correctly?</Text>;
|
||||
} else {
|
||||
return <Text block>No results found for your current filter settings.</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingMore = isFetchingNextPage || isPlaceholderData;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!filters && <SearchFilterGroup>{filters}</SearchFilterGroup>}
|
||||
{(() => {
|
||||
if (isError) {
|
||||
return <ErrorCard error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Text block>Searching...</Text>;
|
||||
}
|
||||
|
||||
const results = data?.pages.flatMap((page) => page.data) ?? [];
|
||||
|
||||
if (!results.length) {
|
||||
if (searchArgs.query) {
|
||||
return (
|
||||
<Text block>
|
||||
No results found for query "{searchArgs.query}". Did you spell it correctly?
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return <Text block>No results found for your current filter settings.</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoadingMore = isFetchingNextPage || isPlaceholderData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<Column style={{ "--gap": "16px", opacity: isFetching && !isFetchingNextPage ? 0.5 : 1 }}>
|
||||
{children}
|
||||
</Column>
|
||||
{(hasNextPage || isPlaceholderData) && (
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Button
|
||||
variant="silent"
|
||||
isCircle
|
||||
onClick={() => {
|
||||
if (!isLoadingMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
}}
|
||||
title="Load more"
|
||||
>
|
||||
<Icon
|
||||
icon={isLoadingMore ? faSpinner : faChevronDown}
|
||||
className={isLoadingMore ? "fa-spin" : undefined}
|
||||
/>
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
@@ -16,59 +15,61 @@ import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
const query = graphql(`
|
||||
query SearchGlobal($query: String!) {
|
||||
search(search: $query, first: 4) {
|
||||
anime {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
slug
|
||||
}
|
||||
animethemes {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
id
|
||||
anime {
|
||||
slug
|
||||
}
|
||||
}
|
||||
artists {
|
||||
...ArtistSummaryCardArtist
|
||||
slug
|
||||
}
|
||||
series {
|
||||
slug
|
||||
name
|
||||
}
|
||||
studios {
|
||||
slug
|
||||
name
|
||||
}
|
||||
playlists {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardPlaylistWithOwner
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
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 { data, error, isLoading, isError } = useQuery({
|
||||
queryKey: ["searchGlobal", searchQuery],
|
||||
queryFn: fetchSearchResults,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: { query: searchQuery },
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -82,12 +83,12 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
|
||||
const {
|
||||
anime: animeResults = [],
|
||||
themes: themeResults = [],
|
||||
animethemes: themeResults = [],
|
||||
artists: artistResults = [],
|
||||
series: seriesResults = [],
|
||||
studios: studioResults = [],
|
||||
playlists: playlistResults = [],
|
||||
} = data.data.search;
|
||||
} = data.search;
|
||||
|
||||
const totalResults =
|
||||
animeResults.length +
|
||||
@@ -107,14 +108,14 @@ 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={anime} />}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="theme"
|
||||
title="Themes"
|
||||
results={themeResults}
|
||||
renderSummaryCard={(theme) => (
|
||||
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable />
|
||||
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable={theme} />
|
||||
)}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
@@ -154,7 +155,7 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
title="Playlists"
|
||||
results={playlistResults}
|
||||
renderSummaryCard={(playlist) => (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} playlistWithOwner={playlist} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,29 +1,84 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import type { SearchPlaylistQuery, SearchPlaylistQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { PlaylistSortableColumns } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
sortBy: "name",
|
||||
interface Filter {
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
sortBy: "NAME",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchPlaylist($query: String, $sort: [PlaylistSortableColumns!], $page: Int!) {
|
||||
playlistPagination(search: $query, sort: $sort, first: 15, page: $page) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardPlaylistWithOwner
|
||||
id
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchPlaylistProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-playlist", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-playlist-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<PlaylistSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "playlist", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.playlistPagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -40,46 +95,33 @@ export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchPlaylist;
|
||||
}}
|
||||
renderResult={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? <SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option> : null}
|
||||
<SearchFilterSortBy.Option value="NAME">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="NAME_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((playlist) => (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} playlistWithOwner={playlist} />
|
||||
))}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,87 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterFirstLetter } from "@/components/search-filter/SearchFilterFirstLetter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import type { SearchSeriesQuery, SearchSeriesQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { SeriesSortableColumns } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
interface Filter {
|
||||
firstLetter: string | null;
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
firstLetter: null,
|
||||
sortBy: "name",
|
||||
sortBy: "NAME",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchSeries($query: String, $name_like: String, $sort: [SeriesSortableColumns!], $page: Int!) {
|
||||
seriesPagination(search: $query, name_like: $name_like, sort: $sort, first: 15, page: $page) {
|
||||
data {
|
||||
slug
|
||||
name
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchSeriesProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-series", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-series-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.firstLetter ? { name_like: `${filter.firstLetter}%` } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<SeriesSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "series", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.seriesPagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -42,49 +98,39 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity<SearchSeriesQuery["searchSeries"]["data"][number]>
|
||||
entity="series"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(
|
||||
gql`
|
||||
query SearchSeries($args: SearchArgs!) {
|
||||
searchSeries(args: $args) {
|
||||
data {
|
||||
slug
|
||||
name
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchSeries;
|
||||
}}
|
||||
renderResult={(series) => (
|
||||
<SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />
|
||||
)}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? <SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option> : null}
|
||||
<SearchFilterSortBy.Option value="NAME">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="NAME_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((series) => (
|
||||
<SummaryCard
|
||||
key={series.slug}
|
||||
title={series.name}
|
||||
description="Series"
|
||||
to={`/series/${series.slug}`}
|
||||
/>
|
||||
))}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,87 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { StudioSummaryCard } from "@/components/card/StudioSummaryCard";
|
||||
import { StudioCoverImage } from "@/components/image/StudioCoverImage";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterFirstLetter } from "@/components/search-filter/SearchFilterFirstLetter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import type { SearchStudioQuery, SearchStudioQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { StudioSortableColumns } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
interface Filter {
|
||||
firstLetter: string | null;
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
firstLetter: null,
|
||||
sortBy: "name",
|
||||
sortBy: "NAME",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchStudio($query: String, $name_like: String, $sort: [StudioSortableColumns!], $page: Int!) {
|
||||
studioPagination(search: $query, name_like: $name_like, sort: $sort, first: 15, page: $page) {
|
||||
data {
|
||||
...StudioSummaryCardStudio
|
||||
slug
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchStudioProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-studio", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-studio-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.firstLetter ? { name_like: `${filter.firstLetter}%` } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<StudioSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "studio", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.studioPagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -43,50 +98,32 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity<SearchStudioQuery["searchStudio"]["data"][number]>
|
||||
entity="studio"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchStudioQuery, SearchStudioQueryVariables>(
|
||||
gql`
|
||||
${StudioCoverImage.fragments.studio}
|
||||
|
||||
query SearchStudio($args: SearchArgs!) {
|
||||
searchStudio(args: $args) {
|
||||
data {
|
||||
...StudioCoverImageStudio
|
||||
slug
|
||||
name
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchStudio;
|
||||
}}
|
||||
renderResult={(studio) => <StudioSummaryCard key={studio.slug} studio={studio} />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? <SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option> : null}
|
||||
<SearchFilterSortBy.Option value="NAME">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="NAME_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((studio) => <StudioSummaryCard key={studio.slug} studio={studio} />)}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,88 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import { SearchEntity } from "@/components/search/SearchEntity";
|
||||
import { SearchFilterFirstLetter } from "@/components/search-filter/SearchFilterFirstLetter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterSortBy } from "@/components/search-filter/SearchFilterSortBy";
|
||||
import { SearchFilterThemeType } from "@/components/search-filter/SearchFilterThemeType";
|
||||
import type { SearchThemeQuery, SearchThemeQueryVariables } from "@/generated/graphql";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { AnimeThemeSortableColumns, ThemeType } from "@/graphql/generated/graphql";
|
||||
import useFilterStorage from "@/hooks/useFilterStorage";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
const initialFilter = {
|
||||
firstLetter: null,
|
||||
interface Filter {
|
||||
type: ThemeType | null;
|
||||
sortBy: string | null;
|
||||
}
|
||||
|
||||
const initialFilter: Filter = {
|
||||
type: null,
|
||||
sortBy: "song.title",
|
||||
sortBy: "SONG_TITLE",
|
||||
};
|
||||
|
||||
const query = graphql(`
|
||||
query SearchTheme($query: String, $type: ThemeType, $sort: [AnimeThemeSortableColumns!], $page: Int!) {
|
||||
animethemePagination(search: $query, type: $type, sort: $sort, first: 15, page: $page) {
|
||||
data {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
slug
|
||||
}
|
||||
paginationInfo {
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface SearchThemeProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-theme", {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-theme-v2", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
const variables = {
|
||||
...(searchQuery ? { query: searchQuery } : {}),
|
||||
...(filter.type ? { type: filter.type } : {}),
|
||||
...(filter.sortBy ? { sort: filter.sortBy.split(",") as Array<AnimeThemeSortableColumns> } : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPlaceholderData,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["searchEntity", "theme", variables],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await client.query({
|
||||
query,
|
||||
variables: {
|
||||
...variables,
|
||||
page: pageParam,
|
||||
},
|
||||
});
|
||||
|
||||
return data.animethemePagination;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _, lastPageParam) =>
|
||||
lastPage.paginationInfo.hasMorePages ? lastPageParam + 1 : null,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
@@ -44,61 +99,38 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity<SearchThemeQuery["searchTheme"]["data"][number]>
|
||||
entity="theme"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
has: "song",
|
||||
"song][title-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
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
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchTheme;
|
||||
}}
|
||||
renderResult={(theme) => (
|
||||
<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")} />
|
||||
<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="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SearchFilterGroup>
|
||||
<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_DESC">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="ANIME_YEAR,ANIME_SEASON,SONG_TITLE">
|
||||
Old ➜ New
|
||||
</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="ANIME_YEAR_DESC,ANIME_SEASON_DESC,SONG_TITLE">
|
||||
New ➜ Old
|
||||
</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="CREATED_AT_DESC">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</SearchFilterGroup>
|
||||
<SearchEntity
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
hasResults={!!data?.pages.length}
|
||||
hasNextPage={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
>
|
||||
{data?.pages
|
||||
.flatMap((page) => page.data)
|
||||
.map((theme) => <ThemeSummaryCard key={theme.slug} theme={theme} expandable={theme} />)}
|
||||
</SearchEntity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Table } from "@/components/table/Table";
|
||||
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "@/components/table/Table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "@/components/table/Table";
|
||||
import { ContentWarningTags } from "@/components/tag/ContentWarningTags";
|
||||
import { EpisodeTag } from "@/components/tag/EpisodeTag";
|
||||
import { VideoTags } from "@/components/tag/VideoTags";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type { ThemeTableThemeFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { either, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
|
||||
export const THEME_TABLE_THEME = graphql(`
|
||||
fragment ThemeTableTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
anime {
|
||||
slug
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
...EpisodeTagEntry
|
||||
...ContentWarningTagsEntry
|
||||
version
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export interface ThemeTableProps {
|
||||
themes: Array<ThemeTableThemeFragment>;
|
||||
themes: Array<FragmentType<typeof THEME_TABLE_THEME>>;
|
||||
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void;
|
||||
}
|
||||
|
||||
export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
|
||||
export function ThemeTable({ themes: themesFragment, onPlay }: ThemeTableProps) {
|
||||
const themes = getFragmentData(THEME_TABLE_THEME, themesFragment);
|
||||
|
||||
const rows = themes
|
||||
.filter((theme) => theme.anime && theme.entries.length && theme.entries[0]?.videos.length)
|
||||
.filter(
|
||||
(theme) => theme.anime && theme.animethemeentries.length && theme.animethemeentries[0]?.videos.nodes.length,
|
||||
)
|
||||
.sort(either(themeTypeComparator).or(themeIndexComparator).chain())
|
||||
.map((theme) =>
|
||||
theme.entries.map((entry, entryIndex) =>
|
||||
entry.videos.map((video, videoIndex) => {
|
||||
theme.animethemeentries.map((entry, entryIndex) =>
|
||||
entry.videos.nodes.map((video, videoIndex) => {
|
||||
const anime = theme.anime as NonNullable<(typeof theme)["anime"]>;
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
return (
|
||||
@@ -84,36 +112,3 @@ export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeTable.fragments = {
|
||||
theme: gql`
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
${EpisodeTag.fragments.entry}
|
||||
${ContentWarningTags.fragments.entry}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment ThemeTableTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
anime {
|
||||
slug
|
||||
}
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
...EpisodeTagEntry
|
||||
...ContentWarningTagsEntry
|
||||
videos {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
}
|
||||
}
|
||||
song {
|
||||
title
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Tag } from "@/components/tag/Tag";
|
||||
import type { ContentWarningTagsEntryFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const CONTENT_WARNING_TAGS_ENTRY = graphql(`
|
||||
fragment ContentWarningTagsEntry on AnimeThemeEntry {
|
||||
spoiler
|
||||
nsfw
|
||||
}
|
||||
`);
|
||||
|
||||
interface ContentWarningTagsProps {
|
||||
entry: ContentWarningTagsEntryFragment;
|
||||
entry: FragmentType<typeof CONTENT_WARNING_TAGS_ENTRY>;
|
||||
}
|
||||
|
||||
export function ContentWarningTags({ entry }: ContentWarningTagsProps) {
|
||||
export function ContentWarningTags({ entry: entryFragment }: ContentWarningTagsProps) {
|
||||
const entry = getFragmentData(CONTENT_WARNING_TAGS_ENTRY, entryFragment);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entry.spoiler && (
|
||||
@@ -25,12 +33,3 @@ export function ContentWarningTags({ entry }: ContentWarningTagsProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ContentWarningTags.fragments = {
|
||||
entry: gql`
|
||||
fragment ContentWarningTagsEntry on Entry {
|
||||
spoiler
|
||||
nsfw
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { faFilm } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Tag } from "@/components/tag/Tag";
|
||||
import type { EpisodeTagEntryFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const EPISODE_TAG_ENTRY = graphql(`
|
||||
fragment EpisodeTagEntry on AnimeThemeEntry {
|
||||
episodes
|
||||
}
|
||||
`);
|
||||
|
||||
interface EpisodeTagProps {
|
||||
entry: EpisodeTagEntryFragment;
|
||||
entry: FragmentType<typeof EPISODE_TAG_ENTRY>;
|
||||
}
|
||||
|
||||
export function EpisodeTag({ entry }: EpisodeTagProps) {
|
||||
export function EpisodeTag({ entry: entryFragment }: EpisodeTagProps) {
|
||||
const entry = getFragmentData(EPISODE_TAG_ENTRY, entryFragment);
|
||||
|
||||
return (
|
||||
<Tag icon={faFilm} title={entry.episodes ? `Used in episode ${entry.episodes}` : "Used in all episodes"}>
|
||||
{entry.episodes || "—"}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeTag.fragments = {
|
||||
entry: gql`
|
||||
fragment EpisodeTagEntry on Entry {
|
||||
episodes
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { ContentWarningTags } from "@/components/tag/ContentWarningTags";
|
||||
import { EpisodeTag } from "@/components/tag/EpisodeTag";
|
||||
import type { ThemeEntryTagsEntryFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
type ThemeEntryTagsProps = {
|
||||
entry: ThemeEntryTagsEntryFragment;
|
||||
};
|
||||
export const THEME_ENTRY_TAGS_ENTRY = graphql(`
|
||||
fragment ThemeEntryTagsEntry on AnimeThemeEntry {
|
||||
...EpisodeTagEntry
|
||||
...ContentWarningTagsEntry
|
||||
}
|
||||
`);
|
||||
|
||||
interface ThemeEntryTagsProps {
|
||||
entry: FragmentType<typeof THEME_ENTRY_TAGS_ENTRY>;
|
||||
}
|
||||
|
||||
export function ThemeEntryTags({ entry: entryFragment }: ThemeEntryTagsProps) {
|
||||
const entry = getFragmentData(THEME_ENTRY_TAGS_ENTRY, entryFragment);
|
||||
|
||||
export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
|
||||
return (
|
||||
<Row style={{ "--gap": "8px", "--align-items": "baseline" }}>
|
||||
<EpisodeTag entry={entry} />
|
||||
@@ -17,15 +24,3 @@ export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeEntryTags.fragments = {
|
||||
entry: gql`
|
||||
${EpisodeTag.fragments.entry}
|
||||
${ContentWarningTags.fragments.entry}
|
||||
|
||||
fragment ThemeEntryTagsEntry on Entry {
|
||||
...EpisodeTagEntry
|
||||
...ContentWarningTagsEntry
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { BorderTag } from "@/components/tag/BorderTag";
|
||||
import { Tag } from "@/components/tag/Tag";
|
||||
import type { VideoTagsVideoFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
const StyledVideoTags = styled(Row)`
|
||||
align-items: center;
|
||||
@@ -13,11 +11,25 @@ const StyledVideoTags = styled(Row)`
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
export const VIDEO_TAGS_VIDEO = graphql(`
|
||||
fragment VideoTagsVideo on Video {
|
||||
resolution
|
||||
nc
|
||||
subbed
|
||||
lyrics
|
||||
uncen
|
||||
source
|
||||
overlap
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoTagsProps {
|
||||
video: VideoTagsVideoFragment;
|
||||
video: FragmentType<typeof VIDEO_TAGS_VIDEO>;
|
||||
}
|
||||
|
||||
export function VideoTags({ video }: VideoTagsProps) {
|
||||
export function VideoTags(props: VideoTagsProps) {
|
||||
const video = getFragmentData(VIDEO_TAGS_VIDEO, props.video);
|
||||
|
||||
return (
|
||||
<StyledVideoTags>
|
||||
<Tag title="Resolution">{video.resolution}p</Tag>
|
||||
@@ -32,23 +44,9 @@ export function VideoTags({ video }: VideoTagsProps) {
|
||||
|
||||
{!!video.source && <BorderTag title="Source">{video.source.toUpperCase()}</BorderTag>}
|
||||
|
||||
{video.overlap !== "NONE" && (
|
||||
{!!video.overlap && video.overlap !== "NONE" && (
|
||||
<BorderTag title="Overlap">{video.overlap.toUpperCase().replace("TRANSITION", "TRANS")}</BorderTag>
|
||||
)}
|
||||
</StyledVideoTags>
|
||||
);
|
||||
}
|
||||
|
||||
VideoTags.fragments = {
|
||||
video: gql`
|
||||
fragment VideoTagsVideo on Video {
|
||||
resolution
|
||||
nc
|
||||
subbed
|
||||
lyrics
|
||||
uncen
|
||||
source
|
||||
overlap
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import type { Announcement } from "@/lib/client/announcement";
|
||||
import { fetchAnnouncements } from "@/lib/client/announcement";
|
||||
import theme from "@/theme";
|
||||
import { ShowAnnouncements } from "@/utils/settings";
|
||||
|
||||
const StyledBody = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledAnnouncements = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export function AnnouncementToast() {
|
||||
const { closeToast } = useToasts();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showAnnouncements]);
|
||||
|
||||
if (!announcements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Toast $hoverable $color="text-warning" onClick={() => closeToast("announcement")}>
|
||||
<StyledBody>
|
||||
<StyledAnnouncements>
|
||||
{announcements.map((announcement) => (
|
||||
<Text key={announcement.id} as="p" dangerouslySetInnerHTML={{ __html: announcement.content }} />
|
||||
))}
|
||||
</StyledAnnouncements>
|
||||
<Text color="text-disabled">(Click to dismiss.)</Text>
|
||||
</StyledBody>
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import type { FetchThemeSummaryCardData } from "@/components/card/ThemeSummaryCard";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
|
||||
interface PlaylistAddToastProps {
|
||||
theme: Exclude<FetchThemeSummaryCardData, null>;
|
||||
}
|
||||
|
||||
export function PlaylistAddToast({ theme }: PlaylistAddToastProps) {
|
||||
return (
|
||||
<Toast as={Link} href="/profile/playlist" $hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={theme.song} /> was added to the playlist!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
</Row>
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import type { PlaylistTrackRemoveToastPlaylistFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const PLAYLIST_REMOVE_TOAST_PLAYLIST = graphql(`
|
||||
fragment PlaylistRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistRemoveToastProps {
|
||||
playlist: PlaylistTrackRemoveToastPlaylistFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_REMOVE_TOAST_PLAYLIST>;
|
||||
}
|
||||
|
||||
export function PlaylistRemoveToast({ playlist }: PlaylistRemoveToastProps) {
|
||||
export function PlaylistRemoveToast({ playlist: playlistFragment }: PlaylistRemoveToastProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_REMOVE_TOAST_PLAYLIST, playlistFragment);
|
||||
|
||||
return (
|
||||
<Toast>
|
||||
<Text color="text-primary">{playlist.name}</Text> was deleted!
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistRemoveToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type { PlaylistTrackAddToastEntryFragment, PlaylistTrackAddToastPlaylistFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_TOAST_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackAddToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_TOAST_ENTRY = graphql(`
|
||||
fragment PlaylistTrackAddToastEntry on AnimeThemeEntry {
|
||||
animetheme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddToastProps {
|
||||
playlist: PlaylistTrackAddToastPlaylistFragment;
|
||||
entry: PlaylistTrackAddToastEntryFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_ADD_TOAST_PLAYLIST>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_ADD_TOAST_ENTRY>;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddToast({ playlist, entry }: PlaylistTrackAddToastProps) {
|
||||
export function PlaylistTrackAddToast({
|
||||
playlist: playlistFragment,
|
||||
entry: entryFragment,
|
||||
}: PlaylistTrackAddToastProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_ADD_TOAST_PLAYLIST, playlistFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_ADD_TOAST_ENTRY, entryFragment);
|
||||
|
||||
return (
|
||||
<Toast as={Link} href={`/playlist/${playlist.id}`} $hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={entry.theme?.song ?? null} /> was added to{" "}
|
||||
<SongTitle song={entry.animetheme.song} /> was added to{" "}
|
||||
<Text color="text-primary">{playlist.name}</Text>!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
@@ -26,23 +47,3 @@ export function PlaylistTrackAddToast({ playlist, entry }: PlaylistTrackAddToast
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackAddToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistTrackAddToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${SongTitle.fragments.song}
|
||||
|
||||
fragment PlaylistTrackAddToastEntry on Entry {
|
||||
theme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type {
|
||||
PlaylistTrackRemoveToastEntryFragment,
|
||||
PlaylistTrackRemoveToastPlaylistFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_TOAST_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_TOAST_ENTRY = graphql(`
|
||||
fragment PlaylistTrackRemoveToastEntry on AnimeThemeEntry {
|
||||
animetheme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackRemoveToastProps {
|
||||
playlist: PlaylistTrackRemoveToastPlaylistFragment;
|
||||
entry: PlaylistTrackRemoveToastEntryFragment;
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_REMOVE_TOAST_PLAYLIST>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_REMOVE_TOAST_ENTRY>;
|
||||
}
|
||||
|
||||
export function PlaylistTrackRemoveToast({ playlist, entry }: PlaylistTrackRemoveToastProps) {
|
||||
export function PlaylistTrackRemoveToast({
|
||||
playlist: playlistFragment,
|
||||
entry: entryFragment,
|
||||
}: PlaylistTrackRemoveToastProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_REMOVE_TOAST_PLAYLIST, playlistFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_REMOVE_TOAST_ENTRY, entryFragment);
|
||||
|
||||
return (
|
||||
<Toast as={Link} href={`/playlist/${playlist.id}`} $hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={entry.theme?.song ?? null} /> was removed from{" "}
|
||||
<SongTitle song={entry.animetheme.song} /> was removed from{" "}
|
||||
<Text color="text-primary">{playlist.name}</Text>!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
@@ -29,23 +47,3 @@ export function PlaylistTrackRemoveToast({ playlist, entry }: PlaylistTrackRemov
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackRemoveToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistTrackRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${SongTitle.fragments.song}
|
||||
|
||||
fragment PlaylistTrackRemoveToastEntry on Entry {
|
||||
theme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -6,15 +6,14 @@ import useAuth from "@/hooks/useAuth";
|
||||
|
||||
interface PageRevalidationProps {
|
||||
lastBuildAt: number;
|
||||
apiRequests: number;
|
||||
}
|
||||
|
||||
export function PageRevalidation({ lastBuildAt, apiRequests, ...props }: PageRevalidationProps) {
|
||||
export function PageRevalidation({ lastBuildAt, ...props }: PageRevalidationProps) {
|
||||
const router = useRouter();
|
||||
const { me } = useAuth();
|
||||
const canRevalidate = useMemo(() => {
|
||||
const userPermissions = me.user?.permissions ?? [];
|
||||
const rolePermissions = me.user?.roles.flatMap((role) => role.permissions) ?? [];
|
||||
const userPermissions = me?.permissions.nodes ?? [];
|
||||
const rolePermissions = me?.roles.nodes.flatMap((role) => role.permissions.nodes) ?? [];
|
||||
for (const permission of [...userPermissions, ...rolePermissions]) {
|
||||
if (permission.name === "revalidate pages") {
|
||||
return true;
|
||||
@@ -61,10 +60,6 @@ export function PageRevalidation({ lastBuildAt, apiRequests, ...props }: PageRev
|
||||
</span>
|
||||
);
|
||||
|
||||
const apiRequestsDescription = apiRequests
|
||||
? ` using ${apiRequests} API request${apiRequests === 1 ? "" : "s"}`
|
||||
: "";
|
||||
|
||||
const rebuildDescription = isRevalidating
|
||||
? "Rebuild in progress... The page will automatically reload after it's finished."
|
||||
: "Click to start a rebuild.";
|
||||
@@ -72,7 +67,7 @@ export function PageRevalidation({ lastBuildAt, apiRequests, ...props }: PageRev
|
||||
if (canRevalidate) {
|
||||
return (
|
||||
<Text variant="small" color="text-disabled" link={!isRevalidating} onClick={revalidate} {...props}>
|
||||
Page was last updated {lastBuildDescription} ago{apiRequestsDescription}. {rebuildDescription}
|
||||
Page was last updated {lastBuildDescription} ago. {rebuildDescription}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ import { useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Maybe } from "@graphql-tools/utils";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { PerformancesArtistFragment, PerformancesSongFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
const StyledArtist = styled(Text)`
|
||||
&:not(:first-of-type)::before {
|
||||
@@ -24,16 +21,44 @@ const StyledArtistLink = styled(Text).attrs({ as: "a", link: true })`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
|
||||
export const PERFORMANCES_SONG = graphql(`
|
||||
fragment PerformancesSong on Song {
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
__typename
|
||||
... on Artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
... on Membership {
|
||||
group {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const PERFORMANCES_ARTIST = graphql(`
|
||||
fragment PerformancesArtist on Artist {
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
export interface PerformancesProps {
|
||||
song: PerformancesSongFragment | null;
|
||||
artist?: PerformancesArtistFragment;
|
||||
song: FragmentType<typeof PERFORMANCES_SONG> | null;
|
||||
artist?: FragmentType<typeof PERFORMANCES_ARTIST>;
|
||||
maxPerformances?: number | null;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
interface ArtistNameProps {
|
||||
alias: Maybe<string>;
|
||||
as: Maybe<string>;
|
||||
alias: string | null;
|
||||
as: string | null;
|
||||
artist: {
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -50,10 +75,17 @@ export function getDisplayedArtistName({ alias, artist, as }: ArtistNameProps) {
|
||||
return artistName;
|
||||
}
|
||||
|
||||
export function Performances({ song, artist, maxPerformances = 3, expandable = false }: PerformancesProps) {
|
||||
export function Performances({
|
||||
song: songFragment,
|
||||
artist: artistFragment,
|
||||
maxPerformances = 3,
|
||||
expandable = false,
|
||||
}: PerformancesProps) {
|
||||
const song = getFragmentData(PERFORMANCES_SONG, songFragment);
|
||||
const artist = getFragmentData(PERFORMANCES_ARTIST, artistFragment);
|
||||
const [expandPerformances, setExpandPerformances] = useState(false);
|
||||
|
||||
if (!song?.performances?.length) {
|
||||
if (!song?.performances.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,7 +93,12 @@ export function Performances({ song, artist, maxPerformances = 3, expandable = f
|
||||
maxPerformances = song.performances.length;
|
||||
}
|
||||
|
||||
const performances = [...song.performances].sort((a, b) => a.artist.name.localeCompare(b.artist.name));
|
||||
const performances = [...song.performances]
|
||||
.map((performance) => ({
|
||||
...performance,
|
||||
artist: performance.artist.__typename === "Artist" ? performance.artist : performance.artist.group,
|
||||
}))
|
||||
.sort((a, b) => a.artist.name.localeCompare(b.artist.name));
|
||||
const performancesShown = performances.slice(0, maxPerformances);
|
||||
const performancesHidden = performances.slice(maxPerformances);
|
||||
|
||||
@@ -121,23 +158,3 @@ export function Performances({ song, artist, maxPerformances = 3, expandable = f
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
Performances.fragments = {
|
||||
song: gql`
|
||||
fragment PerformancesSong on Song {
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
artist: gql`
|
||||
fragment PerformancesArtist on Artist {
|
||||
slug
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { SongTitleSongFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const SONG_TITLE_SONG = graphql(`
|
||||
fragment SongTitleSong on Song {
|
||||
title
|
||||
}
|
||||
`);
|
||||
|
||||
export interface SongTitleProps extends ComponentPropsWithoutRef<typeof Text> {
|
||||
song: SongTitleSongFragment | null;
|
||||
song: FragmentType<typeof SONG_TITLE_SONG> | null;
|
||||
href?: ComponentPropsWithoutRef<typeof Link>["href"];
|
||||
}
|
||||
|
||||
export function SongTitle({ song, href, ...props }: SongTitleProps) {
|
||||
export function SongTitle({ song: songFragment, href, ...props }: SongTitleProps) {
|
||||
const song = getFragmentData(SONG_TITLE_SONG, songFragment);
|
||||
const songTitle = song?.title || "T.B.A.";
|
||||
|
||||
if (href) {
|
||||
@@ -28,11 +33,3 @@ export function SongTitle({ song, href, ...props }: SongTitleProps) {
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
SongTitle.fragments = {
|
||||
song: gql`
|
||||
fragment SongTitleSong on Song {
|
||||
title
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type { SongTitleWithArtistsArtistFragment, SongTitleWithArtistsSongFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const SONG_TITLE_WITH_ARTISTS_SONG = graphql(`
|
||||
fragment SongTitleWithArtistsSong on Song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
`);
|
||||
|
||||
export const SONG_TITLE_WITH_ARTISTS_ARTIST = graphql(`
|
||||
fragment SongTitleWithArtistsArtist on Artist {
|
||||
...PerformancesArtist
|
||||
}
|
||||
`);
|
||||
|
||||
interface SongTitleWithArtistsProps {
|
||||
song: SongTitleWithArtistsSongFragment | null;
|
||||
song: FragmentType<typeof SONG_TITLE_WITH_ARTISTS_SONG> | null;
|
||||
songTitleLinkTo?: string;
|
||||
artist?: SongTitleWithArtistsArtistFragment;
|
||||
artist?: FragmentType<typeof SONG_TITLE_WITH_ARTISTS_ARTIST>;
|
||||
onPlay?: () => void;
|
||||
}
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function SongTitleWithArtists({ song, songTitleLinkTo, artist, onPlay }: SongTitleWithArtistsProps) {
|
||||
export function SongTitleWithArtists({
|
||||
song: songFragment,
|
||||
songTitleLinkTo,
|
||||
artist: artistFragment,
|
||||
onPlay,
|
||||
}: SongTitleWithArtistsProps) {
|
||||
const song = getFragmentData(SONG_TITLE_WITH_ARTISTS_SONG, songFragment);
|
||||
const artist = getFragmentData(SONG_TITLE_WITH_ARTISTS_ARTIST, artistFragment);
|
||||
|
||||
return (
|
||||
<Text onClick={onPlay}>
|
||||
<SongTitle song={song} href={songTitleLinkTo} />
|
||||
@@ -21,22 +40,3 @@ export function SongTitleWithArtists({ song, songTitleLinkTo, artist, onPlay }:
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
SongTitleWithArtists.fragments = {
|
||||
song: gql`
|
||||
${SongTitle.fragments.song}
|
||||
${Performances.fragments.song}
|
||||
|
||||
fragment SongTitleWithArtistsSong on Song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
`,
|
||||
artist: gql`
|
||||
${Performances.fragments.artist}
|
||||
|
||||
fragment SongTitleWithArtistsArtist on Artist {
|
||||
...PerformancesArtist
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const StyledPlayerProgress = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBackground = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
@@ -28,7 +29,7 @@ const StyledPlayerProgressBackground = styled.div`
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBar = styled.div`
|
||||
position: relative;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
@@ -71,14 +72,27 @@ const StyledPlayerProgressBarHover = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBuffered = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
background-color: ${theme.colors["text-muted"]};
|
||||
opacity: 0.2;
|
||||
|
||||
${StyledPlayerProgress}:hover & {
|
||||
height: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ProgressBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
|
||||
if (!context) {
|
||||
throw new Error("ProgressBar needs to be inside VideoPlayer!");
|
||||
}
|
||||
|
||||
const { playerRef, progressRef } = context;
|
||||
const { playerRef, progressRef, bufferedRef } = context;
|
||||
|
||||
const progressHoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -146,6 +160,7 @@ export function ProgressBar() {
|
||||
}}
|
||||
>
|
||||
<StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBuffered ref={bufferedRef} />
|
||||
<StyledPlayerProgressBar ref={progressRef} />
|
||||
</StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBarHover ref={progressHoverRef} />
|
||||
|
||||
@@ -117,8 +117,9 @@ export const StyledAudioBackground = styled.div`
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const StyledAudio = styled.audio`
|
||||
@@ -126,17 +127,32 @@ export const StyledAudio = styled.audio`
|
||||
`;
|
||||
|
||||
export const StyledAudioCover = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
outline: none;
|
||||
object-fit: contain;
|
||||
max-width: 80%;
|
||||
height: 80%;
|
||||
border-radius: 8px;
|
||||
|
||||
object-fit: cover;
|
||||
box-shadow: ${theme.shadows.high};
|
||||
|
||||
[data-background] & {
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAudioCoverBackground = styled.img`
|
||||
width: calc(100% + 32px);
|
||||
height: calc(100% + 32px);
|
||||
max-width: none;
|
||||
margin: -16px;
|
||||
|
||||
object-fit: cover;
|
||||
filter: blur(8px);
|
||||
`;
|
||||
|
||||
export const StyledAside = styled.aside`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,11 +2,14 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
||||
import type { PointerEvent, ReactNode, RefObject, SyntheticEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import {
|
||||
StyledAside,
|
||||
StyledAudio,
|
||||
StyledAudioBackground,
|
||||
StyledAudioCover,
|
||||
StyledAudioCoverBackground,
|
||||
StyledPlaybackArea,
|
||||
StyledPlayer,
|
||||
StyledPlayerContent,
|
||||
@@ -14,23 +17,54 @@ import {
|
||||
StyledVideoBackground,
|
||||
} from "@/components/video-player/VideoPlayer.style";
|
||||
import { VideoPlayerBar } from "@/components/video-player/VideoPlayerBar";
|
||||
import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext, { type WatchListItem } from "@/context/playerContext";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import { getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useMouseRelax from "@/hooks/useMouseRelax";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import useWatchHistory from "@/hooks/useWatchHistory";
|
||||
import { AUDIO_URL, VIDEO_URL } from "@/utils/config";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import { AudioMode, GlobalVolume } from "@/utils/settings";
|
||||
import { AudioMode, GlobalVolume, Muted } from "@/utils/settings";
|
||||
|
||||
export const VIDEO_PLAYER_VIDEO = graphql(`
|
||||
fragment VideoPlayerVideo on Video {
|
||||
...VideoPlayerBarVideo
|
||||
...createVideoSlugVideo
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_PLAYER_ENTRY = graphql(`
|
||||
fragment VideoPlayerEntry on AnimeThemeEntry {
|
||||
...VideoPlayerBarEntry
|
||||
...createVideoSlugEntry
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
anime {
|
||||
slug
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoPlayerContextValue {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
entry: VideoSummaryCardEntryFragment;
|
||||
video: ResultOf<typeof VIDEO_PLAYER_VIDEO>;
|
||||
entry: ResultOf<typeof VIDEO_PLAYER_ENTRY>;
|
||||
background: boolean;
|
||||
videoPagePath: string;
|
||||
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
|
||||
progressRef: RefObject<HTMLDivElement | null>;
|
||||
bufferedRef: RefObject<HTMLDivElement | null>;
|
||||
previousVideoPath: string | null;
|
||||
playPreviousTrack(navigate: boolean): void;
|
||||
nextVideoPath: string | null;
|
||||
@@ -40,6 +74,7 @@ interface VideoPlayerContextValue {
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
updateAudioMode(audioMode: string): void;
|
||||
togglePip(): void;
|
||||
}
|
||||
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
|
||||
@@ -52,14 +87,16 @@ type VideoPlayerProps = {
|
||||
};
|
||||
|
||||
export function VideoPlayer({ watchListItem, background, children, overlay, ...props }: VideoPlayerProps) {
|
||||
const { video, entry } = watchListItem;
|
||||
const theme = entry.theme;
|
||||
const { video: videoFragment, entry: entryFragment } = watchListItem;
|
||||
const video = getFragmentData(VIDEO_PLAYER_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(VIDEO_PLAYER_ENTRY, entryFragment);
|
||||
const theme = entry.animetheme;
|
||||
const anime = theme.anime;
|
||||
|
||||
const videoPagePath = `/anime/${anime.slug}/${createVideoSlug(theme, entry, video)}`;
|
||||
|
||||
const videoUrl = `${VIDEO_URL}/${video.basename}`;
|
||||
const audioUrl = `${AUDIO_URL}/${video.audio.basename}`;
|
||||
const audioUrl = video.audio ? `${AUDIO_URL}/${video.audio.basename}` : null;
|
||||
|
||||
const [isPlaying, setPlaying] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState(16 / 9);
|
||||
@@ -68,7 +105,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const bufferedRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
|
||||
const fpsRef = useRef<number>(24);
|
||||
|
||||
const {
|
||||
watchList,
|
||||
@@ -79,9 +118,11 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
isWatchListUsingLocalAutoPlay,
|
||||
isRepeat,
|
||||
} = useContext(PlayerContext);
|
||||
const { toggleFullscreen } = useContext(FullscreenContext);
|
||||
const router = useRouter();
|
||||
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
|
||||
const { smallCover, largeCover } = extractImages(anime);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
const { smallCover, largeCover } = extractImages(anime.images.nodes);
|
||||
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { addToHistory } = useWatchHistory();
|
||||
|
||||
@@ -89,9 +130,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
const playbackAreaMouseRelaxProps = useMouseRelax();
|
||||
|
||||
const previousWatchListItem = getRelativeWatchListItem(-1);
|
||||
const previousVideo = previousWatchListItem?.video;
|
||||
const previousEntry = previousWatchListItem?.entry;
|
||||
const previousTheme = previousEntry?.theme;
|
||||
const previousVideo = getFragmentData(VIDEO_PLAYER_VIDEO, previousWatchListItem?.video);
|
||||
const previousEntry = getFragmentData(VIDEO_PLAYER_ENTRY, previousWatchListItem?.entry);
|
||||
const previousTheme = previousEntry?.animetheme;
|
||||
const previousAnime = previousTheme?.anime;
|
||||
|
||||
const previousVideoPath =
|
||||
@@ -112,9 +153,9 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
);
|
||||
|
||||
const nextWatchListItem = getRelativeWatchListItem(1);
|
||||
const nextVideo = nextWatchListItem?.video;
|
||||
const nextEntry = nextWatchListItem?.entry;
|
||||
const nextTheme = nextEntry?.theme;
|
||||
const nextVideo = getFragmentData(VIDEO_PLAYER_VIDEO, nextWatchListItem?.video);
|
||||
const nextEntry = getFragmentData(VIDEO_PLAYER_ENTRY, nextWatchListItem?.entry);
|
||||
const nextTheme = nextEntry?.animetheme;
|
||||
const nextAnime = nextTheme?.anime;
|
||||
|
||||
const nextVideoPath =
|
||||
@@ -156,9 +197,10 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.muted = muted;
|
||||
playerRef.current.volume = globalVolume;
|
||||
}
|
||||
}, [globalVolume]);
|
||||
}, [globalVolume, muted]);
|
||||
|
||||
useEffect(() => {
|
||||
addToHistory({
|
||||
@@ -176,6 +218,10 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
progressRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
bufferedRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
// We don't want to re-add the theme when the history changes, because it can cause
|
||||
// various issues when multiple tabs are open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -201,6 +247,47 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
|
||||
|
||||
// Calculate frame rate
|
||||
// Source - https://stackoverflow.com/questions/72997777/how-do-i-get-the-frame-rate-of-an-html-video-with-javascript
|
||||
useEffect(() => {
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
|
||||
let lastMediaTime = 0,
|
||||
lastFrameNum = 0,
|
||||
frameNotSeeked = true;
|
||||
const fpsRounder: number[] = [];
|
||||
|
||||
const ticker: VideoFrameRequestCallback = (_, metadata) => {
|
||||
const diff =
|
||||
Math.abs(metadata.mediaTime - lastMediaTime) / Math.abs(metadata.presentedFrames - lastFrameNum);
|
||||
if (
|
||||
diff &&
|
||||
diff < 1 &&
|
||||
frameNotSeeked &&
|
||||
fpsRounder.length < 50 &&
|
||||
videoElement.playbackRate === 1 &&
|
||||
document.hasFocus()
|
||||
) {
|
||||
fpsRounder.push(diff);
|
||||
fpsRef.current = Math.round(fpsRounder.length / fpsRounder.reduce((a, b) => a + b));
|
||||
}
|
||||
frameNotSeeked = true;
|
||||
lastMediaTime = metadata.mediaTime;
|
||||
lastFrameNum = metadata.presentedFrames;
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
fpsRounder.pop();
|
||||
frameNotSeeked = false;
|
||||
};
|
||||
|
||||
videoElement.requestVideoFrameCallback(ticker);
|
||||
videoElement.addEventListener("seeked", handleSeeked);
|
||||
return () => videoElement.removeEventListener("seeked", handleSeeked);
|
||||
}, []);
|
||||
|
||||
function onPlayerMount(player: HTMLVideoElement) {
|
||||
playerRef.current = player;
|
||||
if (playerRef.current) {
|
||||
@@ -222,27 +309,175 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
playerRef.current?.pause();
|
||||
} else {
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = (event.currentTarget.currentTime / event.currentTarget.duration) * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
function togglePip() {
|
||||
const videoElement = document.querySelector("video");
|
||||
if (!videoElement) return;
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture();
|
||||
} else {
|
||||
void videoElement.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAudioMode(audioMode: string) {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
const duration = event.currentTarget.duration;
|
||||
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = (event.currentTarget.currentTime / duration) * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (bufferedRef.current) {
|
||||
const buffered = event.currentTarget.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const bufferedEnd = buffered.end(buffered.length - 1);
|
||||
bufferedRef.current.style.width = `${(bufferedEnd / duration) * 100}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateAudioMode = useCallback(
|
||||
(audioMode: string) => {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
},
|
||||
[setAudioMode],
|
||||
);
|
||||
|
||||
// Handle keyboard inputs
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLocaleLowerCase()) {
|
||||
case " ": // Play/Pause
|
||||
case "k":
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "arrowright": // Seek forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 5;
|
||||
}
|
||||
break;
|
||||
case "l": // Seek forward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime += 10;
|
||||
}
|
||||
break;
|
||||
case "arrowleft": // Seek backward
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 5;
|
||||
}
|
||||
break;
|
||||
case "j": // Seek backward large
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime -= 10;
|
||||
}
|
||||
break;
|
||||
case "n": // Next track
|
||||
event.preventDefault();
|
||||
playNextTrack(true);
|
||||
break;
|
||||
case "b": // Previous track
|
||||
event.preventDefault();
|
||||
playPreviousTrack(true);
|
||||
break;
|
||||
case "m": // Mute
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setMuted(!muted);
|
||||
}
|
||||
break;
|
||||
case "arrowup": // Volume up
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.min(globalVolume + 0.1, 1));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "arrowdown": // Volume down
|
||||
event.preventDefault();
|
||||
if (playerRef.current) {
|
||||
setGlobalVolume(Math.max(globalVolume - 0.1, 0));
|
||||
setMuted(false);
|
||||
}
|
||||
break;
|
||||
case "d": // Download
|
||||
event.preventDefault();
|
||||
if (audioMode === AudioMode.ENABLED) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${audioUrl}?download`;
|
||||
link.click();
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${videoUrl}?download`;
|
||||
link.click();
|
||||
}
|
||||
break;
|
||||
case "f": // Fullscreen
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "a": // Toggle audio mode
|
||||
event.preventDefault();
|
||||
updateAudioMode(audioMode === AudioMode.ENABLED ? AudioMode.DISABLED : AudioMode.ENABLED);
|
||||
break;
|
||||
case "p": // Toggle Picture-in-Picture
|
||||
event.preventDefault();
|
||||
togglePip();
|
||||
break;
|
||||
case ",": // Frame back
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime -= 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
case ".": // Frame forward
|
||||
event.preventDefault();
|
||||
if (playerRef.current && playerRef.current.paused) {
|
||||
playerRef.current.currentTime += 1 / fpsRef.current;
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
togglePlay,
|
||||
playNextTrack,
|
||||
playPreviousTrack,
|
||||
audioMode,
|
||||
toggleFullscreen,
|
||||
updateAudioMode,
|
||||
setMuted,
|
||||
muted,
|
||||
setGlobalVolume,
|
||||
globalVolume,
|
||||
audioUrl,
|
||||
videoUrl,
|
||||
],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
function getRelativeWatchListItem(offset: 1 | -1) {
|
||||
if (!currentWatchListItem) {
|
||||
return null;
|
||||
@@ -282,6 +517,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoPagePath,
|
||||
playerRef,
|
||||
progressRef,
|
||||
bufferedRef,
|
||||
previousVideoPath,
|
||||
playPreviousTrack,
|
||||
nextVideoPath,
|
||||
@@ -291,6 +527,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
updateAudioMode,
|
||||
togglePip,
|
||||
}}
|
||||
>
|
||||
<StyledPlayer
|
||||
@@ -319,6 +556,15 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<StyledAudioBackground style={{ aspectRatio }}>
|
||||
<StyledAudioCoverBackground
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
onLoad={(event) => {
|
||||
setAspectRatio(
|
||||
event.currentTarget.naturalWidth / event.currentTarget.naturalHeight,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<StyledAudioCover
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
@@ -332,6 +578,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={audioUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
@@ -351,6 +598,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
|
||||
ref={onPlayerMount}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useContext } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faBackwardStep, faForwardStep, faPause, faPlay, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faBackwardStep, faForwardStep, faPause, faPlay, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { Solid } from "@/components/box/Solid";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ShareMenu } from "@/components/menu/ShareMenu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
@@ -18,6 +17,7 @@ import { ProgressBar } from "@/components/video-player/ProgressBar";
|
||||
import { VideoPlayerContext } from "@/components/video-player/VideoPlayer";
|
||||
import { VolumeControl } from "@/components/video-player/VolumeControl";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledPlayerBar = styled(Solid)`
|
||||
@@ -86,6 +86,37 @@ const StyledVolumeControl = styled(VolumeControl)`
|
||||
margin-right: auto;
|
||||
`;
|
||||
|
||||
export const VIDEO_PLAYER_BAR_VIDEO = graphql(`
|
||||
fragment VideoPlayerBarVideo on Video {
|
||||
__typename
|
||||
# ...PlaylistTrackAddDialogVideo
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_PLAYER_BAR_ENTRY = graphql(`
|
||||
fragment VideoPlayerBarEntry on AnimeThemeEntry {
|
||||
# ...PlaylistTrackAddDialogEntry
|
||||
animetheme {
|
||||
type
|
||||
sequence
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
performances {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
group {
|
||||
name
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function VideoPlayerBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
@@ -94,8 +125,8 @@ export function VideoPlayerBar() {
|
||||
}
|
||||
|
||||
const {
|
||||
video,
|
||||
entry,
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
background,
|
||||
videoPagePath,
|
||||
previousVideoPath,
|
||||
@@ -108,7 +139,9 @@ export function VideoPlayerBar() {
|
||||
audioUrl,
|
||||
} = context;
|
||||
|
||||
const theme = entry.theme;
|
||||
const video = getFragmentData(VIDEO_PLAYER_BAR_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(VIDEO_PLAYER_BAR_ENTRY, entryFragment);
|
||||
const theme = entry.animetheme;
|
||||
const anime = theme.anime;
|
||||
|
||||
const { clearWatchList } = useContext(PlayerContext);
|
||||
@@ -179,15 +212,15 @@ export function VideoPlayerBar() {
|
||||
</StyledPlayerBarControls>
|
||||
<StyledPlayerBarActions>
|
||||
<StyledVolumeControl />
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<IconTextButton icon={faPlus} variant="solid" collapsible="socialListMax">
|
||||
Add to Playlist
|
||||
</IconTextButton>
|
||||
}
|
||||
/>
|
||||
{/*<PlaylistTrackAddDialog*/}
|
||||
{/* video={video}*/}
|
||||
{/* entry={entry}*/}
|
||||
{/* trigger={*/}
|
||||
{/* <IconTextButton icon={faPlus} variant="solid" collapsible="socialListMax">*/}
|
||||
{/* Add to Playlist*/}
|
||||
{/* </IconTextButton>*/}
|
||||
{/* }*/}
|
||||
{/*/>*/}
|
||||
<ShareMenu pagePath={videoPagePath} videoUrl={videoUrl} audioUrl={audioUrl} />
|
||||
<IconTextButton icon={faXmark} isCircle disabled={!background} onClick={() => clearWatchList()} />
|
||||
</StyledPlayerBarActions>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { Fragment, useContext } from "react";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faCheck, faCompress, faExpand, faGear, faPlus, faShare } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faCompress,
|
||||
faExpand,
|
||||
faGear,
|
||||
faKeyboard,
|
||||
faPlus,
|
||||
faShare,
|
||||
faUpRightFromSquare,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Dialog } from "@radix-ui/react-dialog";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
@@ -18,7 +29,8 @@ import { StyledPlaybackArea } from "@/components/video-player/VideoPlayer.style"
|
||||
import FullscreenContext from "@/context/fullscreenContext";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import type { VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
|
||||
import { getAnimeFromVideoPageFragment, type VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import { AudioMode } from "@/utils/settings";
|
||||
|
||||
@@ -43,13 +55,48 @@ const StyledOverlayButton = styled(IconTextButton)`
|
||||
backdrop-filter: blur(5px);
|
||||
`;
|
||||
|
||||
export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
|
||||
const theme = anime.themes[themeIndex];
|
||||
const entry = theme.entries[entryIndex];
|
||||
const video = entry.videos[videoIndex];
|
||||
const StyledKeyList = styled.dl`
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledKey = styled.dt`
|
||||
text-align: right;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`;
|
||||
const StyledKeyDescription = styled.dd`
|
||||
margin: 0;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`;
|
||||
const StyledKeyHint = styled.kbd`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid ${theme.colors["text-disabled"]};
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 1.25rem;
|
||||
background-color: ${theme.colors["background"]};
|
||||
`;
|
||||
const StyledKeyHintSpace = styled(StyledKeyHint)`
|
||||
width: 96px;
|
||||
`;
|
||||
|
||||
export function VideoPlayerOverlay({ anime: animeFragment, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
|
||||
const anime = getAnimeFromVideoPageFragment(animeFragment);
|
||||
const theme = anime.animethemes[themeIndex];
|
||||
const entry = theme.animethemeentries[entryIndex];
|
||||
const video = entry.videos.nodes[videoIndex];
|
||||
|
||||
const { watchList, setWatchList, currentWatchListItem } = useContext(PlayerContext);
|
||||
const { isFullscreen, toggleFullscreen } = useContext(FullscreenContext);
|
||||
const [isIOS, setIOS] = useState(false);
|
||||
const [isPIPSupported, setPIPSupported] = useState(false);
|
||||
const context = useContext(VideoPlayerContext);
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
|
||||
@@ -59,9 +106,9 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
|
||||
const { updateAudioMode } = context;
|
||||
|
||||
const otherEntries = theme.entries
|
||||
const otherEntries = theme.animethemeentries
|
||||
.map((otherEntry) => {
|
||||
const videos = otherEntry.videos;
|
||||
const videos = otherEntry.videos.nodes;
|
||||
|
||||
if (!videos.length) {
|
||||
return null;
|
||||
@@ -74,14 +121,93 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
})
|
||||
.filter((otherEntry) => !!otherEntry);
|
||||
|
||||
useEffect(() => {
|
||||
setIOS(/iPhone|iPad|iPod/.test(navigator.userAgent));
|
||||
setPIPSupported(typeof document !== "undefined" && document.pictureInPictureEnabled);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledOverlay>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={{ ...entry, theme }}
|
||||
trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}
|
||||
/>
|
||||
{/*<PlaylistTrackAddDialog*/}
|
||||
{/* video={video}*/}
|
||||
{/* entry={{ ...entry, theme }}*/}
|
||||
{/* trigger={<StyledOverlayButton icon={faPlus} isCircle title="Add to playlist" />}*/}
|
||||
{/*/>*/}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<StyledOverlayButton icon={faKeyboard} isCircle title="Keyboard shortcuts" />
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Keyboard Shortcuts">
|
||||
<StyledKeyList>
|
||||
<StyledKey>
|
||||
<StyledKeyHintSpace title="Space">_</StyledKeyHintSpace> or{" "}
|
||||
<StyledKeyHint>K</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Play / Pause</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>→</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>←</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 5 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>L</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>J</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 10 seconds</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>.</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek forward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>,</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Seek backward 1 frame</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>N</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Next track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>B</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Previous track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>M</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Mute / Unmute</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↑</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume up</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>↓</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Volume down</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>D</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Download track</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>F</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle fullscreen</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>A</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle audio mode</StyledKeyDescription>
|
||||
<StyledKey>
|
||||
<StyledKeyHint>P</StyledKeyHint>
|
||||
</StyledKey>
|
||||
<StyledKeyDescription>Toggle picture-in-picture</StyledKeyDescription>
|
||||
</StyledKeyList>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ShareMenu
|
||||
pagePath={context.videoPagePath}
|
||||
videoUrl={context.videoUrl}
|
||||
@@ -162,12 +288,22 @@ export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
{isPIPSupported && audioMode === AudioMode.DISABLED && (
|
||||
<StyledOverlayButton
|
||||
icon={faUpRightFromSquare}
|
||||
isCircle
|
||||
onClick={context.togglePip}
|
||||
title="Toggle picture-in-picture"
|
||||
/>
|
||||
)}
|
||||
{!(isIOS && audioMode === AudioMode.ENABLED) && ( // Hide fullscreen button on iOS when audio mode is enabled
|
||||
<StyledOverlayButton
|
||||
icon={isFullscreen ? faCompress : faExpand}
|
||||
isCircle
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle fullscreen"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</StyledOverlay>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faVolumeHigh, faVolumeLow, faVolumeOff, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Slider } from "@/components/slider/Slider";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { GlobalVolume } from "@/utils/settings";
|
||||
import { GlobalVolume, Muted } from "@/utils/settings";
|
||||
|
||||
const StyledRow = styled(Row)`
|
||||
align-self: stretch;
|
||||
@@ -28,22 +28,25 @@ const StyledSlider = styled(Slider)`
|
||||
|
||||
export function VolumeControl(props: ComponentPropsWithoutRef<typeof StyledRow>) {
|
||||
const [volume, setVolume] = useSetting(GlobalVolume);
|
||||
const [muted, setMuted] = useSetting(Muted);
|
||||
|
||||
let icon;
|
||||
if (volume > 0.5) {
|
||||
if (muted) {
|
||||
icon = faVolumeXmark;
|
||||
} else if (volume > 0.5) {
|
||||
icon = faVolumeHigh;
|
||||
} else if (volume > 0) {
|
||||
icon = faVolumeLow;
|
||||
} else {
|
||||
icon = faVolumeXmark;
|
||||
icon = faVolumeOff;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRow style={{ "--gap": "8px" }} {...props}>
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setVolume(volume === 0 ? 1 : 0)} />
|
||||
<IconTextButton icon={icon} isCircle onClick={() => setMuted(!muted)} />
|
||||
<StyledSlider
|
||||
value={[volume]}
|
||||
onValueChange={([volume]) => setVolume(volume)}
|
||||
onValueChange={([volume]) => { setVolume(volume); setMuted(false); }}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { VideoScriptVideoFragment } from "@/generated/graphql";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
const StyledCodeBlock = styled.pre`
|
||||
overflow-x: auto;
|
||||
@@ -18,11 +16,21 @@ const StyledCodeBlock = styled.pre`
|
||||
}
|
||||
`;
|
||||
|
||||
export const VIDEO_SCRIPT_VIDEO = graphql(`
|
||||
fragment VideoScriptVideo on Video {
|
||||
videoscript {
|
||||
link
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface Props {
|
||||
video: VideoScriptVideoFragment;
|
||||
video: FragmentType<typeof VIDEO_SCRIPT_VIDEO>;
|
||||
}
|
||||
|
||||
export default function VideoScript({ video }: Props) {
|
||||
export default function VideoScript({ video: videoFragment }: Props) {
|
||||
const video = getFragmentData(VIDEO_SCRIPT_VIDEO, videoFragment);
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [videoScript, setVideoScript] = useState<string>();
|
||||
|
||||
@@ -31,7 +39,7 @@ export default function VideoScript({ video }: Props) {
|
||||
const filter = [videoFilter, audioFilter].filter((f) => f).join(" ");
|
||||
|
||||
async function downloadVideoScript() {
|
||||
const url = video.script?.link;
|
||||
const url = video.videoscript?.link;
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
@@ -88,7 +96,7 @@ export default function VideoScript({ video }: Props) {
|
||||
</Column>
|
||||
) : null}
|
||||
</>
|
||||
) : video.script ? (
|
||||
) : video.videoscript ? (
|
||||
<Text variant="small" link color="text-disabled" onClick={downloadVideoScript}>
|
||||
Click to download encoding script.
|
||||
</Text>
|
||||
@@ -100,13 +108,3 @@ export default function VideoScript({ video }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
VideoScript.fragments = {
|
||||
video: gql`
|
||||
fragment VideoScriptVideo on Video {
|
||||
script {
|
||||
link
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { type VIDEO_SUMMARY_CARD_ENTRY, type VIDEO_SUMMARY_CARD_VIDEO } from "@/components/card/VideoSummaryCard";
|
||||
|
||||
export interface WatchListItem {
|
||||
watchListId: number;
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
entry: VideoSummaryCardEntryFragment;
|
||||
video: ResultOf<typeof VIDEO_SUMMARY_CARD_VIDEO>;
|
||||
entry: ResultOf<typeof VIDEO_SUMMARY_CARD_ENTRY>;
|
||||
}
|
||||
|
||||
interface PlayerContextInterface {
|
||||
@@ -15,8 +17,14 @@ interface PlayerContextInterface {
|
||||
setWatchListFactory: (factory: (() => Promise<WatchListItem[]>) | null) => void;
|
||||
currentWatchListItem: WatchListItem | null;
|
||||
setCurrentWatchListItem: (watchListItem: WatchListItem | null) => void;
|
||||
addWatchListItem: (video: VideoSummaryCardVideoFragment, entry: VideoSummaryCardEntryFragment) => void;
|
||||
addWatchListItemNext: (video: VideoSummaryCardVideoFragment, entry: VideoSummaryCardEntryFragment) => void;
|
||||
addWatchListItem: (
|
||||
video: ResultOf<typeof VIDEO_SUMMARY_CARD_VIDEO>,
|
||||
entry: ResultOf<typeof VIDEO_SUMMARY_CARD_ENTRY>,
|
||||
) => void;
|
||||
addWatchListItemNext: (
|
||||
video: ResultOf<typeof VIDEO_SUMMARY_CARD_VIDEO>,
|
||||
entry: ResultOf<typeof VIDEO_SUMMARY_CARD_ENTRY>,
|
||||
) => void;
|
||||
clearWatchList: () => void;
|
||||
isGlobalAutoPlay: boolean;
|
||||
setGlobalAutoPlay: (autoPlay: boolean) => void;
|
||||
@@ -69,8 +77,8 @@ export default PlayerContext;
|
||||
let nextWatchListId = 1;
|
||||
|
||||
export function createWatchListItem(
|
||||
video: VideoSummaryCardVideoFragment,
|
||||
entry: VideoSummaryCardEntryFragment,
|
||||
video: ResultOf<typeof VIDEO_SUMMARY_CARD_VIDEO>,
|
||||
entry: ResultOf<typeof VIDEO_SUMMARY_CARD_ENTRY>,
|
||||
): WatchListItem {
|
||||
return {
|
||||
watchListId: nextWatchListId++,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client";
|
||||
import { BatchHttpLink } from "@apollo/client/link/batch-http";
|
||||
|
||||
const httpLink = new BatchHttpLink({
|
||||
uri: "http://graphql.animethemes.test",
|
||||
credentials: "include",
|
||||
batchMax: 5,
|
||||
batchInterval: 200,
|
||||
});
|
||||
|
||||
// const httpLink = new HttpLink({
|
||||
// uri: "http://graphql.animethemes.test",
|
||||
// credentials: "include",
|
||||
// });
|
||||
|
||||
const logLink = new ApolloLink((operation, forward) => {
|
||||
console.error("Operation:", operation.operationName);
|
||||
return forward(operation);
|
||||
});
|
||||
|
||||
export const client = new ApolloClient({
|
||||
link: ApolloLink.from([logLink, httpLink]),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
type ApolloClientOptions,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
type NormalizedCacheObject,
|
||||
} from "@apollo/client";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
/**
|
||||
* This function is for server-side use only! For client side queries use ./client.ts instead.
|
||||
*/
|
||||
const createApolloClient = (
|
||||
req?: IncomingMessage,
|
||||
options: Omit<ApolloClientOptions<NormalizedCacheObject>, "uri" | "cache"> = {},
|
||||
) => {
|
||||
return new ApolloClient({
|
||||
...options,
|
||||
link: new HttpLink({
|
||||
uri: "http://graphql.animethemes.test",
|
||||
headers: req && {
|
||||
referer: req.headers.referer ?? "",
|
||||
cookie: req.headers.cookie ?? "",
|
||||
},
|
||||
}),
|
||||
cache: new InMemoryCache(),
|
||||
queryDeduplication: false,
|
||||
defaultOptions: {
|
||||
query: {
|
||||
fetchPolicy: "no-cache",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default createApolloClient;
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
|
||||
import type { FragmentDefinitionNode } from 'graphql';
|
||||
import type { Incremental } from './graphql';
|
||||
|
||||
|
||||
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
|
||||
infer TType,
|
||||
any
|
||||
>
|
||||
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
|
||||
? TKey extends string
|
||||
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// return non-nullable if `fragmentType` is non-nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
): TType;
|
||||
// return nullable if `fragmentType` is undefined
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
|
||||
): TType | undefined;
|
||||
// return nullable if `fragmentType` is nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
|
||||
): TType | null;
|
||||
// return nullable if `fragmentType` is nullable or undefined
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
|
||||
): TType | null | undefined;
|
||||
// return array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
): Array<TType>;
|
||||
// return array of nullable if `fragmentType` is array of nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
|
||||
): Array<TType> | null | undefined;
|
||||
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
): ReadonlyArray<TType>;
|
||||
// return readonly array of nullable if `fragmentType` is array of nullable
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
|
||||
): ReadonlyArray<TType> | null | undefined;
|
||||
export function getFragmentData<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
|
||||
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
|
||||
return fragmentType as any;
|
||||
}
|
||||
|
||||
|
||||
export function makeFragmentData<
|
||||
F extends DocumentTypeDecoration<any, any>,
|
||||
FT extends ResultOf<F>
|
||||
>(data: FT, _fragment: F): FragmentType<F> {
|
||||
return data as FragmentType<F>;
|
||||
}
|
||||
export function isFragmentReady<TQuery, TFrag>(
|
||||
queryNode: DocumentTypeDecoration<TQuery, any>,
|
||||
fragmentNode: TypedDocumentNode<TFrag>,
|
||||
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined
|
||||
): data is FragmentType<typeof fragmentNode> {
|
||||
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__
|
||||
?.deferredFields;
|
||||
|
||||
if (!deferredFields) return true;
|
||||
|
||||
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
|
||||
const fragName = fragDef?.name?.value;
|
||||
|
||||
const fields = (fragName && deferredFields[fragName]) || [];
|
||||
return fields.length > 0 && fields.every(field => data && field in data);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
+67
-31
@@ -1,8 +1,7 @@
|
||||
import gql from "graphql-tag";
|
||||
import useSWR, { mutate as mutateGlobal } from "swr";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
|
||||
import type { CheckAuthQuery } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { AUTH_PATH } from "@/utils/config";
|
||||
|
||||
@@ -32,6 +31,12 @@ interface LoginProps {
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUserInformationProps {
|
||||
setErrors: (errors: LoginErrors) => void;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ForgotPasswordProps {
|
||||
email: string;
|
||||
}
|
||||
@@ -43,34 +48,33 @@ interface ResetPasswordProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export default function useAuth() {
|
||||
const { data: me } = useSWR(
|
||||
"/api/me",
|
||||
async () => {
|
||||
const { data } = await fetchDataClient<CheckAuthQuery>(gql`
|
||||
query CheckAuth {
|
||||
me {
|
||||
user {
|
||||
id
|
||||
const meQuery = graphql(`
|
||||
query UseAuthMe {
|
||||
me {
|
||||
...ProfileImageUser
|
||||
id
|
||||
name
|
||||
email
|
||||
permissions {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
roles {
|
||||
nodes {
|
||||
permissions {
|
||||
nodes {
|
||||
name
|
||||
email
|
||||
permissions {
|
||||
name
|
||||
}
|
||||
roles {
|
||||
permissions {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
return data.me;
|
||||
},
|
||||
{ fallbackData: { user: null }, dedupingInterval: 2000 },
|
||||
);
|
||||
export default function useAuth() {
|
||||
const { data } = useQuery(meQuery);
|
||||
|
||||
const csrf = () => axios.get(`/sanctum/csrf-cookie`);
|
||||
|
||||
@@ -81,7 +85,11 @@ export default function useAuth() {
|
||||
|
||||
axios
|
||||
.post(`${AUTH_PATH}/register`, props)
|
||||
.then(() => mutateGlobal(() => true))
|
||||
.then(() =>
|
||||
client.refetchQueries({
|
||||
include: "active",
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (error.response.status !== 422) {
|
||||
throw error;
|
||||
@@ -98,7 +106,30 @@ export default function useAuth() {
|
||||
|
||||
await axios
|
||||
.post(`${AUTH_PATH}/login`, props)
|
||||
.then(() => mutateGlobal(() => true))
|
||||
.then(() =>
|
||||
client.refetchQueries({
|
||||
include: "active",
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (error.response.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setErrors(error.response.data.errors);
|
||||
});
|
||||
};
|
||||
|
||||
const updateUserInformation = async ({ setErrors, ...props }: UpdateUserInformationProps) => {
|
||||
await csrf();
|
||||
|
||||
await axios
|
||||
.put(`${AUTH_PATH}/user/profile-information`, props)
|
||||
.then(() =>
|
||||
client.refetchQueries({
|
||||
include: "active",
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
if (error.response.status !== 422) {
|
||||
throw error;
|
||||
@@ -125,13 +156,18 @@ export default function useAuth() {
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await axios.post(`${AUTH_PATH}/logout`).then(() => mutateGlobal(() => true));
|
||||
await axios.post(`${AUTH_PATH}/logout`).then(() =>
|
||||
client.refetchQueries({
|
||||
include: "active",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
me,
|
||||
me: data?.me,
|
||||
register,
|
||||
login,
|
||||
updateUserInformation,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
resendEmailVerification,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { SearchArgs } from "@/generated/graphql";
|
||||
import type { SimpleSearchArgs } from "@/lib/client/search";
|
||||
import { toSearchArgs } from "@/lib/client/search";
|
||||
export interface CommonSearchArgs {
|
||||
query: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export default function useEntitySearch<T>(
|
||||
export default function useEntitySearch<Data, Args>(
|
||||
entity: string,
|
||||
fetchResults: (searchArgs: SearchArgs) => Promise<{
|
||||
data: Array<T>;
|
||||
fetchResults: (searchArgs: NoInfer<Args> & CommonSearchArgs) => Promise<{
|
||||
data: Array<Data>;
|
||||
nextPage: number | null;
|
||||
}>,
|
||||
searchArgs: SimpleSearchArgs,
|
||||
searchArgs: Args & Omit<CommonSearchArgs, "page">,
|
||||
) {
|
||||
const fetchEntityPage = ({ pageParam }: { pageParam: number }) =>
|
||||
fetchResults({
|
||||
...toSearchArgs(searchArgs),
|
||||
...searchArgs,
|
||||
page: pageParam,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import useLocalStorageState from "use-local-storage-state";
|
||||
|
||||
import type { FetchThemeSummaryCardData } from "@/components/card/ThemeSummaryCard";
|
||||
import { fetchThemeSummaryCardData } from "@/components/card/ThemeSummaryCard";
|
||||
import { PlaylistAddToast } from "@/components/toast/PlaylistAddToast";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
|
||||
interface LocalPlaylistTheme {
|
||||
id: number;
|
||||
}
|
||||
|
||||
type LocalPlaylist = Array<NonNullable<FetchThemeSummaryCardData> & LocalPlaylistTheme>;
|
||||
|
||||
export default function useLocalPlaylist() {
|
||||
const [localPlaylist, setLocalPlaylist] = useLocalStorageState<LocalPlaylist>("local-playlist", {
|
||||
defaultValue: [],
|
||||
});
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
function addToPlaylist(theme: LocalPlaylistTheme) {
|
||||
fetchThemeSummaryCardData(theme.id).then((themeFresh) => {
|
||||
if (themeFresh) {
|
||||
setLocalPlaylist([
|
||||
...localPlaylist,
|
||||
{
|
||||
...theme,
|
||||
...themeFresh,
|
||||
},
|
||||
]);
|
||||
|
||||
dispatchToast(String(theme.id), <PlaylistAddToast theme={themeFresh} />);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromPlaylist(theme: LocalPlaylistTheme) {
|
||||
setLocalPlaylist(localPlaylist.filter((t) => t.id !== theme.id));
|
||||
}
|
||||
|
||||
function isInPlaylist(theme: LocalPlaylistTheme) {
|
||||
return !!localPlaylist.find((t) => t.id === theme.id);
|
||||
}
|
||||
|
||||
function setPlaylist(playlist: LocalPlaylist) {
|
||||
setLocalPlaylist(playlist);
|
||||
}
|
||||
|
||||
return {
|
||||
localPlaylist,
|
||||
addToPlaylist,
|
||||
removeFromPlaylist,
|
||||
isInPlaylist,
|
||||
setPlaylist,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useContext } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { VIDEO_SUMMARY_CARD_ENTRY, VIDEO_SUMMARY_CARD_VIDEO } from "@/components/card/VideoSummaryCard";
|
||||
import PlayerContext, { createWatchListItem } from "@/context/playerContext";
|
||||
import { getFragmentData } from "@/graphql/generated";
|
||||
import { fetchRandomThemes, type RandomThemesOptions } from "@/lib/client/randomTheme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
|
||||
@@ -14,10 +16,10 @@ export default function useRandomThemes() {
|
||||
const themes = await fetchRandomThemes(options);
|
||||
|
||||
return themes.map((theme) => {
|
||||
const entry = theme.entries[0];
|
||||
const video = entry.videos[0];
|
||||
const entry = getFragmentData(VIDEO_SUMMARY_CARD_ENTRY, theme.animethemeentries[0]);
|
||||
const video = getFragmentData(VIDEO_SUMMARY_CARD_VIDEO, theme.animethemeentries[0].videos.nodes[0]);
|
||||
|
||||
return createWatchListItem(video, { ...entry, theme });
|
||||
return createWatchListItem(video, entry);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,7 +31,7 @@ export default function useRandomThemes() {
|
||||
setCurrentWatchListItem(watchList[0]);
|
||||
|
||||
const { video, entry } = watchList[0];
|
||||
const theme = entry.theme;
|
||||
const theme = entry.animetheme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (anime && entry && video) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
import useLocalStorageState from "use-local-storage-state";
|
||||
|
||||
import type { ThemeSummaryCardThemeFragment } from "@/generated/graphql";
|
||||
import type { THEME_SUMMARY_CARD_THEME } from "@/components/card/ThemeSummaryCard";
|
||||
|
||||
interface WatchHistoryTheme extends ThemeSummaryCardThemeFragment {
|
||||
interface WatchHistoryTheme extends ResultOf<typeof THEME_SUMMARY_CARD_THEME> {
|
||||
id: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { CLIENT_API_URL } from "@/utils/config";
|
||||
|
||||
export interface Announcement {
|
||||
id: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function fetchAnnouncements(): Promise<Array<Announcement>> {
|
||||
const res = await fetch(`${CLIENT_API_URL}/announcement`);
|
||||
const json = await res.json();
|
||||
|
||||
return json.announcements as Array<Announcement>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
|
||||
import { searchResolvers, searchTypeDefs } from "@/lib/client/search";
|
||||
import { buildFetchData } from "@/lib/common";
|
||||
import resolversAnimeThemes from "@/lib/common/animethemes/resolvers";
|
||||
import typeDefsAnimeThemes from "@/lib/common/animethemes/type-defs";
|
||||
|
||||
const schema = makeExecutableSchema({
|
||||
typeDefs: mergeTypeDefs([typeDefsAnimeThemes, searchTypeDefs]),
|
||||
resolvers: mergeResolvers([resolversAnimeThemes, searchResolvers]),
|
||||
});
|
||||
|
||||
export const fetchDataClient = buildFetchData(schema);
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ASTNode } from "graphql";
|
||||
|
||||
import type { ApiExecutionResult } from "@/lib/common";
|
||||
|
||||
export async function fetchDataClient<T>(query: string | ASTNode): Promise<ApiExecutionResult<T>>;
|
||||
export async function fetchDataClient<T, P extends Record<string, unknown>>(
|
||||
query: string | ASTNode,
|
||||
args: P,
|
||||
context?: { apiRequests?: number },
|
||||
root?: Record<string, unknown>,
|
||||
): Promise<ApiExecutionResult<T>>;
|
||||
export async function fetchDataClient<T, P extends Record<string, unknown>>(
|
||||
query: string | ASTNode,
|
||||
args?: P,
|
||||
context?: { apiRequests?: number },
|
||||
root?: Record<string, unknown>,
|
||||
): Promise<ApiExecutionResult<T>> {
|
||||
// Code-split anything GraphQL related to reduce initial bundle size.
|
||||
const { fetchDataClient } = await import("lib/client/chunk");
|
||||
|
||||
if (!args) {
|
||||
return fetchDataClient<T>(query);
|
||||
}
|
||||
|
||||
return fetchDataClient<T, P>(query, args, context, root);
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { CLIENT_API_URL } from "@/utils/config";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
const RANDOM_GRILL = graphql(`
|
||||
query RandomGrill {
|
||||
imagePagination(facet: GRILL, sort: [RANDOM], first: 1) {
|
||||
data {
|
||||
link
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export async function fetchRandomGrill(): Promise<string> {
|
||||
const res = await fetch(`${CLIENT_API_URL}/image?filter[facet]=Grill&sort=random&page[size]=1`);
|
||||
const json = await res.json();
|
||||
const { data } = await client.query({
|
||||
query: RANDOM_GRILL,
|
||||
});
|
||||
|
||||
return json.images[0].link as string;
|
||||
return data.imagePagination.data[0].link;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,60 @@
|
||||
import gql from "graphql-tag";
|
||||
import type { VariablesOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
import type { RandomThemeQuery, RandomThemeQueryVariables } from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client/index";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { AnimeMediaFormat, ThemeType } from "@/graphql/generated/graphql";
|
||||
|
||||
const RANDOM_THEME = graphql(`
|
||||
query RandomTheme($type: [ThemeType!], $mediaFormat: [AnimeMediaFormat!], $animeYearMin: Int, $animeYearMax: Int) {
|
||||
animethemeShuffle(
|
||||
type: $type
|
||||
mediaFormat: $mediaFormat
|
||||
year_gte: $animeYearMin
|
||||
year_lte: $animeYearMax
|
||||
spoiler: false
|
||||
first: 10
|
||||
) {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
animethemeentries {
|
||||
...VideoSummaryCardEntry
|
||||
videos {
|
||||
nodes {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export interface RandomThemesOptions {
|
||||
themeType?: string;
|
||||
mediaFormat?: string;
|
||||
themeType?: ThemeType;
|
||||
mediaFormat?: AnimeMediaFormat;
|
||||
animeYearMin?: number;
|
||||
animeYearMax?: number;
|
||||
}
|
||||
|
||||
export async function fetchRandomThemes(options?: RandomThemesOptions) {
|
||||
const args = {
|
||||
sortBy: "random",
|
||||
filters: [
|
||||
{ key: "has", value: "anime,animethemeentries" },
|
||||
{ key: "spoiler", value: "false" },
|
||||
],
|
||||
} satisfies RandomThemeQueryVariables["args"];
|
||||
const args: VariablesOf<typeof RANDOM_THEME> = {};
|
||||
|
||||
if (options?.themeType) {
|
||||
args.filters.push({ key: "type", value: options.themeType });
|
||||
args.type = options.themeType;
|
||||
}
|
||||
if (options?.mediaFormat) {
|
||||
args.filters.push({ key: "anime][media_format", value: options.mediaFormat });
|
||||
args.mediaFormat = options.mediaFormat;
|
||||
}
|
||||
if (options?.animeYearMin) {
|
||||
args.filters.push({ key: "anime][year-gte", value: String(options.animeYearMin) });
|
||||
args.animeYearMin = options.animeYearMin;
|
||||
}
|
||||
if (options?.animeYearMax) {
|
||||
args.filters.push({ key: "anime][year-lte", value: String(options.animeYearMax) });
|
||||
args.animeYearMax = options.animeYearMax;
|
||||
}
|
||||
|
||||
const { data } = await fetchDataClient<RandomThemeQuery, RandomThemeQueryVariables>(
|
||||
gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
|
||||
query RandomTheme($args: SearchArgs!) {
|
||||
searchTheme(args: $args) {
|
||||
data {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
entries {
|
||||
videos {
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args },
|
||||
);
|
||||
|
||||
return data.searchTheme.data.map((theme) => {
|
||||
// Remove all entries which have spoilers (the filter parameter guarantees at least one spoiler-free entry)
|
||||
while (theme.entries[0].spoiler) {
|
||||
theme.entries.shift();
|
||||
}
|
||||
|
||||
return theme;
|
||||
const { data } = await client.query({
|
||||
query: RANDOM_THEME,
|
||||
variables: args,
|
||||
});
|
||||
|
||||
return data.animethemeShuffle;
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import type { Resolvers, SearchArgs } from "@/generated/graphql-resolvers";
|
||||
import { createApiResolverNotNull, fetchJson } from "@/lib/common/animethemes/api";
|
||||
import type {
|
||||
ApiAnime,
|
||||
ApiAnimeIndex,
|
||||
ApiArtist,
|
||||
ApiArtistIndex,
|
||||
ApiIndex,
|
||||
ApiPlaylist,
|
||||
ApiPlaylistIndex,
|
||||
ApiSeries,
|
||||
ApiSeriesIndex,
|
||||
ApiStudio,
|
||||
ApiStudioIndex,
|
||||
ApiTheme,
|
||||
ApiThemeIndex,
|
||||
} from "@/lib/common/animethemes/types";
|
||||
|
||||
export interface SimpleSearchArgs {
|
||||
query?: string | null;
|
||||
filters?: Record<string, string | null>;
|
||||
sortBy?: string | null;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export const searchTypeDefs = gql`
|
||||
type Query {
|
||||
search(args: SearchArgs!): GlobalSearchResult!
|
||||
searchAnime(args: SearchArgs!): AnimeSearchResult!
|
||||
searchTheme(args: SearchArgs!): ThemeSearchResult!
|
||||
searchArtist(args: SearchArgs!): ArtistSearchResult!
|
||||
searchSeries(args: SearchArgs!): SeriesSearchResult!
|
||||
searchStudio(args: SearchArgs!): StudioSearchResult!
|
||||
searchPlaylist(args: SearchArgs!): PlaylistSearchResult!
|
||||
}
|
||||
|
||||
type GlobalSearchResult {
|
||||
anime: [Anime!]!
|
||||
themes: [Theme!]!
|
||||
artists: [Artist!]!
|
||||
series: [Series!]!
|
||||
studios: [Studio!]!
|
||||
playlists: [Playlist!]!
|
||||
}
|
||||
|
||||
interface EntitySearchResult {
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type AnimeSearchResult implements EntitySearchResult {
|
||||
data: [Anime!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type ThemeSearchResult implements EntitySearchResult {
|
||||
data: [Theme!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type ArtistSearchResult implements EntitySearchResult {
|
||||
data: [Artist!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type SeriesSearchResult implements EntitySearchResult {
|
||||
data: [Series!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type StudioSearchResult implements EntitySearchResult {
|
||||
data: [Studio!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
type PlaylistSearchResult implements EntitySearchResult {
|
||||
data: [Playlist!]!
|
||||
nextPage: Int
|
||||
}
|
||||
|
||||
input SearchArgs {
|
||||
query: String
|
||||
filters: [Filter!]
|
||||
sortBy: String
|
||||
page: Int
|
||||
}
|
||||
|
||||
input Filter {
|
||||
key: String!
|
||||
value: String
|
||||
}
|
||||
`;
|
||||
|
||||
interface GlobalSearchResult {
|
||||
search: {
|
||||
anime: Array<ApiAnime>;
|
||||
animethemes: Array<ApiTheme>;
|
||||
artists: Array<ApiArtist>;
|
||||
series: Array<ApiSeries>;
|
||||
studios: Array<ApiStudio>;
|
||||
playlists: Array<ApiPlaylist>;
|
||||
};
|
||||
}
|
||||
|
||||
export const searchResolvers: Resolvers = {
|
||||
Query: {
|
||||
// TODO: This resolver has to be custom, because the search endpoint uses scoped includes
|
||||
// TODO: which isn't supported by the default API resolver.
|
||||
search: async (_, { args }) => {
|
||||
const searchParams = getSearchParams(args, true);
|
||||
|
||||
// TODO: Hardcoded includes and fields should be generate dynamically by API resolver instead
|
||||
searchParams.append(
|
||||
"include[anime]",
|
||||
"animethemes.animethemeentries.videos,animethemes.group,animethemes.song,images",
|
||||
);
|
||||
searchParams.append(
|
||||
"include[animetheme]",
|
||||
"animethemeentries.videos.audio,anime.images,song.artists,group",
|
||||
);
|
||||
searchParams.append("include[artist]", "images,songs");
|
||||
searchParams.append("include[playlist]", "user");
|
||||
searchParams.append("fields[anime]", "id,name,slug,year,season,media_format");
|
||||
searchParams.append("fields[animetheme]", "id,type,sequence");
|
||||
searchParams.append("fields[group]", "id,name,slug");
|
||||
searchParams.append("fields[animethemeentry]", "id,version,episodes,spoiler,nsfw");
|
||||
searchParams.append("fields[video]", "id,tags,resolution,nc,subbed,lyrics,uncen,source,overlap,basename");
|
||||
searchParams.append("fields[image]", "id,facet,link");
|
||||
searchParams.append("fields[song]", "id,title");
|
||||
searchParams.append("fields[artist]", "id,name,slug");
|
||||
searchParams.append("fields[series]", "id,name,slug");
|
||||
searchParams.append("fields[studio]", "id,name,slug");
|
||||
searchParams.append("fields[playlist]", "id,name,visibility,tracks_count");
|
||||
searchParams.append("fields[user]", "id,name");
|
||||
|
||||
const result = await fetchJson<GlobalSearchResult>(`/search?${searchParams}`);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Search returned null.");
|
||||
}
|
||||
|
||||
return {
|
||||
anime: result.search.anime,
|
||||
themes: result.search.animethemes,
|
||||
artists: result.search.artists,
|
||||
series: result.search.series,
|
||||
studios: result.search.studios,
|
||||
playlists: result.search.playlists,
|
||||
};
|
||||
},
|
||||
searchAnime: createApiResolverNotNull<ApiAnimeIndex>()({
|
||||
endpoint: (_, { args }) => `/anime?${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.anime,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Anime",
|
||||
}),
|
||||
searchTheme: createApiResolverNotNull<ApiThemeIndex>()({
|
||||
endpoint: (_, { args }) => `/animetheme?${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.animethemes,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Theme",
|
||||
}),
|
||||
searchArtist: createApiResolverNotNull<ApiArtistIndex>()({
|
||||
endpoint: (_, { args }) => `/artist?${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.artists,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Artist",
|
||||
}),
|
||||
searchSeries: createApiResolverNotNull<ApiSeriesIndex>()({
|
||||
endpoint: (_, { args }) => `/series?${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.series,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Series",
|
||||
}),
|
||||
searchStudio: createApiResolverNotNull<ApiStudioIndex>()({
|
||||
endpoint: (_, { args }) => `/studio?${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.studios,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Studio",
|
||||
}),
|
||||
searchPlaylist: createApiResolverNotNull<ApiPlaylistIndex>()({
|
||||
endpoint: (_, { args }) =>
|
||||
`/playlist?fields[playlist]=id,name,visibility,tracks_count&${getSearchParams(args)}`,
|
||||
extractFromResponse: (response, _, args) => ({
|
||||
data: response.playlists,
|
||||
nextPage: getNextPage(response, args),
|
||||
}),
|
||||
type: "Playlist",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
function getSearchParams({ query, filters, sortBy, page = 1 }: SearchArgs, isGlobalSearch = false): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (!isGlobalSearch) {
|
||||
searchParams.append("page[size]", "15");
|
||||
searchParams.append("page[number]", String(page));
|
||||
} else {
|
||||
searchParams.append("page[limit]", "4");
|
||||
searchParams.append("fields[search]", "anime,animethemes,artists,series,studios,playlists");
|
||||
}
|
||||
|
||||
if (query) {
|
||||
searchParams.append("q", query);
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
for (const { key, value } of filters) {
|
||||
if (value) {
|
||||
searchParams.append(`filter[${key}]`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
searchParams.append("sort", sortBy);
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
function getNextPage(response: ApiIndex, args: Record<string, unknown>) {
|
||||
return response.links.next !== null ? ((args.args as SearchArgs)?.page as number) + 1 : undefined;
|
||||
}
|
||||
|
||||
export function toSearchArgs(simpleSearchArgs: SimpleSearchArgs): SearchArgs {
|
||||
return {
|
||||
...simpleSearchArgs,
|
||||
filters: simpleSearchArgs.filters
|
||||
? Object.entries(simpleSearchArgs.filters).map(([key, value]) => ({ key, value }))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
import type { GraphQLOutputType, GraphQLResolveInfo } from "graphql";
|
||||
import type { Path } from "graphql/jsutils/Path";
|
||||
import type { ResolveTree } from "graphql-parse-resolve-info";
|
||||
import { parseResolveInfo } from "graphql-parse-resolve-info";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import type { InputMaybe, ResolverFn } from "@/generated/graphql-resolvers";
|
||||
import type { ApiIndex } from "@/lib/common/animethemes/types";
|
||||
import { AUTH_REFERER, CLIENT_API_URL, PAGINATION_PAGE_SIZE, SERVER_API_KEY, SERVER_API_URL } from "@/utils/config";
|
||||
import devLog from "@/utils/devLog";
|
||||
|
||||
const limit = pLimit(5);
|
||||
|
||||
export const API_URL = `${SERVER_API_URL || CLIENT_API_URL}`;
|
||||
|
||||
export const INCLUDES = {
|
||||
Anime: {
|
||||
synonyms: "animesynonyms",
|
||||
themes: "animethemes",
|
||||
series: "series",
|
||||
studios: "studios",
|
||||
resources: "resources",
|
||||
images: "images",
|
||||
},
|
||||
Theme: {
|
||||
song: "song",
|
||||
group: "group",
|
||||
anime: "anime",
|
||||
entries: "animethemeentries",
|
||||
},
|
||||
Artist: {
|
||||
performances: "songs",
|
||||
resources: "resources",
|
||||
images: "images",
|
||||
groups: "groups",
|
||||
members: "members",
|
||||
},
|
||||
Song: {
|
||||
themes: "animethemes",
|
||||
performances: "artists",
|
||||
},
|
||||
Entry: {
|
||||
videos: "videos",
|
||||
theme: "animetheme",
|
||||
},
|
||||
Video: {
|
||||
audio: "audio",
|
||||
script: "videoscript",
|
||||
entries: "animethemeentries",
|
||||
tracks: "tracks",
|
||||
},
|
||||
Audio: {
|
||||
videos: "videos",
|
||||
},
|
||||
Series: {
|
||||
anime: "anime",
|
||||
},
|
||||
Studio: {
|
||||
anime: "anime",
|
||||
resources: "resources",
|
||||
images: "images",
|
||||
},
|
||||
ResourceWithImages: {
|
||||
images: "images",
|
||||
},
|
||||
Performance: {
|
||||
song: "_",
|
||||
artist: "_",
|
||||
},
|
||||
ArtistMembership: {
|
||||
group: "_",
|
||||
member: "_",
|
||||
},
|
||||
FeaturedTheme: {
|
||||
entry: "animethemeentry",
|
||||
video: "video",
|
||||
},
|
||||
Playlist: {
|
||||
tracks: "tracks",
|
||||
user: "user",
|
||||
},
|
||||
PlaylistTrack: {
|
||||
entry: "animethemeentry",
|
||||
video: "video",
|
||||
playlist: "playlist",
|
||||
previous: "previous",
|
||||
next: "next",
|
||||
},
|
||||
UserAuth: {
|
||||
permissions: "permissions",
|
||||
roles: "roles",
|
||||
},
|
||||
UserRole: {
|
||||
permissions: "permissions",
|
||||
},
|
||||
AnimeSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
ThemeSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
ArtistSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
SeriesSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
StudioSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
PlaylistSearchResult: {
|
||||
data: "_",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ALLOWED_INCLUDES: Record<string, Array<string>> = {
|
||||
Anime: [
|
||||
"animesynonyms",
|
||||
"series",
|
||||
"animethemes.song.artists",
|
||||
"animethemes.song.artists.images",
|
||||
"animethemes.group",
|
||||
"images",
|
||||
"resources",
|
||||
"studios.images",
|
||||
"animethemes.animethemeentries.videos.audio",
|
||||
"animethemes.animethemeentries.videos.videoscript",
|
||||
"animethemes.animethemeentries.videos.tracks.playlist",
|
||||
"animethemes.animethemeentries.videos.tracks.playlist.user",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.anime",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.anime.images",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.animethemeentries.videos",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.animethemeentries.videos.audio",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.song.artists",
|
||||
"animethemes.animethemeentries.videos.animethemeentries.animetheme.group",
|
||||
],
|
||||
Theme: ["anime.images", "animethemeentries.videos", "animethemeentries.videos.audio", "song.artists", "group"],
|
||||
Artist: [
|
||||
"songs.animethemes.anime",
|
||||
"members",
|
||||
"groups",
|
||||
"groups.songs.artists",
|
||||
"groups.songs.animethemes.song",
|
||||
"groups.songs.animethemes.group",
|
||||
"groups.songs.animethemes.song.artists",
|
||||
"groups.songs.animethemes.anime.images",
|
||||
"groups.songs.animethemes.animethemeentries",
|
||||
"groups.songs.animethemes.animethemeentries.videos",
|
||||
"groups.songs.animethemes.animethemeentries.videos.audio",
|
||||
"resources",
|
||||
"images",
|
||||
"songs.artists",
|
||||
"songs.animethemes.song",
|
||||
"songs.animethemes.song.artists",
|
||||
"songs.animethemes.group",
|
||||
"songs.animethemes.anime.images",
|
||||
"songs.animethemes.animethemeentries",
|
||||
"songs.animethemes.animethemeentries.videos",
|
||||
"songs.animethemes.animethemeentries.videos.audio",
|
||||
],
|
||||
Series: [
|
||||
"anime",
|
||||
"anime.images",
|
||||
"anime.animethemes.animethemeentries.videos",
|
||||
"anime.animethemes.song",
|
||||
"anime.animethemes.group",
|
||||
],
|
||||
Studio: [
|
||||
"anime",
|
||||
"anime.images",
|
||||
"anime.animethemes.animethemeentries.videos",
|
||||
"anime.animethemes.song",
|
||||
"anime.animethemes.group",
|
||||
"resources",
|
||||
"images",
|
||||
],
|
||||
Song: ["animethemes.anime", "artists"],
|
||||
Video: [
|
||||
"animethemeentries.animetheme.anime.images",
|
||||
"animethemeentries.animetheme.song.artists",
|
||||
"animethemeentries.animetheme.group",
|
||||
"videoscript",
|
||||
"audio",
|
||||
],
|
||||
Audio: ["videos"],
|
||||
Playlist: [
|
||||
"user",
|
||||
"tracks",
|
||||
"tracks.animethemeentry.animetheme.song.artists",
|
||||
"tracks.video.audio",
|
||||
"tracks.animethemeentry.animetheme.anime.images",
|
||||
"tracks.video",
|
||||
"tracks.animethemeentry.animetheme.group",
|
||||
"tracks.previous",
|
||||
"tracks.next",
|
||||
],
|
||||
PlaylistTrack: [
|
||||
"animethemeentry.animetheme.anime.images",
|
||||
"animethemeentry.animetheme.song.artists",
|
||||
"animethemeentry.animetheme.group",
|
||||
"video.audio",
|
||||
"animethemeentry",
|
||||
],
|
||||
UserAuth: ["permissions", "roles.permissions"],
|
||||
FeaturedTheme: [
|
||||
"animethemeentry.animetheme.anime.images",
|
||||
"animethemeentry.animetheme.song.artists",
|
||||
"animethemeentry.animetheme.group",
|
||||
"user",
|
||||
"video",
|
||||
],
|
||||
};
|
||||
|
||||
interface ApiResolverConfig<ApiResponse, ApiResource, Parent, Args> {
|
||||
endpoint: (parent: Parent, args: Args) => string;
|
||||
extractFromResponse: (response: ApiResponse, parent: Parent, args: Args) => ApiResource;
|
||||
extractFromParent?: (parent: Parent, args: Args) => ApiResource | undefined;
|
||||
type?: string | null;
|
||||
baseInclude?: string;
|
||||
}
|
||||
|
||||
export interface ApiResolverContext {
|
||||
cache?: Map<string, unknown | null>;
|
||||
apiRequests: number;
|
||||
req?: IncomingMessage;
|
||||
}
|
||||
|
||||
export function transformedResolver<Result, Parent, Args, ResultTransformed>(
|
||||
resolver: ResolverFn<Result, Parent, ApiResolverContext, Args>,
|
||||
transform: (result: Result, parent: Parent, args: Args) => ResultTransformed,
|
||||
): ResolverFn<ResultTransformed, Parent, ApiResolverContext, Args> {
|
||||
return async (parent, args, context, info) => transform(await resolver(parent, args, context, info), parent, args);
|
||||
}
|
||||
|
||||
export function createApiResolver<ApiResponse>() {
|
||||
return function createApiResolverCurried<ApiResource, Parent, Args>(
|
||||
config: ApiResolverConfig<ApiResponse, ApiResource, Parent, Args>,
|
||||
) {
|
||||
const { extractFromParent } = config;
|
||||
|
||||
return async (parent: Parent, args: Args, context: ApiResolverContext, info: GraphQLResolveInfo) => {
|
||||
if (extractFromParent) {
|
||||
const extracted = extractFromParent(parent, args);
|
||||
if (extracted !== undefined) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
|
||||
const { url, headers } = buildRequest(config, parent, args, context, info);
|
||||
|
||||
// Limiting the concurrent requests is necessary to prevent timeouts.
|
||||
return limit(() => fetchResults(url, headers, config, parent, args, context));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiResolverNotNull<ApiResponse>() {
|
||||
return function createApiResolverNotNullCurried<ApiResource, Parent, Args>(
|
||||
config: ApiResolverConfig<ApiResponse, ApiResource, Parent, Args>,
|
||||
) {
|
||||
const { extractFromParent } = config;
|
||||
|
||||
return async (parent: Parent, args: Args, context: ApiResolverContext, info: GraphQLResolveInfo) => {
|
||||
if (extractFromParent) {
|
||||
const extracted = extractFromParent(parent, args);
|
||||
if (extracted !== undefined) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
|
||||
const { url, headers } = buildRequest(config, parent, args, context, info);
|
||||
|
||||
// Limiting the concurrent requests is necessary to prevent timeouts.
|
||||
return limit(async () => {
|
||||
const results = await fetchResults(url, headers, config, parent, args, context);
|
||||
|
||||
if (results === null) {
|
||||
throw new Error("Fetch returned null which isn't allowed in this resolver!");
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiResolverPaginated<ApiResponse extends ApiIndex>() {
|
||||
return function createApiResolverPaginatedCurried<ApiResource, Parent, Args extends { limit?: InputMaybe<number> }>(
|
||||
config: ApiResolverConfig<ApiResponse, Array<ApiResource>, Parent, Args>,
|
||||
) {
|
||||
const { extractFromParent } = config;
|
||||
|
||||
return async (parent: Parent, args: Args, context: ApiResolverContext, info: GraphQLResolveInfo) => {
|
||||
if (extractFromParent) {
|
||||
const extracted = extractFromParent(parent, args);
|
||||
if (extracted !== undefined) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
|
||||
const { url, headers } = buildRequest(config, parent, args, context, info);
|
||||
|
||||
// Limiting the concurrent requests is necessary to prevent timeouts.
|
||||
return limit(() => fetchResultsPaginated(url, headers, config, parent, args, context));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequest<ApiResponse, ApiResource, Parent, Args>(
|
||||
config: ApiResolverConfig<ApiResponse, ApiResource, Parent, Args>,
|
||||
parent: Parent,
|
||||
args: Args,
|
||||
context: ApiResolverContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) {
|
||||
const { endpoint, type: defaultType = null, baseInclude } = config;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error("Endpoint is required if field cannot be retrieved from parent safely.");
|
||||
}
|
||||
|
||||
const type = defaultType || getTypeName(info.returnType);
|
||||
const path = pathToString(info.path);
|
||||
|
||||
if (info.path.prev) {
|
||||
devLog.warn(`Deep fetch at: ${path}`);
|
||||
}
|
||||
|
||||
let url = endpoint(parent, args);
|
||||
|
||||
const includes = getIncludes(info, baseInclude);
|
||||
if (baseInclude) {
|
||||
includes.push(baseInclude);
|
||||
}
|
||||
const allowedIncludes = includes
|
||||
// Remove includes that are not allowed by the API
|
||||
.filter((include) =>
|
||||
ALLOWED_INCLUDES[type]?.find(
|
||||
(allowedInclude) => allowedInclude === include || allowedInclude.startsWith(include + "."),
|
||||
),
|
||||
)
|
||||
// Remove includes which are already included with another include
|
||||
.filter((include, _, includes) => !includes.find((otherInclude) => otherInclude.startsWith(include + ".")))
|
||||
// Remove duplicates
|
||||
.filter((include, index, includes) => includes.lastIndexOf(include) === index);
|
||||
const disallowedIncludes = includes.filter(
|
||||
(include) =>
|
||||
!ALLOWED_INCLUDES[type]?.find(
|
||||
(allowedInclude) => allowedInclude === include || allowedInclude.startsWith(include + "."),
|
||||
),
|
||||
);
|
||||
|
||||
if (disallowedIncludes.length) {
|
||||
devLog.warn(`Disallowed includes for ${url}:`);
|
||||
devLog.warn(disallowedIncludes.toString());
|
||||
devLog.warn(`Or at least:`);
|
||||
devLog.warn(
|
||||
disallowedIncludes
|
||||
.filter(
|
||||
(include, _, includes) => !includes.find((otherInclude) => otherInclude.startsWith(include + ".")),
|
||||
)
|
||||
.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedIncludes.length) {
|
||||
url += `${url.includes("?") ? "&" : "?"}include=${allowedIncludes.join()}`;
|
||||
}
|
||||
|
||||
const headers: HeadersInit | undefined = context.req
|
||||
? {
|
||||
// Send auth headers from client forward, if provided.
|
||||
referer: context.req.headers.referer ?? AUTH_REFERER,
|
||||
cookie: context.req.headers.cookie ?? "",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (context.req && headers) {
|
||||
// Forward IP address of the requester so we can have proper rate limiting.
|
||||
// X-Real-IP is populated by the Nginx reverse proxy and includes the actual IP address of the requester.
|
||||
// X-Forwarded-IP is read by the API and is used for rate limiting.
|
||||
const realIp = context.req.headers["x-real-ip"];
|
||||
if (realIp) {
|
||||
headers["x-forwarded-ip"] = Array.isArray(realIp) ? realIp[0] : realIp;
|
||||
}
|
||||
}
|
||||
|
||||
devLog.info(path + ": " + url);
|
||||
|
||||
return { url, headers };
|
||||
}
|
||||
|
||||
async function fetchResults<ApiResponse, ApiResource, Parent, Args>(
|
||||
url: string,
|
||||
headers: HeadersInit | undefined,
|
||||
config: ApiResolverConfig<ApiResponse, ApiResource, Parent, Args>,
|
||||
parent: Parent,
|
||||
args: Args,
|
||||
context: ApiResolverContext,
|
||||
) {
|
||||
const { extractFromResponse } = config;
|
||||
|
||||
devLog.info(`Fetching: ${url}`);
|
||||
let json: ApiResponse | null;
|
||||
|
||||
const jsonCached = context?.cache?.get(url);
|
||||
if (jsonCached) {
|
||||
devLog.info("CACHED: " + url);
|
||||
json = jsonCached as ApiResponse;
|
||||
} else {
|
||||
json = await fetchJson(url, { headers });
|
||||
context.apiRequests++;
|
||||
if (!context.cache) {
|
||||
context.cache = new Map();
|
||||
}
|
||||
context.cache.set(url, json);
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractFromResponse(json, parent, args);
|
||||
}
|
||||
|
||||
async function fetchResultsPaginated<
|
||||
ApiResponse extends ApiIndex,
|
||||
ApiResource,
|
||||
Parent,
|
||||
Args extends { limit?: InputMaybe<number> },
|
||||
>(
|
||||
url: string,
|
||||
headers: HeadersInit | undefined,
|
||||
config: ApiResolverConfig<ApiResponse, Array<ApiResource>, Parent, Args>,
|
||||
parent: Parent,
|
||||
args: Args,
|
||||
context: ApiResolverContext,
|
||||
) {
|
||||
const { extractFromResponse } = config;
|
||||
|
||||
devLog.info(`Collecting: ${url}`);
|
||||
const results = [];
|
||||
const pageSize = args.limit ?? PAGINATION_PAGE_SIZE ?? 25;
|
||||
let nextUrl: string | null = `${url}${url.includes("?") ? "&" : "?"}page[size]=${pageSize}`;
|
||||
while (nextUrl) {
|
||||
const json: ApiResponse | null = await fetchJson(nextUrl, { headers });
|
||||
context.apiRequests++;
|
||||
|
||||
if (!json) {
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(...extractFromResponse(json, parent, args));
|
||||
|
||||
devLog.info(`Collecting: ${url}, Got ${results.length}`);
|
||||
nextUrl = !args.limit ? json.links.next : null;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchJson<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
config: RequestInit = {},
|
||||
): Promise<T | null> {
|
||||
const url = path.startsWith(API_URL) ? path : `${API_URL}${path}`;
|
||||
|
||||
config.credentials = "include";
|
||||
|
||||
if (SERVER_API_KEY && !("cookie" in (config.headers ?? {}))) {
|
||||
config.headers = {
|
||||
Authorization: `Bearer ${SERVER_API_KEY}`,
|
||||
...config.headers,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 || response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`API returned with non-ok status code: ${response.status} (${response.statusText}) for ${url}.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new Error(`API returned invalid JSON for ${url}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeName(type: GraphQLOutputType): string {
|
||||
if (!("name" in type)) {
|
||||
return getTypeName(type.ofType);
|
||||
}
|
||||
return type.name;
|
||||
}
|
||||
|
||||
function pathToString(path: Path) {
|
||||
const parts: Array<string | number> = [];
|
||||
for (let part: Path | undefined = path; part; part = part.prev) {
|
||||
parts.push(part.key);
|
||||
}
|
||||
return parts.reverse().join(".");
|
||||
}
|
||||
|
||||
function getIncludes(info: GraphQLResolveInfo, baseInclude?: string) {
|
||||
const infoFragment = parseResolveInfo(info) as ResolveTree;
|
||||
return getIncludesRecursive(infoFragment, baseInclude ? baseInclude + "." : "");
|
||||
}
|
||||
|
||||
function getIncludesRecursive(infoFragment: ResolveTree, parent = "") {
|
||||
const includes: Array<string> = [];
|
||||
|
||||
for (const [type, fields] of Object.entries(infoFragment.fieldsByTypeName)) {
|
||||
for (const [fieldName, field] of Object.entries(fields)) {
|
||||
const typeCasted = type as keyof typeof INCLUDES;
|
||||
const includeConfig = INCLUDES[typeCasted];
|
||||
const fieldNameCasted = fieldName as keyof typeof includeConfig;
|
||||
if (includeConfig?.[fieldNameCasted]) {
|
||||
if (includeConfig[fieldNameCasted] === "_") {
|
||||
includes.push(...getIncludesRecursive(field, parent));
|
||||
} else {
|
||||
const include = parent + includeConfig[fieldNameCasted];
|
||||
includes.push(include);
|
||||
includes.push(...getIncludesRecursive(field, include + "."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return includes;
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import type { Resolvers } from "@/generated/graphql-resolvers";
|
||||
import {
|
||||
createApiResolver,
|
||||
createApiResolverNotNull,
|
||||
createApiResolverPaginated,
|
||||
INCLUDES,
|
||||
transformedResolver,
|
||||
} from "@/lib/common/animethemes/api";
|
||||
import type {
|
||||
ApiAnimeIndex,
|
||||
ApiAnimeShow,
|
||||
ApiAnnouncementIndex,
|
||||
ApiArtistIndex,
|
||||
ApiArtistShow,
|
||||
ApiAudioShow,
|
||||
ApiDumpIndex,
|
||||
ApiEntryShow,
|
||||
ApiFeaturedThemeShow,
|
||||
ApiImageIndex,
|
||||
ApiPageIndex,
|
||||
ApiPageShow,
|
||||
ApiPlaylistIndex,
|
||||
ApiPlaylistShow,
|
||||
ApiPlaylistTrackIndex,
|
||||
ApiPlaylistTrackShow,
|
||||
ApiSeriesIndex,
|
||||
ApiSeriesShow,
|
||||
ApiSongShow,
|
||||
ApiStudioIndex,
|
||||
ApiStudioShow,
|
||||
ApiThemeIndex,
|
||||
ApiThemeShow,
|
||||
ApiUserShow,
|
||||
ApiVideoIndex,
|
||||
ApiVideoShow,
|
||||
ApiYearIndex,
|
||||
ApiYearShow,
|
||||
} from "@/lib/common/animethemes/types";
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
Query: {
|
||||
anime: createApiResolver<ApiAnimeShow>()({
|
||||
endpoint: (_, { slug }) => `/anime/${slug}?fields[playlist]=id,name,visibility,tracks_count`,
|
||||
extractFromResponse: (response) => response.anime,
|
||||
}),
|
||||
animeAll: createApiResolverPaginated<ApiAnimeIndex>()({
|
||||
endpoint: () => `/anime?fields[playlist]=id,name,visibility,tracks_count`,
|
||||
extractFromResponse: (response) => response.anime,
|
||||
}),
|
||||
theme: createApiResolver<ApiThemeShow>()({
|
||||
endpoint: (_, { id }) => `/animetheme/${id}`,
|
||||
extractFromResponse: (response) => response.animetheme,
|
||||
}),
|
||||
themeAll: createApiResolverPaginated<ApiThemeIndex>()({
|
||||
endpoint: (_, { orderBy, orderDesc, has }) =>
|
||||
`/animetheme?sort=${orderDesc ? "-" : ""}${orderBy}&filter[has]=${has}`,
|
||||
extractFromResponse: (response) => response.animethemes,
|
||||
}),
|
||||
artist: createApiResolver<ApiArtistShow>()({
|
||||
endpoint: (_, { slug }) => `/artist/${slug}`,
|
||||
extractFromResponse: (response) => response.artist,
|
||||
}),
|
||||
artistAll: createApiResolverPaginated<ApiArtistIndex>()({
|
||||
endpoint: () => `/artist`,
|
||||
extractFromResponse: (response) => response.artists,
|
||||
}),
|
||||
series: createApiResolver<ApiSeriesShow>()({
|
||||
endpoint: (_, { slug }) => `/series/${slug}`,
|
||||
extractFromResponse: (response) => response.series,
|
||||
}),
|
||||
seriesAll: createApiResolverPaginated<ApiSeriesIndex>()({
|
||||
endpoint: () => `/series`,
|
||||
extractFromResponse: (response) => response.series,
|
||||
}),
|
||||
studio: createApiResolver<ApiStudioShow>()({
|
||||
endpoint: (_, { slug }) => `/studio/${slug}`,
|
||||
extractFromResponse: (response) => response.studio,
|
||||
}),
|
||||
studioAll: createApiResolverPaginated<ApiStudioIndex>()({
|
||||
endpoint: () => `/studio`,
|
||||
extractFromResponse: (response) => response.studios,
|
||||
}),
|
||||
videoAll: createApiResolverPaginated<ApiVideoIndex>()({
|
||||
endpoint: (_, { orderBy, orderDesc }) => `/video?sort=${orderDesc ? "-" : ""}${orderBy}`,
|
||||
extractFromResponse: (response) => response.videos,
|
||||
}),
|
||||
year: (_, { value }) => ({ value }),
|
||||
yearAll: createApiResolverNotNull<ApiYearIndex>()({
|
||||
endpoint: () => `/animeyear`,
|
||||
extractFromResponse: (yearList) => yearList.map((year) => ({ value: year })),
|
||||
}),
|
||||
season: (_, { year, value }) => ({ value, year: { value: year } }),
|
||||
page: createApiResolver<ApiPageShow>()({
|
||||
endpoint: (_, { slug }) => `/page/${slug}?fields[page]=id,slug,name,body,created_at`,
|
||||
extractFromResponse: (response) => response.page,
|
||||
}),
|
||||
pageAll: createApiResolverPaginated<ApiPageIndex>()({
|
||||
endpoint: () => `/page?fields[page]=id,slug,name,body,created_at`,
|
||||
extractFromResponse: (response) => response.pages,
|
||||
}),
|
||||
imageAll: createApiResolverPaginated<ApiImageIndex>()({
|
||||
endpoint: (_, { facet }) => `/image?${facet ? `filter[facet]=${facet}` : ``}`,
|
||||
extractFromResponse: (response) => response.images,
|
||||
}),
|
||||
featuredTheme: createApiResolver<ApiFeaturedThemeShow>()({
|
||||
endpoint: () => `/current/featuredtheme`,
|
||||
extractFromResponse: (response) => response.featuredtheme,
|
||||
}),
|
||||
dumpAll: createApiResolverPaginated<ApiDumpIndex>()({
|
||||
endpoint: () => `/dump?fields[dump]=id,path,link,created_at`,
|
||||
extractFromResponse: (response) => response.dumps,
|
||||
}),
|
||||
announcementAll: createApiResolverPaginated<ApiAnnouncementIndex>()({
|
||||
endpoint: () => `/announcement`,
|
||||
extractFromResponse: (response) => response.announcements,
|
||||
}),
|
||||
playlist: createApiResolver<ApiPlaylistShow>()({
|
||||
endpoint: (_, { id }) => `/playlist/${id}?fields[playlist]=id,name,description,visibility,tracks_count`,
|
||||
extractFromResponse: (response) => response.playlist,
|
||||
}),
|
||||
playlistAll: createApiResolverPaginated<ApiPlaylistIndex>()({
|
||||
endpoint: (_, { orderBy, orderDesc, onlyNonEmpty }) =>
|
||||
`/playlist?sort=${orderDesc ? "-" : ""}${orderBy}&fields[playlist]=id,name,description,visibility,tracks_count${onlyNonEmpty ? "&filter[playlist][tracks_count-gte]=1" : ""}`,
|
||||
extractFromResponse: (response) => response.playlists,
|
||||
}),
|
||||
me: () => ({}),
|
||||
},
|
||||
UserScopedQuery: {
|
||||
user: createApiResolver<ApiUserShow>()({
|
||||
endpoint: () => `/me?fields[user]=id,name,email,email_verified_at,created_at`,
|
||||
extractFromResponse: (response) => response.user,
|
||||
}),
|
||||
playlistAll: createApiResolverPaginated<ApiPlaylistIndex>()({
|
||||
endpoint: (_, { filterVideoId }) =>
|
||||
`/me/playlist?fields[playlist]=id,name,visibility,tracks_count${filterVideoId ? `&filter[track][video_id]=${filterVideoId}` : ``}`,
|
||||
extractFromResponse: (response) => response.playlists,
|
||||
}),
|
||||
},
|
||||
Year: {
|
||||
seasons: createApiResolverNotNull<ApiYearShow>()({
|
||||
endpoint: (year) => `/animeyear/${year.value}`,
|
||||
extractFromResponse: (response, year) => Object.keys(response).map((season) => ({ value: season, year })),
|
||||
}),
|
||||
},
|
||||
Season: {
|
||||
anime: createApiResolverPaginated<ApiAnimeIndex>()({
|
||||
endpoint: (season) => `/anime?filter[year]=${season.year.value}&filter[season]=${season.value}`,
|
||||
extractFromResponse: (response) => response.anime,
|
||||
type: "Anime",
|
||||
}),
|
||||
},
|
||||
Anime: {
|
||||
synonyms: createApiResolverNotNull<ApiAnimeShow<"animesynonyms">>()({
|
||||
extractFromParent: (anime) => anime.animesynonyms,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.animesynonyms,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.synonyms,
|
||||
}),
|
||||
themes: transformedResolver(
|
||||
createApiResolverNotNull<ApiAnimeShow<"animethemes">>()({
|
||||
extractFromParent: (anime) => anime.animethemes,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.animethemes,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.themes,
|
||||
}),
|
||||
(themes, anime) => themes.map((theme) => ({ ...theme, anime })),
|
||||
),
|
||||
series: createApiResolverNotNull<ApiAnimeShow<"series">>()({
|
||||
extractFromParent: (anime) => anime.series,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.series,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.series,
|
||||
}),
|
||||
studios: createApiResolverNotNull<ApiAnimeShow<"studios">>()({
|
||||
extractFromParent: (anime) => anime.studios,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.studios,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.studios,
|
||||
}),
|
||||
resources: transformedResolver(
|
||||
createApiResolverNotNull<ApiAnimeShow<"resources">>()({
|
||||
extractFromParent: (anime) => anime.resources,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.resources,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.resources,
|
||||
}),
|
||||
(resources) => resources.map(({ animeresource, ...resource }) => ({ ...animeresource, ...resource })),
|
||||
),
|
||||
images: createApiResolverNotNull<ApiAnimeShow<"images">>()({
|
||||
extractFromParent: (anime) => anime.images,
|
||||
endpoint: (anime) => `/anime/${anime.slug}`,
|
||||
extractFromResponse: (response) => response.anime.images,
|
||||
type: "Anime",
|
||||
baseInclude: INCLUDES.Anime.images,
|
||||
}),
|
||||
},
|
||||
Theme: {
|
||||
sequence: (theme) => theme.sequence || 0,
|
||||
song: createApiResolver<ApiThemeShow<"song">>()({
|
||||
extractFromParent: (theme) => theme.song,
|
||||
endpoint: (theme) => `/animetheme/${theme.id}`,
|
||||
extractFromResponse: (response) => response.animetheme.song,
|
||||
type: "Theme",
|
||||
baseInclude: INCLUDES.Theme.song,
|
||||
}),
|
||||
group: createApiResolver<ApiThemeShow<"group">>()({
|
||||
extractFromParent: (theme) => theme.group,
|
||||
endpoint: (theme) => `/animetheme/${theme.id}`,
|
||||
extractFromResponse: (response) => response.animetheme.group,
|
||||
type: "Theme",
|
||||
baseInclude: INCLUDES.Theme.group,
|
||||
}),
|
||||
anime: createApiResolverNotNull<ApiThemeShow<"anime">>()({
|
||||
extractFromParent: (theme) => theme.anime,
|
||||
endpoint: (theme) => `/animetheme/${theme.id}`,
|
||||
extractFromResponse: (response) => response.animetheme.anime,
|
||||
type: "Theme",
|
||||
baseInclude: INCLUDES.Theme.anime,
|
||||
}),
|
||||
entries: createApiResolverNotNull<ApiThemeShow<"animethemeentries">>()({
|
||||
extractFromParent: (theme) => theme.animethemeentries,
|
||||
endpoint: (theme) => `/animetheme/${theme.id}`,
|
||||
extractFromResponse: (response) => response.animetheme.animethemeentries,
|
||||
type: "Theme",
|
||||
baseInclude: INCLUDES.Theme.entries,
|
||||
}),
|
||||
},
|
||||
Artist: {
|
||||
performances: transformedResolver(
|
||||
createApiResolverNotNull<ApiArtistShow<"songs">>()({
|
||||
extractFromParent: (artist) => artist.songs,
|
||||
endpoint: (artist) => `/artist/${artist.slug}`,
|
||||
extractFromResponse: (response) => response.artist.songs,
|
||||
type: "Artist",
|
||||
baseInclude: INCLUDES.Artist.performances,
|
||||
}),
|
||||
(songs, artist) => songs.map((song) => ({ ...song.artistsong, song, artist })),
|
||||
),
|
||||
resources: transformedResolver(
|
||||
createApiResolverNotNull<ApiArtistShow<"resources">>()({
|
||||
extractFromParent: (artist) => artist.resources,
|
||||
endpoint: (artist) => `/artist/${artist.slug}`,
|
||||
extractFromResponse: (response) => response.artist.resources,
|
||||
type: "Artist",
|
||||
baseInclude: INCLUDES.Artist.resources,
|
||||
}),
|
||||
(resources) => resources.map(({ artistresource, ...resource }) => ({ ...artistresource, ...resource })),
|
||||
),
|
||||
images: transformedResolver(
|
||||
createApiResolverNotNull<ApiArtistShow<"images">>()({
|
||||
extractFromParent: (artist) => artist.images,
|
||||
endpoint: (artist) => `/artist/${artist.slug}`,
|
||||
extractFromResponse: (response) => response.artist.images,
|
||||
type: "Artist",
|
||||
baseInclude: INCLUDES.Artist.images,
|
||||
}),
|
||||
(images) => images.map(({ artistimage, ...image }) => ({ ...artistimage, ...image })),
|
||||
),
|
||||
groups: transformedResolver(
|
||||
createApiResolverNotNull<ApiArtistShow<"groups">>()({
|
||||
extractFromParent: (artist) => artist.groups,
|
||||
endpoint: (artist) => `/artist/${artist.slug}`,
|
||||
extractFromResponse: (response) => response.artist.groups,
|
||||
type: "Artist",
|
||||
baseInclude: INCLUDES.Artist.groups,
|
||||
}),
|
||||
(groups, artist) => groups.map((group) => ({ ...group.artistmember, group, member: artist })),
|
||||
),
|
||||
members: transformedResolver(
|
||||
createApiResolverNotNull<ApiArtistShow<"members">>()({
|
||||
extractFromParent: (artist) => artist.members,
|
||||
endpoint: (artist) => `/artist/${artist.slug}`,
|
||||
extractFromResponse: (response) => response.artist.members,
|
||||
type: "Artist",
|
||||
baseInclude: INCLUDES.Artist.members,
|
||||
}),
|
||||
(members, artist) => members.map((member) => ({ ...member.artistmember, member, group: artist })),
|
||||
),
|
||||
},
|
||||
Song: {
|
||||
themes: createApiResolverNotNull<ApiSongShow<"animethemes">>()({
|
||||
extractFromParent: (song) => song.animethemes,
|
||||
endpoint: (song) => `/song/${song.id}`,
|
||||
extractFromResponse: (response) => response.song.animethemes,
|
||||
type: "Song",
|
||||
baseInclude: INCLUDES.Song.themes,
|
||||
}),
|
||||
performances: transformedResolver(
|
||||
createApiResolverNotNull<ApiSongShow<"artists">>()({
|
||||
extractFromParent: (song) => song.artists,
|
||||
endpoint: (song) => `/song/${song.id}`,
|
||||
extractFromResponse: (response) => response.song.artists,
|
||||
type: "Song",
|
||||
baseInclude: INCLUDES.Song.performances,
|
||||
}),
|
||||
(artists, song) => artists.map((artist) => ({ ...artist.artistsong, artist, song })),
|
||||
),
|
||||
},
|
||||
Entry: {
|
||||
version: (entry) => entry.version || 1,
|
||||
videos: createApiResolverNotNull<ApiEntryShow<"videos">>()({
|
||||
extractFromParent: (entry) => entry.videos,
|
||||
endpoint: (entry) => `/animethemeentry/${entry.id}`,
|
||||
extractFromResponse: (response) => response.animethemeentry.videos,
|
||||
type: "Entry",
|
||||
baseInclude: INCLUDES.Entry.videos,
|
||||
}),
|
||||
theme: createApiResolverNotNull<ApiEntryShow<"animetheme">>()({
|
||||
extractFromParent: (entry) => entry.animetheme,
|
||||
endpoint: (entry) => `/animethemeentry/${entry.id}`,
|
||||
extractFromResponse: (response) => response.animethemeentry.animetheme,
|
||||
type: "Entry",
|
||||
baseInclude: INCLUDES.Entry.theme,
|
||||
}),
|
||||
},
|
||||
Video: {
|
||||
audio: createApiResolverNotNull<ApiVideoShow<"audio">>()({
|
||||
extractFromParent: (video) => video.audio,
|
||||
endpoint: (video) => `/video/${video.basename}`,
|
||||
extractFromResponse: (response) => response.video.audio,
|
||||
type: "Video",
|
||||
baseInclude: INCLUDES.Video.audio,
|
||||
}),
|
||||
script: createApiResolver<ApiVideoShow<"videoscript">>()({
|
||||
extractFromParent: (video) => video.videoscript,
|
||||
endpoint: (video) => `/video/${video.basename}`,
|
||||
extractFromResponse: (response) => response.video.videoscript,
|
||||
type: "Video",
|
||||
baseInclude: INCLUDES.Video.script,
|
||||
}),
|
||||
entries: createApiResolverNotNull<ApiVideoShow<"animethemeentries">>()({
|
||||
extractFromParent: (video) => video.animethemeentries,
|
||||
endpoint: (video) => `/video/${video.basename}`,
|
||||
extractFromResponse: (response) => response.video.animethemeentries,
|
||||
type: "Video",
|
||||
baseInclude: INCLUDES.Video.entries,
|
||||
}),
|
||||
},
|
||||
Audio: {
|
||||
videos: createApiResolverNotNull<ApiAudioShow<"videos">>()({
|
||||
extractFromParent: (audio) => audio.videos,
|
||||
endpoint: (audio) => `/audio/${audio.basename}`,
|
||||
extractFromResponse: (response) => response.audio.videos,
|
||||
type: "Audio",
|
||||
baseInclude: INCLUDES.Audio.videos,
|
||||
}),
|
||||
},
|
||||
Series: {
|
||||
anime: createApiResolverNotNull<ApiSeriesShow<"anime">>()({
|
||||
extractFromParent: (series) => series.anime,
|
||||
endpoint: (series) => `/series/${series.slug}`,
|
||||
extractFromResponse: (response) => response.series.anime,
|
||||
type: "Series",
|
||||
baseInclude: INCLUDES.Series.anime,
|
||||
}),
|
||||
},
|
||||
Studio: {
|
||||
anime: createApiResolverNotNull<ApiStudioShow<"anime">>()({
|
||||
extractFromParent: (studio) => studio.anime,
|
||||
endpoint: (studio) => `/studio/${studio.slug}`,
|
||||
extractFromResponse: (response) => response.studio.anime,
|
||||
type: "Studio",
|
||||
baseInclude: INCLUDES.Studio.anime,
|
||||
}),
|
||||
resources: transformedResolver(
|
||||
createApiResolverNotNull<ApiStudioShow<"resources">>()({
|
||||
extractFromParent: (studio) => studio.resources,
|
||||
endpoint: (studio) => `/studio/${studio.slug}`,
|
||||
extractFromResponse: (response) => response.studio.resources,
|
||||
type: "Studio",
|
||||
baseInclude: INCLUDES.Studio.resources,
|
||||
}),
|
||||
(resources) => resources.map(({ studioresource, ...resource }) => ({ ...studioresource, ...resource })),
|
||||
),
|
||||
images: createApiResolverNotNull<ApiStudioShow<"images">>()({
|
||||
extractFromParent: (studio) => studio.images,
|
||||
endpoint: (studio) => `/studio/${studio.slug}`,
|
||||
extractFromResponse: (response) => response.studio.images,
|
||||
type: "Studio",
|
||||
baseInclude: INCLUDES.Studio.images,
|
||||
}),
|
||||
},
|
||||
Performance: {
|
||||
artist: (performance) => performance.artist,
|
||||
song: (performance) => performance.song,
|
||||
},
|
||||
FeaturedTheme: {
|
||||
entry: createApiResolver<ApiFeaturedThemeShow<"animethemeentry">>()({
|
||||
extractFromParent: (featuredTheme) => featuredTheme.animethemeentry,
|
||||
endpoint: (featuredTheme) => `/featuredtheme/${featuredTheme.id}`,
|
||||
extractFromResponse: (response) => response.featuredtheme.animethemeentry,
|
||||
type: "FeaturedTheme",
|
||||
baseInclude: INCLUDES.FeaturedTheme.entry,
|
||||
}),
|
||||
video: createApiResolver<ApiFeaturedThemeShow<"video">>()({
|
||||
extractFromParent: (featuredTheme) => featuredTheme.video,
|
||||
endpoint: (featuredTheme) => `/featuredtheme/${featuredTheme.id}`,
|
||||
extractFromResponse: (response) => response.featuredtheme.video,
|
||||
type: "FeaturedTheme",
|
||||
baseInclude: INCLUDES.FeaturedTheme.video,
|
||||
}),
|
||||
},
|
||||
VideoOverlap: {
|
||||
NONE: "None",
|
||||
TRANSITION: "Transition",
|
||||
OVER: "Over",
|
||||
},
|
||||
Playlist: {
|
||||
tracks: transformedResolver(
|
||||
createApiResolverPaginated<ApiPlaylistTrackIndex>()({
|
||||
extractFromParent: (playlist) => playlist.tracks,
|
||||
endpoint: (playlist) => `/playlist/${playlist.id}/track`,
|
||||
extractFromResponse: (response) => response.tracks,
|
||||
}),
|
||||
(tracks, playlist) => tracks.map((track) => ({ ...track, playlist })),
|
||||
),
|
||||
forward: transformedResolver(
|
||||
createApiResolverPaginated<ApiPlaylistTrackIndex>()({
|
||||
extractFromParent: (playlist) => playlist.tracks,
|
||||
endpoint: (playlist) => `/playlist/${playlist.id}/forward`,
|
||||
extractFromResponse: (response) => response.tracks,
|
||||
}),
|
||||
(tracks, playlist) => tracks.map((track) => ({ ...track, playlist })),
|
||||
),
|
||||
user: createApiResolverNotNull<ApiPlaylistShow<"user">>()({
|
||||
extractFromParent: (playlist) => playlist.user,
|
||||
endpoint: (playlist) => `/playlist/${playlist.id}`,
|
||||
extractFromResponse: (response) => response.playlist.user,
|
||||
type: "Playlist",
|
||||
baseInclude: INCLUDES.Playlist.user,
|
||||
}),
|
||||
},
|
||||
PlaylistTrack: {
|
||||
entry: createApiResolverNotNull<ApiPlaylistTrackShow<"animethemeentry">>()({
|
||||
extractFromParent: (track) => track.animethemeentry,
|
||||
endpoint: (track) => `/playlist/${track.playlist.id}/track/${track.id}`,
|
||||
extractFromResponse: (response) => response.track.animethemeentry,
|
||||
type: "PlaylistTrack",
|
||||
baseInclude: INCLUDES.PlaylistTrack.entry,
|
||||
}),
|
||||
video: createApiResolverNotNull<ApiPlaylistTrackShow<"video">>()({
|
||||
extractFromParent: (track) => track.video,
|
||||
endpoint: (track) => `/playlist/${track.playlist.id}/track/${track.id}`,
|
||||
extractFromResponse: (response) => response.track.video,
|
||||
type: "PlaylistTrack",
|
||||
baseInclude: INCLUDES.PlaylistTrack.video,
|
||||
}),
|
||||
},
|
||||
UserAuth: {
|
||||
roles: createApiResolverNotNull<ApiUserShow<"roles">>()({
|
||||
extractFromParent: (user) => user.roles,
|
||||
endpoint: () => `/me`,
|
||||
extractFromResponse: (response) => response.user.roles,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default resolvers;
|
||||
@@ -1,312 +0,0 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
anime(id: Int, slug: String): Anime
|
||||
animeAll(limit: Int, year: Int, season: String): [Anime!]!
|
||||
theme(id: Int): Theme
|
||||
themeAll(limit: Int, orderBy: String, orderDesc: Boolean, has: String): [Theme!]!
|
||||
artist(id: Int, slug: String): Artist
|
||||
artistAll(limit: Int): [Artist!]!
|
||||
series(id: Int, slug: String): Series
|
||||
seriesAll(limit: Int): [Series!]!
|
||||
studio(id: Int, slug: String): Studio
|
||||
studioAll(limit: Int): [Studio!]!
|
||||
videoAll(limit: Int, orderBy: String, orderDesc: Boolean): [Video!]!
|
||||
year(value: Int!): Year
|
||||
yearAll: [Year!]!
|
||||
season(year: Int!, value: String!): Season
|
||||
page(id: Int, slug: String): Page
|
||||
pageAll: [Page!]!
|
||||
imageAll(limit: Int, facet: String): [Image!]!
|
||||
featuredTheme: FeaturedTheme
|
||||
dumpAll: [Dump!]!
|
||||
playlist(id: String!): Playlist
|
||||
playlistAll(limit: Int, orderBy: String, orderDesc: Boolean, onlyNonEmpty: Boolean): [Playlist!]!
|
||||
announcementAll: [Announcement!]!
|
||||
me: UserScopedQuery!
|
||||
}
|
||||
|
||||
type UserScopedQuery {
|
||||
user: UserAuth
|
||||
playlistAll(limit: Int, filterVideoId: Int): [Playlist!]
|
||||
}
|
||||
|
||||
interface ResourceWithImages {
|
||||
images: [Image!]!
|
||||
}
|
||||
|
||||
type Year {
|
||||
value: Int!
|
||||
seasons: [Season!]!
|
||||
}
|
||||
|
||||
type Season {
|
||||
value: String!
|
||||
year: Year
|
||||
anime: [Anime!]!
|
||||
}
|
||||
|
||||
type Anime implements ResourceWithImages {
|
||||
id: Int!
|
||||
name: String!
|
||||
slug: String!
|
||||
year: Int
|
||||
season: String
|
||||
synopsis: String
|
||||
media_format: String
|
||||
synonyms: [Synonym!]!
|
||||
themes: [Theme!]!
|
||||
series: [Series!]!
|
||||
resources: [Resource!]!
|
||||
images: [Image!]!
|
||||
studios: [Studio!]!
|
||||
}
|
||||
|
||||
type Synonym {
|
||||
id: Int!
|
||||
text: String
|
||||
anime: Anime
|
||||
}
|
||||
|
||||
type Theme {
|
||||
id: Int!
|
||||
type: String!
|
||||
sequence: Int
|
||||
song: Song
|
||||
group: ThemeGroup
|
||||
anime: Anime!
|
||||
entries: [Entry!]!
|
||||
}
|
||||
|
||||
type ThemeGroup {
|
||||
id: Int!
|
||||
name: String!
|
||||
slug: String!
|
||||
}
|
||||
|
||||
type Entry {
|
||||
id: Int!
|
||||
version: Int
|
||||
episodes: String
|
||||
nsfw: Boolean!
|
||||
spoiler: Boolean!
|
||||
theme: Theme!
|
||||
videos: [Video!]!
|
||||
}
|
||||
|
||||
type Video {
|
||||
id: Int!
|
||||
filename: String!
|
||||
basename: String!
|
||||
path: String!
|
||||
size: Int!
|
||||
mimetype: String!
|
||||
link: String!
|
||||
resolution: Int
|
||||
nc: Boolean!
|
||||
subbed: Boolean!
|
||||
lyrics: Boolean!
|
||||
uncen: Boolean!
|
||||
source: VideoSource
|
||||
overlap: VideoOverlap!
|
||||
tags: String!
|
||||
audio: Audio!
|
||||
script: VideoScript
|
||||
entries: [Entry!]!
|
||||
tracks: [PlaylistTrack!]!
|
||||
}
|
||||
|
||||
type VideoScript {
|
||||
id: Int!
|
||||
path: String!
|
||||
link: String!
|
||||
}
|
||||
|
||||
enum VideoSource {
|
||||
WEB
|
||||
RAW
|
||||
BD
|
||||
DVD
|
||||
VHS
|
||||
LD
|
||||
}
|
||||
|
||||
enum VideoOverlap {
|
||||
NONE
|
||||
TRANSITION
|
||||
OVER
|
||||
}
|
||||
|
||||
type Audio {
|
||||
id: Int!
|
||||
filename: String!
|
||||
basename: String!
|
||||
path: String!
|
||||
size: Int!
|
||||
mimetype: String!
|
||||
link: String!
|
||||
videos: [Video!]!
|
||||
}
|
||||
|
||||
type Song {
|
||||
id: Int
|
||||
title: String
|
||||
themes: [Theme!]!
|
||||
performances: [Performance!]!
|
||||
}
|
||||
|
||||
type Performance {
|
||||
song: Song!
|
||||
artist: Artist!
|
||||
alias: String
|
||||
as: String
|
||||
}
|
||||
|
||||
type Artist implements ResourceWithImages {
|
||||
id: Int!
|
||||
information: String
|
||||
slug: String!
|
||||
name: String!
|
||||
performances: [Performance!]!
|
||||
members: [ArtistMembership!]!
|
||||
groups: [ArtistMembership!]!
|
||||
resources: [Resource!]!
|
||||
images: [Image!]!
|
||||
}
|
||||
|
||||
type ArtistMembership {
|
||||
group: Artist!
|
||||
member: Artist!
|
||||
alias: String
|
||||
as: String
|
||||
notes: String
|
||||
}
|
||||
|
||||
type Series {
|
||||
id: Int!
|
||||
slug: String!
|
||||
name: String!
|
||||
anime: [Anime!]!
|
||||
}
|
||||
|
||||
type Resource {
|
||||
id: Int!
|
||||
link: String
|
||||
external_id: Int
|
||||
site: String
|
||||
as: String
|
||||
}
|
||||
|
||||
type Image {
|
||||
id: Int!
|
||||
path: String!
|
||||
size: Int!
|
||||
mimetype: String!
|
||||
depth: Int
|
||||
facet: String
|
||||
link: String!
|
||||
}
|
||||
|
||||
type Studio implements ResourceWithImages {
|
||||
id: Int!
|
||||
slug: String!
|
||||
name: String!
|
||||
anime: [Anime!]!
|
||||
resources: [Resource!]!
|
||||
images: [Image!]!
|
||||
}
|
||||
|
||||
type Announcement {
|
||||
id: Int
|
||||
content: String!
|
||||
}
|
||||
|
||||
type Page {
|
||||
id: Int
|
||||
slug: String!
|
||||
name: String!
|
||||
body: String!
|
||||
created_at: String!
|
||||
}
|
||||
|
||||
type FeaturedTheme {
|
||||
id: Int!
|
||||
start_at: String!
|
||||
end_at: String!
|
||||
video: Video
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
type Dump {
|
||||
id: Int!
|
||||
path: String!
|
||||
link: String!
|
||||
created_at: String!
|
||||
}
|
||||
|
||||
type Playlist {
|
||||
id: String!
|
||||
name: String!
|
||||
description: String
|
||||
visibility: PlaylistVisibility!
|
||||
tracks_count: Int!
|
||||
tracks: [PlaylistTrack!]!
|
||||
forward: [PlaylistTrack!]!
|
||||
user: UserPublic!
|
||||
}
|
||||
|
||||
enum PlaylistVisibility {
|
||||
Public
|
||||
Unlisted
|
||||
Private
|
||||
}
|
||||
|
||||
type PlaylistTrack {
|
||||
id: String!
|
||||
entry: Entry!
|
||||
video: Video!
|
||||
playlist: Playlist!
|
||||
previous: PlaylistTrack
|
||||
next: PlaylistTrack
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: Int!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type UserPublic implements User {
|
||||
id: Int!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type UserAuth implements User {
|
||||
id: Int!
|
||||
name: String!
|
||||
email: String!
|
||||
email_verified_at: String
|
||||
created_at: String!
|
||||
permissions: [Permission!]!
|
||||
roles: [UserRole!]!
|
||||
}
|
||||
|
||||
type UserRole {
|
||||
name: String!
|
||||
color: String
|
||||
priority: Int
|
||||
default: Boolean!
|
||||
permissions: [Permission!]!
|
||||
}
|
||||
|
||||
type Permission {
|
||||
id: Int!
|
||||
name: String!
|
||||
}
|
||||
|
||||
schema {
|
||||
query: Query
|
||||
}
|
||||
`;
|
||||
|
||||
export default typeDefs;
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { ASTNode, GraphQLSchema } from "graphql";
|
||||
import { graphql, print } from "graphql";
|
||||
|
||||
import type { ApiResolverContext } from "@/lib/common/animethemes/api";
|
||||
|
||||
export interface ApiExecutionResult<T> {
|
||||
data: T;
|
||||
apiRequests: number;
|
||||
}
|
||||
|
||||
export function buildFetchData(schema: GraphQLSchema) {
|
||||
async function fetchData<T>(query: string | ASTNode): Promise<ApiExecutionResult<T>>;
|
||||
async function fetchData<T, P extends Record<string, unknown> = Record<string, unknown>>(
|
||||
query: string | ASTNode,
|
||||
args?: P,
|
||||
context?: Partial<ApiResolverContext>,
|
||||
root?: Record<string, unknown>,
|
||||
): Promise<ApiExecutionResult<T>>;
|
||||
async function fetchData<T, P extends Record<string, unknown> = Record<string, unknown>>(
|
||||
query: string | ASTNode,
|
||||
args?: P,
|
||||
context: Partial<ApiResolverContext> = {},
|
||||
root?: Record<string, unknown>,
|
||||
): Promise<ApiExecutionResult<T>> {
|
||||
const result = await graphql({
|
||||
schema,
|
||||
source: typeof query === "string" ? query : print(query),
|
||||
contextValue: {
|
||||
apiRequests: 0,
|
||||
...context,
|
||||
},
|
||||
variableValues: args ?? {},
|
||||
rootValue: root ?? {},
|
||||
});
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(`Failed to execute GraphQL query: ${JSON.stringify(result.errors, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("Empty data on GraphQL result without any error message.");
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data as T,
|
||||
apiRequests: context.apiRequests ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return fetchData;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import brackets from "@/lib/server/animebracket/brackets.json";
|
||||
import mappings from "@/lib/server/animebracket/mappings.json";
|
||||
import devLog from "@/utils/devLog";
|
||||
import fetchJson from "@/utils/fetchJson";
|
||||
|
||||
interface ApiBracket {
|
||||
name: string;
|
||||
winnerCharacterId: number | null;
|
||||
}
|
||||
|
||||
type ApiBracketRound = Array<ApiBracketPairing>;
|
||||
|
||||
interface ApiBracketPairing {
|
||||
tier: number;
|
||||
group: number;
|
||||
filler?: boolean;
|
||||
order: number;
|
||||
character1: ApiBracketCharacter;
|
||||
character2: ApiBracketCharacter;
|
||||
}
|
||||
|
||||
interface ApiBracketCharacter {
|
||||
id: number;
|
||||
seed: number;
|
||||
name: string;
|
||||
source: string;
|
||||
image: string;
|
||||
votes: number;
|
||||
}
|
||||
|
||||
export interface Bracket {
|
||||
slug: string;
|
||||
name: string;
|
||||
currentRound: BracketRound | null;
|
||||
currentGroup: number | null;
|
||||
rounds: Array<BracketRound>;
|
||||
}
|
||||
|
||||
export interface BracketRound {
|
||||
name: string | null;
|
||||
tier: number;
|
||||
pairings: Array<BracketPairing>;
|
||||
}
|
||||
|
||||
export interface BracketPairing {
|
||||
order: number;
|
||||
group: number;
|
||||
characterA: BracketCharacter;
|
||||
characterB: BracketCharacter;
|
||||
votesA: number | null;
|
||||
votesB: number | null;
|
||||
}
|
||||
|
||||
export interface BracketCharacter {
|
||||
id: number;
|
||||
seed: number;
|
||||
name: string;
|
||||
source: string;
|
||||
image: string;
|
||||
votes: number;
|
||||
themeId: number | null;
|
||||
}
|
||||
|
||||
export async function fetchBracket(slug: string): Promise<Bracket | null> {
|
||||
if (!Object.hasOwn(brackets, slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = brackets[slug as keyof typeof brackets] ?? { slug };
|
||||
|
||||
devLog.info(`Fetching bracket: ${config.slug}`);
|
||||
|
||||
const bracket = await fetchJson<ApiBracket>(`https://animebracket.com/api/bracket/${config.slug}`);
|
||||
|
||||
if (bracket === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config.name) {
|
||||
config.name = bracket.name;
|
||||
}
|
||||
|
||||
const rounds = await fetchJson<Array<ApiBracketRound>>(`https://animebracket.com/api/results/${config.slug}`);
|
||||
|
||||
if (rounds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Some brackets are broken and still in a running state. We can check if the bracket has a winner already and
|
||||
// not fetch the current round in that case.
|
||||
const currentRound = bracket.winnerCharacterId
|
||||
? null
|
||||
: await fetchJson<ApiBracketRound>(`https://animebracket.com/api/rounds/${config.slug}`);
|
||||
|
||||
devLog.info(`Fetched bracket: ${config.slug} (${rounds.length} rounds)`);
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: config.name,
|
||||
currentRound: currentRound
|
||||
? {
|
||||
name: getRoundName(currentRound),
|
||||
tier: currentRound[0].tier,
|
||||
pairings: getPairingsFromRound(currentRound),
|
||||
}
|
||||
: null,
|
||||
currentGroup: currentRound ? currentRound[0].group : null,
|
||||
rounds: rounds.map((round) => ({
|
||||
name: getRoundName(round),
|
||||
tier: round[0].tier,
|
||||
pairings: getPairingsFromRound(round),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvailableBrackets() {
|
||||
return Object.entries(brackets).map(([slug, bracket]) => ({
|
||||
slug,
|
||||
name: bracket.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function getRoundName(round: ApiBracketRound) {
|
||||
switch (round.length) {
|
||||
case 1:
|
||||
return "Finals";
|
||||
case 2:
|
||||
return "Semifinals";
|
||||
case 4:
|
||||
return "Quarterfinals";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPairingsFromRound(round: ApiBracketRound): Array<BracketPairing> {
|
||||
return round
|
||||
.filter((pairing) => !pairing.filler)
|
||||
.map((pairing) => {
|
||||
const characterAId = String(pairing.character1.id);
|
||||
const characterBId = String(pairing.character2.id);
|
||||
|
||||
return {
|
||||
order: +pairing.order,
|
||||
group: +pairing.group,
|
||||
characterA: {
|
||||
...pairing.character1,
|
||||
themeId: characterAId in mappings ? mappings[characterAId as keyof typeof mappings] : null,
|
||||
},
|
||||
characterB: {
|
||||
...pairing.character2,
|
||||
themeId: characterBId in mappings ? mappings[characterBId as keyof typeof mappings] : null,
|
||||
},
|
||||
votesA: pairing.character1.votes || null,
|
||||
votesB: pairing.character2.votes || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { Resolvers } from "@/generated/graphql-resolvers";
|
||||
import { createApiResolver } from "@/lib/common/animethemes/api";
|
||||
import type { ApiThemeShow } from "@/lib/common/animethemes/types";
|
||||
import brackets from "@/lib/server/animebracket/brackets.json";
|
||||
import mappings from "@/lib/server/animebracket/mappings.json";
|
||||
import devLog from "@/utils/devLog";
|
||||
|
||||
interface SourceBracket {
|
||||
name: string;
|
||||
winnerCharacterId: number | null;
|
||||
}
|
||||
|
||||
type SourceRound = Array<SourcePairing>;
|
||||
|
||||
interface SourcePairing {
|
||||
tier: number;
|
||||
group: number;
|
||||
filler: boolean;
|
||||
order: number;
|
||||
character1: SourceCharacter;
|
||||
character2: SourceCharacter;
|
||||
}
|
||||
|
||||
interface SourceCharacter {
|
||||
id: number;
|
||||
seed: number;
|
||||
name: string;
|
||||
source: string;
|
||||
image: string;
|
||||
votes: number;
|
||||
}
|
||||
|
||||
export interface ModelBracket {
|
||||
slug: string;
|
||||
name: string;
|
||||
currentRound?: ModelBracketRound | null;
|
||||
currentGroup?: number | null;
|
||||
rounds?: Array<ModelBracketRound>;
|
||||
}
|
||||
|
||||
export interface ModelBracketRound {
|
||||
tier: number;
|
||||
pairings: Array<SourcePairing>;
|
||||
}
|
||||
|
||||
export interface ModelBracketPairing {
|
||||
order: number;
|
||||
group: number;
|
||||
characterA: ModelBracketCharacter;
|
||||
characterB: ModelBracketCharacter;
|
||||
votesA: number | null;
|
||||
votesB: number | null;
|
||||
}
|
||||
|
||||
export interface ModelBracketCharacter {
|
||||
id: number;
|
||||
seed: number;
|
||||
name: string;
|
||||
source: string;
|
||||
image: string;
|
||||
votes?: number;
|
||||
theme?: null;
|
||||
themeMapping?: number;
|
||||
}
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
Query: {
|
||||
bracket: async (_, { slug }) => {
|
||||
if (!Object.hasOwn(brackets, slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = brackets[slug as keyof typeof brackets] ?? { slug };
|
||||
|
||||
devLog.info(`Fetching bracket: ${config.slug}`);
|
||||
const bracket = await fetchJson<SourceBracket>(`https://animebracket.com/api/bracket/${config.slug}`);
|
||||
|
||||
if (!config.name) {
|
||||
config.name = bracket.name;
|
||||
}
|
||||
|
||||
const rounds = await fetchJson<Array<SourceRound>>(`https://animebracket.com/api/results/${config.slug}`);
|
||||
// Some brackets are broken and still in a running state. We can check if the bracket has a winner already and
|
||||
// not fetch the current round in that case.
|
||||
const currentPairings = bracket.winnerCharacterId
|
||||
? null
|
||||
: await fetchJson<SourceRound>(`https://animebracket.com/api/rounds/${config.slug}`);
|
||||
devLog.info(`Fetched bracket: ${config.slug} (${rounds.length} rounds)`);
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: config.name,
|
||||
currentRound: currentPairings
|
||||
? {
|
||||
tier: currentPairings[0].tier,
|
||||
pairings: currentPairings,
|
||||
}
|
||||
: null,
|
||||
currentGroup: currentPairings ? currentPairings[0].group : null,
|
||||
rounds: rounds.map((round) => ({
|
||||
tier: round[0].tier,
|
||||
pairings: round,
|
||||
})),
|
||||
};
|
||||
},
|
||||
bracketAll: async () => {
|
||||
return Object.entries(brackets).map(([slug, bracket]) => ({
|
||||
slug,
|
||||
name: bracket.name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
BracketRound: {
|
||||
name: (round) => {
|
||||
switch (round.pairings.length) {
|
||||
case 1:
|
||||
return "Finals";
|
||||
case 2:
|
||||
return "Semifinals";
|
||||
case 4:
|
||||
return "Quarterfinals";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
pairings: (round) => {
|
||||
return round.pairings
|
||||
.filter((pairing) => !pairing.filler)
|
||||
.map((pairing) => ({
|
||||
order: +pairing.order,
|
||||
group: +pairing.group,
|
||||
characterA: pairing.character1,
|
||||
characterB: pairing.character2,
|
||||
votesA: pairing.character1.votes || null,
|
||||
votesB: pairing.character2.votes || null,
|
||||
}));
|
||||
},
|
||||
},
|
||||
BracketPairing: {
|
||||
characterA: (pairing) => createBracketCharacter(pairing.characterA),
|
||||
characterB: (pairing) => createBracketCharacter(pairing.characterB),
|
||||
},
|
||||
BracketCharacter: {
|
||||
theme: createApiResolver<ApiThemeShow>()({
|
||||
extractFromParent: (character) => character.theme,
|
||||
endpoint: (character) => `/animetheme/${character.themeMapping}`,
|
||||
extractFromResponse: (response) => response.animetheme,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default resolvers;
|
||||
|
||||
function createBracketCharacter(modelCharacter: ModelBracketCharacter) {
|
||||
return {
|
||||
id: modelCharacter.id,
|
||||
seed: modelCharacter.seed,
|
||||
name: modelCharacter.name,
|
||||
source: modelCharacter.source,
|
||||
image: modelCharacter.image,
|
||||
theme: String(modelCharacter.id) in mappings ? undefined : null,
|
||||
themeMapping: mappings[String(modelCharacter.id) as keyof typeof mappings],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
bracket(slug: String!): Bracket
|
||||
bracketAll: [Bracket!]!
|
||||
}
|
||||
|
||||
type Bracket {
|
||||
slug: String!
|
||||
name: String!
|
||||
currentRound: BracketRound
|
||||
currentGroup: Int
|
||||
rounds: [BracketRound!]!
|
||||
}
|
||||
|
||||
type BracketRound {
|
||||
bracket: Bracket
|
||||
tier: Int!
|
||||
name: String
|
||||
pairings: [BracketPairing!]!
|
||||
}
|
||||
|
||||
type BracketPairing {
|
||||
round: BracketRound
|
||||
order: Int!
|
||||
group: Int!
|
||||
characterA: BracketCharacter!
|
||||
characterB: BracketCharacter!
|
||||
votesA: Int
|
||||
votesB: Int
|
||||
}
|
||||
|
||||
type BracketCharacter {
|
||||
pairing: BracketPairing
|
||||
id: Int!
|
||||
seed: Int!
|
||||
name: String!
|
||||
source: String!
|
||||
image: String!
|
||||
theme: Theme
|
||||
}
|
||||
`;
|
||||
|
||||
export default typeDefs;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user