feat: Added document pages (#108)

* ThemeSummaryCard now includes the theme group (if available).
* ThemeDetailCard no longer includes the theme group in the slug.
* FeaturedTheme now falls back to cover if video fails to load.
* Added new multi cover image component.
* Added more generic input component.
* Added back-end code for MAL and AniList integration.
* Added "Also Used As" section to video page.
* Added "Related Themes" section to video page.
* Re-organized video page.
* Re-implemented sort by filter component.
* Fixed bug where list boxes crashed the site on mobile.
* Fixed bug where switcher links would not work.
* Fixed styled-components SSR in development.
* Started incremental implementation of GraphQL fragments.
* Removed use-media as a dependency.
This commit is contained in:
Manuel S
2022-03-26 03:08:55 +01:00
committed by GitHub
parent bcbd1ffceb
commit d801654c56
58 changed files with 8764 additions and 556 deletions
+7525 -53
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -15,24 +15,33 @@
"@fortawesome/react-fontawesome": "^0.1.14",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@mapbox/rehype-prism": "^0.8.0",
"@next/bundle-analyzer": "^12.0.3",
"@reach/listbox": "^0.16.2",
"@reach/menu-button": "^0.16.2",
"common-tags": "^1.8.0",
"framer-motion": "^6.2.1",
"graphql": "^15.6.1",
"graphql-tag": "^2.12.6",
"knex": "^0.95.11",
"lodash-es": "^4.17.21",
"mysql2": "^2.3.3",
"next": "^12.1.0",
"prismjs": "^1.25.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-slug": "^5.0.1",
"rehype-stringify": "^9.0.3",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"sass": "^1.43.4",
"styled-components": "^5.3.3",
"use-debounce": "^7.0.0",
"use-media": "^1.4.0"
"unified": "^10.1.2",
"use-debounce": "^7.0.0"
},
"devDependencies": {
"eslint": "7.32.0",
+1 -1
View File
@@ -1 +1 @@
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 86.6"><title>logo</title><polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/><polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/></svg>
<svg id="Ebene_1" fill="currentColor" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 86.6"><title>logo</title><polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/><polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/></svg>

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 321 B

-20
View File
@@ -1,20 +0,0 @@
{
"name": "AnimeThemes",
"short_name": "AnimeThemes",
"icons": [
{
"src": "/wiki/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/wiki/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"start_url": "/wiki/",
"background_color": "#ffffff",
"display": "standalone"
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
import { useMedia } from "use-media";
import styled, { css } from "styled-components";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
const StyledButton = styled(Button)`
gap: 8px;
@@ -17,7 +17,7 @@ const StyledText = styled.span`
`;
export function IconTextButton({ icon, children, collapsible, ...props }) {
const isMobile = useMedia({ maxWidth: "720px" });
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const isCollapsed = collapsible && isMobile;
return (
+30 -1
View File
@@ -11,6 +11,7 @@ import styled from "styled-components";
import useToggle from "hooks/useToggle";
import { motion } from "framer-motion";
import theme from "theme";
import gql from "graphql-tag";
const StyledThemeContainerInline = styled.div`
display: flex;
@@ -110,7 +111,7 @@ function Themes({ anime, maxThemes = false }) {
const videoSlug = createVideoSlug(theme, entry, video);
return (
<Link key={theme.slug} href={`/anime/${anime.slug}/${videoSlug}`} passHref>
<Link key={theme.slug + theme.group} href={`/anime/${anime.slug}/${videoSlug}`} passHref>
<Button
as={motion.a}
layoutId={anime.slug + theme.slug}
@@ -134,3 +135,31 @@ function Themes({ anime, maxThemes = false }) {
);
});
}
AnimeSummaryCard.fragment = gql`
${useImage.fragment}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment AnimeSummaryCard_anime on Anime {
...useImage_resourceWithImages
slug
name
year
season
themes {
...createVideoSlug_theme
slug
group
type
sequence
entries {
...createVideoSlug_entry
videos {
...createVideoSlug_video
}
}
}
}
`;
+9 -7
View File
@@ -64,13 +64,15 @@ export function SummaryCard({ title, description, image, to, children, ...props
</Link>
) : title}
</Text>
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
+1 -1
View File
@@ -14,7 +14,7 @@ export function ThemeDetailCard({ theme }) {
return (
<StyledThemeCard>
<StyledRow>
<StyledSequence>{theme.slug}</StyledSequence>
<StyledSequence>{theme.type}{theme.sequence || null}</StyledSequence>
<SongTitleWithArtists song={theme.song}/>
<ThemeMenu theme={theme}/>
</StyledRow>
+43 -1
View File
@@ -5,6 +5,7 @@ import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
import { ThemeMenu } from "components/menu";
import gql from "graphql-tag";
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, ...props }) {
@@ -27,7 +28,7 @@ export function ThemeSummaryCard({ theme, artist, children, ...props }) {
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.slug}</span>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
@@ -47,3 +48,44 @@ export function ThemeSummaryCard({ theme, artist, children, ...props }) {
</SummaryCard>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${useImage.fragment}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragment}
fragment ThemeSummaryCard_theme on Theme {
...createVideoSlug_theme
...ThemeMenu_theme
slug
type
sequence
group
anime {
...useImage_resourceWithImages
slug
name
}
song {
...SongTitleWithArtists_song
}
entries {
...createVideoSlug_entry
videos {
...createVideoSlug_video
}
}
}
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCard_artist on Artist {
...SongTitleWithArtists_artist
}
`
};
+27 -5
View File
@@ -3,9 +3,10 @@ import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import useImage from "hooks/useImage";
import { useMemo } from "react";
import { useEffect, useState } from "react";
import useSetting from "hooks/useSetting";
import useCompatability from "hooks/useCompatability";
import gql from "graphql-tag";
const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
const grills = [
@@ -116,9 +117,12 @@ const Box = styled.div``;
export function FeaturedTheme({ theme }) {
const featuredVideo = theme.entries[0].videos[0];
const { smallCover: featuredCover } = useImage(theme.anime);
const grill = useMemo(() => grills[Math.floor(Math.random() * grills.length)], []);
const [ grill, setGrill ] = useState(null);
const [ featuredThemePreviewSettingValue ] = useSetting(featuredThemePreviewSetting);
const { canPlayVideo } = useCompatability({ canPlayVideo: false });
const [ fallbackToCover, setFallbackToCover ] = useState(false);
useEffect(() => setGrill(grills[Math.floor(Math.random() * grills.length)]), []);
const FeaturedThemeWrapper = featuredThemePreviewSettingValue !== "disabled"
? StyledWrapper
@@ -129,22 +133,23 @@ export function FeaturedTheme({ theme }) {
return (
<FeaturedThemeWrapper>
{featuredThemePreviewSettingValue === "video" && canPlayVideo && (
{featuredThemePreviewSettingValue === "video" && canPlayVideo && !fallbackToCover && (
<StyledOverflowHidden>
<StyledVideo
src={`${videoBaseUrl}/video/${featuredVideo.basename}`}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
/>
</StyledOverflowHidden>
)}
{featuredThemePreviewSettingValue === "cover" && (
{(featuredThemePreviewSettingValue === "cover" || fallbackToCover) && (
<StyledOverflowHidden>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
)}
{featuredThemePreviewSettingValue !== "disabled" && (
{featuredThemePreviewSettingValue !== "disabled" && grill && (
<StyledGrillContainer>
<StyledGrill src={grill}/>
</StyledGrillContainer>
@@ -153,3 +158,20 @@ export function FeaturedTheme({ theme }) {
</FeaturedThemeWrapper>
);
}
FeaturedTheme.fragment = gql`
${ThemeSummaryCard.fragments.theme}
${useImage.fragment}
fragment FeaturedTheme_theme on Theme {
...ThemeSummaryCard_theme
anime {
...useImage_resourceWithImages
}
entries {
videos {
basename
}
}
}
`;
+8
View File
@@ -0,0 +1,8 @@
export function Logo(props) {
return (
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
</svg>
);
}
+110 -7
View File
@@ -1,25 +1,124 @@
import useImage from "hooks/useImage";
import { AspectRatio } from "components/utils";
import styled from "styled-components";
import styled, { css } from "styled-components";
import theme from "theme";
import { Logo } from "components/image";
function getTranslationX(item, itemCount) {
switch (itemCount) {
case 4:
switch (item) {
case 1: return -33;
case 2: return -16.5;
case 3: return 16.5;
case 4: return 33;
}
break;
case 3:
switch (item) {
case 1: return -25;
case 2: return 0;
case 3: return 25;
}
break;
}
return 0;
}
const StyledCoverContainer = styled.div`
display: flex;
flex-direction: row;
position: relative;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
`;
const StyledCoverItemContainer = styled.div`
flex: 1;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
${(props) => props.$itemCount && css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(
0 0,
calc(100% / (${props.$itemCount} - 1)) 0,
0 100%,
0 100%
);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(
100% 0,
100% 0,
100% 100%,
calc(100% / (${props.$itemCount} - 1) * 2) 100%
);
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:not(:hover) {
transition: clip-path 500ms, z-index 1000ms;
z-index: 0;
}
`}
`;
const StyledCover = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
background-size: cover;
background-position: center;
transition: transform 500ms;
transform: translateX(var(--translate-x));
&:hover {
transition: transform 250ms;
transform: scale(1.1);
}
`;
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function MultiCoverImage({ resourcesWithImages, ...props }) {
@@ -39,8 +138,8 @@ export function MultiCoverImage({ resourcesWithImages, ...props }) {
return (
<AspectRatio ratio={2 / 3}>
<StyledCoverContainer>
{images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover}>
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
<StyledCover
loading="lazy"
src={largeCover}
@@ -50,7 +149,11 @@ export function MultiCoverImage({ resourcesWithImages, ...props }) {
{...props}
/>
</StyledCoverItemContainer>
))}
)) : (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
)}
</StyledCoverContainer>
</AspectRatio>
);
+1
View File
@@ -1,3 +1,4 @@
export { FullWidthImage } from "./FullWidthImage";
export { CoverImage } from "./CoverImage";
export { MultiCoverImage } from "./MultiCoverImage";
export { Logo } from "./Logo";
@@ -1,7 +1,7 @@
import styled from "styled-components";
import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons";
import theme from "theme";
import { Icon } from "components/icon";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
const StyledSearchInput = styled.div`
@@ -39,10 +39,12 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
}
`;
export function SearchInput({ query, setQuery, onSubmit, inputProps = {}, ...props }) {
export function Input({ value, onChange, onSubmit, resettable = false, icon = null, inputProps = {}, ...props }) {
return (
<StyledSearchInput {...props}>
<Icon icon={faSearch} color="text-disabled"/>
{icon && (
<Icon icon={icon} color="text-disabled"/>
)}
<StyledForm onSubmit={(event) => {
event.preventDefault();
if (onSubmit) {
@@ -51,15 +53,14 @@ export function SearchInput({ query, setQuery, onSubmit, inputProps = {}, ...pro
}}>
<StyledInput
type="text"
placeholder="Search"
value={query}
onChange={(e) => setQuery && setQuery(e.target.value)}
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
</StyledForm>
{!!query && (
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faTimes} onClick={() => setQuery && setQuery("")}/>
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
</StyledResetButton>
)}
</StyledSearchInput>
+1 -1
View File
@@ -1 +1 @@
export { SearchInput } from "./SearchInput";
export { Input } from "./Input";
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMedia } from "use-media";
import { ListboxCustom, ListboxNative } from "components/listbox";
import theme from "theme";
import { createContext, useContext } from "react";
import useMediaQuery from "hooks/useMediaQuery";
const ListboxContext = createContext();
@@ -21,7 +21,7 @@ export function Listbox({
disabled = false,
...props
}) {
const isMobile = useMedia({ maxWidth: theme.breakpoints.mobileMax });
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
if (isMobile) {
return (
+1 -1
View File
@@ -54,7 +54,7 @@ export function ListboxNative({ children, value, onChange, resettable, defaultVa
removeLabel: useCallback((value) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
newMap.remove(value);
newMap.delete(value);
return newMap;
});
}, [])
+12 -1
View File
@@ -3,6 +3,7 @@ import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { Text } from "components/text";
import { Menu } from "components/menu";
import { useLocalPlaylist } from "context/localPlaylistContext";
import gql from "graphql-tag";
export function ThemeMenu({ theme }) {
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
@@ -30,4 +31,14 @@ export function ThemeMenu({ theme }) {
{options}
</Menu>
);
}
}
ThemeMenu.fragment = gql`
fragment ThemeMenu_theme on Theme {
id
# Hidden inside local playlist context
song {
title
}
}
`;
+1 -7
View File
@@ -27,7 +27,6 @@ import { Text } from "components/text";
import useCurrentSeason from "hooks/useCurrentSeason";
import navigateToRandomTheme from "utils/navigateToRandomTheme";
import { useRouter } from "next/router";
import withBasePath from "utils/withBasePath";
export function Navigation({ offsetToggleButton = false }) {
const [ show, setShow ] = useState(false);
@@ -50,12 +49,7 @@ export function Navigation({ offsetToggleButton = false }) {
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref>
<StyledLogoContainer>
<StyledLogo
src={withBasePath("/img/logo.svg")}
alt="Logo"
width="277"
height="150"
/>
<StyledLogo width="277" height="150"/>
</StyledLogoContainer>
</Link>
<StyledNavigationLinks>
@@ -4,6 +4,7 @@ import { Button } from "components/button";
import theme from "theme";
import { blurOut, zoomIn } from "styles/animations";
import { Solid } from "components/box";
import { Logo } from "components/image";
export const StyledNavigation = styled(Solid).attrs({ as: "nav" })`
position: sticky;
@@ -93,13 +94,10 @@ export const StyledLogoContainer = styled.a`
}
`;
export const StyledLogo = styled.img`
export const StyledLogo = styled(Logo)`
width: auto;
height: 2rem;
[theme="dark"] & {
filter: invert();
}
color: ${theme.colors["text"]};
`;
export const StyledMobileToggle = styled(Button)`
@@ -1,13 +1,14 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Text } from "components/text";
import { SearchInput } from "components/input";
import { Input } from "components/input";
import { HorizontalScroll } from "components/utils";
import { Switcher } from "components/switcher";
import styled from "styled-components";
import theme from "theme";
import { useRouter } from "next/router";
import { capitalize } from "lodash-es";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
const StyledSearchOptions = styled.div`
display: grid;
@@ -66,13 +67,16 @@ export function SearchNavigation() {
<>
<Text variant="h1">Search</Text>
<StyledSearchOptions>
<SearchInput
query={searchQuery}
setQuery={updateSearchQuery}
<Input
value={searchQuery}
onChange={updateSearchQuery}
inputProps={{
ref: inputRef,
spellCheck: false
spellCheck: false,
placeholder: "Search"
}}
resettable
icon={faSearch}
/>
<HorizontalScroll fixShadows>
<Switcher selectedItem={entity || null}>
@@ -2,15 +2,17 @@ import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
export function SearchFilterSortBy({ options, value, setValue }) {
export function SearchFilterSortBy({ children, value, setValue }) {
return (
<SearchFilter>
<Text variant="h2">Sort by</Text>
<Listbox value={value} onChange={setValue}>
{options.map((option) => (
<Listbox.Option key={option} value={option}>{option}</Listbox.Option>
))}
{children}
</Listbox>
</SearchFilter>
);
}
SearchFilterSortBy.Option = function SearchFilterSortByOption(props) {
return <Listbox.Option {...props}/>;
};
+15 -16
View File
@@ -9,20 +9,11 @@ import { SearchEntity } from "components/search";
import { AnimeSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
const sortByFields = new Map([
[ "A ➜ Z", "name" ],
[ "Z ➜ A", "-name" ],
[ "Old ➜ New", "year,season,name" ],
[ "New ➜ Old", "-year,-season,name" ],
[ "Last Added", "-created_at" ]
]);
const sortByOptions = [ ...sortByFields.keys() ];
const initialFilter = {
firstLetter: null,
season: null,
year: null,
sortBy: sortByOptions[0]
sortBy: "name"
};
export function SearchAnime({ searchQuery }) {
@@ -34,7 +25,7 @@ export function SearchAnime({ searchQuery }) {
season: filter.season,
year: filter.year
},
sortBy: searchQuery ? null : sortByFields.get(filter.sortBy)
sortBy: searchQuery ? null : filter.sortBy
});
return (
@@ -45,11 +36,19 @@ export function SearchAnime({ searchQuery }) {
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSeason value={filter.season} setValue={updateFilter("season")}/>
<SearchFilterYear value={filter.year} setValue={updateFilter("year")}/>
<SearchFilterSortBy
options={searchQuery ? [ "Relevance" ] : sortByOptions}
value={searchQuery ? "Relevance" : filter.sortBy}
setValue={updateFilter("sortBy")}
/>
<SearchFilterSortBy value={searchQuery ? null : filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : (
<>
<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>
</>
}
renderSummaryCard={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime}/>}
+13 -14
View File
@@ -4,16 +4,9 @@ import { SearchEntity } from "components/search";
import { ArtistSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
const sortByFields = new Map([
[ "A ➜ Z", "name" ],
[ "Z ➜ A", "-name" ],
[ "Last Added", "-created_at" ]
]);
const sortByOptions = [ ...sortByFields.keys() ];
const initialFilter = {
firstLetter: null,
sortBy: sortByOptions[0]
sortBy: "name"
};
export function SearchArtist({ searchQuery }) {
@@ -23,7 +16,7 @@ export function SearchArtist({ searchQuery }) {
filters: {
"name][like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: searchQuery ? null : sortByFields.get(filter.sortBy)
sortBy: searchQuery ? null : filter.sortBy
});
return (
@@ -32,11 +25,17 @@ export function SearchArtist({ searchQuery }) {
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy
options={searchQuery ? [ "Relevance" ] : sortByOptions}
value={searchQuery ? "Relevance" : filter.sortBy}
setValue={updateFilter("sortBy")}
/>
<SearchFilterSortBy value={searchQuery ? null : filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : (
<>
<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>
</>
}
renderSummaryCard={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist}/>}
+13 -14
View File
@@ -4,16 +4,9 @@ import { SearchEntity } from "components/search";
import { SummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
const sortByFields = new Map([
[ "A ➜ Z", "name" ],
[ "Z ➜ A", "-name" ],
[ "Last Added", "-created_at" ]
]);
const sortByOptions = [ ...sortByFields.keys() ];
const initialFilter = {
firstLetter: null,
sortBy: sortByOptions[0]
sortBy: "name"
};
export function SearchSeries({ searchQuery }) {
@@ -23,7 +16,7 @@ export function SearchSeries({ searchQuery }) {
filters: {
"name][like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: searchQuery ? null : sortByFields.get(filter.sortBy)
sortBy: searchQuery ? null : filter.sortBy
});
return (
@@ -32,11 +25,17 @@ export function SearchSeries({ searchQuery }) {
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy
options={searchQuery ? [ "Relevance" ] : sortByOptions}
value={searchQuery ? "Relevance" : filter.sortBy}
setValue={updateFilter("sortBy")}
/>
<SearchFilterSortBy value={searchQuery ? null : filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : (
<>
<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>
</>
}
renderSummaryCard={(series) => <SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />}
+13 -14
View File
@@ -4,16 +4,9 @@ import { SearchEntity } from "components/search";
import { SummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
const sortByFields = new Map([
[ "A ➜ Z", "name" ],
[ "Z ➜ A", "-name" ],
[ "Last Added", "-created_at" ]
]);
const sortByOptions = [ ...sortByFields.keys() ];
const initialFilter = {
firstLetter: null,
sortBy: sortByOptions[0]
sortBy: "name"
};
export function SearchStudio({ searchQuery }) {
@@ -23,7 +16,7 @@ export function SearchStudio({ searchQuery }) {
filters: {
"name][like": filter.firstLetter ? `${filter.firstLetter}%` : null,
},
sortBy: searchQuery ? null : sortByFields.get(filter.sortBy)
sortBy: searchQuery ? null : filter.sortBy
});
return (
@@ -32,11 +25,17 @@ export function SearchStudio({ searchQuery }) {
filters={
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterSortBy
options={searchQuery ? [ "Relevance" ] : sortByOptions}
value={searchQuery ? "Relevance" : filter.sortBy}
setValue={updateFilter("sortBy")}
/>
<SearchFilterSortBy value={searchQuery ? null : filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : (
<>
<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>
</>
}
renderSummaryCard={(studio) => <SummaryCard key={studio.slug} title={studio.name} description="Studio" to={`/studio/${studio.slug}`} />}
+15 -16
View File
@@ -4,19 +4,10 @@ import { SearchEntity } from "components/search";
import { ThemeSummaryCard } from "components/card";
import useSessionStorage from "hooks/useSessionStorage";
const sortByFields = new Map([
[ "A ➜ Z", "song.title" ],
[ "Z ➜ A", "-song.title" ],
[ "Old ➜ New", "anime.year,anime.season,song.title" ],
[ "New ➜ Old", "-anime.year,-anime.season,song.title" ],
[ "Last Added", "-created_at" ]
]);
const sortByOptions = [ ...sortByFields.keys() ];
const initialFilter = {
firstLetter: null,
type: null,
sortBy: sortByOptions[0]
sortBy: "song.title"
};
export function SearchTheme({ searchQuery }) {
@@ -28,7 +19,7 @@ export function SearchTheme({ searchQuery }) {
"song][title][like": filter.firstLetter ? `${filter.firstLetter}%` : null,
type: filter.type
},
sortBy: searchQuery ? null : sortByFields.get(filter.sortBy)
sortBy: searchQuery ? null : filter.sortBy
});
return (
@@ -38,11 +29,19 @@ export function SearchTheme({ searchQuery }) {
<>
<SearchFilterFirstLetter value={filter.firstLetter} setValue={updateFilter("firstLetter")}/>
<SearchFilterThemeType value={filter.type} setValue={updateFilter("type")}/>
<SearchFilterSortBy
options={searchQuery ? [ "Relevance" ] : sortByOptions}
value={searchQuery ? "Relevance" : filter.sortBy}
setValue={updateFilter("sortBy")}
/>
<SearchFilterSortBy value={searchQuery ? null : filter.sortBy} setValue={updateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
) : (
<>
<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>
</>
}
renderSummaryCard={(theme) => <ThemeSummaryCard key={theme.anime.slug + theme.slug} theme={theme}/>}
+7 -5
View File
@@ -2,7 +2,7 @@ import styled from "styled-components";
import { LayoutGroup, motion } from "framer-motion";
import theme from "theme";
import { uniqueId as createUniqueId } from "lodash-es";
import { createContext, useContext, useMemo } from "react";
import { createContext, forwardRef, useContext, useMemo } from "react";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "components/icon";
@@ -70,7 +70,7 @@ export function Switcher({ selectedItem, onChange, children, ...props }) {
);
}
Switcher.Option = function SwitcherItem({ children, value, ...props }) {
Switcher.Option = forwardRef(function SwitcherItem({ children, value, ...props }, ref) {
const context = useContext(SwitcherContext);
const isSelected = context.selectedItem === value;
@@ -78,6 +78,7 @@ Switcher.Option = function SwitcherItem({ children, value, ...props }) {
<StyledButton
style={{ "--color": isSelected && theme.colors["text-on-primary"] }}
onClick={() => context.select(value)}
ref={ref}
{...props}
>
{isSelected && (
@@ -91,9 +92,9 @@ Switcher.Option = function SwitcherItem({ children, value, ...props }) {
<StyledTop>{children}</StyledTop>
</StyledButton>
);
};
});
Switcher.Reset = function SwitcherReset(props) {
Switcher.Reset = forwardRef(function SwitcherReset(props, ref) {
const context = useContext(SwitcherContext);
if (context.selectedItem === null) {
@@ -106,9 +107,10 @@ Switcher.Reset = function SwitcherReset(props) {
style={{ "--color": theme.colors["text-disabled"] }}
isCircle
onClick={() => context.select(null)}
ref={ref}
{...props}
>
<Icon icon={faTimes}/>
</StyledButton>
);
};
});
+1 -11
View File
@@ -1,15 +1,5 @@
import styled from "styled-components";
export const AspectRatio = styled.div`
position: relative;
height: 0;
padding-top: ${(props) => 1 / props.ratio * 100}%;
& > * {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
aspect-ratio: ${(props) => props.ratio};
`;
+27
View File
@@ -0,0 +1,27 @@
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import { Text } from "components/text";
Prism.manual = true;
export function Highlight({ children, block = false }) {
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;
}
@@ -1,6 +1,7 @@
import Link from "next/link";
import { Text } from "components/text";
import styled from "styled-components";
import gql from "graphql-tag";
const StyledArtist = styled(Text)`
&:not(:first-of-type)::before {
@@ -87,3 +88,23 @@ export function SongTitleWithArtists({ song, songTitleLinkTo, artist }) {
</Text>
);
}
SongTitleWithArtists.fragments = {
song: gql`
fragment SongTitleWithArtists_song on Song {
title
performances {
as
artist {
slug
name
}
}
}
`,
artist: gql`
fragment SongTitleWithArtists_artist on Artist {
slug
}
`
};
+1
View File
@@ -5,3 +5,4 @@ export { SongTitleWithArtists } from "./SongTitleWithArtists";
export { ThemeEntryTags } from "./ThemeEntryTags";
export { VideoTags } from "./VideoTags";
export { HorizontalScroll } from "./HorizontalScroll";
export { Highlight } from "./Highlight";
+3 -2
View File
@@ -11,7 +11,6 @@ import {
import { faDownload, faExpandAlt, faPause, faPlay, faTimes } from "@fortawesome/free-solid-svg-icons";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { useMedia } from "use-media";
import { Icon } from "components/icon";
import { IconTextButton } from "components/button";
import { Card } from "components/card";
@@ -21,6 +20,8 @@ import { Container } from "components/container";
import useCompatability from "hooks/useCompatability";
import { useRouter } from "next/router";
import { videoBaseUrl } from "lib/client/api";
import useMediaQuery from "hooks/useMediaQuery";
import styledTheme from "theme";
export function VideoPlayer({ anime, theme, entry, video, background, ...props }) {
const [isPlaying, setPlaying] = useState(false);
@@ -28,7 +29,7 @@ export function VideoPlayer({ anime, theme, entry, video, background, ...props }
const playerRef = useRef();
const progressRef = useRef();
const { clearCurrentVideo } = useContext(PlayerContext);
const isMobile = useMedia({ maxWidth: "720px" });
const isMobile = useMediaQuery(`(max-width: ${styledTheme.breakpoints.mobileMax})`);
const videoUrl = `${videoBaseUrl}/video/${video.basename}`;
const router = useRouter();
+13
View File
@@ -1,3 +1,5 @@
import gql from "graphql-tag";
export default function useImage(resourceWithImages) {
const images = {};
@@ -18,3 +20,14 @@ export default function useImage(resourceWithImages) {
return images;
}
useImage.fragment = gql`
#graphql
fragment useImage_resourceWithImages on ResourceWithImages {
images {
link
facet
}
}
`;
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
export default function useMediaQuery(query, initialIsMatching = false) {
const [ isMatching, setIsMatching ] = useState(initialIsMatching);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setIsMatching(mediaQuery.matches);
const onChange = (event) => setIsMatching(event.matches);
mediaQuery.addEventListener("change", onChange);
return () => mediaQuery.removeEventListener("change", onChange);
}, [ query ]);
return isMatching;
}
+2 -1
View File
@@ -49,7 +49,8 @@ const entityConfigs = {
"id",
"type",
"sequence",
"slug"
"slug",
"group"
],
animethemeentry: [
"version"
+117
View File
@@ -0,0 +1,117 @@
module.exports = {
Query: {
animeList: async (_, { username, site }, context) => {
if (site === "MY_ANIME_LIST") {
context.externalSite = 7;
const response = await fetch(
`https://api.myanimelist.net/v2/users/${username}/animelist?fields=list_status&limit=1000`,
{ headers: { "X-MAL-CLIENT-ID": process.env.MAL_API_KEY } }
);
const json = await response.json();
if (!json.data) {
return null;
}
const animeList = json.data;
return animeList.map(({ node, list_status }) => ({
externalId: node.id,
externalName: node.title,
externalImage: node.main_picture.medium,
listStatus: {
status: convertMyAnimeListStatusType(list_status.status),
score: list_status.score,
updatedAt: list_status.updated_at
}
}));
} else if (site === "ANI_LIST") {
context.externalSite = 3;
// language=GraphQL
const query = `
query ($username: String) {
MediaListCollection(userName: $username, type: ANIME) {
lists {
entries {
mediaId
status
score(format: POINT_10)
updatedAt
media {
title {
romaji
}
coverImage {
medium
}
}
}
}
}
}
`;
const response = await fetch(
"https://graphql.anilist.co",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
query,
variables: {
username
}
})
}
);
const json = await response.json();
if (!json.data) {
return null;
}
const animeList = json.data.MediaListCollection.lists.flatMap((list) => list.entries);
return animeList.map((entry) => ({
externalId: entry.mediaId,
externalName: entry.media.title.romaji,
externalImage: entry.media.coverImage.medium,
listStatus: {
status: convertAniListStatusType(entry.status),
score: entry.score,
updatedAt: entry.updatedAt
}
}));
}
return null;
}
}
};
function convertMyAnimeListStatusType(statusType) {
return statusType.toUpperCase();
}
function convertAniListStatusType(statusType) {
switch (statusType) {
case "CURRENT":
return "WATCHING";
case "PLANNING":
return "PLAN_TO_WATCH";
case "COMPLETED":
case "REPEATING":
return "COMPLETED";
case "DROPPED":
return "DROPPED";
case "PAUSED":
return "ON_HOLD";
default:
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
module.exports = `
#graphql
type Query {
animeList(username: String!, site: AnimeListSite!): [AnimeListEntry]
}
type AnimeListEntry {
externalId: Int!
externalName: String!
externalImage: String!
listStatus: AnimeListEntryStatus!
anime: Anime
}
type AnimeListEntryStatus {
status: AnimeListEntryStatusType!
score: Int!
updatedAt: String!
}
enum AnimeListSite {
MY_ANIME_LIST
ANI_LIST
}
enum AnimeListEntryStatusType {
WATCHING
COMPLETED
ON_HOLD
DROPPED
PLAN_TO_WATCH
}
`;
+38 -2
View File
@@ -156,6 +156,24 @@ module.exports = {
.groupBy([ "season", "year" ])
.select([ "season", "year" ])
.then((results) => results.map((anime) => ({ value: anime.season, year: { value: year } }))),
page: (_, { id, slug }) => knex("pages")
.where((builder) => {
builder.where("deleted_at", null);
if (id) {
builder.where("page_id", id);
}
if (slug) {
builder.where("slug", slug);
}
})
.first(),
pageAll: () => {
const query = knex("pages");
query.where("deleted_at", null);
return query.select();
},
counter: () => ({})
},
Year: {
@@ -258,7 +276,8 @@ module.exports = {
.innerJoin("anime_theme_entry_video", "anime_theme_entry_video.video_id", "videos.video_id")
.where("deleted_at", null)
.where({ "anime_theme_entry_video.entry_id": entry.entry_id })
.select("videos.*")
.select("videos.*"),
theme: (entry) => knex("anime_themes").where("deleted_at", null).where({ theme_id: entry.theme_id }).first(),
},
Video: {
source: (video) => videoSource.get(video.source),
@@ -283,7 +302,12 @@ module.exports = {
}
return tags.join("");
}
},
entries: (video) => knex("anime_theme_entries")
.innerJoin("anime_theme_entry_video", "anime_theme_entry_video.entry_id", "anime_theme_entries.entry_id")
.where("deleted_at", null)
.where({ "anime_theme_entry_video.video_id": video.video_id })
.select("anime_theme_entries.*")
},
Image: {
facet: (image) => imageFacet.get(image.facet),
@@ -316,5 +340,17 @@ module.exports = {
.where("deleted_at", null)
.where("theme_id", character.theme)
.first()
},
AnimeListEntry: {
anime: (animeListEntry, _, context) => knex("anime")
.innerJoin("anime_resource", "anime_resource.anime_id", "anime.anime_id")
.innerJoin("resources", "resources.resource_id", "anime_resource.resource_id")
.where({
"anime.deleted_at": null,
"resources.deleted_at": null,
"resources.site": context.externalSite,
"resources.external_id": animeListEntry.externalId
})
.first("anime.*")
}
};
+15 -2
View File
@@ -16,9 +16,15 @@ module.exports = `
yearAll: [Year]
season(year: Int!, value: String!): Season
seasonAll(year: Int): [Season]
page(id: Int, slug: String): Page
pageAll: [Page]
counter: Counter
}
interface ResourceWithImages {
images: [Image]
}
type Year {
value: Int!
seasons: [Season]
@@ -40,7 +46,7 @@ module.exports = `
season: Int!
}
type Anime {
type Anime implements ResourceWithImages {
id: Int
name: String!
slug: String!
@@ -111,7 +117,7 @@ module.exports = `
as: String
}
type Artist {
type Artist implements ResourceWithImages {
id: Int
slug: String!
name: String!
@@ -159,6 +165,13 @@ module.exports = `
id: Int
content: String!
}
type Page {
id: Int
slug: String!
name: String!
body: String!
}
schema {
query: Query
+6 -4
View File
@@ -1,20 +1,22 @@
const { graphql } = require("graphql");
const { graphql, print } = require("graphql");
const { makeExecutableSchema } = require("@graphql-tools/schema");
const { mergeTypeDefs, mergeResolvers } = require("@graphql-tools/merge");
const schema = makeExecutableSchema({
typeDefs: mergeTypeDefs([
require("lib/server/animethemes/type-defs"),
require("lib/server/animebracket/type-defs")
require("lib/server/animebracket/type-defs"),
require("lib/server/animelist/type-defs")
]),
resolvers: mergeResolvers([
require("lib/server/animethemes/resolvers"),
require("lib/server/animebracket/resolvers")
require("lib/server/animebracket/resolvers"),
require("lib/server/animelist/resolvers")
])
});
exports.fetchData = async (query, args = {}) => {
const result = await graphql(schema, query, null, null, args);
const result = await graphql(schema, typeof query === "string" ? query : print(query), null, {}, args);
if (result.errors) {
console.error(JSON.stringify(result.errors, null, 2));
+3 -3
View File
@@ -11,8 +11,6 @@ import { VideoPlayer } from "components/video-player";
import PlayerContext from "context/playerContext";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import "@fortawesome/fontawesome-svg-core/styles.css";
import "styles/prism.scss";
import Head from "next/head";
import withBasePath from "utils/withBasePath";
import { SEO } from "components/seo";
@@ -22,6 +20,9 @@ import { LocalPlaylistProvider } from "context/localPlaylistContext";
import { ToastProvider } from "context/toastContext";
import { AnnouncementToast, ToastHub } from "components/toast";
import "@fortawesome/fontawesome-svg-core/styles.css";
import "styles/prism.scss";
config.autoAddCss = false;
const queryClient = new QueryClient();
@@ -72,7 +73,6 @@ export default function MyApp({ Component, pageProps }) {
<link rel="apple-touch-icon" sizes="180x180" href={withBasePath("/apple-touch-icon.png")}/>
<link rel="icon" type="image/png" sizes="32x32" href={withBasePath("/favicon-32x32.png")}/>
<link rel="icon" type="image/png" sizes="16x16" href={withBasePath("/favicon-16x16.png")}/>
<link rel="manifest" href={withBasePath("/site.webmanifest")}/>
<link rel="mask-icon" href={withBasePath("/safari-pinned-tab.svg")} color="#ffffff"/>
<meta name="msapplication-TileColor" content="#ffffff"/>
<meta name="theme-color" content="#1c1823"/>
+18 -49
View File
@@ -1,5 +1,4 @@
import Document, { Head, Html, Main, NextScript } from "next/document";
import { ServerStyleSheet } from "styled-components";
import { Head, Html, Main, NextScript } from "next/document";
const ThemeInjection = () => {
// language=JavaScript
@@ -22,51 +21,21 @@ const ThemeInjection = () => {
);
};
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
};
} finally {
sheet.seal();
}
}
render() {
return (
<Html lang="en">
<Head>
{/* eslint-disable-next-line @next/next/google-font-display */}
<link
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=fallback"
rel="stylesheet"
/>
</Head>
<body theme="dark">
<ThemeInjection/>
<Main/>
<NextScript/>
</body>
</Html>
);
}
export default function Document() {
return (
<Html lang="en">
<Head>
{/* eslint-disable-next-line @next/next/google-font-display */}
<link
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=fallback"
rel="stylesheet"
/>
</Head>
<body theme="dark">
<ThemeInjection/>
<Main/>
<NextScript/>
</body>
</Html>
);
}
@@ -1,21 +1,22 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SongTitleWithArtists, ThemeEntryTags, VideoTags } from "components/utils";
import { Button, VideoButton } from "components/button";
import { AnimeSummaryCard, ArtistSummaryCard, SummaryCard } from "components/card";
import { AnimeSummaryCard, ArtistSummaryCard, SummaryCard, ThemeSummaryCard } from "components/card";
import useImage from "hooks/useImage";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import { videoBaseUrl } from "lib/client/api";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "components/icon";
import { useWatchHistory } from "context/watchHistoryContext";
import { useLocalPlaylist } from "context/localPlaylistContext";
import styled from "styled-components";
import theme from "theme";
import createVideoSlug from "utils/createVideoSlug";
import gql from "graphql-tag";
const StyledVideoInfo = styled.div`
display: grid;
@@ -44,18 +45,8 @@ const StyledVideoTagsInfo = styled(Row)`
const StyledRelatedGrid = styled.div`
display: grid;
grid-template-columns: 2fr 1fr;
grid-gap: 32px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
}
`;
const StyledSummaryCardGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 16px;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 16px 32px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
@@ -88,6 +79,15 @@ export default function VideoPage({ anime, theme, entry, video }) {
const { smallCover, largeCover } = useImage(anime);
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
const { addToHistory } = useWatchHistory();
const [ showMoreRelatedThemes, setShowMoreRelatedThemes ] = useState(false);
const relatedThemes = anime.themes
.filter((relatedTheme) => relatedTheme.slug !== theme.slug)
.slice(0, showMoreRelatedThemes ? undefined : 6);
const usedAlsoAs = video.entries
.map((entry) => entry.theme)
.filter((otherTheme) => otherTheme.anime.slug !== anime.slug);
useEffect(() => addToHistory({ ...theme, anime }), [ addToHistory, anime, theme ]);
@@ -188,23 +188,51 @@ export default function VideoPage({ anime, theme, entry, video }) {
</Row>
<StyledRelatedGrid>
<Column style={{ "--gap": "16px" }}>
<Text variant="h2">Related entries</Text>
<StyledSummaryCardGrid>
<Column style={{ "--gap": "16px" }}>
<AnimeSummaryCard anime={anime} hideThemes/>
{!!anime.series?.length && anime.series.map((series) => (
<SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />
<Text variant="h2">Information</Text>
<AnimeSummaryCard anime={anime} hideThemes/>
{!!anime.series?.length && anime.series.map((series) => (
<SummaryCard key={series.slug} title={series.name} description="Series" to={`/series/${series.slug}`} />
))}
{!!anime.studios?.length && anime.studios.map((studio) => (
<SummaryCard key={studio.slug} title={studio.name} description="Studio" to={`/studio/${studio.slug}`} />
))}
{!!theme.song.performances?.length && (
<>
<Text variant="h2">Artists</Text>
{theme.song.performances.map((performance) => (
<ArtistSummaryCard
key={performance.artist.name}
artist={performance.artist}
as={performance.as}
/>
))}
{!!anime.studios?.length && anime.studios.map((studio) => (
<SummaryCard key={studio.slug} title={studio.name} description="Studio" to={`/studio/${studio.slug}`} />
</>
)}
{!!usedAlsoAs.length && (
<>
<Text variant="h2">Also Used As</Text>
{usedAlsoAs.map((theme) => (
<ThemeSummaryCard key={theme.anime.slug} theme={theme}/>
))}
</Column>
<Column style={{ "--gap": "16px" }}>
{!!theme.song.performances && theme.song.performances.map((performance) => (
<ArtistSummaryCard key={performance.artist.name} artist={performance.artist} as={performance.as}/>
</>
)}
</Column>
<Column style={{ "--gap": "16px" }}>
{!!relatedThemes.length && (
<>
<Text variant="h2">Related themes</Text>
{relatedThemes.map((theme) => (
<ThemeSummaryCard key={theme.slug} theme={theme}/>
))}
</Column>
</StyledSummaryCardGrid>
{!showMoreRelatedThemes && anime.themes.length > 6 && (
<Row style={{ "--justify-content": "center" }}>
<Button variant="silent" isCircle onClick={() => setShowMoreRelatedThemes(true)}>
<Icon icon={faChevronDown}/>
</Button>
</Row>
)}
</>
)}
</Column>
{!!otherEntries.length && (
<StyledRelatedEntries>
@@ -240,9 +268,9 @@ export default function VideoPage({ anime, theme, entry, video }) {
}
export async function getStaticProps({ params: { animeSlug, videoSlug } }) {
const { data } = await fetchData(`
#graphql
const { data } = await fetchData(gql`
${ThemeSummaryCard.fragments.theme}
query($animeSlug: String!) {
anime(slug: $animeSlug) {
name
@@ -250,6 +278,7 @@ export async function getStaticProps({ params: { animeSlug, videoSlug } }) {
year
season
themes {
...ThemeSummaryCard_theme
id
slug
song {
@@ -282,6 +311,11 @@ export async function getStaticProps({ params: { animeSlug, videoSlug } }) {
subbed
uncen
tags
entries {
theme {
...ThemeSummaryCard_theme
}
}
}
}
}
+17 -23
View File
@@ -14,12 +14,14 @@ import { SearchFilter, SearchFilterGroup, SearchFilterSortBy } from "components/
import { Collapse } from "components/utils";
import { Listbox } from "components/listbox";
import {
animeNameComparator,
animePremiereComparator,
chain,
getComparator,
resourceSiteComparator,
reverse,
songTitleComparator
SONG_A_Z,
SONG_A_Z_ANIME,
SONG_NEW_OLD,
SONG_OLD_NEW,
SONG_Z_A,
SONG_Z_A_ANIME
} from "utils/comparators";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
@@ -46,17 +48,6 @@ const performanceFilters = new Map([
]);
const performanceFilterOptions = [ ...performanceFilters.keys() ];
const toAnime = (comparator) => (a, b) => comparator(a.anime, b.anime);
const sortByComparators = new Map([
[ "A ➜ Z (Song)", songTitleComparator ],
[ "Z ➜ A (Song)", reverse(songTitleComparator) ],
[ "A ➜ Z (Anime)", chain(toAnime(animeNameComparator), songTitleComparator) ],
[ "Z ➜ A (Anime)", chain(reverse(toAnime(animeNameComparator)), songTitleComparator) ],
[ "Old ➜ New", chain(toAnime(animePremiereComparator), toAnime(animeNameComparator), songTitleComparator) ],
[ "New ➜ Old", chain(reverse(toAnime(animePremiereComparator)), toAnime(animeNameComparator), songTitleComparator) ]
]);
const sortByOptions = [ ...sortByComparators.keys() ];
export default function ArtistDetailPage({ artist }) {
const { largeCover } = useImage(artist);
@@ -73,7 +64,7 @@ export default function ArtistDetailPage({ artist }) {
const [ showFilter, toggleShowFilter ] = useToggle();
const [ filterPerformance, setFilterPerformance ] = useState(performanceFilterOptions[0]);
const [ filterAlias, setFilterAlias ] = useState(aliasFilterOptions[0]);
const [ sortBy, setSortBy ] = useState(sortByOptions[0]);
const [ sortBy, setSortBy ] = useState(SONG_A_Z);
const themes = artist.performances
.flatMap(
@@ -87,7 +78,7 @@ export default function ArtistDetailPage({ artist }) {
performance.as === filterAlias
))
.filter(performanceFilters.get(filterPerformance))
.sort(sortByComparators.get(sortBy));
.sort(getComparator(sortBy));
return (
<>
@@ -162,11 +153,14 @@ export default function ArtistDetailPage({ artist }) {
))}
</Listbox>
</SearchFilter>
<SearchFilterSortBy
options={sortByOptions}
value={sortBy}
setValue={setSortBy}
/>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={SONG_A_Z}>A Z (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_Z_A}>Z A (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_A_Z_ANIME}>A Z (Anime)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_Z_A_ANIME}>Z A (Anime)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_OLD_NEW}>Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_NEW_OLD}>New Old</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<Column style={{ "--gap": "16px" }}>
+9 -54
View File
@@ -3,7 +3,6 @@ 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 { SearchInput } from "components/input";
import {
faArrowRight,
faChevronDown,
@@ -19,7 +18,7 @@ import {
import { Icon } from "components/icon";
import styled from "styled-components";
import theme from "theme";
import { Collapse, HeightTransition } from "components/utils";
import { Collapse, HeightTransition, Highlight } from "components/utils";
import ColorThemeContext from "context/colorThemeContext";
import { ExternalLink } from "components/external-link";
import { Switcher } from "components/switcher";
@@ -30,11 +29,11 @@ import { DescriptionList } from "components/description-list";
import { codeBlock } from "common-tags";
import { Listbox } from "components/listbox";
import { fetchData } from "lib/server";
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import { Menu } from "components/menu";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { Input } from "components/input";
import gql from "graphql-tag";
const ColorGrid = styled.div`
display: grid;
@@ -414,9 +413,9 @@ export default function DesignPage({ demoData }) {
<Text variant="h2">Input</Text>
<ExampleGrid>
<Highlight>
{`<SearchInput/>`}
{`<Input/>`}
</Highlight>
<SearchInput/>
<Input/>
</ExampleGrid>
<Text variant="h2">Image</Text>
@@ -637,57 +636,13 @@ function Color({ color }) {
);
}
function Highlight({ children, block = false }) {
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;
}
export async function getStaticProps() {
const { data } = await fetchData(`
#graphql
const { data } = await fetchData(gql`
${AnimeSummaryCard.fragment}
query {
anime(slug: "bakemonogatari") {
name
slug
year
season
themes {
slug
type
sequence
entries {
version
videos {
tags
}
}
}
resources {
site
link
}
images {
facet
link
}
...AnimeSummaryCard_anime
}
}
`);
+7 -57
View File
@@ -13,6 +13,7 @@ import navigateToRandomTheme from "utils/navigateToRandomTheme";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import { FeaturedTheme } from "components/featured-theme";
import gql from "graphql-tag";
const BigButton = styled(Button)`
justify-content: flex-end;
@@ -192,67 +193,16 @@ export default function HomePage({ featuredTheme, recentlyAdded }) {
}
export async function getStaticProps() {
const { data } = await fetchData(`
#graphql
const { data } = await fetchData(gql`
${FeaturedTheme.fragment}
${ThemeSummaryCard.fragments.theme}
query {
featuredTheme: theme(id: 10968) {
id
slug
anime {
slug
name
images {
facet
link
}
}
song {
title
performances {
artist {
slug
name
}
as
}
}
entries {
version
videos {
basename
tags
}
}
...FeaturedTheme_theme
}
recentlyAdded: themeAll(orderBy: "theme_id", orderDesc: true, limit: 10) {
id
slug
anime {
slug
name
images {
facet
link
}
}
song {
title
performances {
artist {
slug
name
}
as
}
}
entries {
version
videos {
basename
tags
}
}
...ThemeSummaryCard_theme
}
}
`);
+270
View File
@@ -0,0 +1,270 @@
import Link from "next/link";
import { fetchData } from "lib/server";
import markdownToHtml from "utils/markdownToHtml";
import { unified } from "unified";
import rehypeReact from "rehype-react";
import { Text } from "components/text";
import { createElement, Fragment, useEffect, useState } from "react";
import rehypeParse from "rehype-parse";
import styled from "styled-components";
import { slug } from "github-slugger";
import { motion } from "framer-motion";
import theme from "theme";
import { SEO } from "components/seo";
const StyledGrid = styled.div`
display: flex;
gap: 32px;
& > :nth-child(1) {
flex: 3;
min-width: 0;
}
& > :nth-child(2) {
flex: 1;
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
}
`;
const StyledMarkdown = styled.div`
line-height: 1.75;
& h1 {
margin-bottom: 32px;
}
& h2 {
margin-bottom: 24px;
font-size: 1.2rem;
}
& p + h2 {
margin-top: 48px;
}
& 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 {
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;
list-style: none;
padding-left: 16px;
& > li {
position: relative;
display: flex;
align-items: center;
}
`;
const StyledDot = styled(motion.div)`
position: absolute;
left: -16px;
width: 4px;
height: 100%;
border-radius: 4px;
background-color: ${theme.colors["text-primary"]};
`;
export default function DocumentPage({ page }) {
const components = unified()
.use(rehypeParse, { fragment: true })
.use(rehypeReact, {
createElement,
Fragment,
components: {
a: ({ children, href, ...props }) => {
if (href.startsWith("/")) {
return (
<Link href={href} passHref>
<Text as="a" link {...props}>{children}</Text>
</Link>
);
}
return <Text as="a" link href={href} {...props}>{children}</Text>;
},
h1: ({ children, ...props }) => <Text variant="h1" {...props}>{children}</Text>,
h2: ({ children, ...props }) => <Text variant="h2" {...props}>{children}</Text>,
code: ({ children, ...props }) => <Text variant="code" {...props}>{children}</Text>
}
})
.processSync(page.body.html).result;
return (
<StyledGrid>
<SEO title={page.name}/>
<StyledMarkdown>
{components}
</StyledMarkdown>
<TableOfContents headings={page.body.headings}/>
</StyledGrid>
);
}
function TableOfContents({ headings }) {
const [currentHeading, setCurrentHeading] = useState();
useEffect(() => {
function onScroll() {
const headings = [...document.querySelectorAll("h2")];
let currentHeading = null;
for (const heading of headings) {
if (heading.offsetTop > window.scrollY + window.innerHeight) {
break;
}
currentHeading = heading;
if (heading.offsetTop > window.scrollY) {
break;
}
}
setCurrentHeading(currentHeading?.id);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<StyledTableOfContents>
{headings.map(({ text }) => (
<li key={text}>
{slug(text) === currentHeading && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug(text) === currentHeading ? "text-muted" : "text-disabled"} href={`#${slug(text)}`}>{text}</Text>
</li>
))}
</StyledTableOfContents>
);
}
export async function getStaticProps({ params: { pageSlug } }) {
const { data } = await fetchData(`
#graphql
query($pageSlug: String!) {
page(slug: $pageSlug) {
name
body
}
}
`, {
pageSlug: pageSlug.join("/")
});
if (!data.page) {
return {
notFound: true
};
}
return {
props: {
page: {
...data.page,
body: markdownToHtml(data.page.body)
}
},
revalidate: 5 * 60
};
}
export async function getStaticPaths() {
const { data } = await fetchData(`
#graphql
query {
pageAll {
slug
}
}
`);
const paths = data.pageAll.map((page) => ({
params: {
pageSlug: page.slug.split("/")
}
}));
return {
paths,
fallback: "blocking"
};
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import { Text } from "components/text";
export default function DocumentIndexPage({ pages }) {
return (
<>
<SEO title="Pages"/>
<Text variant="h1">Pages</Text>
{pages.map((page) => (
<Link key={page.slug} href={`/page/${page.slug}`} passHref>
<Text as="a" link>{page.name}</Text>
</Link>
))}
</>
);
}
export async function getStaticProps() {
const { data } = await fetchData(`
#graphql
query {
pageAll {
slug
name
}
}
`);
return {
props: {
pages: data.pageAll
},
revalidate: 5 * 60
};
}
@@ -8,7 +8,16 @@ import { useState } from "react";
import { FilterToggleButton } from "components/button";
import { SearchFilterGroup, SearchFilterSortBy } from "components/search-filter";
import { Collapse } from "components/utils";
import { animeNameComparator, animePremiereComparator, chain, reverse, songTitleComparator } from "utils/comparators";
import {
getComparator,
SONG_A_Z,
SONG_A_Z_ANIME,
SONG_NEW_OLD,
SONG_OLD_NEW,
SONG_Z_A,
SONG_Z_A_ANIME,
UNSORTED
} from "utils/comparators";
import { SEO } from "components/seo";
import { Reorder } from "framer-motion";
import { useLocalPlaylist } from "context/localPlaylistContext";
@@ -38,25 +47,13 @@ const StyledReorderContainer = styled.div`
}
`;
const toAnime = (comparator) => (a, b) => comparator(a.anime, b.anime);
const sortByComparators = new Map([
[ "Recently added", () => 0 ],
[ "A ➜ Z (Song)", songTitleComparator ],
[ "Z ➜ A (Song)", reverse(songTitleComparator) ],
[ "A ➜ Z (Anime)", chain(toAnime(animeNameComparator), songTitleComparator) ],
[ "Z ➜ A (Anime)", chain(reverse(toAnime(animeNameComparator)), songTitleComparator) ],
[ "Old ➜ New", chain(toAnime(animePremiereComparator), toAnime(animeNameComparator), songTitleComparator) ],
[ "New ➜ Old", chain(reverse(toAnime(animePremiereComparator)), toAnime(animeNameComparator), songTitleComparator) ]
]);
const sortByOptions = [ ...sortByComparators.keys() ];
export default function PlaylistPage() {
const { localPlaylist, setPlaylist } = useLocalPlaylist();
const [ showFilter, toggleShowFilter ] = useToggle();
const [ sortBy, setSortBy ] = useState(sortByOptions[0]);
const [ sortBy, setSortBy ] = useState(UNSORTED);
const themes = localPlaylist.sort(sortByComparators.get(sortBy));
const themes = localPlaylist.sort(getComparator(sortBy));
return (
<>
@@ -76,11 +73,15 @@ export default function PlaylistPage() {
</StyledHeader>
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy
options={sortByOptions}
value={sortBy}
setValue={setSortBy}
/>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={UNSORTED}>Recently Added</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_A_Z}>A Z (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_Z_A}>Z A (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_A_Z_ANIME}>A Z (Anime)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_Z_A_ANIME}>Z A (Anime)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_OLD_NEW}>Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_NEW_OLD}>New Old</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<StyledReorderContainer>
+9 -16
View File
@@ -8,7 +8,7 @@ import useToggle from "hooks/useToggle";
import { useState } from "react";
import { SearchFilterGroup, SearchFilterSortBy } from "components/search-filter";
import { FilterToggleButton } from "components/button";
import { animeNameComparator, animePremiereComparator, chain, reverse } from "utils/comparators";
import { ANIME_A_Z, ANIME_NEW_OLD, ANIME_OLD_NEW, ANIME_Z_A, getComparator } from "utils/comparators";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import theme from "theme";
@@ -22,21 +22,13 @@ const StyledDesktopOnly = styled.div`
}
`;
const sortByComparators = new Map([
[ "Old ➜ New", chain(animePremiereComparator, animeNameComparator) ],
[ "New ➜ Old", chain(reverse(animePremiereComparator), animeNameComparator) ],
[ "A ➜ Z", animeNameComparator ],
[ "Z ➜ A", reverse(animeNameComparator) ]
]);
const sortByOptions = [ ...sortByComparators.keys() ];
export default function SeriesDetailPage({ series }) {
const anime = series.anime;
const [ showFilter, toggleShowFilter ] = useToggle();
const [ sortBy, setSortBy ] = useState(sortByOptions[0]);
const [ sortBy, setSortBy ] = useState(ANIME_OLD_NEW);
const animeSorted = [ ...anime ].sort(sortByComparators.get(sortBy));
const animeSorted = [ ...anime ].sort(getComparator(sortBy));
return (
<>
@@ -56,11 +48,12 @@ export default function SeriesDetailPage({ series }) {
</Row>
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy
options={sortByOptions}
value={sortBy}
setValue={setSortBy}
/>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={ANIME_OLD_NEW}>Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_NEW_OLD}>New Old</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_A_Z}>A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_Z_A}>Z A</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<Column style={{ "--gap": "16px" }}>
+13 -19
View File
@@ -9,11 +9,12 @@ import { SearchFilterGroup, SearchFilterSortBy } from "components/search-filter"
import useToggle from "hooks/useToggle";
import { FilterToggleButton } from "components/button";
import {
animeNameComparator,
animePremiereComparator,
chain,
ANIME_A_Z,
ANIME_NEW_OLD,
ANIME_OLD_NEW,
ANIME_Z_A,
getComparator,
resourceSiteComparator,
reverse
} from "utils/comparators";
import { fetchData } from "lib/server";
import { DescriptionList } from "components/description-list";
@@ -36,21 +37,13 @@ const StyledList = styled.div`
text-align: center;
`;
const sortByComparators = new Map([
[ "A ➜ Z", animeNameComparator ],
[ "Z ➜ A", reverse(animeNameComparator) ],
[ "Old ➜ New", chain(animePremiereComparator, animeNameComparator) ],
[ "New ➜ Old", chain(reverse(animePremiereComparator), animeNameComparator) ]
]);
const sortByOptions = [ ...sortByComparators.keys() ];
export default function StudioDetailPage({ studio }) {
const anime = studio.anime;
const [ showFilter, toggleShowFilter ] = useToggle();
const [ sortBy, setSortBy ] = useState(sortByOptions[0]);
const [ sortBy, setSortBy ] = useState(ANIME_A_Z);
const animeSorted = [ ...anime ].sort(sortByComparators.get(sortBy));
const animeSorted = [ ...anime ].sort(getComparator(sortBy));
return (
<>
@@ -85,11 +78,12 @@ export default function StudioDetailPage({ studio }) {
</Row>
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy
options={sortByOptions}
value={sortBy}
setValue={setSortBy}
/>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={ANIME_A_Z}>A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_Z_A}>Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_OLD_NEW}>Old New</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_NEW_OLD}>New Old</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<Column style={{ "--gap": "16px" }}>
+4 -22
View File
@@ -3,6 +3,7 @@ import { Text } from "components/text";
import { Column } from "components/box";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import gql from "graphql-tag";
const seasonOrder = [ "Winter", "Spring", "Summer", "Fall" ];
@@ -31,8 +32,8 @@ export default function SeasonDetailPage({ animeAll, year, season }) {
export async function getStaticProps({ params: { year, season } }) {
year = +year;
const { data } = await fetchData(`
#graphql
const { data } = await fetchData(gql`
${AnimeSummaryCard.fragment}
query($year: Int = 0, $season: String!) {
yearAll {
@@ -44,26 +45,7 @@ export async function getStaticProps({ params: { year, season } }) {
season(year: $year, value: $season) {
anime {
slug
name
themes {
slug
type
sequence
entries {
version
videos {
tags
}
}
}
resources {
link
site
}
images {
facet
link
}
...AnimeSummaryCard_anime
}
}
+4 -22
View File
@@ -7,6 +7,7 @@ import { Icon } from "components/icon";
import { Text } from "components/text";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import gql from "graphql-tag";
const seasonOrder = [ "Winter", "Spring", "Summer", "Fall" ];
@@ -44,8 +45,8 @@ function SeasonPreview({ season, year, animeList }) {
export async function getStaticProps({ params: { year } }) {
year = +year;
const { data } = await fetchData(`
#graphql
const { data } = await fetchData(gql`
${AnimeSummaryCard.fragment}
query($year: Int!) {
yearAll {
@@ -56,26 +57,7 @@ export async function getStaticProps({ params: { year } }) {
value
anime {
slug
name
themes {
slug
type
sequence
entries {
version
videos {
tags
}
}
}
resources {
link
site
}
images {
facet
link
}
...AnimeSummaryCard_anime
}
}
}
+8 -1
View File
@@ -33,6 +33,8 @@ export default createGlobalStyle`
font-family: "Nunito", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
html, body, #__next {
@@ -48,7 +50,7 @@ export default createGlobalStyle`
}
p {
line-height: 1.5;
line-height: inherit;
}
button {
@@ -89,6 +91,11 @@ export default createGlobalStyle`
margin: 0;
}
hr {
margin: 16px 0;
border-color: ${theme.colors["text-disabled"]};
}
@media (min-device-width: 600px) {
html {
scrollbar-color: ${theme.colors["gray-700"]} transparent;
+34
View File
@@ -30,3 +30,37 @@ export const themeIndexComparator = (a, b) => a.sequence - b.sequence;
export const studioNameComparator = (a, b) => a.name.localeCompare(b.name);
export const seriesNameComparator = (a, b) => a.name.localeCompare(b.name);
export const resourceSiteComparator = (a, b) => a.site.localeCompare(b.site);
export const UNSORTED = "unsorted";
export const ANIME_A_Z = "anime-a-z";
export const ANIME_Z_A = "anime-z-a";
export const ANIME_OLD_NEW = "anime-old-new";
export const ANIME_NEW_OLD = "anime-new-old";
export const SONG_A_Z = "song-a-z";
export const SONG_Z_A = "song-z-a";
export const SONG_A_Z_ANIME = "song-a-z-anime";
export const SONG_Z_A_ANIME = "song-z-a-anime";
export const SONG_OLD_NEW = "song-old-new";
export const SONG_NEW_OLD = "song-new-old";
const toAnime = (comparator) => (a, b) => comparator(a.anime, b.anime);
const comparators = new Map([
[ UNSORTED, () => 0 ],
[ ANIME_A_Z, animeNameComparator ],
[ ANIME_Z_A, reverse(animeNameComparator) ],
[ ANIME_OLD_NEW, chain(animePremiereComparator, animeNameComparator) ],
[ ANIME_NEW_OLD, chain(reverse(animePremiereComparator), animeNameComparator) ],
[ SONG_A_Z, songTitleComparator ],
[ SONG_Z_A, reverse(songTitleComparator) ],
[ SONG_A_Z_ANIME, chain(toAnime(animeNameComparator), songTitleComparator) ],
[ SONG_Z_A_ANIME, chain(reverse(toAnime(animeNameComparator)), songTitleComparator) ],
[ SONG_OLD_NEW, chain(toAnime(animePremiereComparator), toAnime(animeNameComparator), songTitleComparator) ],
[ SONG_NEW_OLD, chain(reverse(toAnime(animePremiereComparator)), toAnime(animeNameComparator), songTitleComparator) ]
]);
export function getComparator(name) {
return comparators.get(name);
}
+20 -2
View File
@@ -3,7 +3,9 @@
*
* `<OP|ED>[v#][-[Tags]]`
*/
function createVideoSlug(theme, entry, video) {
import gql from "graphql-tag";
export default function createVideoSlug(theme, entry, video) {
let slug = theme.slug;
if (entry.version && entry.version !== 1) {
@@ -17,4 +19,20 @@ function createVideoSlug(theme, entry, video) {
return slug;
}
export default createVideoSlug;
createVideoSlug.fragments = {
theme: gql`
fragment createVideoSlug_theme on Theme {
slug
}
`,
entry: gql`
fragment createVideoSlug_entry on Entry {
version
}
`,
video: gql`
fragment createVideoSlug_video on Video {
tags
}
`
};
+37
View File
@@ -0,0 +1,37 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypePrism from "@mapbox/rehype-prism";
import rehypeSlug from "rehype-slug";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
export default function markdownToHtml(markdown) {
const html = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypePrism)
.use(rehypeSlug)
.use(rehypeStringify)
.processSync(markdown)
.toString();
const headings = getHeadings(markdown);
return {
html,
headings
};
}
function getHeadings(markdown) {
return [...markdown.matchAll(/^(##) (.*)$/gm)].map(([, prefix, text]) => ({
text: stripMarkdown(text),
level: prefix.length
}));
}
function stripMarkdown(markdown) {
return markdown.replaceAll(/\[(.*?)]\(.*?\)/g, "$1");
}