feat: Added missing brackets (@theinternetftw) (#136)

* Added bracket chart.
* Added border radius to all sides of cards.
* Added skeletons to home page.
* Added support for system color theme.
* Added code-splitting for GraphQL.
* Added config constants.
* Added warning banner on staging.
* Added URL caching to API resolver (only for non-page request for now).
* Made featured theme load by default.
* Changed static path fetching to only fetch paths if not on staging (except for bracket pages).
* Changed default color theme to system.
* Moved color theme toggle from navigation to profile page.
* Replaced sketchy local storage implementation with external package.
* Fixed wrong fallback for featured theme on devices which don't support our codecs.
* Fixed wrong links in footer.
* Fixed code-splitting of Prism package.
* Fixed "/" character not working in search input.
* Fixed glitching multi cover image on playlist page.
* Updated README.md.
This commit is contained in:
Manuel S
2022-06-08 00:20:46 +02:00
committed by GitHub
parent d7329f73b7
commit b195c9d667
61 changed files with 4000 additions and 794 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://staging.animethemes.moe
+1 -8
View File
@@ -31,14 +31,7 @@
}
],
"no-duplicate-imports": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
"components/*/*"
]
}
],
"no-unused-vars": "error",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
+40 -13
View File
@@ -8,28 +8,55 @@
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory.
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or staging URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
```
To get to use all features of the client, you may configure some additional variables.
This is a list of all available options:
```ini
; Back-end API configuration
; These values are required.
; ===== Server-side =====
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; If specified, this API key will be used to make requests to the AnimeThemes API.
; This is used to by-pass rate limiting.
ANIMETHEMES_API_KEY=...
; (Optional) The URL to the AnimeThemes API to use on the front-end.
; For development this is "http://localhost" by default.
; For production this is "https://staging.animethemes.moe" by default.
NEXT_PUBLIC_API_URL=http://localhost
; The token to use for manual revalidation.
REVALIDATE_TOKEN=secret
; (Optional) The URL from which video files should be served.
; By default this is "https://animethemes.moe".
NEXT_PUBLIC_VIDEO_URL=https://animethemes.moe
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
; (Optional) The base path the app should be hosted on.
; By default the app is hosted on the root path (/).
; ===== Server-side + Client-side =====
; The base path the app should be hosted on.
NEXT_PUBLIC_BASE_PATH=/wiki
; The URL to the AnimeThemes API which will be used on the browser.
NEXT_PUBLIC_API_URL=https://api.animethemes.moe
; The URL from which video files should be served.
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
; The URL to use for app links.
NEXT_PUBLIC_APP_URL=https://app.animethemes.moe
; Set to any truthy value to activate staging mode.
; Staging mode only pre-renders a small subset of pages to reduce build time.
STAGING=true
```
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
@@ -56,4 +83,4 @@ For more information on environment variables see the [Next.js documentation](ht
### APIs
- [AnimeThemes API](https://staging.animethemes.moe/api/docs/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+10 -4
View File
@@ -1,15 +1,21 @@
const { info } = require("next/dist/build/output/log");
const { info, error } = require("next/dist/build/output/log");
const { ANALYZE, STAGING, BASE_PATH, validateConfig } = require("./src/utils/config");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
enabled: ANALYZE,
});
if (process.env.NEXT_PUBLIC_STAGING) {
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
return process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
module.exports = withBundleAnalyzer({
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
+23 -1
View File
@@ -35,7 +35,8 @@
"rehype-react": "^7.0.4",
"sass": "^1.43.4",
"styled-components": "^5.3.3",
"unified": "^10.1.2"
"unified": "^10.1.2",
"use-local-storage-state": "^17.2.0"
},
"devDependencies": {
"eslint": "7.32.0",
@@ -4400,6 +4401,21 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-local-storage-state": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-17.2.0.tgz",
"integrity": "sha512-CcnSJ3oaCbb4aXPRpctXNoAOfJ1V4+OWLHrvA2s2hOYCE7HG0QEyKd324XAs7FsyXv/uzYcu7sPxn+KwvJIwlw==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/astoilkov"
},
"peerDependencies": {
"react": ">=16.8.0 < 18",
"react-dom": ">=16.8.0 < 18"
}
},
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@@ -7733,6 +7749,12 @@
"punycode": "^2.1.0"
}
},
"use-local-storage-state": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/use-local-storage-state/-/use-local-storage-state-17.2.0.tgz",
"integrity": "sha512-CcnSJ3oaCbb4aXPRpctXNoAOfJ1V4+OWLHrvA2s2hOYCE7HG0QEyKd324XAs7FsyXv/uzYcu7sPxn+KwvJIwlw==",
"requires": {}
},
"v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+2 -1
View File
@@ -36,7 +36,8 @@
"rehype-react": "^7.0.4",
"sass": "^1.43.4",
"styled-components": "^5.3.3",
"unified": "^10.1.2"
"unified": "^10.1.2",
"use-local-storage-state": "^17.2.0"
},
"devDependencies": {
"eslint": "7.32.0",
+200
View File
@@ -0,0 +1,200 @@
import styled from "styled-components";
import { Text } from "components/text";
import { faDiagramProject } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { memo, useCallback, useRef, useState } from "react";
import theme from "theme";
import { motion } from "framer-motion";
import { Button } from "components/button";
import { BracketThemeSummaryCard } from "components/bracket/BracketThemeSummaryCard";
const StyledBracketContainer = styled.div`
flex: 1 1 0;
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
`;
const StyledBracket = styled(motion.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
`;
const StyledCanvas = styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
`;
const StyledRound = styled.div`
display: flex;
flex-direction: column;
gap: 64px;
`;
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
`;
const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
width: 384px;
`;
export function BracketChart({ bracket }) {
const [showBracketChart, setShowBracketChart] = useState(false);
const bracketRef = useRef();
const onBracketInit = useCallback((bracket) => {
bracketRef.current = bracket;
if (bracket) {
bracket.requestFullscreen();
bracket.addEventListener("fullscreenchange", onFullscreenChange);
function onFullscreenChange() {
if (!document.fullscreenElement) {
setShowBracketChart(false);
bracket.removeEventListener("fullscreenchange", onFullscreenChange);
}
}
}
}, []);
const onCanvasInit = useCallback((canvas) => {
if (!canvas) {
return;
}
const parentRect = canvas.parentNode.getBoundingClientRect();
canvas.width = parentRect.width;
canvas.height = parentRect.height;
/** @type CanvasRenderingContext2D */
const ctx = canvas.getContext("2d");
ctx.strokeStyle = "#75ead4";
ctx.lineWidth = 2;
ctx.translate(-parentRect.left, -parentRect.top);
const cards = document.querySelectorAll("[data-theme]");
const cardsById = [...cards].reduce((map, card) => {
const id = card.dataset.theme;
if (!map[id]) {
map[id] = [];
}
map[id].push(card);
return map;
}, {});
for (const cards of Object.values(cardsById)) {
for (let i = 0; i < cards.length - 1; i++) {
const current = cards[i];
const currentRect = current.getBoundingClientRect();
const next = cards[i + 1];
const nextRect = next.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(currentRect.left + currentRect.width, currentRect.top + currentRect.height / 2);
ctx.bezierCurveTo(
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
currentRect.top + currentRect.height / 2,
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2
);
ctx.stroke();
}
}
}, []);
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject}/>
<span>Open Bracket Chart</span>
</Button>
{showBracketChart ? (
<StyledBracketContainer ref={onBracketInit}>
<StyledBracket drag dragConstraints={bracketRef}>
<StyledCanvas ref={onCanvasInit}/>
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => <BracketRound key={round.tier} round={round}/>)}
</StyledBracket>
</StyledBracketContainer>
) : null}
</>
);
}
const BracketRound = memo(function BracketRound({ round }) {
if (!round.pairings || !round.pairings.length) {
return null;
}
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings}/>
</StyledRound>
);
});
function BracketPairings({ pairings }) {
return pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
}
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }) {
const isVoted = !!contestantVotes;
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
contestant={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
votes={contestantVotes}
data-theme={contestant.id}
/>
);
}
@@ -0,0 +1,82 @@
import styled from "styled-components";
import { SummaryCard } from "components/card";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
import { SongTitleWithArtists } from "components/utils";
import { Column } from "components/box";
import { Icon } from "components/icon";
import { faAward, faSeedling, faUsers } from "@fortawesome/pro-solid-svg-icons";
import { CornerIcon } from "components/icon/CornerIcon";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
const StyledRank = styled(Text)`
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
`;
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }) {
const theme = contestant.theme;
const { smallCover } = useImage(theme?.anime);
let to = null;
let description = contestant.source;
if (theme) {
const entry = theme.entries?.[0];
const video = entry?.videos?.[0];
const videoSlug = createVideoSlug(theme, entry, video);
to = `/anime/${theme.anime.slug}/${videoSlug}`;
description = (
<SummaryCard.Description>
<span>{theme.slug}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref prefetch={false}>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
</SummaryCard.Description>
);
}
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
description={description}
image={smallCover}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
</StyledSummaryCardWrapper>
);
}
+1 -1
View File
@@ -57,7 +57,7 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
export function AnimeSummaryCard({ anime, previewThemes = false, expandable = false, ...props }) {
export function AnimeSummaryCard({ anime, expandable = false, ...props }) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
+15 -5
View File
@@ -5,12 +5,11 @@ import { withHover } from "styles/mixins";
export const Card = styled(Solid)`
display: block;
position: relative;
padding: 16px 24px;
border-color: ${(props) => theme.colors[props.color] || props.color || theme.colors["text-primary"]};
border-left-width: 4px;
border-left-style: solid;
border-radius: 0 8px 8px 0;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
box-shadow: ${theme.shadows.medium};
@@ -21,4 +20,15 @@ export const Card = styled(Solid)`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
&:before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => theme.colors[props.color] || props.color || theme.colors["text-primary"]};
}
`;
+24 -16
View File
@@ -14,23 +14,21 @@ const StyledSummaryCard = styled(Card)`
gap: 16px;
height: 64px;
padding: 0 1rem 0 0;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})`
width: 48px;
height: 64px;
object-fit: cover;
${loadingAnimation}
${(props) => props.isPlaceholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
width: 48px;
height: 64px;
object-fit: cover;
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background: white;
` : loadingAnimation}
`;
const StyledBody = styled(Column)`
@@ -46,7 +44,7 @@ export function SummaryCard({ title, description, image, to, children, ...props
return (
<StyledSummaryCard {...props}>
<Link href={to} prefetch={false}>
<ConditionalWrapper as={Link} condition={to} href={to} prefetch={false}>
<a>
<StyledCover
alt="Cover"
@@ -56,13 +54,13 @@ export function SummaryCard({ title, description, image, to, children, ...props
onError={() => setImageNotFound(true)}
/>
</a>
</Link>
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
<Link href={to} passHref prefetch={false}>
<ConditionalWrapper as={Link} condition={to} href={to} passHref prefetch={false}>
<Text as="a" link>{title}</Text>
</Link>
</ConditionalWrapper>
) : title}
</Text>
{!!description && (
@@ -80,6 +78,16 @@ export function SummaryCard({ title, description, image, to, children, ...props
);
}
function ConditionalWrapper({ as: Wrapper, condition, ...props }) {
if (condition) {
return (
<Wrapper {...props}/>
);
}
return props.children;
}
SummaryCard.Description = function SummaryCardDescription({ children }) {
return (
<>
+10 -6
View File
@@ -10,8 +10,7 @@ import gql from "graphql-tag";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
import { VIDEO_URL } from "utils/config";
const slowPan = keyframes`
from {
@@ -75,6 +74,7 @@ const StyledCenter = styled.div`
const StyledVideo = styled.video`
width: 100%;
filter: blur(5px);
background-color: ${theme.colors["solid-on-card"]};
`;
const StyledCover = styled.img`
@@ -152,7 +152,7 @@ export function FeaturedTheme({ theme }) {
function FeaturedThemeBackground({ theme }) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability({ canPlayVideo: false });
const { canPlayVideo } = useCompatability();
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const { smallCover: featuredCover } = useImage(theme.anime);
@@ -180,16 +180,20 @@ function FeaturedThemeBackground({ theme }) {
<Link {...linkProps}>
<StyledOverflowHidden>
<StyledVideo
src={`${videoBaseUrl}/${video.basename}`}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
/>
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
</StyledVideo>
</StyledOverflowHidden>
</Link>
);
} else if (featuredThemePreview === FeaturedThemePreview.COVER || fallbackToCover) {
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<Link {...linkProps}>
<StyledOverflowHidden>
+4 -3
View File
@@ -8,6 +8,7 @@ import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import { forwardRef } from "react";
import { APP_URL } from "utils/config";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -45,7 +46,7 @@ export function Footer() {
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/transparency">
<FooterTextLink href={`${APP_URL}/transparency`}>
Transparency
</FooterTextLink>
<Link href="/wiki/donate" passHref>
@@ -60,10 +61,10 @@ export function Footer() {
</Link>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/terms-of-service">
<FooterTextLink href={`${APP_URL}/terms-of-service`}>
Terms of Service
</FooterTextLink>
<FooterTextLink href="https://staging.animethemes.moe/privacy-policy">
<FooterTextLink href={`${APP_URL}/privacy-policy`}>
Privacy Policy
</FooterTextLink>
<FooterTextLink href="mailto:admin@animethemes.moe">
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
export const CornerIcon = styled(Icon).attrs({
size: "2x"
})`
position: absolute;
right: 0;
top: 0;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
+1 -1
View File
@@ -2,7 +2,7 @@ import { Icon } from "components/icon";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Menu } from "components/menu";
import { useLocalPlaylist } from "context/localPlaylistContext";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import gql from "graphql-tag";
export function ThemeMenu({ theme }) {
+2 -23
View File
@@ -1,16 +1,6 @@
import { useContext, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import {
faBars,
faLightbulb,
faMoon,
faRandom,
faSearch,
faSpinner,
faTimes,
faTv,
faUser
} from "@fortawesome/pro-solid-svg-icons";
import { faBars, faRandom, faSearch, faTimes, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
import {
StyledCollapsibleLink,
StyledLogo,
@@ -21,7 +11,6 @@ import {
StyledNavigationLinks
} from "./Navigation.style";
import { Button } from "components/button";
import ColorThemeContext from "context/colorThemeContext";
import { Icon } from "components/icon";
import { Text } from "components/text";
import useCurrentSeason from "hooks/useCurrentSeason";
@@ -30,7 +19,6 @@ import { useRouter } from "next/router";
export function Navigation({ offsetToggleButton = false }) {
const [ show, setShow ] = useState(false);
const { colorTheme, toggleColorTheme } = useContext(ColorThemeContext);
const { currentYear, currentSeason } = useCurrentSeason();
const router = useRouter();
@@ -76,15 +64,6 @@ export function Navigation({ offsetToggleButton = false }) {
<span>My Profile</span>
</StyledCollapsibleLink>
</Link>
<Button
variant="silent"
isCircle
onClick={toggleColorTheme}
alignSelf="center"
title="Toggle color theme"
>
<Icon icon={colorTheme === null ? faSpinner : colorTheme === "dark" ? faLightbulb : faMoon} spin={colorTheme === null} />
</Button>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
@@ -43,6 +43,7 @@ const updateSearchQuery = debounce((router, newSearchQuery) => {
export function SearchNavigation() {
const router = useRouter();
const [routerWasReady, setRouterWasReady] = useState(router.isReady);
const { entity, ...query } = router.query;
const { q: initialSearchQuery = "" } = query;
@@ -53,12 +54,6 @@ export function SearchNavigation() {
updateSearchQuery(router, newInputSearchQuery);
};
useEffect(() => {
if (router.isReady) {
setInputSearchQuery(initialSearchQuery);
}
}, [router.isReady]);
const inputRef = useRef();
const onMountInput = useCallback((input) => {
@@ -74,7 +69,7 @@ export function SearchNavigation() {
useEffect(() => {
const hotkeyListener = (event) => {
if ((event.key === "s" && event.ctrlKey) || (event.key === "/")) {
if (inputRef.current !== document.activeElement && ((event.key === "s" && event.ctrlKey) || (event.key === "/"))) {
event.preventDefault();
inputRef.current?.focus({
preventScroll: true
@@ -88,6 +83,12 @@ export function SearchNavigation() {
return () => window.removeEventListener("keydown", hotkeyListener);
}, []);
if (router.isReady !== routerWasReady) {
setRouterWasReady(router.isReady);
setInputSearchQuery(initialSearchQuery);
return null;
}
return (
<>
<Text variant="h1">Search</Text>
+32
View File
@@ -0,0 +1,32 @@
import styled from "styled-components";
import { loadingAnimation } from "styles/mixins";
import { fadeIn } from "styles/animations";
import theme from "theme";
const StyledSkeleton = styled.div`
${loadingAnimation}
`;
const StyledSkeletonSummaryCard = styled(StyledSkeleton)`
height: 64px;
border-radius: ${theme.scalars.borderRadiusCard};
`;
const StyledContent = styled.div`
animation: ${fadeIn} 500ms var(--delay) both;
`;
export function Skeleton({ children, variant, delay = 0 }) {
if (!children) {
switch (variant) {
case "summary-card":
return <StyledSkeletonSummaryCard/>;
}
}
return (
<StyledContent style={{ "--delay": `${delay}ms` }}>
{children}
</StyledContent>
);
}
+1
View File
@@ -0,0 +1 @@
export { Skeleton } from "./Skeleton";
+1 -1
View File
@@ -6,7 +6,7 @@ export const Table = styled.div``;
Table.Body = styled.div`
border: 2px solid ${theme.colors["solid-on-card"]};
border-radius: 8px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
`;
-1
View File
@@ -5,5 +5,4 @@ export { SongTitle } from "./SongTitle";
export { Performances } from "./Performances";
export { SongTitleWithArtists } from "./SongTitleWithArtists";
export { HorizontalScroll } from "./HorizontalScroll";
export { Highlight } from "./Highlight";
export { ErrorBoundary } from "./ErrorBoundary";
+2 -2
View File
@@ -19,12 +19,12 @@ import { Column, Row } from "components/box";
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";
import { SongTitle } from "components/utils";
import useSetting from "hooks/useSetting";
import { GlobalVolume } from "utils/settings";
import { VIDEO_URL } from "utils/config";
export function VideoPlayer({ anime, theme, entry, video, background, ...props }) {
const [isPlaying, setPlaying] = useState(false);
@@ -33,7 +33,7 @@ export function VideoPlayer({ anime, theme, entry, video, background, ...props }
const progressRef = useRef();
const { clearCurrentVideo } = useContext(PlayerContext);
const isMobile = useMediaQuery(`(max-width: ${styledTheme.breakpoints.mobileMax})`);
const videoUrl = `${videoBaseUrl}/${video.basename}`;
const videoUrl = `${VIDEO_URL}/${video.basename}`;
const router = useRouter();
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
+1 -1
View File
@@ -2,7 +2,7 @@ import { createContext } from "react";
const ColorThemeContext = createContext({
colorTheme: null,
toggleColorTheme: () => {}
setColorTheme: () => {}
});
export default ColorThemeContext;
-85
View File
@@ -1,85 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useToasts } from "context/toastContext";
import { PlaylistAddToast } from "components/toast";
import { ThemeSummaryCard } from "components/card";
const LocalPlaylistContext = createContext();
export function LocalPlaylistProvider({ children }) {
const { dispatchToast } = useToasts();
const [ localPlaylist, setLocalPlaylist ] = useState([]);
const reload = useCallback(() => setLocalPlaylist(load()), [ setLocalPlaylist ]);
const addToPlaylist = useCallback((theme) => {
ThemeSummaryCard.fetchData(theme.id).then((themeFiltered) => {
const localPlaylist = load();
localPlaylist.push(themeFiltered);
save(localPlaylist);
reload();
dispatchToast(theme.id, <PlaylistAddToast theme={theme}/>);
});
}, [ dispatchToast, reload ]);
const removeFromPlaylist = useCallback((theme) => {
const localPlaylist = load();
const index = localPlaylist.findIndex((t) => t.id === theme.id);
if (index !== -1) {
localPlaylist.splice(index, 1);
}
save(localPlaylist);
reload();
}, [ reload ]);
const isInPlaylist = useCallback((theme) => !!localPlaylist.find((t) => t.id === theme.id), [ localPlaylist ]);
const setPlaylist = useCallback((playlist) => {
save(playlist);
reload();
}, [ reload ]);
useEffect(() => {
reload();
window.addEventListener("storage", reload);
return () => window.removeEventListener("storage", reload);
}, [ reload ]);
const value = {
localPlaylist,
addToPlaylist,
removeFromPlaylist,
isInPlaylist,
setPlaylist
};
return (
<LocalPlaylistContext.Provider value={value}>
{children}
</LocalPlaylistContext.Provider>
);
}
export function useLocalPlaylist() {
return useContext(LocalPlaylistContext);
}
function load() {
const raw = window.localStorage.getItem("local-playlist");
if (raw) {
return JSON.parse(raw);
}
return [];
}
function save(localPlaylist) {
window.localStorage.setItem("local-playlist", JSON.stringify(localPlaylist));
}
-77
View File
@@ -1,77 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { ThemeSummaryCard } from "components/card";
const WatchHistoryContext = createContext();
export function WatchHistoryProvider({ children }) {
const [ history, setHistory ] = useState([]);
const reload = useCallback(() => setHistory(load()), [ setHistory ]);
const addToHistory = useCallback((theme) => {
ThemeSummaryCard.fetchData(theme.id).then((themeFiltered) => {
let history = load();
// Don't add if the most recent entry is the same as the new one
if (history[history.length - 1]?.id === theme.id) {
return;
}
// Remove all previous occurences of the theme to avoid duplicates
history = history.filter((t) => t.id !== theme.id);
history.push(themeFiltered);
// Keep history below 100 entries
if (history.length > 100) {
history.shift();
}
save(history);
reload();
});
}, [ reload ]);
const clearHistory = useCallback(() => {
save([]);
reload();
}, [ reload ]);
useEffect(() => {
reload();
window.addEventListener("storage", reload);
return () => window.removeEventListener("storage", reload);
}, [ reload ]);
const value = {
history,
addToHistory,
clearHistory
};
return (
<WatchHistoryContext.Provider value={value}>
{children}
</WatchHistoryContext.Provider>
);
}
export function useWatchHistory() {
return useContext(WatchHistoryContext);
}
function load() {
const raw = window.localStorage.getItem("history");
if (raw) {
return JSON.parse(raw);
}
return [];
}
function save(history) {
window.localStorage.setItem("history", JSON.stringify(history));
}
+9 -17
View File
@@ -1,24 +1,16 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import useLocalStorageState from "use-local-storage-state";
export default function useColorTheme() {
const [ theme, setTheme ] = useState(null);
const [ theme, setTheme ] = useLocalStorageState("theme", { ssr: true }, "system");
useEffect(() => {
const body = document.body;
setTheme(document.body.getAttribute("theme"));
}, [setTheme]);
setTheme(body.getAttribute("theme"));
}, []);
useEffect(() => {
document.body.setAttribute("theme", theme);
}, [theme]);
function toggleTheme() {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
const body = document.body;
body.setAttribute("theme", newTheme);
window.localStorage.setItem("theme", newTheme);
}
return [theme, toggleTheme];
return [theme, setTheme];
}
+37
View File
@@ -0,0 +1,37 @@
import useLocalStorageState from "use-local-storage-state";
import { ThemeSummaryCard } from "components/card";
import { PlaylistAddToast } from "components/toast";
import { useToasts } from "context/toastContext";
export default function useLocalPlaylist() {
const [ localPlaylist, setLocalPlaylist ] = useLocalStorageState("local-playlist", { ssr: true, defaultValue: [] });
const { dispatchToast } = useToasts();
function addToPlaylist(theme) {
ThemeSummaryCard.fetchData(theme.id).then((themeFiltered) => {
setLocalPlaylist([ ...localPlaylist, themeFiltered ]);
dispatchToast(theme.id, <PlaylistAddToast theme={theme}/>);
});
}
function removeFromPlaylist(theme) {
setLocalPlaylist(localPlaylist.filter((t) => t.id !== theme.id));
}
function isInPlaylist(theme) {
return !!localPlaylist.find((t) => t.id === theme.id);
}
function setPlaylist(playlist) {
setLocalPlaylist(playlist);
}
return {
localPlaylist,
addToPlaylist,
removeFromPlaylist,
isInPlaylist,
setPlaylist,
};
}
+3 -27
View File
@@ -1,31 +1,7 @@
import { useEffect, useState } from "react";
import useLocalStorageState from "use-local-storage-state";
export default function useSetting({ __KEY__: key, __INITIAL_VALUE__: initialValue }) {
const [setting, setSetting] = useState(initialValue);
const [setting, setSetting] = useLocalStorageState(key, { ssr: true, defaultValue: initialValue });
useEffect(() => {
function reload() {
const localSetting = window.localStorage.getItem(key);
if (localSetting !== null) {
setSetting(localSetting);
}
}
reload();
window.addEventListener("storage", reload);
return () => window.removeEventListener("storage", reload);
}, [ key ]);
function updateSetting(value) {
setSetting(value);
window.localStorage.setItem(key, value);
}
return [
setting,
updateSetting
];
return [setting, setSetting];
}
+37
View File
@@ -0,0 +1,37 @@
import useLocalStorageState from "use-local-storage-state";
import { ThemeSummaryCard } from "components/card";
export default function useLocalPlaylist() {
const [ history, setHistory ] = useLocalStorageState("history", { ssr: true, defaultValue: [] });
function addToHistory(theme) {
ThemeSummaryCard.fetchData(theme.id).then((themeFiltered) => {
// Don't add if the most recent entry is the same as the new one
if (history[history.length - 1]?.id === theme.id) {
return;
}
// Remove all previous occurences of the theme to avoid duplicates
const newHistory = history.filter((t) => t.id !== theme.id);
newHistory.push(themeFiltered);
// Keep history below 100 entries
if (newHistory.length > 100) {
newHistory.shift();
}
setHistory(newHistory);
});
}
function clearHistory() {
setHistory([]);
}
return {
history,
addToHistory,
clearHistory,
};
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { baseUrl } from "lib/client/api";
import { CLIENT_API_URL } from "utils/config";
export async function fetchAnnouncements() {
const res = await fetch(`${baseUrl}/announcement`);
const res = await fetch(`${CLIENT_API_URL}/announcement`);
const json = await res.json();
return json.announcements;
-2
View File
@@ -1,2 +0,0 @@
export const baseUrl = process.env.NEXT_PUBLIC_API_URL;
export const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
+14
View File
@@ -0,0 +1,14 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
import { buildFetchData } from "lib/common";
import typeDefsAnimeThemes from "lib/common/animethemes/type-defs";
import resolversAnimeThemes from "lib/common/animethemes/resolvers";
const schema = makeExecutableSchema({
typeDefs: typeDefsAnimeThemes,
resolvers: resolversAnimeThemes
});
export const fetchDataClient = buildFetchData(schema);
+5 -13
View File
@@ -1,14 +1,6 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
export async function fetchDataClient(...args) {
// Code-split anything GraphQL related to reduce initial bundle size.
const { fetchDataClient } = await import("./chunk");
import { buildFetchData } from "lib/common";
import typeDefsAnimeThemes from "lib/common/animethemes/type-defs";
import resolversAnimeThemes from "lib/common/animethemes/resolvers";
const schema = makeExecutableSchema({
typeDefs: typeDefsAnimeThemes,
resolvers: resolversAnimeThemes
});
export const fetchDataClient = buildFetchData(schema);
return fetchDataClient(...args);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { baseUrl } from "lib/client/api";
import { CLIENT_API_URL } from "utils/config";
export async function fetchRandomGrill() {
const res = await fetch(`${baseUrl}/image?filter[facet]=Grill&sort=random&page[size]=1`);
const res = await fetch(`${CLIENT_API_URL}/image?filter[facet]=Grill&sort=random&page[size]=1`);
const json = await res.json();
return json.images[0].link;
+2 -2
View File
@@ -1,10 +1,10 @@
import { baseUrl } from "lib/client/api";
import { CLIENT_API_URL } from "utils/config";
const backLog = [];
export async function fetchRandomTheme() {
if (!backLog.length) {
const res = await fetch(`${baseUrl}/animetheme?sort=random&include=anime,animethemeentries.videos&filter[has]=animethemeentries&filter[spoiler]=false`);
const res = await fetch(`${CLIENT_API_URL}/animetheme?sort=random&include=anime,animethemeentries.videos&filter[has]=animethemeentries&filter[spoiler]=false`);
const json = await res.json();
backLog.push(...json.animethemes.map((theme) => {
+3 -3
View File
@@ -1,5 +1,5 @@
import { baseUrl } from "lib/client/api";
import { uniq } from "lodash-es";
import { CLIENT_API_URL } from "utils/config";
const entityConfigs = {
anime: {
@@ -139,7 +139,7 @@ const entityConfigs = {
export async function fetchGlobalSearchResults(query, limit, entities) {
const parameters = generateGlobalSearchParameters(entities);
const res = await fetch(`${baseUrl}/search?${parameters.join("&")}&limit=${limit}&q=${encodeURIComponent(query)}`);
const res = await fetch(`${CLIENT_API_URL}/search?${parameters.join("&")}&limit=${limit}&q=${encodeURIComponent(query)}`);
const json = await res.json();
if (!res.ok) {
@@ -172,7 +172,7 @@ export async function fetchEntitySearchResults({
...generateFilterAndSortParameters(filters, sortBy)
];
let url = `${baseUrl}/${entityConfigs[entity].singular || entity}?${parameters.join("&")}&page[size]=${limit}&page[number]=${page}`;
let url = `${CLIENT_API_URL}/${entityConfigs[entity].singular || entity}?${parameters.join("&")}&page[size]=${limit}&page[number]=${page}`;
if (query) {
url += `&q=${encodeURIComponent(query)}`;
}
+18 -6
View File
@@ -1,10 +1,11 @@
import pLimit from "p-limit";
import { parseResolveInfo } from "graphql-parse-resolve-info";
import devLog from "utils/devLog";
import { CLIENT_API_URL, SERVER_API_KEY, SERVER_API_URL } from "utils/config";
const limit = pLimit(5);
export const API_BASE_URL = `${process.env.ANIMETHEMES_API_URL || process.env.NEXT_PUBLIC_API_URL}`;
export const API_URL = `${SERVER_API_URL || CLIENT_API_URL}`;
export const INCLUDES = {
Anime: {
@@ -131,7 +132,7 @@ export function apiResolver(config) {
devLog.warn(`Deep fetch at: ${path}`);
}
let url = `${API_BASE_URL}${endpoint(parent, args)}`;
let url = `${API_URL}${endpoint(parent, args)}`;
const includes = getIncludes(info, baseInclude);
if (baseInclude) {
@@ -165,8 +166,19 @@ export function apiResolver(config) {
return await limit(() => (async () => {
if (!pagination) {
const json = await fetchJson(url);
context.apiRequests++;
let json;
if (context?.cache?.has(url)) {
devLog.info("CACHED: " + url);
json = context.cache.get(url);
} else {
json = await fetchJson(url);
context.apiRequests++;
if (!context.cache) {
context.cache = new Map();
}
context.cache.set(url, json);
}
return transformer(extractor(json, parent, args), parent, args);
} else {
@@ -190,9 +202,9 @@ export function apiResolver(config) {
async function fetchJson(url) {
const config = {};
if (process.env.ANIMETHEMES_API_KEY) {
if (SERVER_API_KEY) {
config.headers = {
Authorization: `Bearer ${process.env.ANIMETHEMES_API_KEY}`
Authorization: `Bearer ${SERVER_API_KEY}`
};
}
+50
View File
@@ -0,0 +1,50 @@
{
"best-opening-ii": {
"slug": "best-opening",
"name": "Best Anime Opening II"
},
"best-opening-iii": {
"slug": "best-anime-opening-1",
"name": "Best Anime Opening III"
},
"best-opening-iv": {
"slug": "r-anime-s-best-opening-contest-",
"name": "Best Anime Opening IV"
},
"best-opening-v": {
"slug": "best-opening-v5",
"name": "Best Anime Opening V"
},
"best-opening-vi": {
"slug": "best-anime-opening-vi-a-salty-angel-s-thesis",
"name": "Best Anime Opening VI: A Salty Angel's Thesis"
},
"best-opening-vii": {
"slug": "best-anime-opening-vii-3-2-1-let-s-salt-",
"name": "Best Anime Opening VII: 3, 2, 1, Let's Salt!"
},
"best-opening-ix": {
"slug": "best-anime-opening-ix-salty-arrow",
"name": "Best Anime Opening IX: Guren na Yumiya"
},
"best-ending-ii": {
"slug": "r-anime-s-best-ending-contest-",
"name": "Best Anime Ending II"
},
"best-ending-iii": {
"slug": "best-ending-iii-the-salt-you-don-t-know",
"name": "Best Anime Ending III: The Salt You Don't Know"
},
"best-ending-iv": {
"slug": "best-ending-iv-don-t-say-salty-",
"name": "Best Anime Ending IV: Don't say \"salty\""
},
"best-ending-v": {
"slug": "best-ending-v-na-thank-you-",
"name": "Best Anime Ending V: Na, Thank You!"
},
"best-ending-vi": {
"slug": "best-ending-6-listen-to-salt-",
"name": "Best Anime Ending VI: Listen to Salt!!"
}
}
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -1,20 +1,38 @@
import brackets from "./brackets.json";
import mappings from "./mappings.json";
import { apiResolver } from "lib/common/animethemes/api";
import devLog from "utils/devLog";
const resolvers = {
Query: {
bracket: async (_, { slug }) => {
const bracket = await fetchJson(`https://animebracket.com/api/bracket/${slug}`);
const rounds = await fetchJson(`https://animebracket.com/api/results/${slug}`);
const currentRound = await fetchJson(`https://animebracket.com/api/rounds/${slug}`);
const config = brackets[slug];
if (!config) {
return null;
}
devLog.info(`Fetching bracket: ${config.slug}`);
const bracket = await fetchJson(`https://animebracket.com/api/bracket/${config.slug}`);
const rounds = await fetchJson(`https://animebracket.com/api/results/${config.slug}`);
// Some brackets are broken and still in a running state. We can check if the bracket has a winner already and
// not fetch the current round in that case.
const currentRound = bracket.winnerCharacterId ? null : await fetchJson(`https://animebracket.com/api/rounds/${config.slug}`);
devLog.info(`Fetched bracket: ${config.slug} (${rounds.length} rounds)`);
return {
slug,
name: bracket.name,
name: config.name,
currentRound,
currentGroup: currentRound,
rounds
};
},
bracketAll: async () => {
return Object.entries(brackets).map(([slug, bracket]) => ({
slug,
name: bracket.name,
}));
}
},
Bracket: {
+1
View File
@@ -3,6 +3,7 @@ const typeDefs = `
type Query {
bracket(slug: String): Bracket
bracketAll: [Bracket]
}
type Bracket {
+16 -6
View File
@@ -15,8 +15,6 @@ import Head from "next/head";
import withBasePath from "utils/withBasePath";
import { SEO } from "components/seo";
import { config } from "@fortawesome/fontawesome-svg-core";
import { WatchHistoryProvider } from "context/watchHistoryContext";
import { LocalPlaylistProvider } from "context/localPlaylistContext";
import { ToastProvider } from "context/toastContext";
import { AnnouncementToast, ToastHub } from "components/toast";
import { Text } from "components/text";
@@ -24,6 +22,9 @@ import { useRouter } from "next/router";
import useSetting from "hooks/useSetting";
import { DeveloperMode, RevalidationToken } from "utils/settings";
import { ErrorBoundary } from "components/utils";
import { STAGING } from "utils/config";
import { Card } from "components/card";
import { ExternalLink } from "components/external-link";
import "@fortawesome/fontawesome-svg-core/styles.css";
import "styles/prism.scss";
@@ -46,7 +47,7 @@ const StyledContainer = styled(Container)`
export default function MyApp({ Component, pageProps }) {
const router = useRouter();
const [colorTheme, toggleColorTheme] = useColorTheme();
const [colorTheme, setColorTheme] = useColorTheme();
const [developerMode] = useSetting(DeveloperMode);
const { lastBuildAt, apiRequests, isVideoPage = false, ...videoPageProps } = pageProps;
@@ -74,14 +75,12 @@ export default function MyApp({ Component, pageProps }) {
return (
<MultiContextProvider providers={[
[ ThemeProvider, { theme } ],
[ ColorThemeContext.Provider, { value: { colorTheme, toggleColorTheme } } ],
[ ColorThemeContext.Provider, { value: { colorTheme, setColorTheme } } ],
[ PlayerContext.Provider, { value: {
currentVideo: lastVideoPageProps?.video,
clearCurrentVideo: () => setLastVideoPageProps(null)
} } ],
[ QueryClientProvider, { client: queryClient } ],
[ WatchHistoryProvider ],
[ LocalPlaylistProvider ],
[ ToastProvider, { initialToasts: [ {
id: "announcement",
content: <AnnouncementToast/>
@@ -113,6 +112,17 @@ export default function MyApp({ Component, pageProps }) {
{pageProps.isSearch && (
<SearchNavigation/>
)}
{STAGING ? (
<Card color="text-warning">
<Text as="p">
<strong>WARNING</strong>: This version of the website is for testing purposes only.
Some pages or functions might not work.
</Text>
<Text as="p">
<ExternalLink href={`https://animethemes.moe${router.asPath}`}>CLICK HERE TO GO BACK TO THE NORMAL SITE</ExternalLink>
</Text>
</Card>
) : null}
<Component {...pageProps}/>
{developerMode === DeveloperMode.ENABLED && lastBuildAt && (
<PageRevalidation lastBuildAt={lastBuildAt} apiRequests={apiRequests}/>
+2 -2
View File
@@ -5,7 +5,7 @@ const ThemeInjection = () => {
// language=JavaScript
const injectTheme = `
(function() {
let theme = window.localStorage.getItem("theme");
let theme = JSON.parse(window.localStorage.getItem("theme"));
if (!theme && window.matchMedia("(prefers-color-scheme: light)").matches) {
theme = "light";
}
@@ -34,7 +34,7 @@ export default class MyDocument extends Document {
rel="stylesheet"
/>
</Head>
<body theme="dark">
<body theme="system">
<ThemeInjection/>
<Main/>
<NextScript/>
@@ -8,11 +8,10 @@ import { AnimeSummaryCard, ArtistSummaryCard, SummaryCard, ThemeSummaryCard } fr
import useImage from "hooks/useImage";
import { fetchData } from "lib/server";
import { SEO } from "components/seo";
import { videoBaseUrl } from "lib/client/api";
import { faChevronDown, faMinus, faPlus, faShare } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { useWatchHistory } from "context/watchHistoryContext";
import { useLocalPlaylist } from "context/localPlaylistContext";
import useWatchHistory from "hooks/useWatchHistory";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import styled from "styled-components";
import theme from "theme";
import createVideoSlug from "utils/createVideoSlug";
@@ -23,6 +22,7 @@ import { Menu } from "components/menu";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
import { ThemeEntryTags, VideoTags } from "components/tag";
import { VIDEO_URL } from "utils/config";
const StyledVideoInfo = styled.div`
display: grid;
@@ -166,7 +166,7 @@ export default function VideoPage({ anime, theme, entry, video }) {
.then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
};
const videoUrl = `${videoBaseUrl}/${video.basename}`;
const videoUrl = `${VIDEO_URL}/${video.basename}`;
return (
<>
@@ -420,7 +420,7 @@ export async function getStaticProps({ params: { animeSlug, videoSlug } }) {
}
export async function getStaticPaths() {
return fetchStaticPaths(async (isStaging) => {
return fetchStaticPaths(async () => {
const { data } = await fetchData(gql`
query {
animeAll {
@@ -441,14 +441,7 @@ export async function getStaticPaths() {
}
`);
let anime = data.animeAll;
if (isStaging) {
// In staging, we only want to pre-render the video pages for the neweset 100 anime.
anime = anime.sort((a, b) => b.id - a.id).slice(0, 100);
}
return anime.flatMap(
return data.animeAll.flatMap(
(anime) => anime.themes.flatMap(
(theme) => theme.entries.flatMap(
(entry) => entry.videos.flatMap(
+2 -9
View File
@@ -224,7 +224,7 @@ export async function getStaticProps({ params: { animeSlug } }) {
}
export async function getStaticPaths() {
return fetchStaticPaths(async (isStaging) => {
return fetchStaticPaths(async () => {
const { data } = await fetchData(gql`
query {
animeAll {
@@ -234,14 +234,7 @@ export async function getStaticPaths() {
}
`);
let anime = data.animeAll;
if (isStaging) {
// In staging, we only want to pre-render the newest 100 anime pages.
anime = anime.sort((a, b) => b.id - a.id).slice(0, 100);
}
return anime.map((anime) => ({
return data.animeAll.map((anime) => ({
params: {
animeSlug: anime.slug
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { fetchData } from "lib/server";
import gql from "graphql-tag";
import createVideoSlug from "utils/createVideoSlug";
import { BASE_PATH, REVALIDATE_TOKEN } from "utils/config";
export default async function handler(req, res) {
const { secret, id, resource, mode = "page" } = req.query;
if (secret !== process.env.REVALIDATE_TOKEN) {
if (secret !== REVALIDATE_TOKEN) {
return res.status(401).json({ message: "Invalid token." });
}
@@ -28,7 +29,7 @@ export default async function handler(req, res) {
}
for (const path of paths) {
await res.unstable_revalidate(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}${path}`);
await res.unstable_revalidate(`${BASE_PATH}${path}`);
}
return res.json({ revalidated: true, affectedPaths: paths });
+2 -1
View File
@@ -18,7 +18,7 @@ import {
import { Icon } from "components/icon";
import styled from "styled-components";
import theme from "theme";
import { Collapse, HeightTransition, Highlight } from "components/utils";
import { Collapse, HeightTransition } from "components/utils";
import ColorThemeContext from "context/colorThemeContext";
import { ExternalLink } from "components/external-link";
import { Switcher } from "components/switcher";
@@ -35,6 +35,7 @@ import { useToasts } from "context/toastContext";
import { Input } from "components/input";
import gql from "graphql-tag";
import getSharedPageProps from "utils/getSharedPageProps";
import { Highlight } from "components/utils/Highlight";
const ColorGrid = styled.div`
display: grid;
+25 -98
View File
@@ -1,36 +1,24 @@
import styled from "styled-components";
import Link from "next/link";
import { SEO } from "components/seo";
import { Text } from "components/text";
import { Column } from "components/box";
import { SummaryCard } from "components/card";
import { faAward, faSeedling, faStopwatch, faUsers } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { Column, Solid } from "components/box";
import { faStopwatch } from "@fortawesome/pro-solid-svg-icons";
import theme from "theme";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { SongTitleWithArtists } from "components/utils";
import { useState } from "react";
import { Switcher } from "components/switcher";
import { fetchData } from "lib/server";
import getSharedPageProps from "utils/getSharedPageProps";
import fetchStaticPaths from "utils/fetchStaticPaths";
import gql from "graphql-tag";
import { CornerIcon } from "components/icon/CornerIcon";
import { BracketThemeSummaryCard } from "components/bracket/BracketThemeSummaryCard";
import { BracketChart } from "components/bracket/BracketChart";
const CornerIcon = styled(Icon).attrs({
size: "2x"
})`
position: absolute;
right: 0;
top: 0;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
const CurrentRound = styled.div`
const CurrentRound = styled(Solid)`
position: relative;
margin: 0 -2rem;
padding: 2rem;
border-radius: 0.5rem;
background-color: ${theme.colors["solid"]};
`;
const StyledHeader = styled.div`
@@ -56,6 +44,7 @@ export default function BracketPage({ bracket }) {
<>
<SEO title={bracket.name} />
<Text variant="h1">{bracket.name}</Text>
<BracketChart bracket={bracket}/>
{!!bracket.currentRound && (
<BracketRound round={bracket.currentRound} initialGroup={String(bracket.currentGroup)} isCurrent/>
)}
@@ -147,8 +136,8 @@ function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<ThemeSummaryCard
theme={contestant.theme}
<BracketThemeSummaryCard
contestant={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
@@ -157,74 +146,6 @@ function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }
);
}
const StyledSummaryCard = styled(SummaryCard)`
position: relative;
width: 100%;
padding-inline-end: 24px;
opacity: var(--opacity);
`;
const StyledRank = styled(Text)`
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
`;
function ThemeSummaryCard({ theme, isVoted, isWinner, seed, votes, ...props }) {
const { smallCover } = useImage(theme.anime);
if (!theme.entries.length) {
return null;
}
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>{theme.slug}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref prefetch={false}>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
</SummaryCard.Description>
);
return (
<StyledSummaryCard
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/>}
description={description}
image={smallCover}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
{...props}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
</StyledSummaryCard>
);
}
export async function getStaticProps({ params: { bracketSlug } }) {
const { data, apiRequests } = await fetchData(`
#graphql
@@ -235,6 +156,7 @@ export async function getStaticProps({ params: { bracketSlug } }) {
name
source
theme {
id
slug
anime {
slug
@@ -312,14 +234,19 @@ export async function getStaticProps({ params: { bracketSlug } }) {
}
export async function getStaticPaths() {
return {
paths: [
{
params: {
bracketSlug: "best-anime-opening-ix-salty-arrow"
return fetchStaticPaths(async () => {
const { data } = await fetchData(gql`
query {
bracketAll {
slug
}
}
],
fallback: false
};
`);
return data.bracketAll.map((bracket) => ({
params: {
bracketSlug: bracket.slug
}
}));
}, true);
}
+28 -32
View File
@@ -12,40 +12,42 @@ import getSharedPageProps from "utils/getSharedPageProps";
const BigButton = styled(Button)`
overflow: hidden;
border-radius: 8px;
border-radius: ${theme.scalars.borderRadiusCard};
width: 100%;
height: 64px;
height: 48px;
justify-content: flex-end;
text-align: end;
gap: 8px;
gap: 16px;
`;
const BigIcon = styled(Icon)`
margin-right: auto;
font-size: 2em;
const BigIcon = styled(Icon)`
font-size: 1.5em;
color: ${theme.colors["text-disabled"]};
`;
const StyledEventGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
const StyledEventList = styled.div`
display: flex;
flex-direction: column;
grid-gap: 16px;
`;
const StyledEventName = styled(Text)`
margin-right: auto;
`;
export default function EventPage({ awards, brackets }) {
return (
<>
<SEO title="Events" description="Watch themes featured in awards and brackets."/>
<Text variant="h1">Events</Text>
<StyledEventGrid>
<StyledEventList>
<Column style={{ "--gap": "16px" }}>
<Text variant="h2">Awards</Text>
{awards.map(({ name, path }) => (
<Link key={path} href={path} passHref prefetch={false}>
<BigButton forwardedAs="a">
<BigIcon icon={faAward}/>
<Text>{name}</Text>
<StyledEventName>{name}</StyledEventName>
<Icon icon={faArrowRight} color="text-primary"/>
</BigButton>
</Link>
@@ -57,13 +59,13 @@ export default function EventPage({ awards, brackets }) {
<Link key={path} href={path} passHref prefetch={false}>
<BigButton forwardedAs="a">
<BigIcon icon={faTrophy}/>
<Text>{name}</Text>
<StyledEventName>{name}</StyledEventName>
<Icon icon={faArrowRight} color="text-primary"/>
</BigButton>
</Link>
))}
</Column>
</StyledEventGrid>
</StyledEventList>
</>
);
}
@@ -75,32 +77,26 @@ export async function getStaticProps() {
path: "/event/anime-awards"
}
];
let totalApiRequests = 0;
const brackets = await Promise.all([
"best-anime-opening-ix-salty-arrow"
].map(async (bracket) => {
const { data, apiRequests } = await fetchData(`
#graphql
const { data, apiRequests } = await fetchData(`
#graphql
query($bracketSlug: String!) {
bracket(slug: $bracketSlug) {
name
}
query {
bracketAll {
slug
name
}
`, { bracketSlug: bracket });
}
`);
totalApiRequests += apiRequests;
return {
name: data.bracket.name,
path: `/event/${bracket}`
};
const brackets = data.bracketAll.map((bracket) => ({
name: bracket.name,
path: `/event/${bracket.slug}`
}));
return {
props: {
...getSharedPageProps(totalApiRequests),
...getSharedPageProps(apiRequests),
awards,
brackets
}
+9 -3
View File
@@ -17,6 +17,8 @@ import { FeaturedTheme } from "components/featured-theme";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import getSharedPageProps from "utils/getSharedPageProps";
import { range } from "lodash-es";
import { Skeleton } from "components/skeleton";
const BigButton = styled(Button)`
justify-content: flex-end;
@@ -82,7 +84,7 @@ const About = styled(Column)`
`;
export default function HomePage({ featuredTheme }) {
const [ recentlyAdded, setRecentlyAdded ] = useState([]);
const [ recentlyAdded, setRecentlyAdded ] = useState(null);
const { currentYear, currentSeason } = useCurrentSeason();
useEffect(() => {
@@ -116,8 +118,12 @@ export default function HomePage({ featuredTheme }) {
</MainGridArea>
<RecentlyAdded>
{recentlyAdded.map((theme, index) => (
<ThemeSummaryCard key={index} theme={theme}/>
{range(10).map((index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{(recentlyAdded !== null && recentlyAdded[index]) ? (
<ThemeSummaryCard theme={recentlyAdded[index]}/>
) : null}
</Skeleton>
))}
</RecentlyAdded>
+2 -2
View File
@@ -2,7 +2,7 @@ import { Text } from "components/text";
import styled from "styled-components";
import { Button } from "components/button";
import { useState } from "react";
import { API_BASE_URL } from "lib/common/animethemes/api";
import { API_URL } from "lib/common/animethemes/api";
import { SEO } from "components/seo";
import getSharedPageProps from "utils/getSharedPageProps";
@@ -61,7 +61,7 @@ function Grill({ grill }) {
}
export async function getStaticProps() {
const res = await fetch(`${API_BASE_URL}/image?filter[facet]=Grill&page[size]=100`);
const res = await fetch(`${API_URL}/image?filter[facet]=Grill&page[size]=100`);
const json = await res.json();
return {
+15 -9
View File
@@ -5,17 +5,12 @@ import { Text } from "components/text";
import { SummaryCard, ThemeSummaryCard } from "components/card";
import { IconTextButton } from "components/button";
import { faKey, faTrash } from "@fortawesome/pro-solid-svg-icons";
import { useWatchHistory } from "context/watchHistoryContext";
import { useLocalPlaylist } from "context/localPlaylistContext";
import useWatchHistory from "hooks/useWatchHistory";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import theme from "theme";
import { SearchFilter, SearchFilterGroup } from "components/search-filter";
import { Listbox } from "components/listbox";
import {
DeveloperMode,
FeaturedThemePreview,
RevalidationToken,
ShowAnnouncements
} from "utils/settings";
import { ColorTheme, DeveloperMode, FeaturedThemePreview, RevalidationToken, ShowAnnouncements } from "utils/settings";
import useSetting from "hooks/useSetting";
import { Input } from "components/input";
@@ -49,6 +44,7 @@ export default function ProfilePage() {
const [featuredThemePreview, setFeaturedThemePreview] = useSetting(FeaturedThemePreview);
const [developerMode, setDeveloperMode] = useSetting(DeveloperMode);
const [revalidationToken, setRevalidationToken] = useSetting(RevalidationToken);
const [colorTheme, setColorTheme] = useSetting(ColorTheme);
return (
<>
@@ -62,6 +58,14 @@ export default function ProfilePage() {
<SummaryCard title="Local Playlist" description={`${localPlaylist.length} themes`} to="/profile/playlist"/>
<Text variant="h2">Settings</Text>
<SearchFilterGroup>
<SearchFilter>
<Text>Color Theme</Text>
<Listbox value={colorTheme} onChange={setColorTheme}>
<Listbox.Option value={ColorTheme.SYSTEM}>System</Listbox.Option>
<Listbox.Option value={ColorTheme.DARK}>Dark</Listbox.Option>
<Listbox.Option value={ColorTheme.LIGHT}>Light</Listbox.Option>
</Listbox>
</SearchFilter>
<SearchFilter>
<Text>Show Announcements</Text>
<Listbox value={showAnnouncements} onChange={setShowAnnouncements}>
@@ -77,6 +81,8 @@ export default function ProfilePage() {
<Listbox.Option value={FeaturedThemePreview.DISABLED}>Disabled</Listbox.Option>
</Listbox>
</SearchFilter>
</SearchFilterGroup>
<SearchFilterGroup>
<SearchFilter>
<Text>Developer Mode</Text>
<Listbox value={developerMode} onChange={setDeveloperMode}>
@@ -88,7 +94,7 @@ export default function ProfilePage() {
<SearchFilter>
<Text>Revalidation Token</Text>
<Input
value={revalidationToken}
value={revalidationToken ?? null}
onChange={setRevalidationToken}
icon={faKey}
/>
+2 -2
View File
@@ -20,7 +20,7 @@ import {
} from "utils/comparators";
import { SEO } from "components/seo";
import { Reorder } from "framer-motion";
import { useLocalPlaylist } from "context/localPlaylistContext";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import theme from "theme";
import { MultiCoverImage } from "components/image";
import { faExclamationCircle } from "@fortawesome/pro-solid-svg-icons";
@@ -99,7 +99,7 @@ export default function PlaylistPage() {
<Text variant="h1">Local Playlist</Text>
<SidebarContainer>
<StyledDesktopOnly>
<MultiCoverImage resourcesWithImages={localPlaylist.map((theme) => theme?.anime)}/>
<MultiCoverImage key={JSON.stringify(localPlaylist)} resourcesWithImages={localPlaylist.map((theme) => theme?.anime)}/>
</StyledDesktopOnly>
<Column style={{ "--gap": "24px" }}>
<StyledHeader>
+20 -10
View File
@@ -8,16 +8,6 @@ export default createGlobalStyle`
box-sizing: border-box;
}
[theme="light"] {
${createCssDefinition(colors)}
${createCssDefinition(shadows)}
}
[theme="dark"] {
${createCssDefinition(darkColors)}
${createCssDefinition(darkShadows)}
}
html {
// Always show a vertical scroll bar, even if the page
// is not scrollable to prevent layout shift.
@@ -35,6 +25,26 @@ export default createGlobalStyle`
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
&[theme="system"] {
${createCssDefinition(darkColors)}
${createCssDefinition(darkShadows)}
@media (prefers-color-scheme: light) {
${createCssDefinition(colors)}
${createCssDefinition(shadows)}
}
}
&[theme="light"] {
${createCssDefinition(colors)}
${createCssDefinition(shadows)}
}
&[theme="dark"] {
${createCssDefinition(darkColors)}
${createCssDefinition(darkShadows)}
}
}
html, body, #__next {
+5 -9
View File
@@ -1,20 +1,16 @@
import { css, keyframes } from "styled-components";
const loadingAnimationKeyframes = keyframes`
0% {
background-position: 0 0;
}
100% {
background-position: 100% 100%;
}
50% {
background-color: rgba(46, 41, 58, 0.5);
}
`;
export const loadingAnimation = css`
background: radial-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.25)) no-repeat;
background-size: 500% 500%;
background-color: rgba(46, 41, 58, 1);
@media (prefers-reduced-motion: no-preference) {
animation: ${loadingAnimationKeyframes} 2s infinite alternate linear;
animation: ${loadingAnimationKeyframes} 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`;
+3
View File
@@ -18,6 +18,9 @@ const theme = {
toast: 20,
menuOverlay: 2,
menuPopover: 1
},
scalars: {
borderRadiusCard: "8px"
}
};
+51
View File
@@ -0,0 +1,51 @@
const { error, warn } = require("next/dist/build/output/log");
const chalk = require("next/dist/lib/chalk").default;
// Server-side
const SERVER_API_URL = process.env.ANIMETHEMES_API_URL;
const SERVER_API_KEY = process.env.ANIMETHEMES_API_KEY;
const REVALIDATE_TOKEN = process.env.REVALIDATE_TOKEN;
const ANALYZE = !!process.env.ANALYZE;
// Server-side + Client-side
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const CLIENT_API_URL = process.env.NEXT_PUBLIC_API_URL;
const VIDEO_URL = process.env.NEXT_PUBLIC_VIDEO_URL;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL;
const STAGING = !!process.env.NEXT_PUBLIC_STAGING;
function validateConfig() {
let isValid = true;
if (!SERVER_API_URL && !CLIENT_API_URL) {
error(`You need to either specify ${chalk.cyan("ANIMETHEMES_API_URL")} or ${chalk.cyan("NEXT_PUBLIC_API_URL")} for API requests to work.`);
isValid = false;
}
if (SERVER_API_URL && !CLIENT_API_URL) {
warn(`It is highly recommended to specify ${chalk.cyan("NEXT_PUBLIC_API_URL")}. Otherwise API on the client-side won't work.`);
}
if (!VIDEO_URL) {
warn(`It is recommended to specify ${chalk.cyan("NEXT_PUBLIC_VIDEO_URL")}. Otherwise videos won't play.`);
}
if (!APP_URL) {
warn(`You haven't specified ${chalk.cyan("NEXT_PUBLIC_APP_URL")}. This is fine for development, but should be fixed in production.`);
}
return isValid;
}
module.exports = {
SERVER_API_URL,
SERVER_API_KEY,
REVALIDATE_TOKEN,
ANALYZE,
BASE_PATH,
CLIENT_API_URL,
VIDEO_URL,
APP_URL,
STAGING,
validateConfig,
};
+6 -6
View File
@@ -1,15 +1,15 @@
export default async function fetchStaticPaths(fetchPaths) {
// In development all pages should be fetched on-demand. This speeds up page generation a lot.
if (process.env.NODE_ENV === "development") {
import { STAGING } from "utils/config";
export default async function fetchStaticPaths(fetchPaths, allPathsInStaging = false) {
// In development and staging all pages should be fetched on-demand. This speeds up page generation a lot.
if (process.env.NODE_ENV === "development" || (STAGING && !allPathsInStaging)) {
return {
paths: [],
fallback: "blocking"
};
}
const isStaging = !!process.env.NEXT_PUBLIC_STAGING;
const paths = await fetchPaths(isStaging);
const paths = await fetchPaths();
return {
paths,
+9 -1
View File
@@ -27,5 +27,13 @@ export const RevalidationToken = Object.freeze({
export const GlobalVolume = Object.freeze({
__KEY__: "volume",
__INITIAL_VALUE__: 1
__INITIAL_VALUE__: 1,
});
export const ColorTheme = Object.freeze({
__KEY__: "theme",
__INITIAL_VALUE__: "system",
SYSTEM: "system",
DARK: "dark",
LIGHT: "light",
});
+3 -1
View File
@@ -1,3 +1,5 @@
import { BASE_PATH } from "utils/config";
export default function withBasePath(path) {
return `${process.env.NEXT_PUBLIC_BASE_PATH || ""}${path}`;
return `${BASE_PATH}${path}`;
}