mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
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:
Generated
+7525
-53
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -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
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -64,6 +64,7 @@ export function SummaryCard({ title, description, image, to, children, ...props
|
||||
</Link>
|
||||
) : title}
|
||||
</Text>
|
||||
{!!description && (
|
||||
<Text variant="small" maxLines={1}>
|
||||
{typeof description === "string" ? (
|
||||
<SummaryCard.Description>
|
||||
@@ -71,6 +72,7 @@ export function SummaryCard({ title, description, image, to, children, ...props
|
||||
</SummaryCard.Description>
|
||||
) : description}
|
||||
</Text>
|
||||
)}
|
||||
</StyledBody>
|
||||
{children}
|
||||
</StyledSummaryCard>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 @@
|
||||
export { SearchInput } from "./SearchInput";
|
||||
export { Input } from "./Input";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}, [])
|
||||
|
||||
@@ -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();
|
||||
@@ -31,3 +32,13 @@ export function ThemeMenu({ theme }) {
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMenu.fragment = gql`
|
||||
fragment ThemeMenu_theme on Theme {
|
||||
id
|
||||
# Hidden inside local playlist context
|
||||
song {
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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}/>;
|
||||
};
|
||||
|
||||
@@ -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}/>}
|
||||
|
||||
@@ -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}/>}
|
||||
|
||||
@@ -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}`} />}
|
||||
|
||||
@@ -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}`} />}
|
||||
|
||||
@@ -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}/>}
|
||||
|
||||
@@ -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,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};
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -5,3 +5,4 @@ export { SongTitleWithArtists } from "./SongTitleWithArtists";
|
||||
export { ThemeEntryTags } from "./ThemeEntryTags";
|
||||
export { VideoTags } from "./VideoTags";
|
||||
export { HorizontalScroll } from "./HorizontalScroll";
|
||||
export { Highlight } from "./Highlight";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,7 +49,8 @@ const entityConfigs = {
|
||||
"id",
|
||||
"type",
|
||||
"sequence",
|
||||
"slug"
|
||||
"slug",
|
||||
"group"
|
||||
],
|
||||
animethemeentry: [
|
||||
"version"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
@@ -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.*")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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!
|
||||
@@ -160,6 +166,13 @@ module.exports = `
|
||||
content: String!
|
||||
}
|
||||
|
||||
type Page {
|
||||
id: Int
|
||||
slug: String!
|
||||
name: String!
|
||||
body: String!
|
||||
}
|
||||
|
||||
schema {
|
||||
query: Query
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"/>
|
||||
|
||||
+2
-33
@@ -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,35 +21,7 @@ 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() {
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
@@ -68,5 +39,3 @@ export default class MyDocument extends Document {
|
||||
</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,9 +188,7 @@ 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" }}>
|
||||
<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}`} />
|
||||
@@ -198,13 +196,43 @@ export default function VideoPage({ anime, theme, entry, video }) {
|
||||
{!!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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!!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}/>
|
||||
{!!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,8 +268,8 @@ 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) {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
+8
-53
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
+6
-56
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user