feat: Migrated anime detail page

This commit is contained in:
Maniload
2026-01-26 21:21:05 +01:00
parent 17392046ac
commit f86b88bb01
8 changed files with 263 additions and 218 deletions
+4 -1
View File
@@ -7,7 +7,7 @@ const config: CodegenConfig = {
"src/pages/about/dump.tsx",
"src/pages/blog/index.tsx",
"src/pages/anime/index.tsx",
// "src/pages/anime/[animeSlug]/index.tsx",
"src/pages/anime/[animeSlug]/index.tsx",
"src/pages/artist/[artistSlug]/index.tsx",
"src/pages/artist/index.tsx",
"src/pages/series/index.tsx",
@@ -24,9 +24,11 @@ const config: CodegenConfig = {
"src/components/card/ArtistSummaryCard.tsx",
"src/components/card/PlaylistSummaryCard.tsx",
"src/components/card/StudioSummaryCard.tsx",
"src/components/card/ThemeDetailCard.tsx",
"src/components/card/ThemeSummaryCard.tsx",
"src/components/card/VideoSummaryCard.tsx",
"src/components/featured-theme/FeaturedTheme.tsx",
"src/components/filter/AnimeThemeFilter.tsx",
"src/components/image/MultiCoverImage.tsx",
"src/components/image/StudioCoverImage.tsx",
"src/components/menu/ThemeMenu.tsx",
@@ -44,6 +46,7 @@ const config: CodegenConfig = {
"src/components/table/ThemeTable.tsx",
"src/components/tag/ContentWarningTags.tsx",
"src/components/tag/EpisodeTag.tsx",
"src/components/tag/ThemeEntryTags.tsx",
"src/components/tag/VideoTags.tsx",
"src/components/utils/SongTitle.tsx",
"src/components/utils/SongTitleWithArtists.tsx",
+44 -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,48 @@ const StyledVideoList = styled(Row)`
}
`;
const fragments = {
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 fragments.theme>;
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
export function ThemeDetailCard({ theme: themeFragment }: ThemeDetailCardProps) {
const theme = getFragmentData(fragments.theme, themeFragment);
const { anime } = theme;
if (!anime) {
@@ -69,10 +103,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 +114,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 +125,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
}
}
}
`,
};
+20 -20
View File
@@ -1,20 +1,35 @@
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";
const fragments = {
theme: graphql(`
fragment AnimeThemeFilterTheme on AnimeTheme {
...ThemeDetailCardTheme
id
type
sequence
group {
name
slug
}
}
`),
};
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>;
themes: Array<FragmentType<typeof fragments.theme>>;
}
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
function AnimeThemeFilterInternal({ themes: themesFragment }: AnimeThemeFilterProps) {
const themes = getFragmentData(fragments.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 +131,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);
+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
}
`,
};
+15 -18
View File
@@ -1,15 +1,24 @@
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;
const fragments = {
entry: graphql(`
fragment ThemeEntryTagsEntry on AnimeThemeEntry {
...EpisodeTagEntry
...ContentWarningTagsEntry
}
`),
};
export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
interface ThemeEntryTagsProps {
entry: FragmentType<typeof fragments.entry>;
}
export function ThemeEntryTags({ entry: entryFragment }: ThemeEntryTagsProps) {
const entry = getFragmentData(fragments.entry, entryFragment);
return (
<Row style={{ "--gap": "8px", "--align-items": "baseline" }}>
<EpisodeTag entry={entry} />
@@ -17,15 +26,3 @@ export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
</Row>
);
}
ThemeEntryTags.fragments = {
entry: gql`
${EpisodeTag.fragments.entry}
${ContentWarningTags.fragments.entry}
fragment ThemeEntryTagsEntry on Entry {
...EpisodeTagEntry
...ContentWarningTagsEntry
}
`,
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+93 -98
View File
@@ -4,7 +4,6 @@ import type { GetStaticPaths, GetStaticProps } from "next";
import Link from "next/link";
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import type { ResultOf } from "@graphql-typed-document-node/core";
import type { ParsedUrlQuery } from "querystring";
import { Column } from "@/components/box/Flex";
@@ -19,15 +18,8 @@ import { SEO } from "@/components/seo/SEO";
import { Text } from "@/components/text/Text";
import { HeightTransition } from "@/components/utils/HeightTransition";
import createApolloClient from "@/graphql/createApolloClient";
import { getFragmentData, graphql } from "@/graphql/generated";
import {
either,
resourceAsComparator,
resourceSiteComparator,
seriesNameComparator,
sortTransformed,
studioNameComparator,
} from "@/utils/comparators";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import { compare, seriesNameComparator, studioNameComparator } from "@/utils/comparators";
import extractImages from "@/utils/extractImages";
import fetchStaticPaths from "@/utils/fetchStaticPaths";
import type { SharedPageProps } from "@/utils/getSharedPageProps";
@@ -42,52 +34,74 @@ const StyledList = styled.div`
text-align: center;
`;
const propsQuery = graphql(`
fragment AnimeDetailPageAnime on Anime {
slug
name
season
year
synopsis
mediaFormatLocalized
animesynonyms {
text
}
series {
nodes {
slug
name
const fragments = {
anime: graphql(`
fragment AnimeDetailPageAnime on Anime {
slug
name
season
seasonLocalized
year
synopsis
mediaFormatLocalized
animesynonyms {
text
}
}
studios {
nodes {
slug
name
}
}
resources {
edges {
node {
site
siteLocalized
link
series {
nodes {
slug
name
}
as
}
studios {
nodes {
slug
name
}
}
resources {
edges {
node {
site
siteLocalized
link
}
as
}
}
images {
nodes {
...extractImagesImage
}
}
animethemes {
...AnimeThemeFilterTheme
}
}
images {
nodes {
...extractImagesImage
}
`),
};
const propsQuery = graphql(`
query AnimeDetailPage($animeSlug: String!) {
anime(slug: $animeSlug) {
...AnimeDetailPageAnime
}
animethemes {
...ThemeDetailCardTheme
}
`);
const pathsQuery = graphql(`
query AnimeDetailPageAll {
animePagination {
data {
...AnimeDetailPageAnime
slug
}
}
}
`);
interface AnimeDetailPageProps extends SharedPageProps {
anime: ResultOf<typeof propsQuery>;
anime: FragmentType<typeof fragments.anime>;
synopsisMarkdownSource: MDXRemoteSerializeResult | null;
}
@@ -95,9 +109,11 @@ interface AnimeDetailPageParams extends ParsedUrlQuery {
animeSlug: string;
}
export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: AnimeDetailPageProps) {
export default function AnimeDetailPage({ anime: animeFragment, synopsisMarkdownSource }: AnimeDetailPageProps) {
const anime = getFragmentData(fragments.anime, animeFragment);
const [collapseSynopsis, setCollapseSynopsis] = useState(true);
const { largeCover } = extractImages(anime.images.nodes);
const { smallCover, largeCover } = extractImages(anime.images.nodes);
return (
<>
@@ -105,12 +121,12 @@ export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: Anime
<Text variant="h1">{anime.name}</Text>
<SidebarContainer>
<Column style={{ "--gap": "24px" }}>
<CoverImage resourceWithImages={anime} alt={`Cover image of ${anime.name}`} />
<CoverImage smallCover={smallCover} largeCover={largeCover} alt={`Cover image of ${anime.name}`} />
<DescriptionList>
{anime.animesynonyms.data.length ? (
{anime.animesynonyms.length ? (
<DescriptionList.Item title="Alternative Titles">
<StyledList>
{anime.animesynonyms.data.map((synonym) => (
{anime.animesynonyms.map((synonym) => (
<Text key={synonym.text}>{synonym.text}</Text>
))}
</StyledList>
@@ -122,7 +138,7 @@ export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: Anime
href={`/year/${anime.year}${anime.season ? `/${anime.season.toLowerCase()}` : ""}`}
link
>
{(anime.season ? anime.season + " " : "") + anime.year}
{(anime.seasonLocalized ? anime.seasonLocalized + " " : "") + anime.year}
</Text>
</DescriptionList.Item>
{anime.series.nodes.length ? (
@@ -153,13 +169,8 @@ export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: Anime
{anime.resources.edges.length ? (
<DescriptionList.Item title="Links">
<StyledList>
{anime.resources.edges
.sort(
sortTransformed(
either(resourceSiteComparator).or(resourceAsComparator).chain(),
(resource) => ({ ...resource.node, ...resource }),
),
)
{[...anime.resources.edges]
.sort((a, b) => compare(a.node.site, b.node.site) || compare(a.as, b.as))
.map((resource) => (
<ExternalLink key={resource.node.link} href={resource.node.link}>
{resource.node.siteLocalized}
@@ -186,10 +197,10 @@ export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: Anime
)}
<Text variant="h2">
Themes
<Text color="text-disabled"> ({anime.themes?.length || 0})</Text>
<Text color="text-disabled"> ({anime.animethemes.length || 0})</Text>
</Text>
{anime.themes?.length ? (
<AnimeThemeFilter themes={anime.themes.map((theme) => ({ ...theme, anime }))} />
{anime.animethemes.length ? (
<AnimeThemeFilter themes={anime.animethemes} />
) : (
<Text as="p">There are no themes for this anime, yet.</Text>
)}
@@ -199,44 +210,35 @@ export default function AnimeDetailPage({ anime, synopsisMarkdownSource }: Anime
);
}
const buildTimeCache: Map<string, ResultOf<typeof propsQuery>> = new Map();
const buildTimeCache: Map<string, FragmentType<typeof fragments.anime>> = new Map();
export const getStaticProps: GetStaticProps<AnimeDetailPageProps, AnimeDetailPageParams> = async ({ params }) => {
const client = createApolloClient();
let data = params ? buildTimeCache.get(params.animeSlug) : null;
let animeFragment = params ? buildTimeCache.get(params.animeSlug) : null;
if (!data) {
data =
(
await client.query({
query: graphql(`
query AnimeDetailPage($animeSlug: String!) {
animePaginator(slug: $animeSlug) {
data {
...AnimeDetailPageAnime
}
}
}
`),
variables: params,
})
).data.animes.data?.[0] ?? null;
if (!animeFragment) {
animeFragment = (
await client.query({
query: propsQuery,
variables: params,
})
).data.anime;
}
if (!data) {
if (!animeFragment) {
return {
notFound: true,
};
}
const anime = getFragmentData(fragments.anime, animeFragment);
return {
props: {
...getSharedPageProps(1),
anime: data.anime,
synopsisMarkdownSource: data.anime.synopsis
? (await serializeMarkdownSafe(data.anime.synopsis)).source
: null,
...getSharedPageProps(),
anime: animeFragment,
synopsisMarkdownSource: anime.synopsis ? (await serializeMarkdownSafe(anime.synopsis)).source : null,
},
// Revalidate after 1 hour (= 3600 seconds).
revalidate: 3600,
@@ -248,21 +250,14 @@ export const getStaticPaths: GetStaticPaths<AnimeDetailPageParams> = () => {
const client = createApolloClient();
const { data } = await client.query({
query: graphql(`
query AnimeDetailPageAll {
animePaginator {
data {
slug
...AnimeDetailPageAnime
}
}
}
`),
query: pathsQuery,
});
data.animes.data.forEach((anime) => buildTimeCache.set(anime.slug, getFragmentData(propsQuery, anime)));
for (const anime of data.animePagination.data) {
buildTimeCache.set(anime.slug, anime);
}
return data.animes.data.map((anime) => ({
return data.animePagination.data.map((anime) => ({
params: {
animeSlug: anime.slug,
},