feat: Replaced Markdown implementation with MDX (#164)

* Replaced announcement toast with MDX component on the home page.
* Added special anime award announcement button.
* Fixed size of corner icons.
* Removed design page.
This commit is contained in:
Mani
2023-02-25 04:37:13 +01:00
committed by GitHub
parent de45d719bc
commit 753c8d01d0
26 changed files with 3345 additions and 1333 deletions
+2876 -282
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -26,19 +26,23 @@
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"marked": "^4.0.12",
"next": "^12.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"prismjs": "^1.27.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-query": "^3.29.0",
"rehype-parse": "^8.0.4",
"rehype-react": "^7.0.4",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"unified": "^10.1.2",
"unist-util-visit": "^4.1.2",
"use-local-storage-state": "^17.2.0",
"use-session-storage-state": "^17.2.0"
},
@@ -48,9 +52,7 @@
"@graphql-codegen/typescript-operations": "2.4.1",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.3",
"@types/node": "^17.0.41",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.27.1",
+7 -7
View File
@@ -1,4 +1,4 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
@@ -82,9 +82,9 @@ const SolidButton = styled(BaseButton)`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
color: ${theme.colors["text"]};
`)}
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
@@ -95,19 +95,19 @@ const SilentButton = styled(BaseButton)`
background-color: transparent;
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text"]};
`)}
`}
&:not(:hover) {
box-shadow: none;
}
${Solid} & {
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
}
`;
+13
View File
@@ -0,0 +1,13 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { Markdown } from "components/markdown/Markdown";
import { AnimeAwardsNowAvailable } from "components/event/AnimeAwardsNowAvailable";
interface AnnouncementCardProps {
announcementSource: MDXRemoteSerializeResult;
}
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
return (
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
);
}
+2 -2
View File
@@ -24,9 +24,9 @@ export const Card = styled(Solid)<{
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
`}
&:before {
@@ -0,0 +1,84 @@
import styled, { keyframes } from "styled-components";
import theme from "theme";
import Link from "next/link";
import { faArrowRight, faTrophy } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { Button } from "components/button";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
const EventButton = styled(Button)`
position: relative;
justify-content: space-between;
height: 64px;
border-radius: 8px;
gap: 8px;
overflow: hidden;
`;
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
const GradientBorder = styled.div`
position: absolute;
inset: 0;
border-radius: 8px;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
--gradient-angle: 0deg;
}
to {
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
`;
interface AnimeAwardsNowAvailableProps {
year: number;
}
export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps) {
return (
<EventButtonContainer>
<GradientBorder />
<Link href="/event/anime-awards" passHref legacyBehavior>
<EventButton forwardedAs="a">
<EventIcon icon={faTrophy} />
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
<Icon icon={faArrowRight} color="text-primary" />
</EventButton>
</Link>
</EventButtonContainer>
);
}
+2 -3
View File
@@ -2,12 +2,11 @@ import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
export const CornerIcon = styled(Icon).attrs({
size: "2x"
})`
export const CornerIcon = styled(Icon)`
position: absolute;
right: 0;
top: 0;
font-size: 32px;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
+135
View File
@@ -0,0 +1,135 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { MDXRemote } from "next-mdx-remote";
import type { ComponentPropsWithoutRef } from "react";
import { TextLink } from "components/text/TextLink";
import { Text } from "components/text";
import { Card } from "components/card";
import styled from "styled-components";
import theme from "theme";
import type { MDXComponents } from "mdx/types";
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
& h2 {
margin-bottom: 24px;
font-size: 1.2rem;
}
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3 {
margin-top: 32px;
}
& p {
margin-bottom: 16px;
}
& table {
width: 100%;
table-layout: auto;
text-align: left;
border-collapse: collapse;
}
& thead {
border-bottom: 1px solid ${theme.colors["text-muted"]};
& th {
font-weight: 600;
vertical-align: bottom;
padding-left: 8px;
padding-right: 8px;
padding-bottom: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
& tbody tr {
border-bottom: 1px solid ${theme.colors["text-disabled"]};
&:last-child {
border-bottom-width: 0;
}
}
& tbody td {
vertical-align: baseline;
padding: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
& ${Card} {
margin-bottom: 16px;
}
`;
interface MarkdownProps {
source: MDXRemoteSerializeResult;
components?: MDXComponents;
}
export function Markdown({ source, components = {} }: MarkdownProps) {
return (
<StyledMarkdown>
<MDXRemote
{...source}
components={{
a: (props: ComponentPropsWithoutRef<"a">) => {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
}
return <Text as="a" link href={href} {...props}/>;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
Card,
...components,
}}
/>
</StyledMarkdown>
);
}
@@ -0,0 +1,84 @@
import type { Heading } from "utils/rehypeExtractHeadings";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import styled from "styled-components";
import { m } from "framer-motion";
import theme from "theme";
const StyledTableOfContents = styled.ul`
position: sticky;
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
`;
const StyledTableOfContentsHeading = styled.li<{ $depth: number }>`
padding-left: ${(props) => props.$depth === 3 && "16px"};
font-size: ${(props) => props.$depth === 3 && "0.9rem"};
`;
const StyledDot = styled(m.div)`
position: absolute;
left: -16px;
width: 4px;
height: 100%;
border-radius: 4px;
background-color: ${theme.colors["text-primary"]};
`;
export function TableOfContents({ headings }: { headings: Array<Heading> }) {
const [currentSlug, setCurrentSlug] = useState<string | undefined>();
useEffect(() => {
function onScroll() {
const headings = [...document.querySelectorAll<HTMLHeadingElement>("h2, h3")];
let currentHeading = null;
for (const heading of headings) {
if (heading.offsetTop > window.scrollY + window.innerHeight) {
break;
}
currentHeading = heading;
if (heading.offsetTop > window.scrollY) {
break;
}
}
setCurrentSlug(currentHeading?.id);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import { Menu as ReachMenu, MenuButton, MenuItem, MenuItems, MenuPopover, } from "@reach/menu-button";
import { Button } from "components/button";
import theme from "theme";
@@ -46,10 +46,10 @@ const StyledMenuItem = styled(MenuItem)`
cursor: pointer;
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
`)}
`}
`;
const StyledMenuOverlay = styled(MenuPopover)`
@media (max-width: 720px) {
+6 -13
View File
@@ -1,16 +1,9 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import { LayoutGroup, m } from "framer-motion";
import theme from "theme";
import { uniqueId as createUniqueId } from "lodash-es";
import type {
ComponentPropsWithoutRef,
ComponentPropsWithRef,
ReactNode } from "react";
import {
createContext,
useContext,
useMemo
} from "react";
import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from "react";
import { createContext, useContext, useMemo } from "react";
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { withHover } from "styles/mixins";
@@ -61,10 +54,10 @@ const StyledButton = styled.button<StyledButtonProps>`
transition: color 500ms;
${withHover(css<StyledButtonProps>`
color: ${(props: StyledButtonProps) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
${withHover`
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
transition-duration: 250ms;
`)}
`}
`;
const StyledButtonBackground = styled(m.div)`
+3 -3
View File
@@ -1,4 +1,4 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import type { Property } from "csstype";
@@ -24,9 +24,9 @@ export const TableRow = styled.div`
border-bottom: 2px solid ${theme.colors["solid-on-card"]};
}
${withHover(css`
${withHover`
background-color: ${theme.colors["solid"]};
`)}
`}
`;
export const TableCell = styled.div<{ style?: { "--span"?: number } }>`
-32
View File
@@ -1,32 +0,0 @@
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import { Text } from "components/text";
Prism.manual = true;
interface HighlightProps {
children: string
block?: boolean
}
export function Highlight({ children, block = false }: HighlightProps) {
const codeHighlighted = Prism.highlight(children, Prism.languages.jsx, "jsx");
const codeBlock = (
<Text
variant="code"
block={block}
className="language-jsx"
dangerouslySetInnerHTML={{ __html: codeHighlighted }}
/>
);
if (block) {
return (
<pre>
{codeBlock}
</pre>
);
}
return codeBlock;
}
+5 -9
View File
@@ -177,6 +177,7 @@ export type Performance = {
export type Query = {
anime: Maybe<Anime>;
animeAll: Array<Anime>;
announcementAll: Array<Announcement>;
artist: Maybe<Artist>;
artistAll: Array<Artist>;
balanceAll: Array<Balance>;
@@ -602,19 +603,19 @@ export type TransparencyIndexPageQueryVariables = Exact<{ [key: string]: never;
export type TransparencyIndexPageQuery = { balanceAll: Array<{ id: number, date: string, service: string, frequency: string, usage: number, month_to_date_balance: number }>, transactionAll: Array<{ id: number, date: string, service: string, description: string, amount: number }> };
export type VideoPageAnimeFragment = { name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, script: { link: string } | null, audio: { basename: string } }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> };
export type VideoPageAnimeFragment = { name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, audio: { basename: string }, script: { link: string } | null }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> };
export type VideoPageQueryVariables = Exact<{
animeSlug: Scalars['String'];
}>;
export type VideoPageQuery = { anime: { name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, script: { link: string } | null, audio: { basename: string } }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> } | null };
export type VideoPageQuery = { anime: { name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, audio: { basename: string }, script: { link: string } | null }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> } | null };
export type VideoPageAllQueryVariables = Exact<{ [key: string]: never; }>;
export type VideoPageAllQuery = { animeAll: Array<{ name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, script: { link: string } | null, audio: { basename: string } }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> }> };
export type VideoPageAllQuery = { animeAll: Array<{ name: string, slug: string, year: number | null, season: string | null, themes: Array<{ id: number, slug: string, type: string, sequence: number | null, group: string | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } }> } | null, entries: Array<{ episodes: string | null, nsfw: boolean, spoiler: boolean, version: number | null, videos: Array<{ basename: string, filename: string, lyrics: boolean, nc: boolean, overlap: VideoOverlap, resolution: number | null, source: VideoSource | null, subbed: boolean, uncen: boolean, tags: string, entries: Array<{ theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }>, audio: { basename: string }, script: { link: string } | null }> }>, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null }>, images: Array<{ facet: string | null, link: string }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }> }> };
export type AnimeDetailPageAnimeFragment = { slug: string, name: string, season: string | null, year: number | null, synopsis: string | null, synonyms: Array<{ text: string | null }>, series: Array<{ slug: string, name: string }>, studios: Array<{ slug: string, name: string }>, resources: Array<{ site: string | null, link: string | null, as: string | null }>, themes: Array<{ slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, episodes: string | null, spoiler: boolean, nsfw: boolean, videos: Array<{ filename: string, tags: string, resolution: number | null, nc: boolean, subbed: boolean, lyrics: boolean, uncen: boolean, source: VideoSource | null, overlap: VideoOverlap }> }> }>, images: Array<{ link: string, facet: string | null }> };
@@ -661,11 +662,6 @@ export type ArtistIndexPageQueryVariables = Exact<{ [key: string]: never; }>;
export type ArtistIndexPageQuery = { artistAll: Array<{ slug: string, name: string }> };
export type DesignPageQueryVariables = Exact<{ [key: string]: never; }>;
export type DesignPageQuery = { anime: { slug: string, name: string, year: number | null, season: string | null, themes: Array<{ group: string | null }>, images: Array<{ link: string, facet: string | null }> } | null };
export type CharacterFragmentFragment = { id: number, seed: number, name: string, source: string, image: string, theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null };
export type RoundFragmentFragment = { tier: number, name: string | null, pairings: Array<{ order: number, group: number, votesA: number | null, votesB: number | null, characterA: { id: number, seed: number, name: string, source: string, image: string, theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null }, characterB: { id: number, seed: number, name: string, source: string, image: string, theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null, entries: Array<{ version: number | null, videos: Array<{ tags: string }> }> } | null } }> };
@@ -702,7 +698,7 @@ export type RecentlyAddedQuery = { recentlyAdded: Array<{ slug: string, type: st
export type HomePageQueryVariables = Exact<{ [key: string]: never; }>;
export type HomePageQuery = { featuredTheme: { theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, entries: Array<{ version: number | null, videos: Array<{ basename: string, tags: string }> }>, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } } | null };
export type HomePageQuery = { featuredTheme: { theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> } | null, entries: Array<{ version: number | null, videos: Array<{ basename: string, tags: string }> }>, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } } | null, announcementAll: Array<{ content: string }> };
export type GalleryPageQueryVariables = Exact<{ [key: string]: never; }>;
+5
View File
@@ -105,6 +105,11 @@ const resolvers: IResolvers = {
extractor: (result) => result.transactions,
pagination: true
}),
announcementAll: apiResolver({
endpoint: () => `/announcement`,
extractor: (result) => result.announcements,
pagination: true
}),
},
Year: {
seasons: apiResolver({
+1
View File
@@ -23,6 +23,7 @@ const typeDefs = `
dumpAll: [Dump!]!
balanceAll: [Balance!]!
transactionAll: [Transaction!]!
announcementAll: [Announcement!]!
}
interface ResourceWithImages {
+2 -5
View File
@@ -17,7 +17,7 @@ import withBasePath from "utils/withBasePath";
import { SEO } from "components/seo";
import { config } from "@fortawesome/fontawesome-svg-core";
import { ToastProvider } from "context/toastContext";
import { AnnouncementToast, ToastHub } from "components/toast";
import { ToastHub } from "components/toast";
import { Text } from "components/text";
import { useRouter } from "next/router";
import useSetting from "hooks/useSetting";
@@ -129,10 +129,7 @@ export default function MyApp({ Component, pageProps }: AppProps<any>) {
} }),
stackContext(QueryClientProvider, { client: queryClient }),
stackContext(ApolloProvider, { client: apolloClient, children: null }),
stackContext(ToastProvider, { initialToasts: [ {
id: "announcement",
content: <AnnouncementToast/>
} ] }),
stackContext(ToastProvider, {}),
stackContext(LazyMotion, { features: () => import("utils/motionFeatures").then(res => res.default) }),
stackContext(ErrorBoundary, {}),
]}>
+2 -2
View File
@@ -1,9 +1,9 @@
import type { GetStaticProps } from "next";
import getSharedPageProps from "utils/getSharedPageProps";
import markdownToHtml from "utils/markdownToHtml";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { DocumentPageProps } from "pages/wiki/[...pageSlug]";
import serializeMarkdown from "utils/serializeMarkdown";
export { default } from "pages/wiki/[...pageSlug]/index";
@@ -15,7 +15,7 @@ export const getStaticProps: GetStaticProps<DocumentPageProps> = async () => {
...getSharedPageProps(),
page: {
name: "Privacy Policy",
body: markdownToHtml(markdown),
...await serializeMarkdown(markdown),
}
},
};
+2 -2
View File
@@ -1,9 +1,9 @@
import type { GetStaticProps } from "next";
import getSharedPageProps from "utils/getSharedPageProps";
import markdownToHtml from "utils/markdownToHtml";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { DocumentPageProps } from "pages/wiki/[...pageSlug]";
import serializeMarkdown from "utils/serializeMarkdown";
export { default } from "pages/wiki/[...pageSlug]/index";
@@ -15,7 +15,7 @@ export const getStaticProps: GetStaticProps<DocumentPageProps> = async () => {
...getSharedPageProps(),
page: {
name: "Terms of Service",
body: markdownToHtml(markdown),
...await serializeMarkdown(markdown),
}
},
};
-685
View File
@@ -1,685 +0,0 @@
import { useContext, useState } from "react";
import { Column, Row } from "components/box";
import { AnimeSummaryCard, Card } from "components/card";
import { Text } from "components/text";
import { Button, IconTextButton, VideoButton } from "components/button";
import {
faArrowRight,
faChevronDown,
faExclamationTriangle,
faLightbulb,
faPlay,
faRocket,
faSpinner,
faStar,
faTag
} from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import styled from "styled-components";
import theme from "theme";
import { Collapse, HeightTransition } from "components/utils";
import ColorThemeContext from "context/colorThemeContext";
import { ExternalLink } from "components/external-link";
import { Switcher } from "components/switcher";
import useToggle from "hooks/useToggle";
import { Tag } from "components/tag/Tag";
import { CoverImage } from "components/image";
import { DescriptionList } from "components/description-list";
import { codeBlock } from "common-tags";
import { Listbox } from "components/listbox";
import { fetchData } from "lib/server";
import { Menu } from "components/menu";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { Input } from "components/form";
import gql from "graphql-tag";
import getSharedPageProps from "utils/getSharedPageProps";
import { Highlight } from "components/utils/Highlight";
import type { DesignPageQuery } from "generated/graphql";
import type { GetStaticProps } from "next";
import { SwitcherOption, SwitcherReset } from "components/switcher/Switcher";
import type { RequiredNonNullable } from "utils/types";
import type { Colors } from "theme/colors";
const ColorGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20ch, 1fr));
grid-gap: 16px;
justify-content: space-around;
`;
const ColorBox = styled.div<{ style: { "--color"?: string } }>`
width: 3rem;
height: 3rem;
border: 2px solid ${theme.colors["text-primary"]};
border-radius: 0.5rem;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.1);
background-color: var(--color);
`;
const ExampleGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
justify-items: flex-start;
align-items: center;
grid-gap: 32px;
`;
const ExampleFullWidth = styled.div`
grid-column: 1 / 3;
display: flex;
gap: 8px;
`;
interface DesignPageProps {
demoData: RequiredNonNullable<DesignPageQuery>
}
export default function DesignPage({ demoData }: DesignPageProps) {
const { colorTheme, setColorTheme } = useContext(ColorThemeContext);
const [moreColors, setMoreColors] = useState(false);
const colors = Object.keys(theme.colors) as Array<keyof Colors>;
const [selectedItem, setSelectedItem] = useState(null);
const [season, setSeason] = useState(null);
const [maxLines, toggleMaxLines] = useToggle(1, 0);
const [collapse, toggleCollapse] = useToggle();
const { dispatchToast, closeToast } = useToasts();
return (
<Column style={{ "--gap": "48px" }}>
<Text variant="h1">Design Documentation</Text>
<Row style={{ "--justify-content": "space-between", "--align-items": "center" }}>
<Text variant="h2">Color</Text>
<Switcher selectedItem={colorTheme} onChange={setColorTheme}>
<SwitcherOption value="system">System</SwitcherOption>
<SwitcherOption value="light">Light</SwitcherOption>
<SwitcherOption value="dark">Dark</SwitcherOption>
</Switcher>
</Row>
<HeightTransition>
<ColorGrid>
{colors.slice(0, moreColors ? colors.length : Math.min(15, colors.length)).map((color) => (
<Color key={color} color={color}/>
))}
</ColorGrid>
</HeightTransition>
<Row style={{ "--justify-content": "flex-end" }}>
<IconTextButton
icon={faChevronDown} rotation={moreColors ? 180 : undefined}
onClick={() => setMoreColors((val) => !val)}
>{moreColors ? "Show less" : "Show more"}</IconTextButton>
</Row>
<Text variant="h2">Typography</Text>
<ExampleGrid>
<Highlight>{`<Text>This is normal text.</Text>`}</Highlight>
<Text>This is normal text.</Text>
<ExampleFullWidth>
<Icon icon={faArrowRight} color="text-primary" />
<Text as="p" color="text-muted">Always wrap text in a <Highlight>{`<Text>`}</Highlight> component. This way basic styling is applied and default browser values get overridden.</Text>
</ExampleFullWidth>
<Highlight>{`<Text variant="h1">This is a page title.</Text>`}</Highlight>
<Text variant="h1">This is a page title.</Text>
<Highlight>{`<Text variant="h2">This is a section title.</Text>`}</Highlight>
<Text variant="h2">This is a section title.</Text>
<Highlight>{`<Text variant="small">This is small text.</Text>`}</Highlight>
<Text variant="small">This is small text.</Text>
<Highlight>{`<Text variant="code">This is code.</Text>`}</Highlight>
<Text variant="code">This is code.</Text>
<ExampleFullWidth>
<Icon icon={faArrowRight} color="text-primary" />
<Text as="p" color="text-muted">Use the <Text variant="code">variant</Text> prop to apply pre-defined styles to your text.</Text>
</ExampleFullWidth>
<Highlight>{`<Text maxLines={1}>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.</Text>`}</Highlight>
<Text maxLines={1}>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.</Text>
<Highlight>{`<Text maxLines={3}>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.</Text>`}</Highlight>
<Text maxLines={3}>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.</Text>
<ExampleFullWidth>
<Icon icon={faArrowRight} color="text-primary" />
<Text as="p" color="text-muted">Use the <Text variant="code">maxLines</Text> prop to add a limit to the number of lines a text should have. Overflowing text will get replaced with three dots (<Text variant="code">...</Text>)</Text>
</ExampleFullWidth>
<Highlight>{`<Text color="text">This is colored text.</Text>`}</Highlight>
<Text color="text">This is colored text.</Text>
<Highlight>{`<Text color="text-muted">This is colored text.</Text>`}</Highlight>
<Text color="text-muted">This is colored text.</Text>
<Highlight>{`<Text color="text-disabled">This is colored text.</Text>`}</Highlight>
<Text color="text-disabled">This is colored text.</Text>
<Highlight>{`<Text color="text-warning">This is colored text.</Text>`}</Highlight>
<Text color="text-warning">This is colored text.</Text>
<pre>
<Highlight block>
{codeBlock`
<ExternalLink href="https://reddit.com/r/AnimeThemes">
This is an external link.
</ExternalLink>
`}
</Highlight>
</pre>
<ExternalLink href="https://reddit.com/r/AnimeThemes">
This is an external link.
</ExternalLink>
</ExampleGrid>
<Text variant="h2">Iconography</Text>
<ExampleGrid>
<Highlight>{`<Icon icon={faRocket}/>`}</Highlight>
<Icon icon={faRocket}/>
<Highlight>{`<Icon icon={faStar} color="text-primary"/>`}</Highlight>
<Icon icon={faStar} color="text-primary"/>
<Highlight>{`<Icon icon={faSpinner} className="fa-spin"/>`}</Highlight>
<Icon icon={faSpinner} className="fa-spin"/>
</ExampleGrid>
<Text variant="h2">Tag</Text>
<ExampleGrid>
<Highlight>{`<Tag icon={faTag}>Tag</Tag>`}</Highlight>
<Tag icon={faTag}>Tag</Tag>
<pre>
<Highlight block>
{codeBlock`
<Tag icon={
<Icon icon={faExclamationTriangle} color="text-warning"/>
}>
<Text color="text-warning">Warning</Text>
</Tag>
`}
</Highlight>
</pre>
<Tag icon={
<Icon icon={faExclamationTriangle} color="text-warning"/>
}>
<Text color="text-warning">Warning</Text>
</Tag>
</ExampleGrid>
<Text variant="h2">Button</Text>
<ExampleGrid>
<Highlight>{`<Button>Button</Button>`}</Highlight>
<Button>Button</Button>
<Highlight>{`<Button variant="primary">Primary</Button>`}</Highlight>
<Button variant="primary">Primary</Button>
<Highlight>{`<Button variant="silent">Silent</Button>`}</Highlight>
<Button variant="silent">Silent</Button>
<pre>
<Highlight block>
{codeBlock`
<Card>
<Button>On Card</Button>
<Button variant="silent">On Card</Button>
</Card>
`}
</Highlight>
</pre>
<Card>
<Row style={{ "--gap": "8px" }}>
<Button>On Card</Button>
<Button variant="silent">Silent On Card</Button>
</Row>
</Card>
<pre>
<Highlight block>
{codeBlock`
<Button isCircle>
<Icon icon={faLightbulb}/>
</Button>
`}
</Highlight>
</pre>
<Button isCircle>
<Icon icon={faLightbulb}/>
</Button>
<pre>
<Highlight block>
{codeBlock`
<Button variant="primary" isCircle>
<Icon icon={faLightbulb}/>
</Button>
`}
</Highlight>
</pre>
<Button variant="primary" isCircle>
<Icon icon={faLightbulb}/>
</Button>
<pre>
<Highlight block>
{codeBlock`
<Button variant="silent" isCircle>
<Icon icon={faLightbulb}/>
</Button>
`}
</Highlight>
</pre>
<Button variant="silent" isCircle>
<Icon icon={faLightbulb}/>
</Button>
<pre>
<Highlight block>
{codeBlock`
<Button>
<Button as="span" variant="primary">
Prefixed
</Button>
Button
</Button>
`}
</Highlight>
</pre>
<Button>
<Button as="span" variant="primary">
Prefixed
</Button>
Button
</Button>
<pre>
<Highlight block>
{codeBlock`
<Button>
<Button as="span" variant="primary" isCircle>
<Icon icon={faPlay}/>
</Button>
Playing
</Button>
`}
</Highlight>
</pre>
<Button>
<Button as="span" variant="primary" isCircle>
<Icon icon={faPlay}/>
</Button>
Play
</Button>
<pre>
<Highlight block>
{codeBlock`
<VideoButton
anime={{ slug: "bakemonogatari" }}
theme={{ slug: "OP1" }}
entry={{ version: "" }}
video={{
resolution: 1080,
nc: true,
source: "BD",
overlap: "None"
}}
/>
`}
</Highlight>
</pre>
<VideoButton
anime={{ slug: "bakemonogatari" }}
theme={{ slug: "OP1" }}
entry={{ version: null }}
video={{
resolution: 1080,
nc: true,
source: "BD",
overlap: "NONE",
lyrics: false,
subbed: false,
uncen: false,
tags: "",
filename: ""
}}
/>
</ExampleGrid>
<Text variant="h2">Switcher</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const [selectedItem, setSelectedItem] = useState(null);
// Inside the render output
<Switcher selectedItem={selectedItem} onChange={setSelectedItem}>
<SwitcherReset/>
<SwitcherOption value="anime">Anime</SwitcherOption>
<SwitcherOption value="theme">Themes</SwitcherOption>
<SwitcherOption value="artists">Artists</SwitcherOption>
</Switcher>
`}
</Highlight>
</pre>
<Switcher selectedItem={selectedItem} onChange={setSelectedItem}>
<SwitcherReset/>
<SwitcherOption value="anime">Anime</SwitcherOption>
<SwitcherOption value="theme">Themes</SwitcherOption>
<SwitcherOption value="artists">Artists</SwitcherOption>
</Switcher>
</ExampleGrid>
<Text variant="h2">Card</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
<Card>
<Text>This is text inside a card.</Text>
</Card>
`}
</Highlight>
</pre>
<Card>
<Text>This is text inside a card.</Text>
</Card>
<pre>
<Highlight block>
{codeBlock`
<Card hoverable>
<Text>This is a hoverable card.</Text>
</Card>
`}
</Highlight>
</pre>
<Card hoverable>
<Text>This is a hoverable card.</Text>
</Card>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const anime = /* ... */
// Inside the render output
<AnimeSummaryCard anime={anime}/>
`}
</Highlight>
</pre>
<AnimeSummaryCard anime={demoData.anime}/>
</ExampleGrid>
<Text variant="h2">Input</Text>
<ExampleGrid>
<Highlight>
{`<Input/>`}
</Highlight>
<Input value="" onChange={() => { /* Do nothing. */ }}/>
</ExampleGrid>
<Text variant="h2">Image</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const anime = /* ... */
// Inside the render output
<div style={{ width: "200px" }}>
<CoverImage resourceWithImages={anime}/>
</div>
`}
</Highlight>
</pre>
<div style={{ width: "200px" }}>
<CoverImage resourceWithImages={demoData.anime}/>
</div>
</ExampleGrid>
<Text variant="h2">Description List</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
<DescriptionList>
<DescriptionList.Item title="Topic">
Description
</DescriptionList.Item>
<DescriptionList.Item title="Another Topic">
<ExternalLink href="https://reddit.com/r/AnimeThemes">
Link
</ExternalLink>
</DescriptionList.Item>
</DescriptionList>
`}
</Highlight>
</pre>
<DescriptionList>
<DescriptionList.Item title="Topic">
Description
</DescriptionList.Item>
<DescriptionList.Item title="Another Topic">
<ExternalLink href="https://reddit.com/r/AnimeThemes">
Link
</ExternalLink>
</DescriptionList.Item>
</DescriptionList>
</ExampleGrid>
<Text variant="h2">Experimental</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const [season, setSeason] = useState(null);
// Inside the render output
<Listbox
options={[
null,
"Winter",
"Spring",
"Summer",
"Fall"
]}
selectedValue={season}
onSelect={setSeason}
nullValue="Any"
resettable
width="150px"
/>
`}
</Highlight>
</pre>
<Listbox
value={season}
onChange={setSeason}
resettable
>
<Listbox.Option value={null} hidden>Any</Listbox.Option>
<Listbox.Option value="winter">Winter</Listbox.Option>
<Listbox.Option value="spring">Spring</Listbox.Option>
<Listbox.Option value="summer">Summer</Listbox.Option>
<Listbox.Option value="fall">Fall</Listbox.Option>
</Listbox>
<pre>
<Highlight block>
{codeBlock`
<Menu>
<Menu.Option onSelect={() => alert("1")}>Option 1</Menu.Option>
<Menu.Option onSelect={() => alert("2")}>Option 2</Menu.Option>
<Menu.Option onSelect={() => alert("3")}>Option 3</Menu.Option>
</Menu>
`}
</Highlight>
</pre>
<Menu>
<Menu.Option onSelect={() => alert("1")}>Option 1</Menu.Option>
<Menu.Option onSelect={() => alert("2")}>Option 2</Menu.Option>
<Menu.Option onSelect={() => alert("3")}>Option 3</Menu.Option>
</Menu>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const { dispatchToast, closeToast } = useToasts();
// Inside the render output
<Button onClick={() => dispatchToast(
"example-toast",
<Toast
hoverable
onClick={() => closeToast("example-toast")}
>
This is a toast.
It will close in 10 seconds or if you click on it.
</Toast>,
10_000
)}>
Show Toast
</Button>
`}
</Highlight>
</pre>
<Button onClick={() => dispatchToast(
"example-toast",
<Toast
hoverable
onClick={() => closeToast("example-toast")}
>
This is a toast.
It will close in 10 seconds or if you click on it.
</Toast>,
10_000
)}>
Show Toast
</Button>
</ExampleGrid>
<Text variant="h2">Utils</Text>
<ExampleGrid>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const [maxLines, toggleMaxLines] = useToggle(1, 0);
// Inside the render output
<Card>
<Column style={{ "--gap": "16px" }}>
<HeightTransition>
<Text maxLines={maxLines}>
Lorem ipsum dolor sit amet ...
</Text>
</HeightTransition>
<Button onClick={toggleMaxLines}>
Toggle text length
</Button>
</Column>
</Card>
`}
</Highlight>
</pre>
<Card>
<Column style={{ "--gap": "16px" }}>
<HeightTransition>
<Text maxLines={maxLines}>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
</Text>
</HeightTransition>
<Button onClick={toggleMaxLines}>
Toggle text length
</Button>
</Column>
</Card>
<pre>
<Highlight block>
{codeBlock`
// At the top of the component
const [collapse, toggleCollapse] = useToggle();
// Inside the render output
<Card>
<Column style={{ "--gap": "16px" }}>
<Collapse collapse={collapse}>
<Text>
Lorem ipsum dolor sit amet ...
</Text>
</Collapse>
<Button onClick={toggleCollapse}>
Toggle collapse
</Button>
</Column>
</Card>
`}
</Highlight>
</pre>
<Card>
<Column style={{ "--gap": "16px" }}>
<Collapse collapse={collapse}>
<Text>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
</Text>
</Collapse>
<Button onClick={toggleCollapse}>
Toggle collapse
</Button>
</Column>
</Card>
</ExampleGrid>
</Column>
);
}
interface ColorProps {
color: keyof Colors
}
function Color({ color }: ColorProps) {
return (
<Column style={{ "--align-items": "center", "--gap": "8px" }}>
<ColorBox style={{ "--color": theme.colors[color] }}/>
<Text variant="code">{color}</Text>
</Column>
);
}
export const getStaticProps: GetStaticProps<DesignPageProps> = async () => {
const { data, apiRequests } = await fetchData<DesignPageQuery>(gql`
${AnimeSummaryCard.fragments.anime}
query DesignPage {
anime(slug: "bakemonogatari") {
...AnimeSummaryCardAnime
}
}
`);
const anime = data.anime;
if (!anime) {
return {
notFound: true
};
}
return {
props: {
...getSharedPageProps(apiRequests),
demoData: { anime }
}
};
};
+17 -3
View File
@@ -19,6 +19,9 @@ import { Skeleton } from "components/skeleton";
import type { HomePageQuery, RecentlyAddedQuery } from "generated/graphql";
import type { GetStaticProps } from "next";
import { gql, useQuery } from "@apollo/client";
import { AnnouncementCard } from "components/card/AnnouncementCard";
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import serializeMarkdown from "utils/serializeMarkdown";
const BigButton = styled(Button)`
justify-content: flex-end;
@@ -84,10 +87,11 @@ const About = styled(Column)`
`;
interface HomePageProps {
featuredTheme: NonNullable<HomePageQuery["featuredTheme"]>["theme"] | null
featuredTheme: NonNullable<HomePageQuery["featuredTheme"]>["theme"] | null;
announcementSources: MDXRemoteSerializeResult[]
}
export default function HomePage({ featuredTheme }: HomePageProps) {
export default function HomePage({ featuredTheme, announcementSources }: HomePageProps) {
const { currentYear, currentSeason } = useCurrentSeason();
const { data, loading } = useQuery<RecentlyAddedQuery>(gql`
@@ -105,6 +109,10 @@ export default function HomePage({ featuredTheme }: HomePageProps) {
<SEO/>
<Text variant="h1">Welcome, to AnimeThemes.moe!</Text>
{announcementSources.length > 0 ? (
<AnnouncementCard announcementSource={announcementSources[0]} />
) : null}
{featuredTheme ? (
<>
<Text variant="h2">Featured Theme</Text>
@@ -249,13 +257,19 @@ export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
...FeaturedThemeTheme
}
}
announcementAll {
content
}
}
`);
return {
props: {
...getSharedPageProps(apiRequests),
featuredTheme: data?.featuredTheme?.theme ?? null
featuredTheme: data?.featuredTheme?.theme ?? null,
announcementSources: await Promise.all(
data?.announcementAll.map(async (announcement) => (await serializeMarkdown(announcement.content)).source)
),
}
};
};
+11 -208
View File
@@ -1,14 +1,5 @@
import { fetchData } from "lib/server";
import type { Heading, Markdown } from "utils/markdownToHtml";
import markdownToHtml from "utils/markdownToHtml";
import { unified } from "unified";
import rehypeReact from "rehype-react";
import { Text } from "components/text";
import type { ComponentPropsWithoutRef } from "react";
import { createElement, Fragment, useEffect, useState } from "react";
import rehypeParse from "rehype-parse";
import styled from "styled-components";
import { m } from "framer-motion";
import theme from "theme";
import { SEO } from "components/seo";
import fetchStaticPaths from "utils/fetchStaticPaths";
@@ -18,8 +9,12 @@ import getSharedPageProps from "utils/getSharedPageProps";
import type { GetStaticPaths, GetStaticProps } from "next";
import type { ParsedUrlQuery } from "querystring";
import type { DocumentPageAllQuery, DocumentPageQuery, DocumentPageQueryVariables } from "generated/graphql";
import { TextLink } from "components/text/TextLink";
import type { RequiredNonNullable } from "utils/types";
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import serializeMarkdown from "utils/serializeMarkdown";
import type { Heading } from "utils/rehypeExtractHeadings";
import { Markdown } from "components/markdown/Markdown";
import { TableOfContents } from "components/markdown/TableOfContents";
const StyledGrid = styled.div`
display: flex;
@@ -39,138 +34,10 @@ const StyledGrid = styled.div`
}
`;
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
& h2 {
margin-bottom: 24px;
font-size: 1.2rem;
}
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3 {
margin-top: 32px;
}
& p {
margin-bottom: 16px;
}
& table {
width: 100%;
table-layout: auto;
text-align: left;
border-collapse: collapse;
}
& thead {
border-bottom: 1px solid ${theme.colors["text-muted"]};
& th {
font-weight: 600;
vertical-align: bottom;
padding-left: 8px;
padding-right: 8px;
padding-bottom: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
& tbody tr {
border-bottom: 1px solid ${theme.colors["text-disabled"]};
&:last-child {
border-bottom-width: 0;
}
}
& tbody td {
vertical-align: baseline;
padding: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
`;
const StyledTableOfContents = styled.ul`
position: sticky;
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
`;
const StyledTableOfContentsHeading = styled.li<{ $depth: number }>`
padding-left: ${(props) => props.$depth === 3 && "16px"};
font-size: ${(props) => props.$depth === 3 && "0.9rem"};
`;
const StyledDot = styled(m.div)`
position: absolute;
left: -16px;
width: 4px;
height: 100%;
border-radius: 4px;
background-color: ${theme.colors["text-primary"]};
`;
export interface DocumentPageProps extends SharedPageProps {
page: Omit<RequiredNonNullable<DocumentPageQuery>["page"], "body"> & {
body: Markdown
source: MDXRemoteSerializeResult;
headings: Heading[];
}
}
@@ -179,79 +46,15 @@ interface DocumentPageParams extends ParsedUrlQuery {
}
export default function DocumentPage({ page }: DocumentPageProps) {
const components = unified()
.use(rehypeParse, { fragment: true })
.use(rehypeReact, {
createElement,
Fragment,
components: {
a: (props: ComponentPropsWithoutRef<"a">) => {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
}
return <Text as="a" link href={href} {...props}/>;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>
}
})
.processSync(page.body.html).result;
return (
<StyledGrid>
<SEO title={page.name}/>
<StyledMarkdown>
{components}
</StyledMarkdown>
<TableOfContents headings={page.body.headings}/>
<Markdown source={page.source} />
<TableOfContents headings={page.headings} />
</StyledGrid>
);
}
function TableOfContents({ headings }: { headings: Array<Heading> }) {
const [currentSlug, setCurrentSlug] = useState<string | undefined>();
useEffect(() => {
function onScroll() {
const headings = [...document.querySelectorAll<HTMLHeadingElement>("h2, h3")];
let currentHeading = null;
for (const heading of headings) {
if (heading.offsetTop > window.scrollY + window.innerHeight) {
break;
}
currentHeading = heading;
if (heading.offsetTop > window.scrollY) {
break;
}
}
setCurrentSlug(currentHeading?.id);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
);
}
export const getStaticProps: GetStaticProps<DocumentPageProps, DocumentPageParams> = async ({ params }) => {
const { data, apiRequests } = await fetchData<DocumentPageQuery, DocumentPageQueryVariables>(gql`
query DocumentPage($pageSlug: String!) {
@@ -275,8 +78,8 @@ export const getStaticProps: GetStaticProps<DocumentPageProps, DocumentPageParam
...getSharedPageProps(apiRequests),
page: {
...data.page,
body: markdownToHtml(data.page.body)
}
...await serializeMarkdown(data.page.body),
},
},
// Revalidate after 1 hour (= 3600 seconds).
revalidate: 3600
+5 -5
View File
@@ -14,7 +14,7 @@ export const loadingAnimation = css`
}
`;
export const withHover = (...args: Parameters<typeof css>) => css`
export const withHover = <T extends object>(...args: Parameters<typeof css<T>>) => css`
@media (hover: hover) and (pointer: fine) {
&:hover {
${css(...args)}
@@ -24,8 +24,8 @@ export const withHover = (...args: Parameters<typeof css>) => css`
export const withColorTheme = (
colorTheme: "light" | "dark",
) => (
...args: Parameters<typeof css>
) => <T extends object>(
...args: Parameters<typeof css<T>>
) => css`
[data-theme="system"] & {
@media (prefers-color-scheme: ${colorTheme}) {
@@ -40,8 +40,8 @@ export const withColorTheme = (
export const defineColorTheme = (
colorTheme: "light" | "dark",
) => (
...args: Parameters<typeof css>
) => <T extends object>(
...args: Parameters<typeof css<T>>
) => css`
&[data-theme="system"] {
@media (prefers-color-scheme: ${colorTheme}) {
-62
View File
@@ -1,62 +0,0 @@
import { marked } from "marked";
import Prism from "prismjs";
import "prismjs/components/prism-powershell";
import "prismjs/components/prism-json";
export interface Markdown {
html: string
headings: Array<Heading>
}
export interface Heading {
text: string
depth: number
slug: string
}
export default function markdownToHtml(markdown: string): Markdown {
const headings: Array<Heading> = [];
const slugger = new marked.Slugger();
const html = marked(markdown, {
highlight(code, lang) {
if (lang) {
if (lang in Prism.languages) {
return Prism.highlight(code, Prism.languages[lang], lang);
}
return `[!] Unknown syntax highlighting language: ${lang}\n${code}`;
}
return code;
},
walkTokens(token) {
if (token.type === "heading") {
// To remove links and other markdown entities from the text we join only the text parts together.
const text = joinTextDeep(token.tokens);
// Slugger keeps track of seen slugs, so we have to generate a slug for every heading.
// This way links will work in the table of contents later.
const slug = slugger.slug(text);
if (token.depth === 2 || token.depth === 3) {
headings.push({
text: text,
depth: token.depth,
slug
});
}
}
}
});
return {
html,
headings
};
}
function joinTextDeep(tokens: Array<marked.Token>): string {
return tokens.map((token) =>
"tokens" in token && token.tokens
? joinTextDeep(token.tokens)
: "text" in token ? token.text : ""
).join("");
}
+42
View File
@@ -0,0 +1,42 @@
import { hasProperty } from "hast-util-has-property";
import { headingRank } from "hast-util-heading-rank";
import { toString } from "hast-util-to-string";
import { visit } from "unist-util-visit";
import type { Root } from "hast";
export interface Heading {
text: string
depth: number
slug: string
}
interface ExtractHeadingsConfig {
minRank?: number;
maxRank?: number;
headings: Heading[];
}
export default function rehypeExtractHeadings({
minRank = 2,
maxRank = 3,
headings,
}: ExtractHeadingsConfig) {
return (tree: Root) => {
visit(tree, "element" as const, (node) => {
const rank = headingRank(node);
if (
rank &&
rank >= minRank &&
rank <= maxRank &&
hasProperty(node, "id") &&
node.properties?.id
) {
headings.push({
text: toString(node),
slug: node.properties.id.toString(),
depth: rank,
});
}
});
};
}
+29
View File
@@ -0,0 +1,29 @@
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import type { Heading } from "utils/rehypeExtractHeadings";
import rehypeExtractHeadings from "utils/rehypeExtractHeadings";
import type { Compatible } from "vfile";
import rehypePrettyCode from "rehype-pretty-code";
export default async function serializeMarkdown(markdown: Compatible) {
const headings: Heading[] = [];
const source = await serialize(markdown, {
mdxOptions: {
remarkPlugins: [
remarkGfm,
],
rehypePlugins: [
rehypeSlug,
[rehypeExtractHeadings, { headings }],
[rehypePrettyCode, { theme: "github-dark-dimmed" }],
],
},
});
return {
source,
headings,
};
}