mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-12 01:54:37 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 963707c82e | |||
| 5e265b3a92 | |||
| 2a78c4cf56 | |||
| c8de140bfe | |||
| 9f8e4da7cb | |||
| 055bb7bea7 | |||
| 855443c6c8 | |||
| d068dcd422 | |||
| c4f83e67ce | |||
| 562868e1fa | |||
| 02798df253 | |||
| 8119526e9e | |||
| 144973ba18 | |||
| c22bb00763 | |||
| 23998c4c64 | |||
| abd3ef6c17 | |||
| 9d2d981632 | |||
| 6392ff5967 | |||
| 5a69a427c7 | |||
| 8e99040807 | |||
| 62eaff6dc1 | |||
| 2995543309 | |||
| de0a8e49c4 | |||
| 46e390eaed | |||
| 39efac2233 | |||
| 7b34229866 | |||
| a0c7351249 | |||
| 7f5f8ef4fa | |||
| 02e42fa3c1 | |||
| e9b4133490 | |||
| 418a6a3dde | |||
| f879241752 | |||
| 3ba96d7236 | |||
| e6fce90ca7 | |||
| f294b1bad2 | |||
| 753c8d01d0 | |||
| de45d719bc | |||
| 2ccd6ee6df | |||
| ef5c9d0325 | |||
| 787e14727a | |||
| 0ef42a79d0 | |||
| 0edf1daa17 | |||
| 70aab886c1 | |||
| 350defb4b3 | |||
| 2168ac6c55 | |||
| bfe3edddbf | |||
| c628185bea | |||
| 2ebf4437ed | |||
| 14a25b725a | |||
| 48b8f1628a | |||
| 09b92ef6f6 |
@@ -29,10 +29,15 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.npmrc
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE (WebStorm)
|
||||
*.iml
|
||||
/.idea
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
## Configuration
|
||||
|
||||
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
|
||||
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
|
||||
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.
|
||||
; you can also use the production or beta 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
|
||||
@@ -37,6 +37,10 @@ ANIMETHEMES_API_KEY=...
|
||||
; The token to use for manual revalidation.
|
||||
REVALIDATE_TOKEN=secret
|
||||
|
||||
; Set to any truthy value to activate minimal build mode.
|
||||
; In minimal build mode, only a small subset of pages get prerendered at build time.
|
||||
MINIMAL_BUILD=true
|
||||
|
||||
; Set to any truthy value to activate the bundle analyzer.
|
||||
ANALYZE=true
|
||||
|
||||
@@ -51,12 +55,18 @@ 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
|
||||
; The URL from which audio files should be served.
|
||||
NEXT_PUBLIC_AUDIO_URL=https://a.animethemes.moe
|
||||
|
||||
; The URL to use for authentication API requests.
|
||||
NEXT_PUBLIC_AUTH_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
|
||||
; In staging mode a warning banner is displayed at the top of the page.
|
||||
NEXT_PUBLIC_STAGING=true
|
||||
|
||||
; To enable verbose logging.
|
||||
NEXT_PUBLIC_VERBOSE_LOGS=true
|
||||
```
|
||||
|
||||
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
schema: [
|
||||
"src/lib/common/animethemes/type-defs.ts",
|
||||
"src/lib/server/animebracket/type-defs.ts",
|
||||
"src/lib/client/search.ts",
|
||||
],
|
||||
documents: [
|
||||
"src/**/*.js",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
],
|
||||
generates: {
|
||||
"src/generated/graphql.ts": {
|
||||
plugins: [
|
||||
"typescript",
|
||||
"typescript-operations",
|
||||
],
|
||||
config: {
|
||||
avoidOptionals: {
|
||||
object: true,
|
||||
field: true,
|
||||
inputValue: false,
|
||||
},
|
||||
enumsAsTypes: true,
|
||||
skipTypename: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
overwrite: true
|
||||
schema:
|
||||
- "src/lib/common/animethemes/type-defs.ts"
|
||||
- "src/lib/server/animebracket/type-defs.ts"
|
||||
- "src/lib/client/search.ts"
|
||||
documents:
|
||||
- "src/**/*.js"
|
||||
- "src/**/*.ts"
|
||||
- "src/**/*.tsx"
|
||||
generates:
|
||||
src/generated/graphql.ts:
|
||||
plugins:
|
||||
- "typescript"
|
||||
- "typescript-operations"
|
||||
config:
|
||||
avoidOptionals:
|
||||
object: true
|
||||
field: true
|
||||
inputValue: false
|
||||
enumsAsTypes: true
|
||||
skipTypename: true
|
||||
require:
|
||||
- ts-node/register
|
||||
@@ -1,27 +0,0 @@
|
||||
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: ANALYZE,
|
||||
});
|
||||
|
||||
if (!validateConfig()) {
|
||||
error("Shutting down because of invalid configuration...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (STAGING) {
|
||||
info("Running animethemes-web in staging mode!");
|
||||
}
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
basePath: BASE_PATH,
|
||||
reactStrictMode: true,
|
||||
compiler: {
|
||||
styledComponents: true
|
||||
},
|
||||
staticPageGenerationTimeout: 3600,
|
||||
experimental: {
|
||||
newNextLinkBehavior: true
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// @ts-check
|
||||
|
||||
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "./src/utils/config.mjs";
|
||||
import { error, info } from "./src/utils/log.mjs";
|
||||
|
||||
import NextBundleAnalyzer from "@next/bundle-analyzer";
|
||||
|
||||
const withBundleAnalyzer = NextBundleAnalyzer({
|
||||
enabled: ANALYZE,
|
||||
});
|
||||
|
||||
if (!validateConfig()) {
|
||||
error("Shutting down because of invalid configuration...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (STAGING) {
|
||||
info("Running animethemes-web in staging mode!");
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
const nextConfig = {
|
||||
basePath: BASE_PATH,
|
||||
reactStrictMode: true,
|
||||
compiler: {
|
||||
styledComponents: true
|
||||
},
|
||||
staticPageGenerationTimeout: 3600,
|
||||
experimental: {
|
||||
// We don't want to multi-thread page building
|
||||
// to make use of caching between page builds.
|
||||
workerThreads: false,
|
||||
cpus: 1,
|
||||
},
|
||||
transpilePackages: [
|
||||
"ahooks"
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on"
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "1; mode=block"
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN"
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff"
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "origin-when-cross-origin"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/wiki/blog/:slug*",
|
||||
destination: "/blog/:slug*",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/wiki/status/:slug*",
|
||||
destination: "/blog/status_:slug*",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export default withBundleAnalyzer(nextConfig);
|
||||
Generated
+8782
-4232
File diff suppressed because it is too large
Load Diff
+28
-17
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.yml"
|
||||
"graphql-codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
@@ -17,28 +17,40 @@
|
||||
"@graphql-tools/schema": "^8.2.0",
|
||||
"@graphql-tools/utils": "^8.6.5",
|
||||
"@next/bundle-analyzer": "^12.0.3",
|
||||
"@reach/listbox": "^0.17.0",
|
||||
"@reach/menu-button": "^0.17.0",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-select": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"ahooks": "^3.7.6",
|
||||
"axios": "^1.2.2",
|
||||
"common-tags": "^1.8.0",
|
||||
"framer-motion": "^6.5.1",
|
||||
"framer-motion": "^10.12.4",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-parse-resolve-info": "^4.12.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"hast-util-has-property": "^2.0.1",
|
||||
"hast-util-heading-rank": "^2.1.1",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^4.0.12",
|
||||
"next": "^12.2.5",
|
||||
"md5": "^2.3.0",
|
||||
"next": "^13.3.0",
|
||||
"next-mdx-remote": "^4.3.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"prismjs": "^1.27.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-query": "^3.29.0",
|
||||
"rehype-parse": "^8.0.4",
|
||||
"rehype-react": "^7.0.4",
|
||||
"rehype-pretty-code": "^0.9.4",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.43.4",
|
||||
"shiki": "^0.14.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"unified": "^10.1.2",
|
||||
"use-local-storage-state": "^17.2.0",
|
||||
"use-session-storage-state": "^17.2.0"
|
||||
"swr": "^2.2.4",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"use-local-storage-state": "^18.3.0",
|
||||
"use-session-storage-state": "^18.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.9.1",
|
||||
@@ -46,15 +58,14 @@
|
||||
"@graphql-codegen/typescript-operations": "2.4.1",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/marked": "^4.0.3",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/node": "^17.0.41",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react": "^18.0.12",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.27.1",
|
||||
"@typescript-eslint/parser": "^5.27.1",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-next": "^12.0.8",
|
||||
"eslint-config-next": "^13.3.0",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.7.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import useAuth from "hooks/useAuth";
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "components/text";
|
||||
import { Column, Row } from "components/box";
|
||||
import { LoginDialog } from "components/dialog/LoginDialog";
|
||||
import { RegisterDialog } from "components/dialog/RegisterDialog";
|
||||
|
||||
interface LoginGateProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LoginGate({ children }: LoginGateProps) {
|
||||
const { me } = useAuth();
|
||||
|
||||
if (me.user) {
|
||||
// User is already logged in, so we can safely show the content.
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
<Text>You need to log in to continue:</Text>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
<LoginDialog />
|
||||
<RegisterDialog />
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import styled from "styled-components";
|
||||
import { Button } from "./Button";
|
||||
import { Icon } from "components/icon";
|
||||
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import PlayerContext from "context/playerContext";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
|
||||
const ScrollButton = styled(m(Button))<{ $bottomOffset: number }>`
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: ${(props) => 16 + props.$bottomOffset}px;
|
||||
padding: 16px;
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
`}
|
||||
`;
|
||||
|
||||
export function BackToTopButton() {
|
||||
const [isButtonVisible, setIsButtonVisible] = useState(false);
|
||||
const { currentWatchListItem } = useContext(PlayerContext);
|
||||
|
||||
function scrollUp() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleScroll() {
|
||||
if (window.scrollY > 2000) {
|
||||
setIsButtonVisible(true);
|
||||
} else {
|
||||
setIsButtonVisible(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isButtonVisible ? (
|
||||
<ScrollButton
|
||||
key={"scrollButton"}
|
||||
initial={{ y: "150%" }}
|
||||
animate={{ y: "0" }}
|
||||
exit={{ y: "450%" }}
|
||||
variant="primary"
|
||||
isCircle={true}
|
||||
onClick={scrollUp}
|
||||
onMouseDown={(event: React.PointerEvent) => event.preventDefault()}
|
||||
$bottomOffset={currentWatchListItem ? 76 : 0}
|
||||
>
|
||||
<Icon icon={faChevronUp} />
|
||||
</ScrollButton>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import { Solid } from "components/box";
|
||||
@@ -9,7 +9,7 @@ export const Button = forwardRef(ButtonWithRef);
|
||||
|
||||
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
|
||||
children?: ReactNode
|
||||
variant?: "solid" | "primary" | "silent"
|
||||
variant?: "solid" | "primary" | "warning" | "silent"
|
||||
isCircle?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -26,6 +26,8 @@ function ButtonWithRef({
|
||||
Component = SolidButton;
|
||||
} else if (variant === "primary") {
|
||||
Component = PrimaryButton;
|
||||
} else if (variant === "warning") {
|
||||
Component = WarningButton;
|
||||
} else if (variant === "silent") {
|
||||
Component = SilentButton;
|
||||
} else {
|
||||
@@ -46,11 +48,13 @@ function ButtonWithRef({
|
||||
|
||||
const BaseButton = styled.button<{ $isCircle: boolean }>`
|
||||
--gap: 0;
|
||||
--focus-ring-color: ${theme.colors["text-primary"]};
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
|
||||
pointer-events: ${(props) => props.disabled && "none"};
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
@@ -71,20 +75,51 @@ const BaseButton = styled.button<{ $isCircle: boolean }>`
|
||||
box-shadow: none;
|
||||
margin: -8px 8px -8px -16px;
|
||||
}
|
||||
|
||||
&:focus:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-color);
|
||||
}
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled(BaseButton)`
|
||||
background-color: ${theme.colors["solid-primary"]};
|
||||
color: ${theme.colors["text-on-primary"]};
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["text-on-primary"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
`}
|
||||
|
||||
&:focus:focus-visible {
|
||||
background-color: ${theme.colors["text-on-primary"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
const WarningButton = styled(BaseButton)`
|
||||
--focus-ring-color: ${theme.colors["text-warning"]};
|
||||
|
||||
background-color: ${theme.colors["solid-warning"]};
|
||||
color: ${theme.colors["text-on-warning"]};
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["text-on-warning"]};
|
||||
color: ${theme.colors["text-warning"]};
|
||||
`}
|
||||
|
||||
&:focus:focus-visible {
|
||||
background-color: ${theme.colors["text-on-warning"]};
|
||||
color: ${theme.colors["text-warning"]};
|
||||
}
|
||||
`;
|
||||
|
||||
const SolidButton = styled(BaseButton)`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
color: ${theme.colors["text"]};
|
||||
`)}
|
||||
`}
|
||||
|
||||
${Solid} & {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
@@ -95,19 +130,26 @@ const SilentButton = styled(BaseButton)`
|
||||
background-color: transparent;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text"]};
|
||||
`)}
|
||||
`}
|
||||
|
||||
&:not(:hover) {
|
||||
&:not(:hover)&:not(:focus:focus-visible) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
${Solid} & {
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`)}
|
||||
`}
|
||||
}
|
||||
|
||||
${Solid} ${Solid} & {
|
||||
${withHover`
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import { Icon } from "components/icon";
|
||||
import { Button } from "components/button";
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
|
||||
gap: 8px;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledText = styled.span<{ collapsible: boolean }>`
|
||||
${(props) => props.collapsible && css`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
const StyledText = styled.span<{ $collapseBreakpoint: string }>`
|
||||
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
|
||||
icon: IconDefinition
|
||||
children: ReactNode
|
||||
collapsible?: boolean
|
||||
icon: IconDefinition | ReactNode
|
||||
children?: ReactNode
|
||||
collapsible?: true | keyof typeof theme.breakpoints
|
||||
}
|
||||
|
||||
export function IconTextButton({ icon, children, collapsible = false, ...props }: IconTextButtonProps) {
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
const isCollapsed = collapsible && isMobile;
|
||||
export const IconTextButton = forwardRef(
|
||||
function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) {
|
||||
let collapseBreakpoint = "0px";
|
||||
|
||||
return (
|
||||
<StyledButton variant="silent" isCircle={isCollapsed} {...props}>
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
<StyledText collapsible={collapsible}>{children}</StyledText>
|
||||
</StyledButton>
|
||||
);
|
||||
if (collapsible === true) {
|
||||
collapseBreakpoint = theme.breakpoints.mobileMax;
|
||||
} else if (collapsible) {
|
||||
collapseBreakpoint = theme.breakpoints[collapsible];
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
|
||||
{isIconDefinition(icon) ? (
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
) : icon}
|
||||
{(children !== null && children !== undefined) ? (
|
||||
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
|
||||
) : null}
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
|
||||
return !!icon && typeof icon === "object" && "icon" in icon;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import PlayerContext from "context/playerContext";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { Icon } from "components/icon";
|
||||
import { Button } from "components/button";
|
||||
import { VideoTags } from "components/tag";
|
||||
import { VideoTags } from "components/tag/VideoTags";
|
||||
import gql from "graphql-tag";
|
||||
import type {
|
||||
VideoButtonAnimeFragment,
|
||||
@@ -25,7 +25,7 @@ interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
|
||||
const { currentVideo } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentVideo ? currentVideo.filename === video.filename : false;
|
||||
const isPlaying = currentVideo ? currentVideo.id === video.id : false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -63,7 +63,7 @@ VideoButton.fragments = {
|
||||
|
||||
fragment VideoButtonVideo on Video {
|
||||
...VideoTagsVideo
|
||||
filename
|
||||
id
|
||||
tags
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Button } from "./Button";
|
||||
export { VideoButton } from "./VideoButton";
|
||||
export { FilterToggleButton } from "./FilterToggleButton";
|
||||
export { IconTextButton } from "./IconTextButton";
|
||||
export { BackToTopButton } from "./BackToTopButton";
|
||||
@@ -84,7 +84,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Anime</span>
|
||||
<span>{anime.media_format ?? "Anime"}</span>
|
||||
{!!anime.year && (
|
||||
<TextLink href={premiereLink}>
|
||||
{premiere}
|
||||
@@ -161,6 +161,7 @@ AnimeSummaryCard.fragments = {
|
||||
name
|
||||
year
|
||||
season
|
||||
media_format
|
||||
themes {
|
||||
group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import { Markdown } from "components/markdown/Markdown";
|
||||
import { AnimeAwardsNowAvailable } from "components/event/AnimeAwardsNowAvailable";
|
||||
|
||||
interface AnnouncementCardProps {
|
||||
announcementSource: MDXRemoteSerializeResult;
|
||||
}
|
||||
|
||||
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
|
||||
return (
|
||||
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,9 @@ export const Card = styled(Solid)<{
|
||||
${(props) => props.hoverable && css`
|
||||
cursor: pointer;
|
||||
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`)}
|
||||
`}
|
||||
`}
|
||||
|
||||
&:before {
|
||||
|
||||
@@ -19,7 +19,7 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
|
||||
`;
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: any
|
||||
error: unknown
|
||||
}
|
||||
|
||||
export function ErrorCard({ error }: ErrorCardProps) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SummaryCard } from "components/card/SummaryCard";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import gql from "graphql-tag";
|
||||
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "generated/graphql";
|
||||
import type { ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Text } from "components/text";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
type PlaylistSummaryCardProps = {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner?: false;
|
||||
} | {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner: true;
|
||||
};
|
||||
|
||||
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Playlist</span>
|
||||
<span>{playlist.visibility}</span>
|
||||
{showOwner ? (
|
||||
<Text link>{playlist.user.name}</Text>
|
||||
) : null}
|
||||
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
|
||||
{children}
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</StyledOverlayButtons>
|
||||
) : null}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistSummaryCard.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistSummaryCardPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
tracks_count
|
||||
}
|
||||
`,
|
||||
showOwner: gql`
|
||||
fragment PlaylistSummaryCardShowOwner on Playlist {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -100,9 +100,8 @@ export function SummaryCard({
|
||||
</ConditionalWrapper>
|
||||
<StyledBody>
|
||||
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
|
||||
{typeof title === "string" ? (
|
||||
to ? <TextLink href={to}>{title}</TextLink> : title
|
||||
) : title}
|
||||
{typeof title === "string" && to ?
|
||||
<TextLink href={to}>{title}</TextLink> : title}
|
||||
</Text>
|
||||
{!!description && (
|
||||
<Text variant="small" maxLines={1}>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Text } from "components/text";
|
||||
import styled, { css } from "styled-components";
|
||||
import { Card } from "components/card";
|
||||
import { Column } from "components/box";
|
||||
import withBasePath from "utils/withBasePath";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { loadingAnimation } from "styles/mixins";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
const StyledSummaryCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
height: 64px;
|
||||
padding: 0 1rem 0 4px;
|
||||
`;
|
||||
|
||||
const StyledCover = styled.img.attrs({
|
||||
loading: "lazy"
|
||||
})<{
|
||||
objectFit?: Property.ObjectFit
|
||||
backgroundColor?: Property.Background
|
||||
isLoading?: boolean
|
||||
isPlaceholder?: boolean
|
||||
}>`
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: ${(props) => props.objectFit ?? "cover"};
|
||||
background: ${(props) => props.backgroundColor};
|
||||
|
||||
${(props) => props.isPlaceholder ? css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background-color: white;
|
||||
` : (props.isLoading ? loadingAnimation : null)}
|
||||
`;
|
||||
|
||||
const StyledBody = styled(Column)`
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
|
||||
children?: ReactNode
|
||||
};
|
||||
|
||||
export function SummaryCard({ children, ...props }: SummaryCardProps) {
|
||||
return (
|
||||
<StyledSummaryCard {...props}>
|
||||
{children}
|
||||
</StyledSummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
SummaryCard.Body = StyledBody;
|
||||
|
||||
interface SummaryCardTitleProps extends ComponentPropsWithoutRef<typeof Text> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCardTitleProps) {
|
||||
return (
|
||||
<Text maxLines={1} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardDescriptionProps {
|
||||
children: string | Array<ReactNode>
|
||||
}
|
||||
|
||||
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
|
||||
return (
|
||||
<Text variant="small" maxLines={1} color="text-muted">
|
||||
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
|
||||
<>
|
||||
{child}
|
||||
{index < length - 1 && (
|
||||
<span> • </span>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCover> {
|
||||
src?: string;
|
||||
}
|
||||
|
||||
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
|
||||
const [ imageNotFound, setImageNotFound ] = useState(false);
|
||||
const [ imageLoading, setImageLoading ] = useState(true);
|
||||
|
||||
return (
|
||||
<StyledCover
|
||||
alt="Cover"
|
||||
src={(!imageNotFound && src) || withBasePath("/img/logo.svg")}
|
||||
isLoading={imageLoading}
|
||||
isPlaceholder={!src || imageNotFound}
|
||||
loading="lazy"
|
||||
{...props}
|
||||
onLoad={(event) => {
|
||||
setImageLoading(false);
|
||||
if (props?.onLoad) {
|
||||
props.onLoad(event);
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageNotFound(true);
|
||||
setImageLoading(false);
|
||||
if (props?.onError) {
|
||||
props.onError();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { VideoButton } from "components/button";
|
||||
import { Performances, SongTitle } from "components/utils";
|
||||
import { Text } from "components/text";
|
||||
import { ThemeMenu } from "components/menu";
|
||||
import { ThemeEntryTags } from "components/tag";
|
||||
import { ThemeMenu } from "components/menu/ThemeMenu";
|
||||
import { ThemeEntryTags } from "components/tag/ThemeEntryTags";
|
||||
import { VideoTags } from "components/tag/VideoTags";
|
||||
import gql from "graphql-tag";
|
||||
import type { ThemeDetailCardThemeFragment } from "generated/graphql";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { Collapse, SongTitleWithArtists } from "components/utils";
|
||||
import { Collapse, Performances, SongTitle, SongTitleWithArtists } from "components/utils";
|
||||
import { Text } from "components/text";
|
||||
import extractImages from "utils/extractImages";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { SummaryCard } from "components/card";
|
||||
import { ThemeMenu } from "components/menu";
|
||||
import { SummaryCard } from "components/card/SummaryCard2";
|
||||
import { ThemeMenu } from "components/menu/ThemeMenu";
|
||||
import gql from "graphql-tag";
|
||||
import { fetchDataClient } from "lib/client";
|
||||
import { Icon } from "components/icon";
|
||||
@@ -66,14 +66,16 @@ type ThemeSummaryCardProps = {
|
||||
theme: ThemeSummaryCardThemeFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable?: false
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void
|
||||
} | {
|
||||
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable: true
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void
|
||||
};
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function ThemeSummaryCard({ theme, artist, children, expandable, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -87,34 +89,41 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const to = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Theme</span>
|
||||
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
function handleToggleExpand(event: MouseEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement && event.target.href) {
|
||||
if (isLink(event.target)) {
|
||||
event.stopPropagation();
|
||||
} else if (expandable && !isMobile) {
|
||||
toggleExpanded();
|
||||
}
|
||||
}
|
||||
|
||||
function isLink(element: EventTarget | null): boolean {
|
||||
if (!element || !(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
} else if (element instanceof HTMLAnchorElement && element.href) {
|
||||
return true;
|
||||
}
|
||||
return isLink(element.parentElement);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard
|
||||
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={to}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
>
|
||||
<SummaryCard onClick={handleToggleExpand} {...props}>
|
||||
<Link href={href} onClick={() => onPlay?.()}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
</Link>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} as={Link} href={href} onClick={() => onPlay?.()} />
|
||||
<Performances song={theme.song} artist={artist} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{children}
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
<ThemeMenu theme={theme}/>
|
||||
@@ -137,7 +146,7 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
|
||||
{expandable && (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledPerformedWith>
|
||||
<ThemeTable themes={[theme]}/>
|
||||
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
|
||||
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
|
||||
<Table style={{ "--columns": "1fr" }}>
|
||||
<TableHead>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Performances, SongTitle, SongTitleWithArtists } from "components/utils";
|
||||
import extractImages from "utils/extractImages";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { SummaryCard } from "components/card/SummaryCard2";
|
||||
import gql from "graphql-tag";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
|
||||
import { type ForwardedRef, forwardRef, type ReactNode } from "react";
|
||||
import { TextLink } from "components/text/TextLink";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "components/icon";
|
||||
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCoverLink = styled(Link)`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledCoverOverlay = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
interface VideoSummaryCardProps {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
menu?: ReactNode;
|
||||
onPlay?(): void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export const VideoSummaryCard = forwardRef(
|
||||
function VideoSummaryCard({ video, menu, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
|
||||
const entry = video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={ref}>
|
||||
<SummaryCard {...props}>
|
||||
<StyledCoverLink href={href} onClick={onPlay}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
{isPlaying ? (
|
||||
<StyledCoverOverlay>
|
||||
<Icon icon={faPlay} />
|
||||
</StyledCoverOverlay>
|
||||
) : null}
|
||||
</StyledCoverLink>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
|
||||
<Performances song={theme.song} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>{videoSlug}{theme.group && ` (${theme.group})`}</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</StyledOverlayButtons>
|
||||
) : null}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const VideoSummaryCardFragmentVideo = gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
slug
|
||||
type
|
||||
sequence
|
||||
group
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,7 +2,7 @@ import styled from "styled-components";
|
||||
|
||||
export const Container = styled.div`
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 24px 16px;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { forwardRef } from "react";
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { Card } from "components/card";
|
||||
import theme from "theme";
|
||||
import { Text } from "components/text";
|
||||
|
||||
const overlayAnimation = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const contentAnimation = keyframes`
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOverlay = styled(RadixDialog.Overlay)`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 32px 16px;
|
||||
overflow: auto;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: ${overlayAnimation} 250ms;
|
||||
`;
|
||||
|
||||
const StyledDialogCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
animation: ${contentAnimation} 250ms;
|
||||
|
||||
margin: auto;
|
||||
padding: 24px;
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
const StyledHeader = styled.div`
|
||||
margin: -24px -24px 24px -24px;
|
||||
padding: 16px 24px;
|
||||
background-color: ${theme.colors["background"]};
|
||||
`;
|
||||
|
||||
interface DialogContentProps extends RadixDialog.DialogContentProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
function DialogContent({ title, children, ...props }, ref) {
|
||||
return (
|
||||
<RadixDialog.Portal>
|
||||
<StyledOverlay>
|
||||
<RadixDialog.Content asChild {...props} ref={ref}>
|
||||
<StyledDialogCard>
|
||||
{title ? (
|
||||
<StyledHeader>
|
||||
<Text variant="h2">{title}</Text>
|
||||
</StyledHeader>
|
||||
) : null}
|
||||
{children}
|
||||
</StyledDialogCard>
|
||||
</RadixDialog.Content>
|
||||
</StyledOverlay>
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Dialog = RadixDialog.Root;
|
||||
|
||||
export const DialogTrigger = RadixDialog.Trigger;
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "components/button";
|
||||
import { Input } from "components/form";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import styled from "styled-components";
|
||||
import useAuth from "hooks/useAuth";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import { Toast } from "components/toast";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
export function ForgotPasswordDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Text variant="small" link color="text-muted">Forgot Password?</Text>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Request a Password Reset">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<ForgotPasswordForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForgotPasswordProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
|
||||
const { forgotPassword } = useAuth();
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const isValid = !!email;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
}>({});
|
||||
|
||||
async function performForgotPassword(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await forgotPassword({
|
||||
email,
|
||||
});
|
||||
|
||||
dispatchToast(
|
||||
"forgot-password-sent",
|
||||
<Toast>We sent you an e-mail with a password reset link.</Toast>
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setErrors(error.response.data.errors);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performForgotPassword}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail</Text>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text color="text-warning">{errors.email}</Text>
|
||||
) : null}
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Submit</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "components/button";
|
||||
import { Input } from "components/form";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import styled from "styled-components";
|
||||
import useAuth from "hooks/useAuth";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import Switch from "components/form/Switch";
|
||||
import { ForgotPasswordDialog } from "components/dialog/ForgotPasswordDialog";
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
export function LoginDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Login</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Login">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginForm onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function LoginForm({ onCancel }: LoginFormProps) {
|
||||
const { login } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isRemember, setRemember] = useState(false);
|
||||
|
||||
const isValid = email && password;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
}>({});
|
||||
|
||||
function performLogin(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
|
||||
login({
|
||||
setErrors,
|
||||
email,
|
||||
password,
|
||||
remember: isRemember,
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performLogin}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail</Text>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text color="text-warning">{errors.email}</Text>
|
||||
) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Password</Text>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
<ForgotPasswordDialog />
|
||||
</SearchFilter>
|
||||
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
|
||||
<Switch
|
||||
id="input-remember"
|
||||
isChecked={isRemember}
|
||||
onCheckedChange={setRemember}
|
||||
/>
|
||||
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
|
||||
</Row>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Login</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Input } from "components/form";
|
||||
import { Button } from "components/button";
|
||||
import styled from "styled-components";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import axios from "lib/client/axios";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { Toast } from "components/toast";
|
||||
|
||||
export function PasswordChangeDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Change Password</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Change Password">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<PasswordChangeForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface PasswordChangeFormProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
|
||||
|
||||
const isValid = currentPassword && newPassword && newPasswordConfirmation;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
current_password?: string[];
|
||||
password?: string[];
|
||||
}>({});
|
||||
|
||||
async function performChangePassword(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
"/user/password",
|
||||
{
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: newPasswordConfirmation,
|
||||
}
|
||||
);
|
||||
|
||||
dispatchToast(
|
||||
"password-change",
|
||||
<Toast>Password changed successfully.</Toast>
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setErrors(error.response.data.errors);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performChangePassword}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Current Password</Text>
|
||||
<Input
|
||||
value={currentPassword}
|
||||
onChange={setCurrentPassword}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.current_password ? errors.current_password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>New Password</Text>
|
||||
<Input
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm New Password</Text>
|
||||
<Input
|
||||
value={newPasswordConfirmation}
|
||||
onChange={setNewPasswordConfirmation}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Change Password</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Input } from "components/form";
|
||||
import { Button } from "components/button";
|
||||
import styled from "styled-components";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Dialog, DialogContent } from "components/dialog/Dialog";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { Toast } from "components/toast";
|
||||
import { useRouter } from "next/router";
|
||||
import useAuth from "hooks/useAuth";
|
||||
|
||||
export function PasswordResetDialog() {
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => setOpen(true), []);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent title="Reset Password">
|
||||
<PasswordResetForm
|
||||
onSuccess={() => router.push("/profile")}
|
||||
onCancel={() => router.push("/")}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
|
||||
const { resetPassword } = useAuth();
|
||||
const { dispatchToast } = useToasts();
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
|
||||
const token = router.query.token;
|
||||
|
||||
const isValid = email && newPassword && newPasswordConfirmation;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
}>({});
|
||||
|
||||
async function performResetPassword(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await resetPassword({
|
||||
email,
|
||||
password: newPassword,
|
||||
password_confirmation: newPasswordConfirmation,
|
||||
token,
|
||||
});
|
||||
|
||||
dispatchToast(
|
||||
"password-reset",
|
||||
<Toast>Password reset successfully.</Toast>
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setErrors(error.response.data.errors);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performResetPassword}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail</Text>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>New Password</Text>
|
||||
<Input
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm New Password</Text>
|
||||
<Input
|
||||
value={newPasswordConfirmation}
|
||||
onChange={setNewPasswordConfirmation}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Reset Password</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Button, IconTextButton } from "components/button";
|
||||
import { faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Input } from "components/form";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
import axios from "lib/client/axios";
|
||||
import styled from "styled-components";
|
||||
import { LoginGate } from "components/auth/LoginGate";
|
||||
import { mutate } from "swr";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
interface PlaylistAddDialogProps {
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Create a new playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistAddForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface PlaylistAddFormProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [visibility, setVisibility] = useState("PUBLIC");
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await axios.post("/api/playlist", {
|
||||
name: title,
|
||||
visibility,
|
||||
});
|
||||
await mutate((key) => [key].flat().includes("/api/me/playlist"));
|
||||
} catch (error: unknown) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
setError(error.response.data.message ?? "An unknown error occured!");
|
||||
}
|
||||
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={submit}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Title</Text>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
<Listbox value={visibility} onValueChange={setVisibility}>
|
||||
<ListboxOption value="PUBLIC">Public</ListboxOption>
|
||||
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
|
||||
<ListboxOption value="PRIVATE">Private</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Create Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Button, IconTextButton } from "components/button";
|
||||
import { faPen } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Input } from "components/form";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
import axios from "lib/client/axios";
|
||||
import styled from "styled-components";
|
||||
import { LoginGate } from "components/auth/LoginGate";
|
||||
import { mutate } from "swr";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import { isAxiosError } from "axios";
|
||||
import gql from "graphql-tag";
|
||||
import type { PlaylistEditDialogPlaylistFragment } from "generated/graphql";
|
||||
|
||||
interface PlaylistEditDialogProps {
|
||||
playlist: PlaylistEditDialogPlaylistFragment;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Edit playlist details">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistEditForm
|
||||
playlist={playlist}
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistEditDialog.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistEditDialogPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistEditDialogPlaylistFragment;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
|
||||
const [title, setTitle] = useState(playlist.name);
|
||||
const [visibility, setVisibility] = useState<string>(playlist.visibility);
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await axios.put(`/api/playlist/${playlist.id}`, {
|
||||
name: title,
|
||||
visibility,
|
||||
});
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
} catch (error: unknown) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
setError(error.response.data.message ?? "An unknown error occured!");
|
||||
}
|
||||
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={submit}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Title</Text>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
<Listbox value={visibility} onValueChange={setVisibility}>
|
||||
<ListboxOption value="Public">Public</ListboxOption>
|
||||
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
|
||||
<ListboxOption value="Private">Private</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Update Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Button, IconTextButton } from "components/button";
|
||||
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import axios from "lib/client/axios";
|
||||
import { LoginGate } from "components/auth/LoginGate";
|
||||
import { mutate } from "swr";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import gql from "graphql-tag";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { PlaylistRemoveToast } from "components/toast/PlaylistRemoveToast";
|
||||
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
|
||||
import type { PlaylistRemoveDialogPlaylistFragment } from "generated/graphql";
|
||||
|
||||
interface PlaylistRemoveDialogProps {
|
||||
playlist: PlaylistRemoveDialogPlaylistFragment;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Delete playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistRemoveForm
|
||||
playlist={playlist}
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistRemoveDialog.fragments = {
|
||||
playlist: gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistRemoveToast.fragments.playlist}
|
||||
|
||||
fragment PlaylistRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistRemoveFormProps {
|
||||
playlist: PlaylistRemoveDialogPlaylistFragment;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
|
||||
async function removePlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/playlist/${playlist.id}`);
|
||||
await mutate((key) => [key].flat().includes("/api/me/playlist"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-${playlist.id}`,
|
||||
<PlaylistRemoveToast playlist={playlist} />
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
|
||||
<PlaylistSummaryCard playlist={playlist} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
|
||||
<Busy isBusy={isBusy}>Delete playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Button, IconTextButton } from "components/button";
|
||||
import { faArrowDown, faListMusic, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Column, Row } from "components/box";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import axios from "lib/client/axios";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { Icon } from "components/icon";
|
||||
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackAddDialogVideoFragment,
|
||||
PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables
|
||||
} from "generated/graphql";
|
||||
import { Text } from "components/text";
|
||||
import { PlaylistTrackAddToast } from "components/toast/PlaylistTrackAddToast";
|
||||
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
|
||||
import { LoginGate } from "components/auth/LoginGate";
|
||||
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
|
||||
import gql from "graphql-tag";
|
||||
import { Skeleton } from "components/skeleton/Skeleton";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import { fetchDataClient } from "lib/client";
|
||||
import { PlaylistAddDialog } from "components/dialog/PlaylistAddDialog";
|
||||
|
||||
interface PlaylistTrackAddDialogProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Add to playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistTrackAddForm
|
||||
video={video}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackAddDialog.fragments = {
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${PlaylistTrackRemoveToast.fragments.video}
|
||||
|
||||
fragment PlaylistTrackAddDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...PlaylistTrackRemoveToastVideo
|
||||
id
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistTrackAddFormProps {
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
const { data: playlists } = useSWR(
|
||||
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
|
||||
async () => {
|
||||
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
|
||||
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
|
||||
me {
|
||||
playlistAll {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
id
|
||||
tracks_count
|
||||
}
|
||||
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
|
||||
id
|
||||
tracks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { filterVideoId: video.id });
|
||||
|
||||
const { playlistAll, playlistAllFiltered } = data.me;
|
||||
|
||||
return playlistAll?.map((playlist) => {
|
||||
return {
|
||||
...playlist,
|
||||
...playlistAllFiltered?.find((p) => p.id === playlist.id)
|
||||
};
|
||||
}) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
if (!playlists) {
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<VideoSummaryCard video={video} />
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Icon icon={faArrowDown} color="text-disabled" />
|
||||
</Row>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{playlists?.length ? playlists.map((playlist) => (
|
||||
<PlaylistTrackAddCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
video={video}
|
||||
/>
|
||||
)) : (
|
||||
<Text>You have not created a playlist, yet.</Text>
|
||||
)}
|
||||
<PlaylistAddDialog trigger={
|
||||
<Button style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Create new Playlist</Text>
|
||||
</Button>
|
||||
} />
|
||||
</Column>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaylistTrackAddCardProps {
|
||||
playlist:
|
||||
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
|
||||
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
|
||||
async function addTrackToPlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.post(`/api/playlist/${playlist.id}/track`, { video_id: video.id, });
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-add-track-${playlist.id}-${video.id}`,
|
||||
<PlaylistTrackAddToast playlist={playlist} video={video} />
|
||||
);
|
||||
}
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
const track = playlist.tracks?.[0];
|
||||
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/playlist/${playlist.id}/track/${track.id}`);
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${track.id}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
|
||||
{!playlist.tracks?.length ? (
|
||||
<IconTextButton
|
||||
icon={faPlus}
|
||||
disabled={isBusy}
|
||||
onClick={addTrackToPlaylist}
|
||||
>
|
||||
<Busy isBusy={isBusy}>Add</Busy>
|
||||
</IconTextButton>
|
||||
) : (
|
||||
<IconTextButton
|
||||
icon={faMinus}
|
||||
disabled={isBusy}
|
||||
onClick={removeTrackFromPlaylist}
|
||||
>
|
||||
<Busy isBusy={isBusy}>Remove</Busy>
|
||||
</IconTextButton>
|
||||
)}
|
||||
</PlaylistSummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
PlaylistTrackRemoveDialogPlaylistFragment,
|
||||
PlaylistTrackRemoveDialogVideoFragment
|
||||
} from "generated/graphql";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Button, IconTextButton } from "components/button";
|
||||
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { LoginGate } from "components/auth/LoginGate";
|
||||
import axios from "lib/client/axios";
|
||||
import { Column, Row } from "components/box";
|
||||
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
|
||||
import { Text } from "components/text";
|
||||
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { mutate } from "swr";
|
||||
import gql from "graphql-tag";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
|
||||
interface PlaylistTrackRemoveDialogProps {
|
||||
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
|
||||
trackId: string;
|
||||
video: PlaylistTrackRemoveDialogVideoFragment;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }: PlaylistTrackRemoveDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Remove from playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistTrackRemoveForm
|
||||
playlist={playlist}
|
||||
trackId={trackId}
|
||||
video={video}
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackRemoveDialog.fragments = {
|
||||
playlist: gql`
|
||||
${PlaylistTrackRemoveToast.fragments.playlist}
|
||||
|
||||
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${PlaylistTrackRemoveToast.fragments.video}
|
||||
|
||||
fragment PlaylistTrackRemoveDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...PlaylistTrackRemoveToastVideo
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
interface PlaylistTrackRemoveFormProps {
|
||||
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
|
||||
trackId: string;
|
||||
video: PlaylistTrackRemoveDialogVideoFragment;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel }: PlaylistTrackRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/playlist/${playlist.id}/track/${trackId}`);
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${trackId}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
|
||||
<VideoSummaryCard video={video} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={isBusy}>Remove from playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Input } from "components/form";
|
||||
import { Button } from "components/button";
|
||||
import useAuth from "hooks/useAuth";
|
||||
import styled from "styled-components";
|
||||
import Switch from "components/form/Switch";
|
||||
import { Column, Row } from "components/box";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
|
||||
import { Busy } from "components/utils/Busy";
|
||||
import Link from "next/link";
|
||||
|
||||
export function RegisterDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create new account</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Create a new account">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<RegisterForm onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface RegisterFormProps {
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
const { register } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const [isTermsAccepted, setTermsAccepted] = useState(false);
|
||||
|
||||
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string[];
|
||||
email?: string[];
|
||||
password?: string[]
|
||||
}>({});
|
||||
|
||||
function performRegister(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
|
||||
register({
|
||||
setErrors,
|
||||
name: username,
|
||||
email,
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
terms: isTermsAccepted,
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performRegister}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Username</Text>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
inputProps={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.name ? errors.name.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail</Text>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Password</Text>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm Password</Text>
|
||||
<Input
|
||||
value={passwordConfirmation}
|
||||
onChange={setPasswordConfirmation}
|
||||
inputProps={{
|
||||
type: "password",
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
|
||||
<Switch
|
||||
id="input-terms"
|
||||
isChecked={isTermsAccepted}
|
||||
onCheckedChange={setTermsAccepted}
|
||||
/>
|
||||
<Text as="label" htmlFor="input-terms">I accept the{" "}
|
||||
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
|
||||
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
|
||||
</Text>
|
||||
</Row>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Create Account</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import theme from "theme";
|
||||
import Link from "next/link";
|
||||
import { faArrowRight, faTrophy } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Text } from "components/text";
|
||||
import { Icon } from "components/icon";
|
||||
import { Button } from "components/button";
|
||||
|
||||
const EventButtonContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const EventButton = styled(Button)`
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const EventIcon = styled(Icon)`
|
||||
margin: 0 0 -1rem -2rem;
|
||||
|
||||
font-size: 56px;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`;
|
||||
|
||||
const GradientBorder = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
width: calc(100% + 4px);
|
||||
height: calc(100% + 4px);
|
||||
transform: translate(-2px, -2px);
|
||||
|
||||
@property --gradient-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
--gradient-angle: 360deg;
|
||||
|
||||
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
--gradient-angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--gradient-angle: 360deg;
|
||||
}
|
||||
`} 5s linear infinite;
|
||||
|
||||
transition: opacity 250ms;
|
||||
opacity: 0;
|
||||
|
||||
${EventButtonContainer}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface AnimeAwardsNowAvailableProps {
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps) {
|
||||
return (
|
||||
<EventButtonContainer>
|
||||
<GradientBorder />
|
||||
<Link href="/event/anime-awards" passHref legacyBehavior>
|
||||
<EventButton forwardedAs="a">
|
||||
<EventIcon icon={faTrophy} />
|
||||
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
|
||||
<Icon icon={faArrowRight} color="text-primary" />
|
||||
</EventButton>
|
||||
</Link>
|
||||
</EventButtonContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import styled, { keyframes } from "styled-components";
|
||||
import theme from "theme";
|
||||
import { ThemeSummaryCard } from "components/card";
|
||||
import extractImages from "utils/extractImages";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSetting from "hooks/useSetting";
|
||||
import useCompatability from "hooks/useCompatability";
|
||||
import { fetchRandomGrill } from "lib/client/randomGrill";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import Link from "next/link";
|
||||
import { VIDEO_URL } from "utils/config";
|
||||
import { VIDEO_URL } from "utils/config.mjs";
|
||||
import gql from "graphql-tag";
|
||||
import type { FeaturedThemeThemeFragment } from "generated/graphql";
|
||||
|
||||
@@ -47,7 +48,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOverflowHidden = styled.a`
|
||||
const StyledOverflowHidden = styled(Link)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
@@ -117,16 +118,20 @@ const StyledGrill = styled.img`
|
||||
const Box = styled.div``;
|
||||
|
||||
interface FeaturedThemeProps {
|
||||
theme: FeaturedThemeThemeFragment
|
||||
theme: FeaturedThemeThemeFragment;
|
||||
hasGrill?: boolean;
|
||||
card?: ReactNode;
|
||||
}
|
||||
|
||||
export function FeaturedTheme({ theme }: FeaturedThemeProps) {
|
||||
export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemeProps) {
|
||||
const [ grill, setGrill ] = useState<string | null>(null);
|
||||
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRandomGrill().then(setGrill);
|
||||
}, []);
|
||||
if (hasGrill) {
|
||||
fetchRandomGrill().then(setGrill);
|
||||
}
|
||||
}, [hasGrill]);
|
||||
|
||||
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
|
||||
? StyledWrapper
|
||||
@@ -135,11 +140,11 @@ export function FeaturedTheme({ theme }: FeaturedThemeProps) {
|
||||
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
|
||||
? (
|
||||
<StyledCenter>
|
||||
<ThemeSummaryCard theme={theme}/>
|
||||
{card ?? <ThemeSummaryCard theme={theme}/>}
|
||||
</StyledCenter>
|
||||
)
|
||||
: (
|
||||
<ThemeSummaryCard theme={theme}/>
|
||||
card ?? <ThemeSummaryCard theme={theme}/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -174,36 +179,30 @@ function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
const linkProps = {
|
||||
href: `/anime/${theme.anime.slug}/${videoSlug}`,
|
||||
passHref: true,
|
||||
};
|
||||
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
|
||||
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
|
||||
return (
|
||||
<Link {...linkProps} legacyBehavior>
|
||||
<StyledOverflowHidden>
|
||||
<StyledVideo
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
onError={() => setFallbackToCover(true)}
|
||||
>
|
||||
<source
|
||||
src={`${VIDEO_URL}/${video.basename}`}
|
||||
type={`video/webm; codecs="vp8, vp9, opus`}
|
||||
/>
|
||||
</StyledVideo>
|
||||
</StyledOverflowHidden>
|
||||
</Link>
|
||||
<StyledOverflowHidden href={href}>
|
||||
<StyledVideo
|
||||
key={video.basename}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
onError={() => setFallbackToCover(true)}
|
||||
>
|
||||
<source
|
||||
src={`${VIDEO_URL}/${video.basename}`}
|
||||
type={`video/webm; codecs="vp8, vp9, opus`}
|
||||
/>
|
||||
</StyledVideo>
|
||||
</StyledOverflowHidden>
|
||||
);
|
||||
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
|
||||
return (
|
||||
<Link {...linkProps} legacyBehavior>
|
||||
<StyledOverflowHidden>
|
||||
<StyledCover src={featuredCover}/>
|
||||
</StyledOverflowHidden>
|
||||
</Link>
|
||||
<StyledOverflowHidden href={href}>
|
||||
<StyledCover src={featuredCover}/>
|
||||
</StyledOverflowHidden>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Column, Row } from "components/box";
|
||||
import { ThemeDetailCard } from "components/card";
|
||||
import { Listbox } from "components/listbox";
|
||||
import { HorizontalScroll } from "components/utils";
|
||||
import { either, themeIndexComparator, themeTypeComparator } from "utils/comparators";
|
||||
import gql from "graphql-tag";
|
||||
import type { AnimeThemeFilterThemeFragment } from "generated/graphql";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface AnimeThemeFilterProps {
|
||||
themes: Array<AnimeThemeFilterThemeFragment>
|
||||
@@ -39,7 +39,7 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
const activeThemes = groups.find((group) => group.name === activeGroup)?.themes ?? [];
|
||||
|
||||
const hasMultipleTypes = activeThemes.find((theme) => theme.type === "OP") && activeThemes.find((theme) => theme.type === "ED");
|
||||
const [ filterType, setFilterType ] = useState(null);
|
||||
const [ filterType, setFilterType ] = useState<string | null>(null);
|
||||
|
||||
const filteredThemes = activeThemes
|
||||
.filter((theme) => !filterType || theme.type === filterType)
|
||||
@@ -51,17 +51,17 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
<HorizontalScroll fixShadows>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && (
|
||||
<Listbox value={activeGroup} onChange={setActiveGroup}>
|
||||
<Listbox value={activeGroup} onValueChange={setActiveGroup}>
|
||||
{groups.map((group) => (
|
||||
<Listbox.Option key={group.name} value={group.name}>{group.name}</Listbox.Option>
|
||||
<ListboxOption key={group.name} value={group.name}>{group.name}</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
{hasMultipleTypes && (
|
||||
<Listbox value={filterType} onChange={setFilterType} resettable highlightNonDefault>
|
||||
<Listbox.Option value={null} hidden>OP & ED</Listbox.Option>
|
||||
<Listbox.Option value="OP">OP</Listbox.Option>
|
||||
<Listbox.Option value="ED">ED</Listbox.Option>
|
||||
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
|
||||
<ListboxOption value="OP">OP</ListboxOption>
|
||||
<ListboxOption value="ED">ED</ListboxOption>
|
||||
</Listbox>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from "components/button";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { APP_URL } from "utils/config";
|
||||
|
||||
const StyledFooter = styled(Solid)`
|
||||
margin-top: auto;
|
||||
@@ -30,11 +29,17 @@ const StyledSocialList = styled(Row)`
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
flex-basis: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// To avoid overlap with scroll back to top button as window width gets smaller
|
||||
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
|
||||
margin-right: 64px;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
|
||||
@@ -46,24 +51,24 @@ export function Footer() {
|
||||
<StyledFooter>
|
||||
<StyledContainer>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href={`${APP_URL}/transparency`}>
|
||||
<FooterTextLink href="/about/transparency">
|
||||
Transparency
|
||||
</FooterTextLink>
|
||||
<FooterTextLink forwardedAs={Link} href="/wiki/donate" target="_self">
|
||||
<FooterTextLink href="/about/donate">
|
||||
Donate
|
||||
</FooterTextLink>
|
||||
<FooterTextLink forwardedAs={Link} href="/wiki/faq" target="_self">
|
||||
<FooterTextLink href="/about/faq">
|
||||
FAQ
|
||||
</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href={`${APP_URL}/terms-of-service`}>
|
||||
<FooterTextLink href="/about/terms-of-service">
|
||||
Terms of Service
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href={`${APP_URL}/privacy-policy`}>
|
||||
<FooterTextLink href="/about/privacy-policy">
|
||||
Privacy Policy
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="mailto:admin@animethemes.moe">
|
||||
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
|
||||
Contact
|
||||
</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
@@ -96,7 +101,7 @@ export function Footer() {
|
||||
|
||||
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
|
||||
return (
|
||||
<Text as="a" target="_blank" rel="noopener" {...props}/>
|
||||
<Text as={Link} {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Icon } from "components/icon";
|
||||
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import type { ComponentPropsWithoutRef, FormEvent } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { withColorTheme } from "styles/mixins";
|
||||
|
||||
const StyledSearchInput = styled.div`
|
||||
@@ -22,14 +22,11 @@ const StyledSearchInput = styled.div`
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${withColorTheme("dark", css`
|
||||
${withColorTheme("dark")`
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
`)}
|
||||
`}
|
||||
}
|
||||
`;
|
||||
const StyledForm = styled.form`
|
||||
flex: 1;
|
||||
`;
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -45,7 +42,6 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
|
||||
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
|
||||
resettable?: boolean
|
||||
icon?: IconDefinition
|
||||
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
|
||||
@@ -54,7 +50,6 @@ interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput>
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
resettable = false,
|
||||
icon,
|
||||
inputProps = {},
|
||||
@@ -65,19 +60,12 @@ export function Input({
|
||||
{icon && (
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
)}
|
||||
<StyledForm onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
}
|
||||
}}>
|
||||
<StyledInput
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange && onChange(event.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
</StyledForm>
|
||||
<StyledInput
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange && onChange(event.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
{resettable && !!value && (
|
||||
<StyledResetButton>
|
||||
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { withColorTheme } from "styles/mixins";
|
||||
|
||||
const StyledSwitch = styled(RadixSwitch.Root)`
|
||||
width: 50px;
|
||||
height: 25px;
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 10px var(--blackA7);
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
|
||||
&:focus {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${withColorTheme("dark")`
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
|
||||
display: block;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
background-color: ${theme.colors["text-disabled"]};
|
||||
border-radius: 9999px;
|
||||
transition: transform 250ms, background-color 250ms;
|
||||
will-change: transform;
|
||||
|
||||
&[data-state='checked'] {
|
||||
transform: translateX(25px);
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props extends RadixSwitch.SwitchProps {
|
||||
isChecked: boolean;
|
||||
onCheckedChange(isChecked: boolean): void;
|
||||
}
|
||||
|
||||
export default function Switch({ isChecked, onCheckedChange, ...props }: Props) {
|
||||
return (
|
||||
<StyledSwitch checked={isChecked} onCheckedChange={onCheckedChange} {...props}>
|
||||
<StyledSwitchThumb />
|
||||
</StyledSwitch>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Input } from "components/form/Input";
|
||||
@@ -2,12 +2,11 @@ import styled from "styled-components";
|
||||
import { Icon } from "components/icon/Icon";
|
||||
import theme from "theme";
|
||||
|
||||
export const CornerIcon = styled(Icon).attrs({
|
||||
size: "2x"
|
||||
})`
|
||||
export const CornerIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
font-size: 32px;
|
||||
color: ${theme.colors["text-primary"]};
|
||||
transform: translate(50%, -33%) rotate(10deg);
|
||||
`;
|
||||
|
||||
@@ -32,6 +32,7 @@ const StyledCoverContainer = styled.div`
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
`;
|
||||
const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
|
||||
position: absolute;
|
||||
@@ -114,18 +115,21 @@ interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCov
|
||||
}
|
||||
|
||||
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
|
||||
const images = [
|
||||
[ extractImages(resourcesWithImages[0]), resourcesWithImages[0] ] as const,
|
||||
[ extractImages(resourcesWithImages[1]), resourcesWithImages[1] ] as const,
|
||||
[ extractImages(resourcesWithImages[2]), resourcesWithImages[2] ] as const,
|
||||
[ extractImages(resourcesWithImages[3]), resourcesWithImages[3] ] as const
|
||||
]
|
||||
.map(([ images, resource ]) => ({
|
||||
largeCover: images.largeCover,
|
||||
smallCover: images.smallCover,
|
||||
resource
|
||||
}))
|
||||
.filter(({ largeCover }) => !!largeCover);
|
||||
const images = resourcesWithImages
|
||||
.filter((resource, index, list) =>
|
||||
list.findIndex((r) => r.name === resource.name) === index
|
||||
)
|
||||
.map((resource) => {
|
||||
const { largeCover, smallCover } = extractImages(resource);
|
||||
|
||||
return {
|
||||
largeCover,
|
||||
smallCover,
|
||||
resource,
|
||||
};
|
||||
})
|
||||
.filter(({ largeCover }) => !!largeCover)
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import gql from "graphql-tag";
|
||||
import type { ProfileImageUserFragment } from "generated/graphql";
|
||||
import md5 from "md5";
|
||||
|
||||
interface ProfileImageProps {
|
||||
user: ProfileImageUserFragment;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
|
||||
const hash = md5(user.email.trim().toLowerCase());
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`}
|
||||
alt={user.name}
|
||||
width={size}
|
||||
height={size}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProfileImage.fragments = {
|
||||
user: gql`
|
||||
fragment ProfileImageUser on UserAuth {
|
||||
name
|
||||
email
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -49,7 +49,6 @@ export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, chil
|
||||
key={firstLetter}
|
||||
href={`#${firstLetter}`}
|
||||
passHref
|
||||
prefetch={false}
|
||||
legacyBehavior>
|
||||
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
|
||||
</Link>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Input } from "components/input/Input";
|
||||
@@ -1,84 +1,168 @@
|
||||
import { ListboxCustom, ListboxNative } from "components/listbox";
|
||||
import styled from "styled-components";
|
||||
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import theme from "theme";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import * as RadixSelect from "@radix-ui/react-select";
|
||||
import { flipDown } from "styles/animations";
|
||||
import { Icon } from "components/icon";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const ListboxContext = createContext({
|
||||
isMobile: false
|
||||
});
|
||||
// Radix' listbox can't handle null values, so we are giving it a fake null value.
|
||||
const NULL_VALUE = "__NULL__";
|
||||
|
||||
export interface ListboxProps<T extends string | null> {
|
||||
children: ReactNode
|
||||
value: T
|
||||
onChange: (newValue: T) => void
|
||||
resettable?: boolean
|
||||
defaultValue?: T | null
|
||||
highlightNonDefault?: boolean
|
||||
disabled?: boolean
|
||||
const StyledListboxButton = styled(Button)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
const StyledListboxReset = styled.span`
|
||||
display: inline-block;
|
||||
isolation: isolate;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
`;
|
||||
const StyledListboxPopover = styled(RadixSelect.Portal)`
|
||||
min-width: max-content;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
|
||||
|
||||
transform-origin: top;
|
||||
animation: ${flipDown} 200ms ease-out;
|
||||
|
||||
// Revert CSS rules set by Radix UI which hide the native scrollbar.
|
||||
[data-radix-select-viewport] {
|
||||
scrollbar-width: revert;
|
||||
-ms-overflow-style: revert;
|
||||
}
|
||||
[data-radix-select-viewport]::-webkit-scrollbar {
|
||||
display: revert;
|
||||
}
|
||||
`;
|
||||
const StyledListboxList = styled(RadixSelect.Content)`
|
||||
width: var(--radix-select-trigger-width);
|
||||
max-height: calc(var(--radix-select-content-available-height) - 32px);
|
||||
padding: 8px 0;
|
||||
`;
|
||||
const StyledListboxOption = styled(RadixSelect.Item)`
|
||||
display: ${(props) => props.hidden ? "none" : "flex"};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 8px 16px;
|
||||
gap: 8px;
|
||||
|
||||
color: ${theme.colors["text-muted"]};
|
||||
cursor: pointer;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text"]};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-state="checked"] {
|
||||
color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
type ListboxProps =
|
||||
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
|
||||
& (PropsNullable | PropsNotNullable)
|
||||
& {
|
||||
resettable?: boolean;
|
||||
highlightNonDefault?: boolean;
|
||||
};
|
||||
|
||||
interface PropsNullable {
|
||||
nullable: true;
|
||||
value: string | null;
|
||||
onValueChange(newValue: string | null): void;
|
||||
defaultValue?: string | null;
|
||||
}
|
||||
|
||||
export function Listbox<T extends string | null>({
|
||||
value,
|
||||
onChange,
|
||||
interface PropsNotNullable {
|
||||
nullable?: false;
|
||||
value: string;
|
||||
onValueChange(newValue: string): void;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
// Adds a reset button to the list box once a non-default value has been selected.
|
||||
resettable = false,
|
||||
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(
|
||||
function Listbox({
|
||||
value,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
nullable,
|
||||
resettable,
|
||||
highlightNonDefault,
|
||||
children,
|
||||
...props
|
||||
}, ref) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
const radixOnValueChange = (newValue: string) => {
|
||||
if (nullable) {
|
||||
onValueChange(newValue === NULL_VALUE ? null : newValue);
|
||||
} else {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
};
|
||||
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
|
||||
|
||||
// The value to return to after a reset.
|
||||
defaultValue = null,
|
||||
|
||||
// Highlights the listbox if an option other than defaultValue is selected.
|
||||
highlightNonDefault = false,
|
||||
|
||||
disabled = false,
|
||||
...props
|
||||
}: ListboxProps<T>) {
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ListboxContext.Provider value={{ isMobile }}>
|
||||
<ListboxNative
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
resettable={resettable}
|
||||
defaultValue={defaultValue}
|
||||
highlightNonDefault={highlightNonDefault}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</ListboxContext.Provider>
|
||||
<RadixSelect.Root value={radixValue} onValueChange={radixOnValueChange} defaultValue={radixDefaultValue} {...props}>
|
||||
<RadixSelect.Trigger asChild ref={ref}>
|
||||
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
|
||||
<RadixSelect.Value />
|
||||
<RadixSelect.Icon>
|
||||
{(resettable && radixDefaultValue && value !== defaultValue) ? (
|
||||
<StyledListboxReset
|
||||
onClick={() => radixOnValueChange(radixDefaultValue)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon icon={faTimes}/>
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort} />
|
||||
)}
|
||||
</RadixSelect.Icon>
|
||||
</StyledListboxButton>
|
||||
</RadixSelect.Trigger>
|
||||
<StyledListboxPopover>
|
||||
<StyledListboxList position="popper" sideOffset={8}>
|
||||
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
|
||||
</StyledListboxList>
|
||||
</StyledListboxPopover>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<ListboxContext.Provider value={{ isMobile }}>
|
||||
<ListboxCustom
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
resettable={resettable}
|
||||
defaultValue={defaultValue}
|
||||
highlightNonDefault={highlightNonDefault}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</ListboxContext.Provider>
|
||||
);
|
||||
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface ListboxOptionProps {
|
||||
value?: string | null
|
||||
children: string
|
||||
hidden?: boolean
|
||||
}
|
||||
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
|
||||
function ListboxOption({ value, children, ...props }, ref) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
|
||||
Listbox.Option = function ListboxOption(props: ListboxOptionProps) {
|
||||
const { isMobile } = useContext(ListboxContext);
|
||||
|
||||
if (isMobile) {
|
||||
return <ListboxNative.Option {...props}/>;
|
||||
return (
|
||||
<StyledListboxOption value={radixValue} {...props} ref={ref}>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Icon icon={faCheck} />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</StyledListboxOption>
|
||||
);
|
||||
}
|
||||
|
||||
return <ListboxCustom.Option {...props}/>;
|
||||
};
|
||||
);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import { Text } from "components/text";
|
||||
import theme from "theme";
|
||||
import {
|
||||
ListboxButton,
|
||||
ListboxInput,
|
||||
ListboxList,
|
||||
ListboxOption,
|
||||
ListboxPopover,
|
||||
useListboxContext,
|
||||
} from "@reach/listbox";
|
||||
import { flipDown } from "styles/animations";
|
||||
import { Icon } from "components/icon";
|
||||
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
|
||||
import type { SyntheticEvent } from "react";
|
||||
|
||||
// ReachUI's listbox can't handle null values, so we are giving it a fake null value.
|
||||
// The caveat is, that we can't use this value normally, thus it's obscure name.
|
||||
const NULL_VALUE = "__NULL__";
|
||||
|
||||
const StyledListbox = styled(ListboxInput)`
|
||||
display: inline-block;
|
||||
`;
|
||||
const StyledListboxButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
const StyledListboxReset = styled.span`
|
||||
display: inline-block;
|
||||
isolation: isolate;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
`;
|
||||
const StyledListboxPopover = styled(ListboxPopover)`
|
||||
z-index: ${theme.zIndices.menuPopover};
|
||||
|
||||
min-width: max-content;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
|
||||
|
||||
transform-origin: top;
|
||||
animation: ${flipDown} 200ms ease-out;
|
||||
`;
|
||||
const StyledListboxList = styled(ListboxList)`
|
||||
max-height: 33vh;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
`;
|
||||
const StyledListboxOption = styled(ListboxOption)`
|
||||
display: ${(props) => props.hidden ? "none" : "flex"};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 8px;
|
||||
|
||||
color: ${theme.colors["text-muted"]};
|
||||
cursor: pointer;
|
||||
|
||||
&[data-current-nav] {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text"]};
|
||||
}
|
||||
|
||||
&[data-current-selected] {
|
||||
color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
export function ListboxCustom<T extends string | null>({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
resettable,
|
||||
defaultValue = null,
|
||||
highlightNonDefault,
|
||||
disabled,
|
||||
...props
|
||||
}: ListboxProps<T>) {
|
||||
function handleChange(newValue: unknown) {
|
||||
if (newValue === NULL_VALUE) {
|
||||
onChange(null as T);
|
||||
} else{
|
||||
onChange(newValue as T);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetClick() {
|
||||
if (!disabled) {
|
||||
onChange(defaultValue as T);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPropagation(event: SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledListbox
|
||||
value={value ?? NULL_VALUE}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
>
|
||||
{({ valueLabel }) => (
|
||||
<>
|
||||
<StyledListboxButton
|
||||
forwardedAs={ListboxButton}
|
||||
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text>{valueLabel}</Text>
|
||||
{(resettable && value !== defaultValue) ? (
|
||||
<StyledListboxReset
|
||||
onClick={handleResetClick}
|
||||
onMouseDown={stopPropagation}
|
||||
>
|
||||
<Icon icon={faTimes}/>
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort}/>
|
||||
)}
|
||||
</StyledListboxButton>
|
||||
<StyledListboxPopover>
|
||||
<StyledListboxList>
|
||||
{children}
|
||||
</StyledListboxList>
|
||||
</StyledListboxPopover>
|
||||
</>
|
||||
)}
|
||||
</StyledListbox>
|
||||
);
|
||||
}
|
||||
|
||||
ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden = false }: ListboxOptionProps) {
|
||||
const context = useListboxContext();
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledListboxOption value={value ?? NULL_VALUE} hidden={hidden}>
|
||||
<Text>{children}</Text>
|
||||
{value === context.value && (
|
||||
<Icon icon={faCheck}/>
|
||||
)}
|
||||
</StyledListboxOption>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import { Text } from "components/text";
|
||||
import theme from "theme";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Icon } from "components/icon";
|
||||
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
|
||||
|
||||
const NULL_VALUE = "__NULL__";
|
||||
|
||||
interface IListboxContext {
|
||||
setLabel: (value: string | null, label: string) => void
|
||||
removeLabel: (value: string | null) => void
|
||||
}
|
||||
|
||||
const ListboxContext = createContext<IListboxContext>({
|
||||
setLabel: () => { /* Do nothing. */ },
|
||||
removeLabel: () => { /* Do nothing. */ },
|
||||
});
|
||||
|
||||
const StyledListbox = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
`;
|
||||
const StyledListboxButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
const StyledListboxReset = styled.span`
|
||||
display: inline-block;
|
||||
isolation: isolate;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
`;
|
||||
const StyledSelect = styled.select`
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`;
|
||||
|
||||
export function ListboxNative<T extends string | null>({
|
||||
children,
|
||||
value,
|
||||
onChange,
|
||||
resettable,
|
||||
defaultValue = null,
|
||||
highlightNonDefault,
|
||||
disabled,
|
||||
...props
|
||||
}: ListboxProps<T>) {
|
||||
const [valueLabels, setValueLabels] = useState(() => new Map());
|
||||
|
||||
const contextValue: IListboxContext = {
|
||||
setLabel: useCallback((value, label) => {
|
||||
setValueLabels((oldMap) => {
|
||||
const newMap = new Map(oldMap);
|
||||
newMap.set(value, label);
|
||||
return newMap;
|
||||
});
|
||||
}, []),
|
||||
removeLabel: useCallback((value) => {
|
||||
setValueLabels((oldMap) => {
|
||||
const newMap = new Map(oldMap);
|
||||
newMap.delete(value);
|
||||
return newMap;
|
||||
});
|
||||
}, [])
|
||||
};
|
||||
|
||||
function handleChange(newValue: T) {
|
||||
if (newValue === NULL_VALUE) {
|
||||
onChange(null as T);
|
||||
} else{
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetClick() {
|
||||
if (!disabled) {
|
||||
onChange(defaultValue as T);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPropagation(event: SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledListbox {...props}>
|
||||
<StyledSelect
|
||||
value={value ?? NULL_VALUE}
|
||||
onChange={(event) => handleChange(event.target.value as T)}
|
||||
>
|
||||
<ListboxContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ListboxContext.Provider>
|
||||
</StyledSelect>
|
||||
<StyledListboxButton
|
||||
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text>{valueLabels.get(value)}</Text>
|
||||
{(resettable && value !== defaultValue) ? (
|
||||
<StyledListboxReset
|
||||
onClick={handleResetClick}
|
||||
onMouseDown={stopPropagation}
|
||||
>
|
||||
<Icon icon={faTimes}/>
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort}/>
|
||||
)}
|
||||
</StyledListboxButton>
|
||||
</StyledListbox>
|
||||
);
|
||||
}
|
||||
|
||||
ListboxNative.Option = function ListboxNativeOption({ value = null, children }: ListboxOptionProps) {
|
||||
const { setLabel, removeLabel } = useContext(ListboxContext);
|
||||
|
||||
useEffect(() => {
|
||||
setLabel(value, children);
|
||||
|
||||
return () => removeLabel(value);
|
||||
}, [children, removeLabel, setLabel, value]);
|
||||
|
||||
return (
|
||||
<option value={value ?? NULL_VALUE}>
|
||||
{children}
|
||||
</option>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Listbox } from "./Listbox";
|
||||
export { ListboxCustom } from "./ListboxCustom";
|
||||
export { ListboxNative } from "./ListboxNative";
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import { MDXRemote } from "next-mdx-remote";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { TextLink } from "components/text/TextLink";
|
||||
import { Text } from "components/text";
|
||||
import { Card } from "components/card";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
line-height: 1.75;
|
||||
word-break: break-word;
|
||||
|
||||
& h1 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
& h2 {
|
||||
margin-bottom: 24px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
& h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
& p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
& thead {
|
||||
border-bottom: 1px solid ${theme.colors["text-muted"]};
|
||||
|
||||
& th {
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& tbody tr {
|
||||
border-bottom: 1px solid ${theme.colors["text-disabled"]};
|
||||
|
||||
&:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& tbody td {
|
||||
vertical-align: baseline;
|
||||
padding: 8px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& pre {
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
& pre > code {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
& ${Card} {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& img {
|
||||
border-radius: ${theme.scalars.borderRadiusCard};
|
||||
}
|
||||
`;
|
||||
|
||||
interface MarkdownProps {
|
||||
source: MDXRemoteSerializeResult;
|
||||
components?: MDXComponents;
|
||||
}
|
||||
|
||||
export function Markdown({ source, components = {} }: MarkdownProps) {
|
||||
return (
|
||||
<StyledMarkdown>
|
||||
<MDXRemote
|
||||
{...source}
|
||||
components={{
|
||||
a: (props: ComponentPropsWithoutRef<"a">) => {
|
||||
const { href } = props;
|
||||
|
||||
if (href?.startsWith("/")) {
|
||||
return <TextLink href={href} {...props}/>;
|
||||
}
|
||||
|
||||
return <Text as="a" link href={href} {...props}/>;
|
||||
},
|
||||
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
|
||||
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
|
||||
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
|
||||
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
|
||||
Card,
|
||||
...components,
|
||||
}}
|
||||
/>
|
||||
</StyledMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Heading } from "utils/rehypeExtractHeadings";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "components/text";
|
||||
import styled from "styled-components";
|
||||
import { m } from "framer-motion";
|
||||
import theme from "theme";
|
||||
|
||||
const StyledTableOfContents = styled.ul`
|
||||
position: sticky;
|
||||
// TODO: Magic value neccessary?
|
||||
top: 92px;
|
||||
align-self: flex-start;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
max-height: calc(100vh - 92px);
|
||||
padding-left: 16px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
|
||||
& > li {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTableOfContentsHeading = styled.li<{ $depth: number }>`
|
||||
padding-left: ${(props) => props.$depth === 3 && "16px"};
|
||||
font-size: ${(props) => props.$depth === 3 && "0.9rem"};
|
||||
`;
|
||||
|
||||
const StyledDot = styled(m.div)`
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
`;
|
||||
|
||||
export function TableOfContents({ headings }: { headings: Array<Heading> }) {
|
||||
const [currentSlug, setCurrentSlug] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
const headings = [...document.querySelectorAll<HTMLHeadingElement>("h2, h3")];
|
||||
|
||||
let currentHeading = null;
|
||||
for (const heading of headings) {
|
||||
if (heading.offsetTop > window.scrollY + window.innerHeight) {
|
||||
break;
|
||||
}
|
||||
currentHeading = heading;
|
||||
if (heading.offsetTop > window.scrollY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentSlug(currentHeading?.id);
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll);
|
||||
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledTableOfContents>
|
||||
{headings.map(({ text, slug, depth }) => (
|
||||
<StyledTableOfContentsHeading key={slug} $depth={depth}>
|
||||
{slug === currentSlug && (
|
||||
<StyledDot layoutId="dot"/>
|
||||
)}
|
||||
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
|
||||
</StyledTableOfContentsHeading>
|
||||
))}
|
||||
</StyledTableOfContents>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +1,74 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Menu as ReachMenu, MenuButton, MenuItem, MenuItems, MenuPopover, } from "@reach/menu-button";
|
||||
import { Button } from "components/button";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import { Icon } from "components/icon";
|
||||
import { faEllipsisV } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { fadeIn, flipDown, slideIn } from "styles/animations";
|
||||
import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
const StyledMenuItems = styled(MenuItems)`
|
||||
z-index: ${theme.zIndices.menuPopover};
|
||||
import { flipDown } from "styles/animations";
|
||||
import { forwardRef } from "react";
|
||||
import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu";
|
||||
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
const StyledMenuContent = styled(RadixMenu.Content)`
|
||||
width: max-content;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
max-height: var(--radix-dropdown-menu-content-available-height);
|
||||
padding: 8px 0;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
|
||||
|
||||
transform-origin: top;
|
||||
animation: ${flipDown} 200ms ease-out;
|
||||
|
||||
@media (max-width: 720px) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
z-index: ${theme.zIndices.toast};
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: ${theme.shadows.high};
|
||||
animation: ${slideIn()} 200ms both;
|
||||
}
|
||||
`;
|
||||
const StyledMenuItem = styled(MenuItem)`
|
||||
|
||||
export const Menu = RadixMenu.Root;
|
||||
export const MenuTrigger = RadixMenu.Trigger;
|
||||
|
||||
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
|
||||
function MenuContent({ children, ...props }, forwardedRef) {
|
||||
return (
|
||||
<RadixMenu.Portal>
|
||||
<StyledMenuContent align="start" sideOffset={8} collisionPadding={8} {...props} ref={forwardedRef}>
|
||||
{children}
|
||||
</StyledMenuContent>
|
||||
</RadixMenu.Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const MenuItem = styled(RadixMenu.Item)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
${withHover(css`
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text"]};
|
||||
`)}
|
||||
`;
|
||||
const StyledMenuOverlay = styled(MenuPopover)`
|
||||
@media (max-width: 720px) {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
z-index: ${theme.zIndices.menuOverlay};
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: ${fadeIn} 200ms both;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: revert;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MenuProps extends PropsWithChildren {
|
||||
button?: (button: typeof MenuButton) => ReactNode
|
||||
}
|
||||
export const MenuLabel = styled(RadixMenu.Label)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
font-size: 0.8rem;
|
||||
`;
|
||||
|
||||
export function Menu({ button, children }: MenuProps) {
|
||||
return (
|
||||
<ReachMenu>
|
||||
{button ? button(MenuButton) : (
|
||||
<Button as={MenuButton} variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisV}/>
|
||||
</Button>
|
||||
)}
|
||||
<StyledMenuOverlay>
|
||||
<StyledMenuItems>
|
||||
{children}
|
||||
</StyledMenuItems>
|
||||
</StyledMenuOverlay>
|
||||
</ReachMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuOptionProps extends PropsWithChildren, ComponentPropsWithoutRef<typeof StyledMenuItem> {}
|
||||
|
||||
Menu.Option = function MenuOption({ children, ...props }: MenuOptionProps) {
|
||||
return (
|
||||
<StyledMenuItem onSelect={() => { /* do nothing */ }} {...props}>
|
||||
{children}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
};
|
||||
export const MenuSeparator = styled(RadixMenu.Separator)`
|
||||
height: 2px;
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
@@ -1,51 +1,91 @@
|
||||
import { Icon } from "components/icon";
|
||||
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faEllipsisV, faListMusic } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Text } from "components/text";
|
||||
import { Menu } from "components/menu";
|
||||
import useLocalPlaylist from "hooks/useLocalPlaylist";
|
||||
import gql from "graphql-tag";
|
||||
import type { ThemeMenuThemeFragment } from "generated/graphql";
|
||||
import { PlaylistTrackAddDialog } from "components/dialog/PlaylistTrackAddDialog";
|
||||
import gql from "graphql-tag";
|
||||
import { SongTitleWithArtists } from "components/utils";
|
||||
import extractImages from "utils/extractImages";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu";
|
||||
import { Button } from "components/button";
|
||||
|
||||
interface ThemeMenuProps {
|
||||
theme: ThemeMenuThemeFragment
|
||||
theme: ThemeMenuThemeFragment;
|
||||
}
|
||||
|
||||
export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
|
||||
const entry = theme.entries[0];
|
||||
const video = entry?.videos[0];
|
||||
|
||||
const options = [
|
||||
!!theme.id && (isInPlaylist(theme) ? (
|
||||
<Menu.Option key="add" onSelect={() => removeFromPlaylist(theme)}>
|
||||
<Icon icon={faMinus}/>
|
||||
<Text>Remove from Playlist</Text>
|
||||
</Menu.Option>
|
||||
) : (
|
||||
<Menu.Option key="remove" onSelect={() => addToPlaylist(theme)}>
|
||||
<Icon icon={faPlus}/>
|
||||
<Text>Add to Playlist</Text>
|
||||
</Menu.Option>
|
||||
))
|
||||
].filter((option) => option);
|
||||
|
||||
if (!options.length) {
|
||||
if (!entry || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
{options}
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisV} />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={{
|
||||
// Flip the structure on it's head,
|
||||
// because we need video as the root object here.
|
||||
...video,
|
||||
entries: [{
|
||||
...entry,
|
||||
theme,
|
||||
}],
|
||||
}}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faListMusic} />
|
||||
<Text>Add to Playlist</Text>
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMenu.fragments = {
|
||||
theme: gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
fragment ThemeMenuTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
# Hidden inside local playlist context
|
||||
slug
|
||||
type
|
||||
sequence
|
||||
group
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
title
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
...createVideoSlugVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Menu } from "components/menu/Menu";
|
||||
export { ThemeMenu } from "./ThemeMenu";
|
||||
@@ -1,88 +1,45 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Container } from "components/container";
|
||||
import { Button } from "components/button";
|
||||
import theme from "theme";
|
||||
import { blurOut, zoomIn } from "styles/animations";
|
||||
import { Solid } from "components/box";
|
||||
import { Logo } from "components/image";
|
||||
import { withColorTheme } from "styles/mixins";
|
||||
import { ProfileImage } from "components/image/ProfileImage";
|
||||
|
||||
export const StyledNavigation = styled(Solid).attrs({ as: "nav" })<{ show: boolean }>`
|
||||
export const StyledNavigation = styled.nav<{ $floating: boolean }>`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: ${theme.zIndices.navigation};
|
||||
background-color: ${theme.colors["background"]};
|
||||
box-shadow: ${theme.shadows.medium};
|
||||
|
||||
@media (max-width: 720px) {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
transition: 100ms ease;
|
||||
transition-property: background-color, box-shadow;
|
||||
|
||||
${(props) => props.$floating && css`
|
||||
transition: 500ms ease;
|
||||
background-color: transparent;
|
||||
|
||||
${(props) => props.show && css`
|
||||
display: flex;
|
||||
|
||||
animation: ${blurOut} 250ms ease forwards;
|
||||
`}
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
[data-fullscreen] & {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledNavigationContainer = styled(Container)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
@media (min-width: 721px) {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
width: auto;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
animation: ${zoomIn} 250ms ease;
|
||||
will-change: transform;
|
||||
|
||||
${withColorTheme("dark", css`
|
||||
border: 2px solid ${theme.colors["text-disabled"]};
|
||||
`)}
|
||||
}
|
||||
|
||||
padding: 8px 16px;
|
||||
`;
|
||||
|
||||
export const StyledNavigationLinks = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledCollapsibleLink = styled(Button).attrs({ variant: "silent" })`
|
||||
gap: 8px;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.tabletMin}) and (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
& > :last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
export const StyledLogoContainer = styled.a`
|
||||
@@ -96,19 +53,27 @@ export const StyledLogoContainer = styled.a`
|
||||
|
||||
export const StyledLogo = styled(Logo)`
|
||||
width: auto;
|
||||
height: 2rem;
|
||||
height: 1.5rem;
|
||||
color: ${theme.colors["text"]};
|
||||
`;
|
||||
|
||||
export const StyledMobileToggle = styled(Button)`
|
||||
position: fixed;
|
||||
bottom: ${(props) => props.offsetToggleButton ? "5rem" : "1rem"};
|
||||
right: 1rem;
|
||||
export const StyledProfileImageIcon = styled.div`
|
||||
position: relative;
|
||||
width: 1.25em;
|
||||
height: 1em;
|
||||
`;
|
||||
|
||||
padding: 1rem;
|
||||
z-index: ${theme.zIndices.navigation};
|
||||
export const StyledProfileImage = styled(ProfileImage)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
max-width: none;
|
||||
border-radius: 9999px;
|
||||
|
||||
@media (min-width: 721px) {
|
||||
display: none;
|
||||
transform: translate(calc(-50% - 4px), -50%);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { faBars, faRandom, faSearch, faTimes, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faRandom, faSearch, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
StyledCollapsibleLink,
|
||||
StyledLogo,
|
||||
StyledLogoContainer,
|
||||
StyledMobileToggle,
|
||||
StyledNavigation,
|
||||
StyledNavigationContainer,
|
||||
StyledNavigationLinks
|
||||
StyledNavigationLinks,
|
||||
StyledProfileImage,
|
||||
StyledProfileImageIcon
|
||||
} from "./Navigation.style";
|
||||
import { Button } from "components/button";
|
||||
import { Icon } from "components/icon";
|
||||
import { Text } from "components/text";
|
||||
import { IconTextButton } from "components/button";
|
||||
import useCurrentSeason from "hooks/useCurrentSeason";
|
||||
import navigateToRandomTheme from "utils/navigateToRandomTheme";
|
||||
import useRandomThemes from "hooks/useRandomThemes";
|
||||
import { useRouter } from "next/router";
|
||||
import useAuth from "hooks/useAuth";
|
||||
|
||||
interface NavigationProps {
|
||||
offsetToggleButton?: boolean
|
||||
}
|
||||
export function Navigation() {
|
||||
const { me } = useAuth();
|
||||
|
||||
export function Navigation({ offsetToggleButton = false }: NavigationProps) {
|
||||
const [ show, setShow ] = useState(false);
|
||||
const { currentYear, currentSeason } = useCurrentSeason();
|
||||
const { playRandomThemes } = useRandomThemes();
|
||||
|
||||
const router = useRouter();
|
||||
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
|
||||
|
||||
const [ isFloating, setFloating ] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setFloating(window.scrollY === 0);
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll);
|
||||
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// If the user clicks on a link which sends them to another page,
|
||||
// we want to close the modal navigation.
|
||||
if (router.pathname !== prevPathname) {
|
||||
setShow(false);
|
||||
setPrevPathname(router.pathname);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>
|
||||
<StyledNavigation show={show} onClick={() => setShow(false)}>
|
||||
<StyledNavigation $floating={isFloating}>
|
||||
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
|
||||
<Link href="/" passHref legacyBehavior>
|
||||
<StyledLogoContainer>
|
||||
@@ -46,41 +54,38 @@ export function Navigation({ offsetToggleButton = false }: NavigationProps) {
|
||||
</Link>
|
||||
<StyledNavigationLinks>
|
||||
<Link href="/search" passHref legacyBehavior>
|
||||
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faSearch}/>
|
||||
<Text>Search</Text>
|
||||
</Button>
|
||||
<IconTextButton forwardedAs="a" icon={faSearch} variant="silent" collapsible style={{ "--gap": "8px" }}>
|
||||
Search
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
<Button variant="silent" style={{ "--gap": "8px" }} onClick={navigateToRandomTheme}>
|
||||
<Icon icon={faRandom}/>
|
||||
<Text>Play Random</Text>
|
||||
</Button>
|
||||
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }} onClick={playRandomThemes}>
|
||||
Shuffle
|
||||
</IconTextButton>
|
||||
<Link
|
||||
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faTv}/>
|
||||
<Text>Current Season</Text>
|
||||
</Button>
|
||||
<IconTextButton forwardedAs="a" variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
|
||||
Current Season
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
<Link href="/profile" passHref legacyBehavior>
|
||||
<StyledCollapsibleLink forwardedAs="a" title="My Profile">
|
||||
<Icon icon={faUser}/>
|
||||
<span>My Profile</span>
|
||||
</StyledCollapsibleLink>
|
||||
<IconTextButton
|
||||
forwardedAs="a"
|
||||
variant="silent"
|
||||
icon={me.user ? (
|
||||
<StyledProfileImageIcon>
|
||||
<StyledProfileImage user={me.user} />
|
||||
</StyledProfileImageIcon>
|
||||
) : faUser}
|
||||
title="My Profile"
|
||||
collapsible
|
||||
>
|
||||
My Profile
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
</StyledNavigationLinks>
|
||||
</StyledNavigationContainer>
|
||||
</StyledNavigation>
|
||||
|
||||
<StyledMobileToggle
|
||||
variant={show ? "solid" : "primary"}
|
||||
isCircle
|
||||
offsetToggleButton={offsetToggleButton}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
<Icon icon={show ? faTimes : faBars}/>
|
||||
</StyledMobileToggle>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Text } from "components/text";
|
||||
import { Input } from "components/input";
|
||||
import { Input } from "components/form";
|
||||
import { HorizontalScroll } from "components/utils";
|
||||
import { Switcher } from "components/switcher";
|
||||
import styled from "styled-components";
|
||||
@@ -112,6 +112,7 @@ export function SearchNavigation() {
|
||||
<SwitcherOption as={Link} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
|
||||
<SwitcherOption as={Link} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
|
||||
<SwitcherOption as={Link} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
|
||||
<SwitcherOption as={Link} href={{ pathname: "/search/playlist", query }} value="playlist">Playlist</SwitcherOption>
|
||||
</Switcher>
|
||||
</HorizontalScroll>
|
||||
</StyledSearchOptions>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Text } from "components/text";
|
||||
import { Listbox } from "components/listbox";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
const letters = createLetters();
|
||||
|
||||
@@ -13,10 +13,10 @@ export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLe
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">First Letter</Text>
|
||||
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
|
||||
<Listbox.Option value={null} hidden>Any</Listbox.Option>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
{letters.map((letter) => (
|
||||
<Listbox.Option key={letter} value={letter}>{letter}</Listbox.Option>
|
||||
<ListboxOption key={letter} value={letter}>{letter}</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Text } from "components/text";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface SearchFilterMediaFormatProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
}
|
||||
|
||||
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Format</Text>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value="TV">TV</ListboxOption>
|
||||
<ListboxOption value="TV Short">TV Short</ListboxOption>
|
||||
<ListboxOption value="Movie">Movie</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Text } from "components/text";
|
||||
import { Listbox } from "components/listbox";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface SearchFilterSeasonProps {
|
||||
value: string | null
|
||||
@@ -11,12 +11,12 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Season</Text>
|
||||
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
|
||||
<Listbox.Option value={null} hidden>Any</Listbox.Option>
|
||||
<Listbox.Option value="Winter">Winter</Listbox.Option>
|
||||
<Listbox.Option value="Spring">Spring</Listbox.Option>
|
||||
<Listbox.Option value="Summer">Summer</Listbox.Option>
|
||||
<Listbox.Option value="Fall">Fall</Listbox.Option>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value="Winter">Winter</ListboxOption>
|
||||
<ListboxOption value="Spring">Spring</ListboxOption>
|
||||
<ListboxOption value="Summer">Summer</ListboxOption>
|
||||
<ListboxOption value="Fall">Fall</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Text } from "components/text";
|
||||
import { Listbox } from "components/listbox";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ListboxOptionProps } from "components/listbox/Listbox";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface SearchFilterSortByProps<T extends string | null> {
|
||||
children: ReactNode
|
||||
@@ -13,13 +14,13 @@ export function SearchFilterSortBy<T extends string | null>({ children, value, s
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Sort by</Text>
|
||||
<Listbox value={value} onChange={setValue}>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} nullable>
|
||||
{children}
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
}
|
||||
|
||||
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ComponentPropsWithoutRef<typeof Listbox.Option>) {
|
||||
return <Listbox.Option {...props}/>;
|
||||
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ListboxOptionProps) {
|
||||
return <ListboxOption {...props}/>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Text } from "components/text";
|
||||
import { Listbox } from "components/listbox";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface SearchFilterThemeTypeProps {
|
||||
value: string | null
|
||||
@@ -11,10 +11,10 @@ export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeType
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Type</Text>
|
||||
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
|
||||
<Listbox.Option value={null} hidden>Any</Listbox.Option>
|
||||
<Listbox.Option value="OP">OP</Listbox.Option>
|
||||
<Listbox.Option value="ED">ED</Listbox.Option>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value="OP">OP</ListboxOption>
|
||||
<ListboxOption value="ED">ED</ListboxOption>
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from "components/text";
|
||||
import { Listbox } from "components/listbox";
|
||||
import useYearList from "hooks/useYearList";
|
||||
import { SearchFilter } from "components/search-filter";
|
||||
import { Listbox, ListboxOption } from "components/listbox/Listbox";
|
||||
|
||||
interface SearchFilterYearProps {
|
||||
value: string | null
|
||||
@@ -14,10 +14,10 @@ export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Year</Text>
|
||||
<Listbox value={value ? String(value) : null} onChange={setValue} resettable highlightNonDefault>
|
||||
<Listbox.Option value={null}>Any</Listbox.Option>
|
||||
<Listbox value={value ? String(value) : null} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
{yearList.map((year) => (
|
||||
<Listbox.Option key={year} value={year ? String(year) : null}>{String(year)}</Listbox.Option>
|
||||
<ListboxOption key={year} value={year ? String(year) : null}>{String(year)}</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
|
||||
@@ -11,11 +11,13 @@ import gql from "graphql-tag";
|
||||
import type { SearchAnimeQuery, SearchAnimeQueryVariables } from "generated/graphql";
|
||||
import { fetchDataClient } from "lib/client";
|
||||
import useFilterStorage from "hooks/useFilterStorage";
|
||||
import { SearchFilterMediaFormat } from "components/search-filter/SearchFilterMediaFormat";
|
||||
|
||||
const initialFilter = {
|
||||
firstLetter: null,
|
||||
season: null,
|
||||
year: null,
|
||||
mediaFormat: null,
|
||||
sortBy: "name",
|
||||
};
|
||||
|
||||
@@ -53,7 +55,8 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
season: filter.season,
|
||||
year: filter.year
|
||||
year: filter.year,
|
||||
media_format: filter.mediaFormat,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
@@ -83,9 +86,10 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")}/>
|
||||
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")}/>
|
||||
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")}/>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fetchDataClient } from "lib/client";
|
||||
import gql from "graphql-tag";
|
||||
import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "generated/graphql";
|
||||
import type { ReactNode } from "react";
|
||||
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
|
||||
|
||||
interface SearchGlobalProps {
|
||||
searchQuery?: string
|
||||
@@ -23,6 +24,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query SearchGlobal($args: SearchArgs!) {
|
||||
search(args: $args) {
|
||||
@@ -45,6 +48,10 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
slug
|
||||
name
|
||||
}
|
||||
playlists {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { args: { query: searchQuery ?? null } });
|
||||
@@ -79,7 +86,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
themes: themeResults = [],
|
||||
artists: artistResults = [],
|
||||
series: seriesResults = [],
|
||||
studios: studioResults = []
|
||||
studios: studioResults = [],
|
||||
playlists: playlistResults = [],
|
||||
} = data.data.search;
|
||||
|
||||
const totalResults =
|
||||
@@ -87,7 +95,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
themeResults.length +
|
||||
artistResults.length +
|
||||
seriesResults.length +
|
||||
studioResults.length;
|
||||
studioResults.length +
|
||||
playlistResults.length;
|
||||
|
||||
if (!totalResults) {
|
||||
return (
|
||||
@@ -133,6 +142,12 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
to={`/studio/${studio.slug}`}/>
|
||||
)}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="playlist"
|
||||
title="Playlists"
|
||||
results={playlistResults}
|
||||
renderSummaryCard={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { SearchFilterSortBy } from "components/search-filter";
|
||||
import { SearchEntity } from "components/search";
|
||||
import { useState } from "react";
|
||||
import gql from "graphql-tag";
|
||||
import type { SearchPlaylistQuery, SearchPlaylistQueryVariables } from "generated/graphql";
|
||||
import { fetchDataClient } from "lib/client";
|
||||
import useFilterStorage from "hooks/useFilterStorage";
|
||||
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
|
||||
|
||||
const initialFilter = {
|
||||
sortBy: "name",
|
||||
};
|
||||
|
||||
interface SearchPlaylistProps {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-playlist", {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (searchQuery !== prevSearchQuery) {
|
||||
// Check if user is switching from non-searching to searching
|
||||
if (searchQuery && !prevSearchQuery) {
|
||||
updateFilter("sortBy", null);
|
||||
}
|
||||
setPrevSearchQuery(searchQuery);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
|
||||
entity="playlist"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query SearchPlaylist($args: SearchArgs!) {
|
||||
searchPlaylist(args: $args) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
|
||||
return data.searchPlaylist;
|
||||
}}
|
||||
renderResult={(playlist) => (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
|
||||
)}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="name">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-name">Z ➜ A</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")}/>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="song.title">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-song.title">Z ➜ A</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SEO({
|
||||
<Head>
|
||||
<title key="title">{titleWithSuffix}</title>
|
||||
<meta key="description" name="description" content={description}/>
|
||||
<meta key="og:title" name="og:title" content={titleWithSuffix}/>
|
||||
<meta key="og:title" name="og:title" content={title}/>
|
||||
<meta key="og:description" name="og:description" content={description}/>
|
||||
<meta key="og:image" name="og:image" content={image}/>
|
||||
<meta key="og:site_name" name="og:site_name" content="AnimeThemes"/>
|
||||
|
||||
@@ -19,7 +19,7 @@ const StyledContent = styled.div<{ style: { "--delay": Property.AnimationDelay }
|
||||
`;
|
||||
|
||||
interface SkeletonProps {
|
||||
children: ReactNode
|
||||
children?: ReactNode
|
||||
variant?: "summary-card"
|
||||
delay?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { forwardRef } from "react";
|
||||
import * as RadixSlider from "@radix-ui/react-slider";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
|
||||
const StyledSlider = styled(RadixSlider.Root)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 100px;
|
||||
height: 20px;
|
||||
`;
|
||||
const StyledTrack = styled(RadixSlider.Track)`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
border-radius: 9999px;
|
||||
height: 4px;
|
||||
`;
|
||||
const StyledRange = styled(RadixSlider.Range)`
|
||||
position: absolute;
|
||||
background-color: ${theme.colors["text-disabled"]};
|
||||
border-radius: 9999px;
|
||||
height: 100%;
|
||||
`;
|
||||
const StyledThumb = styled(RadixSlider.Thumb)`
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: ${theme.colors["text-disabled"]};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colors["text-muted"]};
|
||||
}
|
||||
|
||||
&:focus:focus-within {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(
|
||||
function Slider(props, ref) {
|
||||
const value = (props.value || props.defaultValue) ?? [];
|
||||
|
||||
return (
|
||||
<StyledSlider {...props} ref={ref}>
|
||||
<StyledTrack>
|
||||
<StyledRange />
|
||||
</StyledTrack>
|
||||
{value.map((_, i) => (
|
||||
<StyledThumb key={i} />
|
||||
))}
|
||||
</StyledSlider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,19 +1,13 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { LayoutGroup, m } from "framer-motion";
|
||||
import theme from "theme";
|
||||
import { uniqueId as createUniqueId } from "lodash-es";
|
||||
import type {
|
||||
ComponentPropsWithoutRef,
|
||||
ComponentPropsWithRef,
|
||||
ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo
|
||||
} from "react";
|
||||
import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Icon } from "components/icon";
|
||||
import { withHover } from "styles/mixins";
|
||||
import { Solid } from "components/box";
|
||||
|
||||
interface SwitcherContextValue {
|
||||
selectedItem: string | null
|
||||
@@ -35,6 +29,10 @@ const StyledSwitcher = styled.div`
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${Solid} & {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
`;
|
||||
|
||||
interface StyledButtonProps {
|
||||
@@ -61,10 +59,10 @@ const StyledButton = styled.button<StyledButtonProps>`
|
||||
|
||||
transition: color 500ms;
|
||||
|
||||
${withHover(css<StyledButtonProps>`
|
||||
color: ${(props: StyledButtonProps) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
|
||||
${withHover`
|
||||
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
|
||||
transition-duration: 250ms;
|
||||
`)}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledButtonBackground = styled(m.div)`
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
export const Table = styled.div<{ style: { "--columns": Property.GridTemplateColumns } }>``;
|
||||
export const Table = styled.div<{ style: { "--columns": Property.GridTemplateColumns } }>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const TableBody = styled.div`
|
||||
border: 2px solid ${theme.colors["solid-on-card"]};
|
||||
@@ -22,9 +24,9 @@ export const TableRow = styled.div`
|
||||
border-bottom: 2px solid ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
`)}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const TableCell = styled.div<{ style?: { "--span"?: number } }>`
|
||||
|
||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
||||
import { Table } from "components/table";
|
||||
import { Text } from "components/text";
|
||||
import { SongTitle } from "components/utils";
|
||||
import { ContentWarningTags, EpisodeTag, VideoTags } from "components/tag";
|
||||
import { ContentWarningTags } from "components/tag/ContentWarningTags";
|
||||
import { EpisodeTag } from "components/tag/EpisodeTag";
|
||||
import { VideoTags } from "components/tag/VideoTags";
|
||||
import { Row } from "components/box";
|
||||
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "components/table/Table";
|
||||
import gql from "graphql-tag";
|
||||
@@ -12,23 +14,24 @@ import type { ThemeTableThemeFragment } from "generated/graphql";
|
||||
|
||||
export interface ThemeTableProps {
|
||||
themes: Array<ThemeTableThemeFragment>
|
||||
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void
|
||||
}
|
||||
|
||||
export function ThemeTable({ themes }: ThemeTableProps) {
|
||||
export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
|
||||
const rows = themes
|
||||
.filter((theme) => theme.anime && theme.entries.length && theme.entries[0]?.videos.length)
|
||||
.sort(either(themeTypeComparator).or(themeIndexComparator).chain())
|
||||
.map((theme) => theme.entries.map((entry, entryIndex) => entry.videos.map((video, videoIndex) => {
|
||||
const anime = theme.anime as NonNullable<typeof theme["anime"]>;
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={anime.slug + videoSlug}
|
||||
href={`/anime/${anime.slug}/${videoSlug}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<TableRow as="a">
|
||||
legacyBehavior
|
||||
>
|
||||
<TableRow as="a" onClick={()=> onPlay?.(theme.id, entryIndex, videoIndex)}>
|
||||
<TableCell style={{ "--span": (entryIndex || videoIndex) ? 2 : undefined }}>
|
||||
{!videoIndex && (
|
||||
(entry.version ?? 1) > 1 ? (
|
||||
@@ -90,6 +93,7 @@ ThemeTable.fragments = {
|
||||
|
||||
fragment ThemeTableTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
anime {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { faExclamationCircle } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tag } from "components/tag";
|
||||
import { Tag } from "components/tag/Tag";
|
||||
import { Icon } from "components/icon";
|
||||
import gql from "graphql-tag";
|
||||
import type { ContentWarningTagsEntryFragment } from "generated/graphql";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { faFilm } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tag } from "components/tag";
|
||||
import { Tag } from "components/tag/Tag";
|
||||
import gql from "graphql-tag";
|
||||
import type { EpisodeTagEntryFragment } from "generated/graphql";
|
||||
|
||||
@@ -9,7 +9,7 @@ interface EpisodeTagProps {
|
||||
|
||||
export function EpisodeTag({ entry }: EpisodeTagProps) {
|
||||
return (
|
||||
<Tag icon={faFilm}>
|
||||
<Tag icon={faFilm} title={entry.episodes ? `Used in episode ${entry.episodes}` : "Used in all episodes"}>
|
||||
{entry.episodes || "—"}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ const StyledTag = styled.span`
|
||||
gap: 4px;
|
||||
|
||||
& ${Icon} {
|
||||
transform: translateY(0.2rem);
|
||||
transform: translateY(0.2em);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ContentWarningTags, EpisodeTag } from "components/tag";
|
||||
import { ContentWarningTags } from "components/tag/ContentWarningTags";
|
||||
import { EpisodeTag } from "components/tag/EpisodeTag";
|
||||
import { Row } from "components/box";
|
||||
import gql from "graphql-tag";
|
||||
import type { ThemeEntryTagsEntryFragment } from "generated/graphql";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
faCompactDisc,
|
||||
faEyes
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tag } from "components/tag";
|
||||
import { Tag } from "components/tag/Tag";
|
||||
import { Row } from "components/box";
|
||||
import styled from "styled-components";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Tag } from "./Tag";
|
||||
export { EpisodeTag } from "./EpisodeTag";
|
||||
export { ContentWarningTags } from "./ContentWarningTags";
|
||||
export { ThemeEntryTags } from "./ThemeEntryTags";
|
||||
export { VideoTags } from "./VideoTags";
|
||||
@@ -14,15 +14,14 @@ interface TextProps {
|
||||
weight?: Property.FontWeight
|
||||
color?: keyof Colors
|
||||
as?: string | ReactElement
|
||||
wrapAnywhere?: boolean
|
||||
}
|
||||
|
||||
export const Text = styled.span<TextProps>.attrs(getAttributes)`
|
||||
export const Text = styled.span.attrs(getAttributes)<TextProps>`
|
||||
// Reset margin for elements like <p>
|
||||
margin: 0;
|
||||
scroll-margin-top: 4rem;
|
||||
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
${(props) => props.variant === "h1" && css`
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
@@ -92,6 +91,7 @@ export const Text = styled.span<TextProps>.attrs(getAttributes)`
|
||||
font-style: ${(props) => props.italics && "italic"};
|
||||
font-weight: ${(props) => props.weight};
|
||||
color: ${(props) => props.color && theme.colors[props.color]};
|
||||
overflow-wrap: ${(props) => props.wrapAnywhere && "anywhere"};
|
||||
`;
|
||||
|
||||
function getAttributes(props: TextProps) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Toast } from "components/toast";
|
||||
import { Text } from "components/text";
|
||||
import type { PlaylistTrackRemoveToastPlaylistFragment } from "generated/graphql";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
interface PlaylistRemoveToastProps {
|
||||
playlist: PlaylistTrackRemoveToastPlaylistFragment;
|
||||
}
|
||||
|
||||
export function PlaylistRemoveToast({ playlist }: PlaylistRemoveToastProps) {
|
||||
return (
|
||||
<Toast>
|
||||
<Text color="text-primary">{playlist.name}</Text> was deleted!
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistRemoveToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { Toast } from "components/toast";
|
||||
import { Text } from "components/text";
|
||||
import { Row } from "components/box";
|
||||
import { SongTitle } from "components/utils";
|
||||
import type { PlaylistTrackAddToastPlaylistFragment, PlaylistTrackAddToastVideoFragment } from "generated/graphql";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
interface PlaylistTrackAddToastProps {
|
||||
playlist: PlaylistTrackAddToastPlaylistFragment;
|
||||
video: PlaylistTrackAddToastVideoFragment;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddToast({ playlist, video }: PlaylistTrackAddToastProps) {
|
||||
return (
|
||||
<Link href={`/playlist/${playlist.id}`} passHref legacyBehavior>
|
||||
<Toast as="a" hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={video.entries[0]?.theme?.song ?? null} /> was added to <Text color="text-primary">{playlist.name}</Text>!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
</Row>
|
||||
</Toast>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackAddToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistTrackAddToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${SongTitle.fragments.song}
|
||||
|
||||
fragment PlaylistTrackAddToastVideo on Video {
|
||||
entries {
|
||||
theme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { Toast } from "components/toast";
|
||||
import { Text } from "components/text";
|
||||
import { Row } from "components/box";
|
||||
import { SongTitle } from "components/utils";
|
||||
import type {
|
||||
PlaylistTrackRemoveToastPlaylistFragment,
|
||||
PlaylistTrackRemoveToastVideoFragment
|
||||
} from "generated/graphql";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
interface PlaylistTrackRemoveToastProps {
|
||||
playlist: PlaylistTrackRemoveToastPlaylistFragment;
|
||||
video: PlaylistTrackRemoveToastVideoFragment;
|
||||
}
|
||||
|
||||
export function PlaylistTrackRemoveToast({ playlist, video }: PlaylistTrackRemoveToastProps) {
|
||||
return (
|
||||
<Link href={`/playlist/${playlist.id}`} passHref legacyBehavior>
|
||||
<Toast as="a" hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={video.entries[0]?.theme?.song ?? null} /> was removed from <Text color="text-primary">{playlist.name}</Text>!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
</Row>
|
||||
</Toast>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistTrackRemoveToast.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistTrackRemoveToastPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${SongTitle.fragments.song}
|
||||
|
||||
fragment PlaylistTrackRemoveToastVideo on Video {
|
||||
entries {
|
||||
theme {
|
||||
song {
|
||||
...SongTitleSong
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import styled from "styled-components";
|
||||
import { Icon } from "components/icon";
|
||||
import type { ReactNode } from "react";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
const StyledIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
font-size: 1.25rem;
|
||||
`;
|
||||
|
||||
interface BusyProps {
|
||||
children?: ReactNode;
|
||||
isBusy: boolean;
|
||||
}
|
||||
|
||||
export function Busy({ isBusy, children, ...props }: BusyProps) {
|
||||
return (
|
||||
<StyledWrapper {...props}>
|
||||
<span style={{ visibility: isBusy ? "hidden" : undefined }}>{children}</span>
|
||||
{isBusy ? (
|
||||
<StyledIcon icon={faSpinnerThird} className="fa-spin"/>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-jsx";
|
||||
import { Text } from "components/text";
|
||||
|
||||
Prism.manual = true;
|
||||
|
||||
interface HighlightProps {
|
||||
children: string
|
||||
block?: boolean
|
||||
}
|
||||
|
||||
export function Highlight({ children, block = false }: HighlightProps) {
|
||||
const codeHighlighted = Prism.highlight(children, Prism.languages.jsx, "jsx");
|
||||
const codeBlock = (
|
||||
<Text
|
||||
variant="code"
|
||||
block={block}
|
||||
className="language-jsx"
|
||||
dangerouslySetInnerHTML={{ __html: codeHighlighted }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (block) {
|
||||
return (
|
||||
<pre>
|
||||
{codeBlock}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return codeBlock;
|
||||
}
|
||||
@@ -6,22 +6,22 @@ export const HorizontalScroll = styled.div<{
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
max-width: 100vw;
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
||||
& > * {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
${(props) => !!props.fixShadows && css`
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
margin-top: -16px;
|
||||
margin-bottom: -16px;
|
||||
|
||||
& > * {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRouter } from "next/router";
|
||||
import useSetting from "hooks/useSetting";
|
||||
import { RevalidationToken } from "utils/settings";
|
||||
import { useState } from "react";
|
||||
import { Text } from "components/text";
|
||||
|
||||
interface PageRevalidationProps {
|
||||
lastBuildAt: number
|
||||
apiRequests: number
|
||||
}
|
||||
|
||||
export function PageRevalidation({ lastBuildAt, apiRequests, ...props }: PageRevalidationProps) {
|
||||
const router = useRouter();
|
||||
const [secret] = useSetting(RevalidationToken);
|
||||
|
||||
const [isRevalidating, setRevalidating] = useState(false);
|
||||
|
||||
function revalidate() {
|
||||
if (isRevalidating || !secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRevalidating(true);
|
||||
|
||||
revalidateAsync()
|
||||
.then(() => location.reload())
|
||||
.catch((err) => alert(`Error while revalidating: ${err.message}`))
|
||||
.finally(() => setRevalidating(false));
|
||||
}
|
||||
|
||||
async function revalidateAsync() {
|
||||
const res = await fetch(`${router.basePath}/api/revalidate?secret=${secret}&id=${router.asPath}`);
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.json()).message);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!json.revalidated) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
const minutesSinceLastBuild = Math.round((Date.now() - lastBuildAt) / 60000);
|
||||
const lastBuildDescription = minutesSinceLastBuild
|
||||
? `${minutesSinceLastBuild} minute${minutesSinceLastBuild === 1 ? "" : "s"}`
|
||||
: "a few seconds";
|
||||
|
||||
const apiRequestsDescription = apiRequests
|
||||
? ` using ${apiRequests} API request${apiRequests === 1 ? "" : "s"}`
|
||||
: "";
|
||||
|
||||
const canRebuild = !isRevalidating && !!secret;
|
||||
const rebuildDescription = isRevalidating
|
||||
? "Rebuild in progress... The page will automatically reload after it's finished."
|
||||
: (secret
|
||||
? "Click to start a rebuild."
|
||||
: "Setup the revalidation token on your profile to manually start a rebuild."
|
||||
);
|
||||
|
||||
return (
|
||||
<Text variant="small" color="text-disabled" link={canRebuild} onClick={revalidate} {...props}>
|
||||
Page was last built {lastBuildDescription} ago{apiRequestsDescription}. {rebuildDescription}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function Performances({ song, artist, maxPerformances = 3, expandable = f
|
||||
maxPerformances = song.performances.length;
|
||||
}
|
||||
|
||||
const performances = song.performances.sort((a, b) => a.artist.name.localeCompare(b.artist.name));
|
||||
const performances = [...song.performances].sort((a, b) => a.artist.name.localeCompare(b.artist.name));
|
||||
const performancesShown = performances.slice(0, maxPerformances);
|
||||
const performancesHidden = performances.slice(maxPerformances);
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@ import Link from "next/link";
|
||||
import { Text } from "components/text";
|
||||
import gql from "graphql-tag";
|
||||
import type { SongTitleSongFragment } from "generated/graphql";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export interface SongTitleProps {
|
||||
export interface SongTitleProps extends ComponentPropsWithoutRef<typeof Text> {
|
||||
song: SongTitleSongFragment | null
|
||||
songTitleLinkTo?: string
|
||||
href?: ComponentPropsWithoutRef<typeof Link>["href"]
|
||||
}
|
||||
|
||||
export function SongTitle({ song, songTitleLinkTo }: SongTitleProps) {
|
||||
export function SongTitle({ song, href, ...props }: SongTitleProps) {
|
||||
const songTitle = song?.title || "T.B.A.";
|
||||
|
||||
if (songTitleLinkTo) {
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={songTitleLinkTo} passHref legacyBehavior>
|
||||
<Text as="a" link title={songTitle} italics={!song?.title}>{songTitle}</Text>
|
||||
<Link href={href} passHref legacyBehavior>
|
||||
<Text as="a" link title={songTitle} italics={!song?.title} wrapAnywhere {...props}>{songTitle}</Text>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color="text-primary" weight="600" italics={!song?.title}>{songTitle}</Text>
|
||||
<Text color="text-primary" weight="600" italics={!song?.title} wrapAnywhere {...props}>{songTitle}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ interface SongTitleWithArtistsProps {
|
||||
song: SongTitleWithArtistsSongFragment | null
|
||||
songTitleLinkTo?: string
|
||||
artist?: SongTitleWithArtistsArtistFragment
|
||||
onPlay?: ()=> void
|
||||
}
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function SongTitleWithArtists({ song, songTitleLinkTo, artist }: SongTitleWithArtistsProps) {
|
||||
export function SongTitleWithArtists({ song, songTitleLinkTo, artist, onPlay }: SongTitleWithArtistsProps) {
|
||||
return (
|
||||
<Text>
|
||||
<SongTitle song={song} songTitleLinkTo={songTitleLinkTo}/>
|
||||
<Text onClick={onPlay}>
|
||||
<SongTitle song={song} href={songTitleLinkTo}/>
|
||||
<Performances song={song} artist={artist}/>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { VideoPlayerContext } from "components/video-player-2/VideoPlayer2";
|
||||
|
||||
const StyledPlayerProgress = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBackground = styled.div`
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
|
||||
${StyledPlayerProgress}:hover & {
|
||||
height: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBar = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
|
||||
&:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 200ms;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${StyledPlayerProgress}:hover &:after {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 100ms;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerProgressBarHover = styled.div`
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
transform: translateX(-50%);
|
||||
|
||||
display: none;
|
||||
|
||||
${StyledPlayerProgress}:hover & {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ProgressBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("ProgressBar needs to be inside VideoPlayer!");
|
||||
}
|
||||
|
||||
const {
|
||||
playerRef,
|
||||
progressRef,
|
||||
} = context;
|
||||
|
||||
const progressHoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const nextProgressRef = useRef(0);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseUp() {
|
||||
if (isDraggingRef.current) {
|
||||
isDraggingRef.current = false;
|
||||
document.body.style.removeProperty("user-select");
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime = nextProgressRef.current * playerRef.current.duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const nextProgress = Math.min(1, event.clientX / document.body.getBoundingClientRect().width);
|
||||
if (isDraggingRef.current) {
|
||||
nextProgressRef.current = nextProgress;
|
||||
if (progressRef.current) {
|
||||
progressRef.current.style.width = `${nextProgress * 100}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pointerup", onMouseUp);
|
||||
window.addEventListener("pointermove", onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", onMouseUp);
|
||||
window.removeEventListener("pointermove", onMouseMove);
|
||||
};
|
||||
}, [playerRef, progressRef]);
|
||||
|
||||
return (
|
||||
<StyledPlayerProgress
|
||||
onPointerDown={(event) => {
|
||||
const nextProgress = event.clientX / event.currentTarget.getBoundingClientRect().width;
|
||||
isDraggingRef.current = true;
|
||||
nextProgressRef.current = nextProgress;
|
||||
document.body.style.userSelect = "none";
|
||||
if (progressRef.current) {
|
||||
progressRef.current.style.width = `${nextProgress * 100}%`;
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const nextProgress = event.clientX / event.currentTarget.getBoundingClientRect().width;
|
||||
if (progressHoverRef.current) {
|
||||
progressHoverRef.current.innerText = playerRef.current ? formatTime(nextProgress * playerRef.current.duration) : "";
|
||||
|
||||
const widthCard = progressHoverRef.current.getBoundingClientRect().width;
|
||||
const widthBody = document.body.getBoundingClientRect().width;
|
||||
let left = nextProgress * widthBody;
|
||||
if (left < 8 + widthCard / 2) {
|
||||
left = 8 + widthCard / 2;
|
||||
} else if (left > widthBody -8 - widthCard / 2) {
|
||||
left = widthBody - 8 - widthCard / 2;
|
||||
}
|
||||
progressHoverRef.current.style.left = `${left}px`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBar ref={progressRef} />
|
||||
</StyledPlayerProgressBackground>
|
||||
<StyledPlayerProgressBarHover ref={progressHoverRef} />
|
||||
</StyledPlayerProgress>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (!isFinite(time)) {
|
||||
return "0:00";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60).toLocaleString(undefined, { minimumIntegerDigits: 2 });
|
||||
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Switcher } from "components/switcher";
|
||||
import { m } from "framer-motion";
|
||||
|
||||
export const StyledPlayer = styled.div`
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not([data-background]) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-fullscreen] &[data-relaxed] {
|
||||
cursor: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledPlayerContent = styled.div`
|
||||
flex: 1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-gap: 5%;
|
||||
|
||||
padding: 0 5%;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-gap: 16px;
|
||||
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
[data-background] & {
|
||||
position: fixed;
|
||||
inset: 70px 16px 92px 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-fullscreen] & {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledPlaybackArea = styled(m.div)`
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
|
||||
position: relative;
|
||||
|
||||
margin: 32px 0;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0 -16px;
|
||||
|
||||
[data-background] & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-fullscreen] & {
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-background] & {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
[data-fullscreen] & {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: rgba(0, 0, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledVideoBackground = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
`;
|
||||
|
||||
export const StyledVideo = styled.video`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const StyledAudioBackground = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
`;
|
||||
|
||||
export const StyledAudio = styled.audio`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export const StyledAudioCover = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
outline: none;
|
||||
object-fit: contain;
|
||||
|
||||
[data-background] & {
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAside = styled.aside`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
|
||||
padding-top: 64px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
padding-top: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
[data-background] &, [data-fullscreen] & {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledSwitcher = styled(Switcher)`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const StyledScrollArea = styled.div`
|
||||
flex: 1 0 0;
|
||||
|
||||
padding-bottom: 16px;
|
||||
overflow: auto;
|
||||
`;
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { PointerEvent, ReactNode, RefObject, SyntheticEvent } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
StyledAside,
|
||||
StyledAudio,
|
||||
StyledAudioBackground,
|
||||
StyledAudioCover,
|
||||
StyledPlaybackArea,
|
||||
StyledPlayer,
|
||||
StyledPlayerContent,
|
||||
StyledVideo,
|
||||
StyledVideoBackground
|
||||
} from "./VideoPlayer2.style";
|
||||
import PlayerContext from "context/playerContext";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { useRouter } from "next/router";
|
||||
import useSetting from "hooks/useSetting";
|
||||
import { AudioMode, GlobalVolume } from "utils/settings";
|
||||
import { AUDIO_URL, VIDEO_URL } from "utils/config.mjs";
|
||||
import extractImages from "utils/extractImages";
|
||||
import useWatchHistory from "hooks/useWatchHistory";
|
||||
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
|
||||
import { VideoPlayerBar } from "components/video-player-2/VideoPlayerBar";
|
||||
import useMouseRelax from "hooks/useMouseRelax";
|
||||
|
||||
interface VideoPlayerContextValue {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
background: boolean;
|
||||
videoPagePath: string;
|
||||
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
|
||||
progressRef: RefObject<HTMLDivElement>;
|
||||
previousVideoPath: string | null;
|
||||
playPreviousTrack(navigate: boolean): void;
|
||||
nextVideoPath: string | null;
|
||||
playNextTrack(navigate: boolean): void;
|
||||
isPlaying: boolean;
|
||||
togglePlay(): void;
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
updateAudioMode(audioMode: string): void;
|
||||
}
|
||||
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
|
||||
|
||||
type VideoPlayerProps = {
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
background: boolean;
|
||||
children?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
|
||||
export function VideoPlayer2({ video, background, children, overlay, ...props }: VideoPlayerProps) {
|
||||
const entry = video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme.anime;
|
||||
|
||||
const videoPagePath = `/anime/${anime.slug}/${createVideoSlug(theme, entry, video)}`;
|
||||
|
||||
const videoUrl = `${VIDEO_URL}/${video.basename}`;
|
||||
const audioUrl = `${AUDIO_URL}/${video.audio.basename}`;
|
||||
|
||||
const [isPlaying, setPlaying] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState(16 / 9);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
watchList,
|
||||
currentWatchListItem,
|
||||
setCurrentWatchListItem,
|
||||
isAutoPlay,
|
||||
isForceAutoPlay,
|
||||
} = useContext(PlayerContext);
|
||||
const router = useRouter();
|
||||
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
|
||||
const { smallCover, largeCover } = extractImages(anime);
|
||||
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { addToHistory } = useWatchHistory();
|
||||
const isRelaxed = useMouseRelax();
|
||||
|
||||
const previousVideo = getWatchListVideo(-1);
|
||||
const previousEntry = previousVideo?.entries[0];
|
||||
const previousTheme = previousEntry?.theme;
|
||||
const previousAnime = previousTheme?.anime;
|
||||
|
||||
const previousVideoPath = (previousAnime && previousTheme && previousVideo)
|
||||
? `/anime/${previousAnime.slug}/${createVideoSlug(previousTheme, previousEntry, previousVideo)}`
|
||||
: null;
|
||||
|
||||
const playPreviousTrack = useCallback((navigate = false) => {
|
||||
if (previousVideoPath) {
|
||||
setCurrentWatchListItem(previousVideo);
|
||||
if (navigate) {
|
||||
router.push(previousVideoPath);
|
||||
}
|
||||
}
|
||||
}, [previousVideo, previousVideoPath, router, setCurrentWatchListItem]);
|
||||
|
||||
const nextVideo = getWatchListVideo(1);
|
||||
const nextEntry = nextVideo?.entries[0];
|
||||
const nextTheme = nextEntry?.theme;
|
||||
const nextAnime = nextTheme?.anime;
|
||||
|
||||
const nextVideoPath = (nextAnime && nextTheme && nextVideo)
|
||||
? `/anime/${nextAnime.slug}/${createVideoSlug(nextTheme, nextEntry, nextVideo)}`
|
||||
: null;
|
||||
|
||||
const playNextTrack = useCallback((navigate = false) => {
|
||||
if ((isAutoPlay || isForceAutoPlay) && nextVideoPath) {
|
||||
setCurrentWatchListItem(nextVideo);
|
||||
if (navigate) {
|
||||
router.push(nextVideoPath);
|
||||
}
|
||||
}
|
||||
}, [isAutoPlay, isForceAutoPlay, nextVideo, nextVideoPath, router, setCurrentWatchListItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.volume = globalVolume;
|
||||
}
|
||||
}, [globalVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
addToHistory({
|
||||
...theme,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
videos: [
|
||||
video,
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reset the progress bar (otherwise we'd have to wait for the player to load).
|
||||
if (progressRef.current) {
|
||||
progressRef.current.style.width = "0%";
|
||||
}
|
||||
|
||||
// We don't want to re-add the theme when the history changes, because it can cause
|
||||
// various issues when multiple tabs are open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme && smallCover && navigator.mediaSession) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: `${theme.slug} • ${theme.song?.title || "T.B.A."}`,
|
||||
artist: theme.song?.performances
|
||||
? theme.song.performances.map((performance) => performance.as || performance.artist.name).join(", ")
|
||||
: undefined,
|
||||
album: anime.name,
|
||||
artwork: [
|
||||
{ src: smallCover, sizes: "512x512", type: "image/jpeg" }
|
||||
]
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("previoustrack", () => { playPreviousTrack(true); });
|
||||
navigator.mediaSession.setActionHandler("nexttrack", () => { playNextTrack(true); });
|
||||
}
|
||||
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
|
||||
|
||||
function onPlayerMount(player: HTMLVideoElement) {
|
||||
playerRef.current = player;
|
||||
if (playerRef.current) {
|
||||
setPlaying(!playerRef.current.paused);
|
||||
if ("videoWidth" in playerRef.current) {
|
||||
setAspectRatio(playerRef.current.videoWidth / playerRef.current.videoHeight);
|
||||
}
|
||||
playerRef.current.volume = globalVolume;
|
||||
if (currentTimeBeforeModeSwitch.current) {
|
||||
playerRef.current.currentTime = currentTimeBeforeModeSwitch.current;
|
||||
currentTimeBeforeModeSwitch.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPlayerClick(event: PointerEvent) {
|
||||
if (!background && event.nativeEvent.pointerType === "mouse" && event.nativeEvent.button === 0) {
|
||||
togglePlay();
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying) {
|
||||
playerRef.current?.pause();
|
||||
} else {
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
|
||||
if (progressRef.current) {
|
||||
// Update the progress bar using a ref to prevent re-rendering.
|
||||
const progress = event.currentTarget.currentTime / event.currentTarget.duration * 100;
|
||||
progressRef.current.style.width = `${progress}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAudioMode(audioMode: string) {
|
||||
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
|
||||
setAudioMode(audioMode);
|
||||
}
|
||||
|
||||
function getWatchListVideo(offset: 1 | -1) {
|
||||
if (!currentWatchListItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTrackIndex = watchList.findIndex((item) => item.watchListId === currentWatchListItem.watchListId);
|
||||
|
||||
if (currentTrackIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTrackIndex = currentTrackIndex + offset;
|
||||
|
||||
if (!watchList[nextTrackIndex]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return watchList[nextTrackIndex];
|
||||
}
|
||||
|
||||
const constraintRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider value={{
|
||||
video,
|
||||
background,
|
||||
videoPagePath,
|
||||
playerRef,
|
||||
progressRef,
|
||||
previousVideoPath,
|
||||
playPreviousTrack,
|
||||
nextVideoPath,
|
||||
playNextTrack,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
updateAudioMode,
|
||||
}}>
|
||||
<StyledPlayer
|
||||
ref={containerRef}
|
||||
data-background={background || undefined}
|
||||
data-relaxed={isRelaxed || undefined}
|
||||
{...props}
|
||||
>
|
||||
<StyledPlayerContent ref={constraintRef}>
|
||||
<StyledPlaybackArea
|
||||
layout
|
||||
drag={background}
|
||||
dragConstraints={constraintRef}
|
||||
animate={background ? undefined : {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
onDoubleClick={() => router.push(videoPagePath)}
|
||||
>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<StyledAudioBackground style={{ aspectRatio }}>
|
||||
<StyledAudioCover
|
||||
src={largeCover}
|
||||
onPointerDown={onPlayerClick}
|
||||
onLoad={(event) => {
|
||||
setAspectRatio(event.currentTarget.naturalWidth / event.currentTarget.naturalHeight);
|
||||
}}
|
||||
/>
|
||||
<StyledAudio
|
||||
ref={onPlayerMount}
|
||||
src={audioUrl}
|
||||
autoPlay
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
setPlaying(false);
|
||||
playNextTrack(!background);
|
||||
}}
|
||||
onTimeUpdate={updateProgress}
|
||||
onVolumeChange={(event: SyntheticEvent<HTMLAudioElement>) => setGlobalVolume(event.currentTarget.volume)}
|
||||
/>
|
||||
{overlay}
|
||||
</StyledAudioBackground>
|
||||
) : (
|
||||
<StyledVideoBackground style={{ aspectRatio }}>
|
||||
<StyledVideo
|
||||
ref={onPlayerMount}
|
||||
src={videoUrl}
|
||||
autoPlay
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
setPlaying(false);
|
||||
playNextTrack(!background);
|
||||
}}
|
||||
onPointerDown={onPlayerClick}
|
||||
onTimeUpdate={updateProgress}
|
||||
onVolumeChange={(event: SyntheticEvent<HTMLVideoElement>) => setGlobalVolume(event.currentTarget.volume)}
|
||||
onLoadedMetadata={(event) => {
|
||||
setAspectRatio(event.currentTarget.videoWidth / event.currentTarget.videoHeight);
|
||||
}}
|
||||
/>
|
||||
{overlay}
|
||||
</StyledVideoBackground>
|
||||
)}
|
||||
</StyledPlaybackArea>
|
||||
<StyledAside>
|
||||
{children}
|
||||
</StyledAside>
|
||||
</StyledPlayerContent>
|
||||
<VideoPlayerBar />
|
||||
</StyledPlayer>
|
||||
</VideoPlayerContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Column, Solid } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { Performances, SongTitle } from "components/utils";
|
||||
import Link from "next/link";
|
||||
import { ConditionalWrapper } from "components/utils/ConditionalWrapper";
|
||||
import {
|
||||
faBackwardStep,
|
||||
faForwardStep,
|
||||
faListMusic,
|
||||
faPause,
|
||||
faPlay,
|
||||
faShare,
|
||||
faXmark
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Icon } from "components/icon";
|
||||
import { PlaylistTrackAddDialog } from "components/dialog/PlaylistTrackAddDialog";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu";
|
||||
import { IconTextButton } from "components/button";
|
||||
import { AudioMode } from "utils/settings";
|
||||
import { ProgressBar } from "components/video-player-2/ProgressBar";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Toast } from "components/toast";
|
||||
import { useToasts } from "context/toastContext";
|
||||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "components/video-player-2/VideoPlayer2";
|
||||
import useSetting from "hooks/useSetting";
|
||||
import PlayerContext from "context/playerContext";
|
||||
import { VolumeControl } from "components/video-player-2/VolumeControl";
|
||||
|
||||
const StyledPlayerBar = styled(Solid)`
|
||||
position: relative;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
padding: 10px 32px;
|
||||
|
||||
transition: opacity 100ms;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
[data-fullscreen] & {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
background-color: rgba(46, 41, 58, 0.8);
|
||||
}
|
||||
|
||||
[data-fullscreen] [data-relaxed] & {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerBarControls = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
& > :first-child, & > :last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPlayerBarControl = styled(IconTextButton)`
|
||||
font-size: 2rem;
|
||||
`;
|
||||
|
||||
const StyledPlayerBarActions = styled.div`
|
||||
align-self: stretch;
|
||||
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.tabletMax}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledVolumeControl = styled(VolumeControl)`
|
||||
margin-right: auto;
|
||||
`;
|
||||
|
||||
export function VideoPlayerBar() {
|
||||
const context = useContext(VideoPlayerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("VideoPlayerBar needs to be inside VideoPlayer!");
|
||||
}
|
||||
|
||||
const {
|
||||
video,
|
||||
background,
|
||||
videoPagePath,
|
||||
previousVideoPath,
|
||||
playPreviousTrack,
|
||||
nextVideoPath,
|
||||
playNextTrack,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
videoUrl,
|
||||
audioUrl,
|
||||
} = context;
|
||||
|
||||
const entry = video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme.anime;
|
||||
|
||||
const { dispatchToast } = useToasts();
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
const { setCurrentWatchListItem } = useContext(PlayerContext);
|
||||
|
||||
function saveToClipboard(url: string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
.then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPlayerBar>
|
||||
<Column style={{ "--gap": "8px" }}>
|
||||
<Text color="text-muted" maxLines={1}>
|
||||
<SongTitle song={theme.song} href={videoPagePath} />
|
||||
<Text variant="small"> - </Text>
|
||||
<Text weight={600}>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</Text>
|
||||
<Text variant="small"> from </Text>
|
||||
<Link href={`/anime/${anime.slug}`} passHref legacyBehavior>
|
||||
<Text as="a" link>{anime.name}</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
{!!theme.song?.performances?.length && (
|
||||
<Text variant="small" color="text-muted" maxLines={1}>
|
||||
<Text>Performed</Text>
|
||||
<Performances song={theme.song} maxPerformances={3} />
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
<StyledPlayerBarControls>
|
||||
{previousVideoPath ? (
|
||||
<ConditionalWrapper
|
||||
condition={!background}
|
||||
wrap={(children) => <Link href={previousVideoPath}>{children}</Link>}
|
||||
>
|
||||
<StyledPlayerBarControl
|
||||
icon={faBackwardStep}
|
||||
isCircle
|
||||
onClick={() => playPreviousTrack(!background)}
|
||||
/>
|
||||
</ConditionalWrapper>
|
||||
) : (
|
||||
<StyledPlayerBarControl
|
||||
icon={faBackwardStep}
|
||||
isCircle
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<StyledPlayerBarControl
|
||||
icon={isPlaying ? faPause : <Icon icon={faPlay} color="text-disabled" style={{ transform: "translateX(2px)" }} />}
|
||||
isCircle
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
{nextVideoPath ? (
|
||||
<ConditionalWrapper
|
||||
condition={!background}
|
||||
wrap={(children) => <Link href={nextVideoPath}>{children}</Link>}
|
||||
>
|
||||
<StyledPlayerBarControl
|
||||
icon={faForwardStep}
|
||||
isCircle
|
||||
onClick={() => playNextTrack(!background)}
|
||||
/>
|
||||
</ConditionalWrapper>
|
||||
) : (
|
||||
<StyledPlayerBarControl
|
||||
icon={faForwardStep}
|
||||
isCircle
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</StyledPlayerBarControls>
|
||||
<StyledPlayerBarActions>
|
||||
<StyledVolumeControl />
|
||||
<PlaylistTrackAddDialog
|
||||
video={{
|
||||
// Flip the structure on it's head,
|
||||
// because we need video as the root object here.
|
||||
...video,
|
||||
entries: [{
|
||||
...entry,
|
||||
theme,
|
||||
}],
|
||||
}}
|
||||
trigger={
|
||||
<IconTextButton icon={faListMusic} variant="solid" collapsible="socialListMax">Add to Playlist</IconTextButton>
|
||||
}
|
||||
/>
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">Share</IconTextButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={() => saveToClipboard(location.href)}>Copy URL to this Page</MenuItem>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
|
||||
) : (
|
||||
<MenuItem onSelect={() => saveToClipboard(videoUrl)}>Copy URL to Embeddable Video</MenuItem>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<IconTextButton
|
||||
icon={faXmark}
|
||||
isCircle
|
||||
disabled={!background}
|
||||
onClick={() => setCurrentWatchListItem(null)}
|
||||
/>
|
||||
</StyledPlayerBarActions>
|
||||
<ProgressBar />
|
||||
</StyledPlayerBar>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user