7 Commits

Author SHA1 Message Date
Maniload b79b8811f0 refactor: Changed all fragments to use the same format 2026-03-21 04:23:31 +01:00
Maniload 37a512ad05 feat: Migrated remaining queries and cleaned up deps 2026-02-26 01:58:31 +01:00
Maniload 77c7a8d82b feat: Migrated playlist, profile and event pages 2026-02-10 00:16:50 +01:00
Maniload 732315b40b feat: Migrated home page 2026-01-26 22:06:19 +01:00
Maniload f86b88bb01 feat: Migrated anime detail page 2026-01-26 21:21:05 +01:00
Maniload 17392046ac feat: Migrated artist page 2026-01-26 20:27:24 +01:00
Maniload be322072cf feat: Started migration to GraphQL backend 2026-01-26 20:26:29 +01:00
130 changed files with 14190 additions and 11666 deletions
+8 -53
View File
@@ -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"],
},
};
+1
View File
@@ -0,0 +1 @@
schema: http://graphql.animethemes.test
+1
View File
@@ -77,6 +77,7 @@ const nextConfig: NextConfig = {
},
];
},
allowedDevOrigins: ["animethemes.test", "*.animethemes.test"],
};
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
+504 -886
View File
File diff suppressed because it is too large Load Diff
+12 -21
View File
@@ -13,16 +13,14 @@
"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",
"@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",
@@ -33,20 +31,17 @@
"@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.3.0",
"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.1.0",
"react-dom": "19.1.0",
@@ -56,15 +51,12 @@
"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",
@@ -72,12 +64,11 @@
"@types/react": "19.1.1",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"esbuild": "^0.27.1",
"eslint": "^8.57.0",
"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"
@@ -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;
+1 -1
View File
@@ -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}</>;
}
+2 -1
View File
@@ -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;
+15 -13
View File
@@ -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
}
}
`,
};
+43 -44
View File
@@ -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
}
`,
};
+73 -67
View File
@@ -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>
);
}
+18 -19
View File
@@ -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
}
}
`,
};
+35 -38
View File
@@ -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`
@@ -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
}
}
`,
};
+18 -17
View File
@@ -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
}
`,
};
+42 -53
View File
@@ -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
}
}
}
`,
};
+104 -119
View File
@@ -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";
@@ -65,43 +57,106 @@ 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}`;
@@ -122,6 +177,14 @@ 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 {...props}>
@@ -131,7 +194,7 @@ export function ThemeSummaryCard({
<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>
@@ -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);
};
+48 -60
View File
@@ -3,16 +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 { VideoMenu } from "@/components/menu/VideoMenu";
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";
@@ -54,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;
@@ -66,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}`;
@@ -109,58 +146,9 @@ export function VideoSummaryCard({
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu !== null ? (
<StyledOverlayButtons>{menu ?? <VideoMenu entry={entry} video={video} />}</StyledOverlayButtons>
) : null}
{menu !== null ? <StyledOverlayButtons>{menu}</StyledOverlayButtons> : null}
{append}
</SummaryCard>
</StyledWrapper>
);
}
export const VideoSummaryCardFragmentVideo = gql`
${createVideoSlug.fragments.video}
${VideoMenu.fragments.video}
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
...VideoMenuVideo
audio {
basename
}
}
`;
export const VideoSummaryCardFragmentEntry = gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${VideoMenu.fragments.entry}
fragment VideoSummaryCardEntry on Entry {
...createVideoSlugEntry
...VideoMenuEntry
id
theme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
}
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
`;
+32 -28
View File
@@ -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>
+53 -48
View File
@@ -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>
+51 -36
View File
@@ -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>
);
}
+182 -127
View File
@@ -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,6 +116,8 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
);
}
const playlists = data.me?.playlists ?? [];
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} entry={entry} menu={null} />
@@ -150,7 +127,13 @@ function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormPr
<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 (
@@ -154,10 +154,16 @@ function PlaylistTrackRemoveForm({
<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>
);
}
+4 -3
View File
@@ -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);
}
+51 -37
View File
@@ -3,14 +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 {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import type { FeaturedThemeEntryFragment, FeaturedThemeVideoFragment } 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";
@@ -123,15 +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 {
entry: FeaturedThemeEntryFragment;
video: FeaturedThemeVideoFragment;
entry: FragmentType<typeof FEATURED_THEME_ENTRY>;
video: FragmentType<typeof FEATURED_THEME_VIDEO>;
hasGrill?: boolean;
card?: ReactNode;
onPlay?(): void;
}
export function FeaturedTheme({ entry, video, 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);
@@ -163,15 +194,21 @@ export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: F
);
}
function FeaturedThemeBackground({ entry, video, 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(entry.theme.anime);
const { smallCover: featuredCover } = extractImages(entry.animetheme.anime.images.nodes);
const videoSlug = createVideoSlug(entry.theme, entry, video);
const videoSlug = createVideoSlug(entry.animetheme, entry, video);
const href = `/anime/${entry.theme.anime.slug}/${videoSlug}`;
const href = `/anime/${entry.animetheme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
@@ -191,26 +228,3 @@ function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
return null;
}
FeaturedTheme.fragments = {
entry: gql`
${VideoSummaryCardFragmentEntry}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeEntry on Entry {
...VideoSummaryCardEntry
theme {
anime {
...extractImagesResourceWithImages
}
}
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
fragment FeaturedThemeVideo on Video {
...VideoSummaryCardVideo
}
`,
};
+18 -20
View File
@@ -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);
+21 -24
View File
@@ -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 { HomePageMostPopularQuery } from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client/index";
import { client } from "@/graphql/client";
import { graphql } from "@/graphql/generated";
export function MostPopularEntries() {
const { data: mostPopular } = useQuery<HomePageMostPopularQuery["entryAll"] | Array<null>>({
const { data: mostPopular = range(10).map(() => null) } = useQuery({
queryKey: ["HomePageTrending"],
queryFn: async () => {
const { data } = await fetchDataClient<HomePageMostPopularQuery>(gql`
${VideoSummaryCardFragmentEntry}
${VideoSummaryCardFragmentVideo}
query HomePageMostPopular {
entryAll(orderBy: "tracks_count", orderDesc: true, limit: 10) {
...VideoSummaryCardEntry
videos {
...VideoSummaryCardVideo
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.entryAll;
return data.animethemeentryPagination.data;
},
placeholderData: range(10).map(() => null),
});
return (
<Column style={{ "--gap": "16px" }}>
{mostPopular?.map((entry, index) => (
{mostPopular.map((entry, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{entry ? <VideoSummaryCard video={entry.videos[0]} entry={entry} /> : null}
{entry ? <VideoSummaryCard video={entry.videos.nodes[0]} entry={entry} /> : null}
</Skeleton>
))}
</Column>
+16 -17
View File
@@ -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>
+21 -24
View File
@@ -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>
+3 -18
View File
@@ -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
}
`,
};
+22 -106
View File
@@ -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
}
`,
};
+12 -13
View File
@@ -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
}
`,
};
+32 -25
View File
@@ -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
}
}
}
`,
};
+1 -1
View File
@@ -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]/)) {
+59 -63
View File
@@ -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
}
}
}
}
`,
};
+49 -52
View File
@@ -1,25 +1,66 @@
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 { VideoMenuEntryFragment, VideoMenuVideoFragment } from "@/generated/graphql";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
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: VideoMenuEntryFragment;
video: VideoMenuVideoFragment;
entry: FragmentType<typeof VIDEO_MENU_ENTRY>;
video: FragmentType<typeof VIDEO_MENU_VIDEO>;
}
export function VideoMenu({ entry, video }: VideoMenuProps) {
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 (
@@ -57,47 +98,3 @@ export function VideoMenu({ entry, video }: VideoMenuProps) {
</Menu>
);
}
VideoMenu.fragments = {
entry: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
fragment VideoMenuEntry on Entry {
...createVideoSlugEntry
id
theme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
}
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
`,
video: gql`
${createVideoSlug.fragments.video}
fragment VideoMenuVideo on Video {
...createVideoSlugVideo
id
basename
audio {
basename
}
}
`,
};
+2 -2
View File
@@ -85,9 +85,9 @@ export function Navigation() {
asChild
variant="silent"
icon={
me.user ? (
me ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me.user} />
<StyledProfileImage user={me} />
</StyledProfileImageIcon>
) : (
faUser
+31 -8
View File
@@ -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>
+29 -5
View File
@@ -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>
);
+118 -59
View File
@@ -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>
</>
);
}
+89 -48
View File
@@ -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>
</>
);
}
+64 -63
View File
@@ -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 &quot;{searchQuery}&quot;. 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 &quot;{searchArgs.query}&quot;. 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>
)}
</>
);
}
+53 -52
View File
@@ -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} />
)}
/>
</>
+89 -47
View File
@@ -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>
</>
);
}
+96 -50
View File
@@ -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>
</>
);
}
+89 -52
View File
@@ -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>
</>
);
}
+96 -64
View File
@@ -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>
</>
);
}
+38 -43
View File
@@ -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
}
}
`,
};
+12 -13
View File
@@ -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
}
`,
};
+11 -12
View File
@@ -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
}
`,
};
+14 -19
View File
@@ -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
}
`,
};
+18 -20
View File
@@ -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>
);
}
-24
View File
@@ -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>
);
}
+12 -14
View File
@@ -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
}
`,
};
+28 -27
View File
@@ -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
}
}
}
`,
};
+4 -9
View File
@@ -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>
);
}
+48 -31
View File
@@ -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
}
`,
};
+10 -13
View File
@@ -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
}
`,
};
+25 -25
View File
@@ -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
}
`,
};
+46 -13
View File
@@ -2,6 +2,8 @@ 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,
@@ -17,7 +19,7 @@ import {
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";
@@ -26,9 +28,38 @@ import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
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>;
@@ -56,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);
@@ -89,7 +122,7 @@ export function VideoPlayer({ watchListItem, background, children, overlay, ...p
const router = useRouter();
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
const [muted, setMuted] = useSetting(Muted);
const { smallCover, largeCover } = extractImages(anime);
const { smallCover, largeCover } = extractImages(anime.images.nodes);
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
const { addToHistory } = useWatchHistory();
@@ -97,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 =
@@ -120,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 =
+47 -14
View File
@@ -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,4 +1,4 @@
import { Fragment, useContext } from "react";
import { Fragment, useContext, useEffect, useState } from "react";
import styled from "styled-components";
import Link from "next/link";
@@ -29,7 +29,7 @@ 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";
@@ -87,15 +87,16 @@ const StyledKeyHintSpace = styled(StyledKeyHint)`
width: 96px;
`;
export function VideoPlayerOverlay({ anime, themeIndex, entryIndex, videoIndex }: VideoPageProps) {
const theme = anime.themes[themeIndex];
const entry = theme.entries[entryIndex];
const video = entry.videos[videoIndex];
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 = /iPhone|iPad|iPod/.test(navigator.userAgent);
const isPIPSupported = typeof document !== "undefined" && document.pictureInPictureEnabled;
const [isIOS, setIOS] = useState(false);
const [isPIPSupported, setPIPSupported] = useState(false);
const context = useContext(VideoPlayerContext);
const [audioMode] = useSetting(AudioMode, { storageSync: false });
@@ -105,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;
@@ -120,14 +121,19 @@ 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" />
+15 -17
View File
@@ -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
}
}
`,
};
+15 -7
View File
@@ -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
+24
View File
@@ -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(),
});
+36
View File
@@ -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;
+87
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export * from "./fragment-masking";
export * from "./gql";
+67 -31
View File
@@ -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,
+9 -8
View File
@@ -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,
});
-55
View File
@@ -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,
};
}
+6 -4
View File
@@ -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) {
+3 -2
View File
@@ -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;
}
-13
View File
@@ -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>;
}
-14
View File
@@ -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);
-26
View File
@@ -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);
}
+16 -4
View File
@@ -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;
}
+40 -49
View File
@@ -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;
}
-245
View File
@@ -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,
};
}
-537
View File
@@ -1,537 +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_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"],
Entry: [
"animetheme.group",
"animetheme.anime.images",
"animetheme.song.artists",
"videos.audio",
],
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";
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;
}
-468
View File
@@ -1,468 +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,
ApiEntryIndex,
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,
}),
entryAll: createApiResolverPaginated<ApiEntryIndex>()({
endpoint: (_, { orderBy, orderDesc }) => `/animethemeentry?sort=${orderDesc ? "-" : ""}${orderBy}`,
extractFromResponse: (response) => response.animethemeentries,
}),
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;
-313
View File
@@ -1,313 +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!]!
entryAll(limit: Int, orderBy: String, orderDesc: Boolean): [Entry!]!
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;
-51
View File
@@ -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;
}
+158
View File
@@ -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,
};
});
}
-169
View File
@@ -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;
}
-45
View File
@@ -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;
-15
View File
@@ -1,15 +0,0 @@
import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { buildFetchData } from "@/lib/common";
import resolversAnimeThemes from "@/lib/common/animethemes/resolvers";
import typeDefsAnimeThemes from "@/lib/common/animethemes/type-defs";
import resolversAnimeBracket from "@/lib/server/animebracket/resolvers";
import typeDefsAnimeBracket from "@/lib/server/animebracket/type-defs";
export const schema = makeExecutableSchema({
typeDefs: mergeTypeDefs([typeDefsAnimeThemes, typeDefsAnimeBracket]),
resolvers: mergeResolvers([resolversAnimeThemes, resolversAnimeBracket]),
});
export const fetchData = buildFetchData(schema);
+53 -39
View File
@@ -4,7 +4,6 @@ import Link from "next/link";
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import type { ParsedUrlQuery } from "querystring";
import { Icon } from "@/components/icon/Icon";
@@ -12,15 +11,14 @@ import { Markdown } from "@/components/markdown/Markdown";
import { TableOfContents } from "@/components/markdown/TableOfContents";
import { SEO } from "@/components/seo/SEO";
import { Text } from "@/components/text/Text";
import type { DocumentPageAllQuery, DocumentPageQuery, DocumentPageQueryVariables } from "@/generated/graphql";
import { fetchData } from "@/lib/server";
import createApolloClient from "@/graphql/createApolloClient";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import theme from "@/theme";
import fetchStaticPaths from "@/utils/fetchStaticPaths";
import type { SharedPageProps } from "@/utils/getSharedPageProps";
import getSharedPageProps from "@/utils/getSharedPageProps";
import type { Heading } from "@/utils/rehypeExtractHeadings";
import { serializeMarkdownSafe } from "@/utils/serializeMarkdown";
import type { RequiredNonNullable } from "@/utils/types";
const StyledGrid = styled.div`
display: flex;
@@ -47,27 +45,53 @@ const ArrowLink = styled(Link)`
gap: 8px;
`;
export const DOCUMENT_PAGE_PAGE = graphql(`
fragment DocumentPagePage on Page {
name
createdAt
}
`);
const propsQuery = graphql(`
query DocumentPage($pageSlug: String!) {
page(slug: $pageSlug) {
...DocumentPagePage
body
}
}
`);
const pathsQuery = graphql(`
query DocumentPageAll {
pagePagination {
data {
slug
}
}
}
`);
export interface DocumentPageProps extends SharedPageProps {
page: Omit<RequiredNonNullable<DocumentPageQuery>["page"], "body"> & {
source: MDXRemoteSerializeResult;
headings: Heading[];
};
page: FragmentType<typeof DOCUMENT_PAGE_PAGE>;
source: MDXRemoteSerializeResult;
headings: Heading[];
}
interface DocumentPageParams extends ParsedUrlQuery {
pageSlug: Array<string>;
}
export default function DocumentPage({ page }: DocumentPageProps) {
const author = typeof page.source.frontmatter?.author === "string" ? page.source.frontmatter.author : undefined;
export default function DocumentPage({ page: pageFragment, source, headings }: DocumentPageProps) {
const page = getFragmentData(DOCUMENT_PAGE_PAGE, pageFragment);
const author = typeof source.frontmatter?.author === "string" ? source.frontmatter.author : undefined;
return (
<>
<PageHeader title={page.name} author={author} createdAt={page.created_at} />
<PageHeader title={page.name} author={author} createdAt={page.createdAt} />
<StyledGrid>
<SEO title={page.name} />
<Markdown source={page.source} />
<TableOfContents headings={page.headings} />
<Markdown source={source} />
<TableOfContents headings={headings} />
</StyledGrid>
<ArrowLink href="/wiki">
<Icon icon={faArrowLeft} color="text-disabled" />
@@ -102,20 +126,14 @@ function PageHeader({ title, author, createdAt }: PageHeaderProps) {
}
export const getStaticProps: GetStaticProps<DocumentPageProps, DocumentPageParams> = async ({ params }) => {
const { data, apiRequests } = await fetchData<DocumentPageQuery, DocumentPageQueryVariables>(
gql`
query DocumentPage($pageSlug: String!) {
page(slug: $pageSlug) {
name
body
created_at
}
}
`,
params && {
const client = createApolloClient();
const { data } = await client.query({
query: propsQuery,
variables: params && {
pageSlug: params.pageSlug.join("/"),
},
);
});
if (!data.page) {
return {
@@ -125,11 +143,9 @@ export const getStaticProps: GetStaticProps<DocumentPageProps, DocumentPageParam
return {
props: {
...getSharedPageProps(apiRequests),
page: {
...data.page,
...(await serializeMarkdownSafe(data.page.body)),
},
...getSharedPageProps(),
page: data.page,
...(await serializeMarkdownSafe(data.page.body)),
},
// Revalidate after 1 hour (= 3600 seconds).
revalidate: 3600,
@@ -137,16 +153,14 @@ export const getStaticProps: GetStaticProps<DocumentPageProps, DocumentPageParam
};
export const getStaticPaths: GetStaticPaths<DocumentPageParams> = () => {
return fetchStaticPaths(async () => {
const { data } = await fetchData<DocumentPageAllQuery>(gql`
query DocumentPageAll {
pageAll {
slug
}
}
`);
const client = createApolloClient();
return data.pageAll.map((page) => ({
return fetchStaticPaths(async () => {
const { data } = await client.query({
query: pathsQuery,
});
return data.pagePagination.data.map((page) => ({
params: {
pageSlug: page.slug.split("/"),
},
+44 -33
View File
@@ -5,6 +5,7 @@ import type { AppProps } from "next/app";
import Head from "next/head";
import { useRouter } from "next/router";
import { ApolloProvider } from "@apollo/client";
import { config } from "@fortawesome/fontawesome-svg-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFullscreen } from "ahooks";
@@ -32,13 +33,17 @@ import FullscreenContext from "@/context/fullscreenContext";
import type { WatchListItem } from "@/context/playerContext";
import PlayerContext, { createWatchListItem } from "@/context/playerContext";
import { ToastProvider } from "@/context/toastContext";
import { client } from "@/graphql/client";
import useColorTheme from "@/hooks/useColorTheme";
import type { VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
import { getAnimeFromVideoPageFragment, type VideoPageProps } from "@/pages/anime/[animeSlug]/[videoSlug]";
import type { YearDetailPageProps } from "@/pages/year/[year]";
import type { SeasonDetailPageProps } from "@/pages/year/[year]/[season]";
import GlobalStyle from "@/styles/global";
import theme from "@/theme";
import { either, sortTransformed, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
import { STAGING } from "@/utils/config";
import { getVideoSlugByProps, getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
import type { SharedPageProps } from "@/utils/getSharedPageProps";
import withBasePath from "@/utils/withBasePath";
import "@fortawesome/fontawesome-svg-core/styles.css";
@@ -68,32 +73,35 @@ const StyledFullWidthContainer = styled(Container)`
max-width: none;
`;
// TODO: Add proper type checking, also extract layout modes out of _app.tsx
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function MyApp({ Component, pageProps }: AppProps) {
type PageProps = SharedPageProps & {
isFullWidthPage?: boolean;
isSearch?: boolean;
} & ({ isVideoPage?: never } | VideoPageProps) &
({ isYearOrSeasonPage?: never } | (YearDetailPageProps | SeasonDetailPageProps));
export default function MyApp({ Component, pageProps }: AppProps<PageProps>) {
const router = useRouter();
const [colorTheme, setColorTheme] = useColorTheme();
const { lastBuildAt, apiRequests, isVideoPage = false, isFullWidthPage = false } = pageProps;
const [watchList, setWatchList] = useState<WatchListItem[]>(() => {
if (isVideoPage) {
if (pageProps.isVideoPage) {
return createDefaultWatchList(pageProps);
}
return [];
});
const [watchListFactory, setWatchListFactory] = useState<(() => Promise<WatchListItem[]>) | null>(null);
const [currentWatchListItemId, setCurrentWatchListItemId] = useState<number | null>(() => {
if (watchList.length) {
const { anime, themeIndex, entryIndex, videoIndex }: VideoPageProps = pageProps;
const video = anime.themes[themeIndex].entries[entryIndex].videos[videoIndex];
if (pageProps.isVideoPage && watchList.length) {
const { anime: animeFragment, themeIndex, entryIndex, videoIndex } = pageProps;
const anime = getAnimeFromVideoPageFragment(animeFragment);
const video = anime.animethemes[themeIndex].animethemeentries[entryIndex].videos.nodes[videoIndex];
return watchList.find((item) => item.video.id === video.id)?.watchListId ?? null;
}
return null;
});
const currentWatchListItem = watchList.find((item) => item.watchListId === currentWatchListItemId) ?? null;
const [isWaitingForVideoPage, setWaitingForVideoPage] = useState(!isVideoPage);
const [isWaitingForVideoPage, setWaitingForVideoPage] = useState(!pageProps.isVideoPage);
const [isGlobalAutoPlay, setGlobalAutoPlay] = useLocalStorageState("auto-play", { defaultValue: false });
const [isLocalAutoPlay, setLocalAutoPlay] = useState(true);
@@ -105,7 +113,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
const [isFullscreen, { toggleFullscreen : nativeToggleFullscreen }] = useFullscreen(() => document.documentElement, {
const [isFullscreen, { toggleFullscreen: nativeToggleFullscreen }] = useFullscreen(() => document.documentElement, {
onEnter() {
document.documentElement.dataset.fullscreen = "true";
},
@@ -127,10 +135,10 @@ export default function MyApp({ Component, pageProps }: AppProps) {
}, [isIOS, nativeToggleFullscreen]);
useEffect(() => {
if (!isVideoPage && isFullscreen) {
if (!pageProps.isVideoPage && isFullscreen) {
toggleFullscreen();
}
}, [isFullscreen, isVideoPage, toggleFullscreen]);
}, [isFullscreen, pageProps.isVideoPage, toggleFullscreen]);
useEffect(() => {
const hotkeyListener = (event: KeyboardEvent) => {
@@ -153,7 +161,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
// Prevent the video player from opening in the background while the video page is still loading.
// This should only happen if the video player is opening for the first time and not on
// subsequent page transitions.
if (isVideoPage && currentWatchListItem && isWaitingForVideoPage) {
if (pageProps.isVideoPage && currentWatchListItem && isWaitingForVideoPage) {
setWaitingForVideoPage(false);
return null;
@@ -163,12 +171,13 @@ export default function MyApp({ Component, pageProps }: AppProps) {
return null;
}
if (isVideoPage && currentVideoSlug !== previousVideoSlug) {
if (pageProps.isVideoPage && currentVideoSlug !== previousVideoSlug) {
setPreviousVideoSlug(currentVideoSlug);
const watchListVideoSlug = currentWatchListItem ? getVideoSlugByWatchListItem(currentWatchListItem) : null;
if (currentVideoSlug !== watchListVideoSlug) {
const { anime, themeIndex, entryIndex, videoIndex }: VideoPageProps = pageProps;
const video = anime.themes[themeIndex].entries[entryIndex].videos[videoIndex];
const { anime: animeFragment, themeIndex, entryIndex, videoIndex } = pageProps;
const anime = getAnimeFromVideoPageFragment(animeFragment);
const video = anime.animethemes[themeIndex].animethemeentries[entryIndex].videos.nodes[videoIndex];
const watchList: WatchListItem[] = createDefaultWatchList(pageProps);
setWatchList(watchList);
@@ -180,7 +189,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
return null;
}
const Container = isFullWidthPage ? StyledFullWidthContainer : StyledContainer;
const Container = pageProps.isFullWidthPage ? StyledFullWidthContainer : StyledContainer;
return (
<MultiContextProvider
@@ -244,6 +253,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
},
}),
stackContext(QueryClientProvider, { client: queryClient }),
stackContext(ApolloProvider, { client }),
stackContext(ToastProvider, {}),
stackContext(LazyMotion, { features: () => import("utils/motionFeatures").then((res) => res.default) }),
stackContext(ErrorBoundary, {}),
@@ -260,11 +270,11 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<meta name="theme-color" content="#1c1823" />
</Head>
<StyledWrapper>
{!isFullWidthPage ? <Navigation /> : null}
{!isVideoPage ? (
{!pageProps.isFullWidthPage ? <Navigation /> : null}
{!pageProps.isVideoPage ? (
<>
<Container>
{!!pageProps.year && (
{pageProps.isYearOrSeasonPage && (
<Column style={{ "--gap": "16px" }}>
<YearNavigation {...pageProps} />
<SeasonNavigation {...pageProps} />
@@ -285,7 +295,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
</Card>
) : null}
<Component {...pageProps} />
{lastBuildAt && <PageRevalidation lastBuildAt={lastBuildAt} apiRequests={apiRequests} />}
{pageProps.lastBuildAt && <PageRevalidation lastBuildAt={pageProps.lastBuildAt} />}
</Container>
<Footer />
</>
@@ -293,10 +303,10 @@ export default function MyApp({ Component, pageProps }: AppProps) {
{currentWatchListItem && !isWaitingForVideoPage && (
<VideoPlayer
watchListItem={currentWatchListItem}
background={!isVideoPage}
overlay={isVideoPage ? <VideoPlayerOverlay {...pageProps} /> : null}
background={!pageProps.isVideoPage}
overlay={pageProps.isVideoPage ? <VideoPlayerOverlay {...pageProps} /> : null}
>
{isVideoPage ? <Component {...pageProps} /> : null}
{pageProps.isVideoPage ? <Component {...pageProps} /> : null}
</VideoPlayer>
)}
</StyledWrapper>
@@ -307,9 +317,9 @@ export default function MyApp({ Component, pageProps }: AppProps) {
type StackContext = (children: ReactNode) => ReactNode;
function stackContext<P>(Provider: ComponentType<P>, props: P): StackContext {
function stackContext<P>(Provider: ComponentType<P>, props: Omit<P, "children">): StackContext {
return function StackContext(children) {
return <Provider {...props}>{children}</Provider>;
return <Provider {...(props as P)}>{children}</Provider>;
};
}
@@ -325,14 +335,15 @@ function MultiContextProvider({ providers = [], children }: MultiContextProvider
}
function createDefaultWatchList(pageProps: VideoPageProps): WatchListItem[] {
const { anime, themeIndex, entryIndex, videoIndex }: VideoPageProps = pageProps;
const { anime: animeFragment, themeIndex, entryIndex, videoIndex } = pageProps;
const anime = getAnimeFromVideoPageFragment(animeFragment);
return anime.themes
return anime.animethemes
.flatMap((theme, index) => {
const entry = themeIndex == index ? theme.entries[entryIndex] : theme.entries[0];
const video = themeIndex == index ? entry?.videos[videoIndex] : entry?.videos[0];
const entry = themeIndex == index ? theme.animethemeentries[entryIndex] : theme.animethemeentries[0];
const video = themeIndex == index ? entry?.videos.nodes[videoIndex] : entry?.videos.nodes[0];
if (!entry || !video || theme.group?.slug !== anime.themes[themeIndex].group?.slug) {
if (!entry || !video || theme.group?.slug !== anime.animethemes[themeIndex].group?.slug) {
return [];
}
+1 -5
View File
@@ -2,8 +2,6 @@ import { ServerStyleSheet } from "styled-components";
import type { DocumentContext } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
import { API_URL } from "@/lib/common/animethemes/api";
const ThemeInjection = () => {
// language=JavaScript
const injectTheme = `
@@ -25,9 +23,7 @@ export default class MyDocument extends Document {
render() {
return (
<Html lang="en" data-theme="system">
<Head>
<link rel="preload" href={`${API_URL}/me`} as="fetch" crossOrigin="use-credentials" />
</Head>
<Head />
<body>
<ThemeInjection />
<Main />
+45 -31
View File
@@ -2,25 +2,37 @@ import type { GetStaticProps } from "next";
import Link from "next/link";
import { faDownload } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { Column } from "@/components/box/Flex";
import { IconTextButton } from "@/components/button/IconTextButton";
import { SummaryCard } from "@/components/card/SummaryCard";
import { SEO } from "@/components/seo/SEO";
import { Text } from "@/components/text/Text";
import type { DumpIndexPageQuery } from "@/generated/graphql";
import { fetchData } from "@/lib/server";
import createApolloClient from "@/graphql/createApolloClient";
import { graphql } from "@/graphql/generated";
import { sortTransformed } from "@/utils/comparators";
import type { SharedPageProps } from "@/utils/getSharedPageProps";
import getSharedPageProps from "@/utils/getSharedPageProps";
import type { Comparator } from "@/utils/types";
interface DumpIndexPageProps extends SharedPageProps, DumpIndexPageQuery {}
const propsQuery = graphql(`
query DumpIndexPage {
dumpPagination {
data {
path
link
createdAt
}
}
}
`);
const sortByDateDesc: Comparator<DumpIndexPageProps["dumpAll"][number]> = (a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
interface DumpIndexPageProps extends SharedPageProps, ResultOf<typeof propsQuery> {}
export default function DumpIndexPage({ dumpAll }: DumpIndexPageProps) {
const sortByDateDesc: Comparator<Date> = (a, b) => b.getTime() - a.getTime();
export default function DumpIndexPage({ dumpPagination }: DumpIndexPageProps) {
return (
<>
<SEO title="Database Dumps" description="Download dumps of the AnimeThemes.moe database." />
@@ -46,37 +58,39 @@ export default function DumpIndexPage({ dumpAll }: DumpIndexPageProps) {
</ul>
</Column>
<Text variant="h2">Available dumps</Text>
{dumpAll.sort(sortByDateDesc).map((dump) => (
<SummaryCard
key={dump.path}
title={dump.path}
description={`Created at: ${new Date(dump.created_at).toLocaleString()}`}
>
<IconTextButton asChild variant="solid" icon={faDownload} collapsible>
<a href={dump.link} download>
Download
</a>
</IconTextButton>
</SummaryCard>
))}
{dumpPagination.data
.sort(
sortTransformed(sortByDateDesc, (dump) => (dump.createdAt ? new Date(dump.createdAt) : new Date())),
)
.map((dump) => (
<SummaryCard
key={dump.path}
title={dump.path}
description={
dump.createdAt ? `Created at: ${new Date(dump.createdAt).toLocaleString("en")}` : undefined
}
>
<IconTextButton asChild variant="solid" icon={faDownload} collapsible>
<a href={dump.link} download>
Download
</a>
</IconTextButton>
</SummaryCard>
))}
</>
);
}
export const getStaticProps: GetStaticProps<DumpIndexPageProps> = async () => {
const { data, apiRequests } = await fetchData<DumpIndexPageQuery>(gql`
query DumpIndexPage {
dumpAll {
path
link
created_at
}
}
`);
const client = createApolloClient();
const { data } = await client.query({
query: propsQuery,
});
const props: DumpIndexPageProps = {
...getSharedPageProps(apiRequests),
dumpAll: data.dumpAll,
...getSharedPageProps(),
...data,
};
return {

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