mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
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:
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=https://staging.animethemes.moe
|
||||
+1
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+23
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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"]};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Skeleton } from "./Skeleton";
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext } from "react";
|
||||
|
||||
const ColorThemeContext = createContext({
|
||||
colorTheme: null,
|
||||
toggleColorTheme: () => {}
|
||||
setColorTheme: () => {}
|
||||
});
|
||||
|
||||
export default ColorThemeContext;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -3,6 +3,7 @@ const typeDefs = `
|
||||
|
||||
type Query {
|
||||
bracket(slug: String): Bracket
|
||||
bracketAll: [Bracket]
|
||||
}
|
||||
|
||||
type Bracket {
|
||||
|
||||
+16
-6
@@ -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}/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ const theme = {
|
||||
toast: 20,
|
||||
menuOverlay: 2,
|
||||
menuPopover: 1
|
||||
},
|
||||
scalars: {
|
||||
borderRadiusCard: "8px"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user