mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 17:44:35 +02:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b79b8811f0 | |||
| 37a512ad05 | |||
| 77c7a8d82b | |||
| 732315b40b | |||
| f86b88bb01 | |||
| 17392046ac | |||
| be322072cf | |||
| 505347face | |||
| cdb5f41404 | |||
| 6c3346897f | |||
| a58a9fb134 | |||
| 74a9affcfb | |||
| 3ab709de25 | |||
| 937d05053a | |||
| 735704377c | |||
| 49f64509ba | |||
| 2023d85579 | |||
| 6e41d6d55f | |||
| b8a43eeda2 | |||
| 0e27c7e1c4 | |||
| 13dd000455 | |||
| b0724e568a | |||
| 88238966fd | |||
| 7a8326706f | |||
| 871e5f5e75 | |||
| 432ca8396b | |||
| aa6da02ee0 | |||
| b25a505a0f | |||
| d5ccb7d08c | |||
| f85e21b8c9 | |||
| b2e68af2ab | |||
| c11baa84f4 | |||
| 633236f347 | |||
| 1f0844465c | |||
| 2f63982ed6 | |||
| 4b7840ba1b | |||
| 8e7fc142eb | |||
| 7919f298e5 | |||
| 6336d71122 | |||
| 555d61ccf6 | |||
| a984ead9a3 | |||
| 48294f4eaf | |||
| 9737e2070e | |||
| 41936a78b6 | |||
| d13a22eaa8 | |||
| 295d93b980 | |||
| 17a99b05d4 | |||
| 28b69b7dc3 | |||
| 8ce027c22b | |||
| 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 |
+38
-68
@@ -1,73 +1,43 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:react/recommended",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"src/generated"
|
||||
],
|
||||
"rules": {
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"curly": [
|
||||
"error",
|
||||
"all"
|
||||
],
|
||||
"object-curly-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"indent": [
|
||||
"error",
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": "error",
|
||||
// For now we don't habe prop-types validation
|
||||
"react/prop-types": "off"
|
||||
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.{ts,tsx}"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"./tsconfig.json"
|
||||
"ignorePatterns": ["src/generated"],
|
||||
"rules": {
|
||||
// Sort imports
|
||||
"simple-import-sort/imports": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
// Packages that are used in almost all files.
|
||||
["^react$", "^react\\u0000$", "^styled-components", "^next"],
|
||||
// All other packages.
|
||||
["^@?\\w"],
|
||||
// Absolute imports and other imports such as Vue-style `@/foo`.
|
||||
// Anything not matched in another group.
|
||||
["^"],
|
||||
// Side effect imports.
|
||||
["^\\u0000"]
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{
|
||||
"allowSingleExtends": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/consistent-type-exports": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"simple-import-sort/exports": "error",
|
||||
// TypeScript rules
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{
|
||||
"allowSingleExtends": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-exports": "error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,16 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.npmrc
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
next.config.mjs
|
||||
|
||||
# IDE (WebStorm)
|
||||
*.iml
|
||||
/.idea
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
npm run graphql-codegen
|
||||
git add src/generated
|
||||
npm run lint
|
||||
npm run type-check
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -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
|
||||
@@ -30,12 +30,9 @@ This is a list of all available options:
|
||||
; The URL to the AnimeThemes API which will be used on the server.
|
||||
ANIMETHEMES_API_URL=http://localhost
|
||||
|
||||
; If specified, this API key will be used to make requests to the AnimeThemes API.
|
||||
; This is used to by-pass rate limiting.
|
||||
ANIMETHEMES_API_KEY=...
|
||||
|
||||
; 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 +48,22 @@ 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 path to use for authentication API requests. It will be appended to NEXT_PUBLIC_API_URL.
|
||||
NEXT_PUBLIC_AUTH_PATH=/auth
|
||||
|
||||
; 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
|
||||
|
||||
; Set the page size to fetch when resolving paginated resources.
|
||||
; If not set, 25 will be used.
|
||||
NEXT_PUBLIC_PAGINATION_PAGE_SIZE=10
|
||||
```
|
||||
|
||||
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
|
||||
@@ -75,12 +82,12 @@ For more information on environment variables see the [Next.js documentation](ht
|
||||
|
||||
## Used technologies
|
||||
|
||||
- [Next.js](https://www.nextjs.org/)
|
||||
- [styled-components](https://styled-components.com/)
|
||||
- [react-query](https://react-query.tanstack.com/)
|
||||
- [Font Awesome](https://fontawesome.com/)
|
||||
- ...some other small packages, see the `package.json`.
|
||||
- [Next.js](https://www.nextjs.org/)
|
||||
- [styled-components](https://styled-components.com/)
|
||||
- [react-query](https://react-query.tanstack.com/)
|
||||
- [Font Awesome](https://fontawesome.com/)
|
||||
- ...some other small packages, see the `package.json`.
|
||||
|
||||
### APIs
|
||||
|
||||
- [AnimeThemes API](https://api-docs.animethemes.moe/)
|
||||
- [AnimeThemes API](https://api-docs.animethemes.moe/)
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: "http://graphql.animethemes.test",
|
||||
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
|
||||
ignoreNoDocuments: true,
|
||||
verbose: true,
|
||||
generates: {
|
||||
"src/graphql/generated/": {
|
||||
preset: "client",
|
||||
presetConfig: {
|
||||
fragmentMasking: { unmaskFunctionName: "getFragmentData" },
|
||||
},
|
||||
config: {
|
||||
avoidOptionals: {
|
||||
field: true,
|
||||
inputValue: false,
|
||||
},
|
||||
enumsAsTypes: true,
|
||||
skipTypename: true,
|
||||
useTypeImports: 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
|
||||
@@ -0,0 +1,7 @@
|
||||
import "react";
|
||||
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
schema: http://graphql.animethemes.test
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
}
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,83 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
import NextBundleAnalyzer from "@next/bundle-analyzer";
|
||||
|
||||
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "@/utils/config";
|
||||
import { error, info } from "@/utils/log";
|
||||
|
||||
const withBundleAnalyzer = NextBundleAnalyzer();
|
||||
|
||||
if (!validateConfig()) {
|
||||
error("Shutting down because of invalid configuration...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (STAGING) {
|
||||
info("Running animethemes-web in staging mode!");
|
||||
}
|
||||
|
||||
const nextConfig: 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,
|
||||
},
|
||||
];
|
||||
},
|
||||
allowedDevOrigins: ["animethemes.test", "*.animethemes.test"],
|
||||
};
|
||||
|
||||
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
|
||||
Generated
+13729
-16267
File diff suppressed because it is too large
Load Diff
+81
-59
@@ -1,61 +1,83 @@
|
||||
{
|
||||
"name": "animethemes-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
|
||||
"@graphql-tools/merge": "^8.2.1",
|
||||
"@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",
|
||||
"common-tags": "^1.8.0",
|
||||
"framer-motion": "^6.5.1",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-parse-resolve-info": "^4.12.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^4.0.12",
|
||||
"next": "^12.2.5",
|
||||
"p-limit": "^3.1.0",
|
||||
"prismjs": "^1.27.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-query": "^3.29.0",
|
||||
"rehype-parse": "^8.0.4",
|
||||
"rehype-react": "^7.0.4",
|
||||
"sass": "^1.43.4",
|
||||
"styled-components": "^5.3.3",
|
||||
"unified": "^10.1.2",
|
||||
"use-local-storage-state": "^17.2.0",
|
||||
"use-session-storage-state": "^17.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.9.1",
|
||||
"@graphql-codegen/typescript": "2.5.0",
|
||||
"@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/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",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.7.3"
|
||||
}
|
||||
"name": "animethemes-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"predev": "npm run compile-config",
|
||||
"prebuild": "npm run compile-config",
|
||||
"prestart": "npm run compile-config",
|
||||
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.ts",
|
||||
"graphql-codegen-watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@next/bundle-analyzer": "15.3.0",
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.62.15",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.13.5",
|
||||
"common-tags": "^1.8.0",
|
||||
"graphql": "^16.10.0",
|
||||
"hast-util-has-property": "^3.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.5.12",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.43.4",
|
||||
"shiki": "^1.26.2",
|
||||
"styled-components": "^6.1.14",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-local-storage-state": "^19.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.2",
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "19.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.4.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ahooks": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"@types/react": "19.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { LoginDialog } from "@/components/dialog/LoginDialog";
|
||||
import { RegisterDialog } from "@/components/dialog/RegisterDialog";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
interface LoginGateProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LoginGate({ children }: LoginGateProps) {
|
||||
const { me } = useAuth();
|
||||
|
||||
if (me) {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
|
||||
const Flex = styled.div<{
|
||||
$wrap?: boolean
|
||||
style?: {
|
||||
"--justify-content"?: Property.JustifyContent
|
||||
"--align-items"?: Property.AlignItems
|
||||
"--gap"?: Property.Gap
|
||||
}
|
||||
$wrap?: boolean;
|
||||
style?: CSSProperties & {
|
||||
"--justify-content"?: Property.JustifyContent;
|
||||
"--align-items"?: Property.AlignItems;
|
||||
"--gap"?: Property.Gap;
|
||||
};
|
||||
}>`
|
||||
--justify-content: initial;
|
||||
--align-items: initial;
|
||||
--gap: initial;
|
||||
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: ${(props) => props.$wrap && "wrap"};
|
||||
|
||||
|
||||
justify-content: var(--justify-content);
|
||||
align-items: var(--align-items);
|
||||
gap: var(--gap);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
|
||||
import theme from "@/theme";
|
||||
|
||||
export const Solid = styled.div`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Row, Column } from "./Flex";
|
||||
export { Solid } from "./Solid";
|
||||
@@ -1,28 +1,34 @@
|
||||
import styled from "styled-components";
|
||||
import { Text } from "components/text";
|
||||
import { faDiagramProject } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Icon } from "components/icon";
|
||||
import type { RefObject } from "react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import theme from "theme";
|
||||
import { m } from "framer-motion";
|
||||
import { Button } from "components/button";
|
||||
import { BracketThemeSummaryCard } from "components/bracket/BracketThemeSummaryCard";
|
||||
import type { BracketPageQuery } from "generated/graphql";
|
||||
import type { RequiredNonNullable } from "utils/types";
|
||||
import type { RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faDiagramProject } from "@fortawesome/free-solid-svg-icons";
|
||||
import { m } from "motion/react";
|
||||
|
||||
import {
|
||||
type BracketCharacterWithTheme,
|
||||
type BracketPairingWithThemes,
|
||||
type BracketRoundWithThemes,
|
||||
BracketThemeSummaryCard,
|
||||
type BracketWithThemes,
|
||||
} from "@/components/bracket/BracketThemeSummaryCard";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledBracketContainer = styled.div`
|
||||
flex: 1 1 0;
|
||||
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
|
||||
cursor: grab;
|
||||
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
&:fullscreen {
|
||||
background-color: ${theme.colors["background"]};
|
||||
}
|
||||
@@ -31,7 +37,7 @@ const StyledBracketContainer = styled.div`
|
||||
const StyledBracket = styled(m.div)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
|
||||
width: min-content;
|
||||
gap: 128px;
|
||||
padding: 16px;
|
||||
@@ -52,7 +58,7 @@ const StyledRound = styled.div`
|
||||
|
||||
const StyledPairing = styled.div`
|
||||
flex: 1;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -64,11 +70,13 @@ const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
|
||||
width: 384px;
|
||||
`;
|
||||
|
||||
interface BracketChartProps extends RequiredNonNullable<BracketPageQuery> {}
|
||||
interface BracketChartProps {
|
||||
bracket: BracketWithThemes;
|
||||
}
|
||||
|
||||
export function BracketChart({ bracket }: BracketChartProps) {
|
||||
const [showBracketChart, setShowBracketChart] = useState(false);
|
||||
const bracketRef = useRef<HTMLDivElement>();
|
||||
const bracketRef = useRef<HTMLDivElement>(undefined);
|
||||
|
||||
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
|
||||
bracketRef.current = bracket;
|
||||
@@ -129,7 +137,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
|
||||
nextRect.top + nextRect.height / 2,
|
||||
nextRect.left,
|
||||
nextRect.top + nextRect.height / 2
|
||||
nextRect.top + nextRect.height / 2,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -139,16 +147,18 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faDiagramProject}/>
|
||||
<Icon icon={faDiagramProject} />
|
||||
<span>Open Bracket Chart</span>
|
||||
</Button>
|
||||
{showBracketChart ? (
|
||||
<StyledBracketContainer ref={onBracketInit}>
|
||||
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement>}>
|
||||
<StyledCanvas ref={onCanvasInit}/>
|
||||
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => (
|
||||
<BracketRound key={round.tier} round={round}/>
|
||||
))}
|
||||
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement | null>}>
|
||||
<StyledCanvas ref={onCanvasInit} />
|
||||
{bracket.rounds
|
||||
.sort((a, b) => a.tier - b.tier)
|
||||
.map((round) => (
|
||||
<BracketRound key={round.tier} round={round} />
|
||||
))}
|
||||
</StyledBracket>
|
||||
</StyledBracketContainer>
|
||||
) : null}
|
||||
@@ -157,7 +167,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
}
|
||||
|
||||
interface BracketRoundProps {
|
||||
round: BracketChartProps["bracket"]["rounds"][number]
|
||||
round: BracketRoundWithThemes;
|
||||
}
|
||||
|
||||
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
@@ -168,53 +178,57 @@ const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
return (
|
||||
<StyledRound>
|
||||
<Text>{round.name}</Text>
|
||||
<BracketPairings pairings={round.pairings}/>
|
||||
<BracketPairings pairings={round.pairings} />
|
||||
</StyledRound>
|
||||
);
|
||||
});
|
||||
|
||||
interface BracketPairingsProps {
|
||||
pairings: BracketRoundProps["round"]["pairings"]
|
||||
pairings: Array<BracketPairingWithThemes>;
|
||||
}
|
||||
|
||||
function BracketPairings({ pairings }: BracketPairingsProps) {
|
||||
const sortedPairings = pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
|
||||
<StyledPairing key={index}>
|
||||
<ContestantCard
|
||||
key={pairing.characterA.id}
|
||||
contestant={pairing.characterA}
|
||||
opponent={pairing.characterB}
|
||||
contestantVotes={pairing.votesA}
|
||||
opponentVotes={pairing.votesB}
|
||||
/>
|
||||
<Text variant="small">VS</Text>
|
||||
<ContestantCard
|
||||
key={pairing.characterB.id}
|
||||
contestant={pairing.characterB}
|
||||
opponent={pairing.characterA}
|
||||
contestantVotes={pairing.votesB}
|
||||
opponentVotes={pairing.votesA}
|
||||
/>
|
||||
</StyledPairing>
|
||||
));
|
||||
const sortedPairings = pairings
|
||||
.sort((a, b) => a.group - b.group || a.order - b.order)
|
||||
.map((pairing, index) => (
|
||||
<StyledPairing key={index}>
|
||||
<ContestantCard
|
||||
key={pairing.characterA.id}
|
||||
contestant={pairing.characterA}
|
||||
opponent={pairing.characterB}
|
||||
contestantVotes={pairing.votesA}
|
||||
opponentVotes={pairing.votesB}
|
||||
/>
|
||||
<Text variant="small">VS</Text>
|
||||
<ContestantCard
|
||||
key={pairing.characterB.id}
|
||||
contestant={pairing.characterB}
|
||||
opponent={pairing.characterA}
|
||||
contestantVotes={pairing.votesB}
|
||||
opponentVotes={pairing.votesA}
|
||||
/>
|
||||
</StyledPairing>
|
||||
));
|
||||
|
||||
return <>{sortedPairings}</>;
|
||||
}
|
||||
|
||||
interface ContestantCardProps {
|
||||
contestant: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
|
||||
opponent: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
|
||||
contestantVotes: number | null
|
||||
opponentVotes: number | null
|
||||
contestant: BracketCharacterWithTheme;
|
||||
opponent: BracketCharacterWithTheme;
|
||||
contestantVotes: number | null;
|
||||
opponentVotes: number | null;
|
||||
}
|
||||
|
||||
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
|
||||
const isVoted = !!contestantVotes && !!opponentVotes;
|
||||
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
|
||||
const isWinner =
|
||||
isVoted &&
|
||||
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
|
||||
|
||||
return (
|
||||
<StyledBracketThemeSummaryCard
|
||||
contestant={contestant}
|
||||
character={contestant}
|
||||
isVoted={isVoted}
|
||||
isWinner={isWinner}
|
||||
seed={contestant.seed}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import styled from "styled-components";
|
||||
import { SummaryCard, ThemeSummaryCard } from "components/card";
|
||||
import { Text } from "components/text";
|
||||
import extractImages from "utils/extractImages";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { SongTitleWithArtists } from "components/utils";
|
||||
import { Column } from "components/box";
|
||||
import { Icon } from "components/icon";
|
||||
import { faAward, faSeedling, faUsers } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { CornerIcon } from "components/icon/CornerIcon";
|
||||
import gql from "graphql-tag";
|
||||
import type { BracketThemeSummaryCardConstestantFragment } from "generated/graphql";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { TextLink } from "components/text/TextLink";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faAward, faSeedling, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { CornerIcon } from "@/components/icon/CornerIcon";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import type { Bracket, BracketCharacter, BracketPairing, BracketRound } from "@/lib/server/animebracket";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledSummaryCardWrapper = styled.div`
|
||||
position: relative;
|
||||
|
||||
|
||||
justify-self: stretch;
|
||||
`;
|
||||
|
||||
const StyledSummaryCard = styled(SummaryCard)`
|
||||
padding-inline-end: 24px;
|
||||
|
||||
|
||||
opacity: var(--opacity);
|
||||
`;
|
||||
|
||||
@@ -30,31 +32,86 @@ const StyledRank = styled(Text)`
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
|
||||
contestant: BracketThemeSummaryCardConstestantFragment
|
||||
isVoted: boolean
|
||||
isWinner: boolean
|
||||
seed: number | null
|
||||
votes: number | null
|
||||
export const BRACKET_THEME_SUMMARY_CARD_THEME = graphql(`
|
||||
fragment BracketThemeSummaryCardTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export interface BracketWithThemes extends Bracket {
|
||||
currentRound: BracketRoundWithThemes | null;
|
||||
rounds: Array<BracketRoundWithThemes>;
|
||||
}
|
||||
export interface BracketRoundWithThemes extends BracketRound {
|
||||
pairings: Array<BracketPairingWithThemes>;
|
||||
}
|
||||
export interface BracketPairingWithThemes extends BracketPairing {
|
||||
characterA: BracketCharacterWithTheme;
|
||||
characterB: BracketCharacterWithTheme;
|
||||
}
|
||||
export interface BracketCharacterWithTheme extends BracketCharacter {
|
||||
theme: FragmentType<typeof BRACKET_THEME_SUMMARY_CARD_THEME> | null;
|
||||
}
|
||||
|
||||
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
|
||||
const theme = contestant.theme;
|
||||
const { smallCover } = extractImages(theme?.anime);
|
||||
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
|
||||
character: BracketCharacterWithTheme;
|
||||
isVoted: boolean;
|
||||
isWinner: boolean;
|
||||
seed: number | null;
|
||||
votes: number | null;
|
||||
}
|
||||
|
||||
export function BracketThemeSummaryCard({
|
||||
character,
|
||||
isVoted,
|
||||
isWinner,
|
||||
seed,
|
||||
votes,
|
||||
...props
|
||||
}: BracketThemeSummaryCardProps) {
|
||||
const theme = getFragmentData(BRACKET_THEME_SUMMARY_CARD_THEME, character.theme);
|
||||
const { smallCover } = extractImages(theme?.anime.images.nodes ?? []);
|
||||
|
||||
let to;
|
||||
let description: ReactNode = contestant.source;
|
||||
let description: ReactNode = character.source;
|
||||
|
||||
if (theme?.anime) {
|
||||
const entry = theme.entries?.[0];
|
||||
const video = entry?.videos?.[0];
|
||||
const entry = theme.animethemeentries?.[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
to = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
|
||||
description = (
|
||||
<SummaryCard.Description>
|
||||
<span>{theme.slug}</span>
|
||||
<span>{theme.type + (theme.sequence || "")}</span>
|
||||
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
@@ -63,44 +120,26 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
|
||||
return (
|
||||
<StyledSummaryCardWrapper {...props}>
|
||||
<StyledSummaryCard
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : character.name}
|
||||
description={description}
|
||||
image={smallCover ?? contestant.image}
|
||||
image={smallCover ?? character.image}
|
||||
to={to}
|
||||
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
|
||||
>
|
||||
<Column style={{ "--gap": "8px" }}>
|
||||
<Text variant="small" color="text-muted" noWrap title="Seed">
|
||||
<Icon icon={faSeedling}/>
|
||||
<Icon icon={faSeedling} />
|
||||
<StyledRank> {seed}</StyledRank>
|
||||
</Text>
|
||||
{isVoted && (
|
||||
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
|
||||
<Icon icon={faUsers}/>
|
||||
<Icon icon={faUsers} />
|
||||
<StyledRank> {votes}</StyledRank>
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
|
||||
</StyledSummaryCard>
|
||||
{isWinner && (
|
||||
<CornerIcon icon={faAward} title="Winner"/>
|
||||
)}
|
||||
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
|
||||
</StyledSummaryCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
BracketThemeSummaryCard.fragments = {
|
||||
contestant: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
|
||||
name
|
||||
source
|
||||
image
|
||||
theme {
|
||||
...ThemeSummaryCardTheme
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
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) => event.preventDefault()}
|
||||
$bottomOffset={currentWatchListItem ? 76 : 0}
|
||||
>
|
||||
<Icon icon={faChevronUp} />
|
||||
</ScrollButton>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import { Solid } from "components/box";
|
||||
import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { type ComponentPropsWithRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
export const Button = forwardRef(ButtonWithRef);
|
||||
import { Solid } from "@/components/box/Solid";
|
||||
import { NestableSlot } from "@/components/utils/NestableSlot";
|
||||
import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
|
||||
children?: ReactNode
|
||||
variant?: "solid" | "primary" | "silent"
|
||||
isCircle?: boolean
|
||||
disabled?: boolean
|
||||
interface ButtonProps extends ComponentPropsWithRef<typeof BaseButton> {
|
||||
asChild?: boolean;
|
||||
variant?: "solid" | "primary" | "warning" | "silent";
|
||||
isCircle?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ButtonWithRef({
|
||||
export function Button({
|
||||
ref,
|
||||
asChild,
|
||||
children,
|
||||
variant = "solid",
|
||||
isCircle = false,
|
||||
disabled = false,
|
||||
title,
|
||||
...props
|
||||
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
|
||||
}: ButtonProps) {
|
||||
let Component;
|
||||
if (variant === "solid") {
|
||||
Component = SolidButton;
|
||||
} else if (variant === "primary") {
|
||||
Component = PrimaryButton;
|
||||
} else if (variant === "warning") {
|
||||
Component = WarningButton;
|
||||
} else if (variant === "silent") {
|
||||
Component = SilentButton;
|
||||
} else {
|
||||
@@ -34,58 +38,94 @@ function ButtonWithRef({
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
as={asChild ? NestableSlot : "button"}
|
||||
$isCircle={isCircle}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
const BaseButton = styled.button<{ $isCircle: boolean }>`
|
||||
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"};
|
||||
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
||||
pointer-events: ${(props) => props.disabled && "none"};
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
|
||||
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
|
||||
|
||||
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
|
||||
border-radius: 999px;
|
||||
gap: var(--gap, 0);
|
||||
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
|
||||
|
||||
|
||||
opacity: ${(props) => props.disabled && "0.5"};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
transition: background-color 250ms;
|
||||
|
||||
|
||||
// Buttons within other buttons should have a special margin and no shadow.
|
||||
& & {
|
||||
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 +135,25 @@ 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,9 +1,13 @@
|
||||
import { faFilter } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { IconTextButton } from "components/button";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
|
||||
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
|
||||
return (
|
||||
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
|
||||
<IconTextButton icon={faFilter} collapsible {...props}>
|
||||
Filter
|
||||
</IconTextButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,64 @@
|
||||
import { Icon } from "components/icon";
|
||||
import { Button } from "components/button";
|
||||
import styled, { css } from "styled-components";
|
||||
import theme from "theme";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { NestableSlottable } from "@/components/utils/NestableSlot";
|
||||
import theme from "@/theme";
|
||||
|
||||
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
|
||||
interface IconTextButtonProps extends Omit<ComponentPropsWithoutRef<typeof StyledButton>, "$collapseBreakpoint"> {
|
||||
ref?: RefObject<HTMLButtonElement>;
|
||||
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 function IconTextButton({ ref, icon, children, collapsible, ...props }: IconTextButtonProps) {
|
||||
let collapseBreakpoint = "0px";
|
||||
|
||||
if (collapsible === true) {
|
||||
collapseBreakpoint = theme.breakpoints.mobileMax;
|
||||
} else if (collapsible) {
|
||||
collapseBreakpoint = theme.breakpoints[collapsible];
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton variant="silent" isCircle={isCollapsed} {...props}>
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
<StyledText collapsible={collapsible}>{children}</StyledText>
|
||||
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
|
||||
<NestableSlottable child={children}>
|
||||
{(child) => (
|
||||
<>
|
||||
{isIconDefinition(icon) ? <Icon icon={icon} color="text-disabled" /> : icon}
|
||||
{child !== null && child !== undefined ? (
|
||||
<StyledText $collapseBreakpoint={collapseBreakpoint}>{child}</StyledText>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NestableSlottable>
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
|
||||
return !!icon && typeof icon === "object" && "icon" in icon;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,77 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { useContext } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { faCompactDisc, faPlay } from "@fortawesome/pro-solid-svg-icons";
|
||||
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 gql from "graphql-tag";
|
||||
import type {
|
||||
VideoButtonAnimeFragment,
|
||||
VideoButtonEntryFragment,
|
||||
VideoButtonThemeFragment,
|
||||
VideoButtonVideoFragment
|
||||
} from "generated/graphql";
|
||||
|
||||
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { VideoTags } from "@/components/tag/VideoTags";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
|
||||
|
||||
export const VIDEO_BUTTON_ANIME = graphql(`
|
||||
fragment VideoButtonAnime on Anime {
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_THEME = graphql(`
|
||||
fragment VideoButtonTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_ENTRY = graphql(`
|
||||
fragment VideoButtonEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_BUTTON_VIDEO = graphql(`
|
||||
fragment VideoButtonVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
|
||||
anime: VideoButtonAnimeFragment
|
||||
theme: VideoButtonThemeFragment
|
||||
entry: VideoButtonEntryFragment
|
||||
video: VideoButtonVideoFragment
|
||||
anime: FragmentType<typeof VIDEO_BUTTON_ANIME>;
|
||||
theme: FragmentType<typeof VIDEO_BUTTON_THEME>;
|
||||
entry: FragmentType<typeof VIDEO_BUTTON_ENTRY>;
|
||||
video: FragmentType<typeof VIDEO_BUTTON_VIDEO>;
|
||||
}
|
||||
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
|
||||
const { currentVideo } = useContext(PlayerContext);
|
||||
export function VideoButton({
|
||||
anime: animeFragment,
|
||||
theme: themeFragment,
|
||||
entry: entryFragment,
|
||||
video: videoFragment,
|
||||
...props
|
||||
}: VideoButtonProps) {
|
||||
const anime = getFragmentData(VIDEO_BUTTON_ANIME, animeFragment);
|
||||
const theme = getFragmentData(VIDEO_BUTTON_THEME, themeFragment);
|
||||
const entry = getFragmentData(VIDEO_BUTTON_ENTRY, entryFragment);
|
||||
const video = getFragmentData(VIDEO_BUTTON_VIDEO, videoFragment);
|
||||
|
||||
const { currentWatchListItem } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentVideo ? currentVideo.filename === video.filename : false;
|
||||
const isPlaying = currentWatchListItem
|
||||
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/anime/${anime.slug}/${videoSlug}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<Button as="a" {...props}>
|
||||
<Button as="span" variant="primary" isCircle>
|
||||
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined}/>
|
||||
<Button asChild {...props}>
|
||||
<Link href={`/anime/${anime.slug}/${videoSlug}`}>
|
||||
<Button asChild variant="primary" isCircle>
|
||||
<span>
|
||||
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined} />
|
||||
</span>
|
||||
</Button>
|
||||
<VideoTags video={video} hideTextOnMobile/>
|
||||
</Button>
|
||||
</Link>
|
||||
<VideoTags video={video} />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
VideoButton.fragments = {
|
||||
anime: gql`
|
||||
fragment VideoButtonAnime on Anime {
|
||||
slug
|
||||
}
|
||||
`,
|
||||
theme: gql`
|
||||
fragment VideoButtonTheme on Theme {
|
||||
slug
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
fragment VideoButtonEntry on Entry {
|
||||
version
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment VideoButtonVideo on Video {
|
||||
...VideoTagsVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { Button } from "./Button";
|
||||
export { VideoButton } from "./VideoButton";
|
||||
export { FilterToggleButton } from "./FilterToggleButton";
|
||||
export { IconTextButton } from "./IconTextButton";
|
||||
@@ -1,23 +1,25 @@
|
||||
import { Fragment } from "react";
|
||||
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import { Text } from "components/text";
|
||||
import extractImages from "utils/extractImages";
|
||||
import { Icon } from "components/icon";
|
||||
import { SummaryCard } from "components/card";
|
||||
import { Fragment, type MouseEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
import useToggle from "hooks/useToggle";
|
||||
import theme from "theme";
|
||||
import { uniq } from "lodash-es";
|
||||
import { Collapse } from "components/utils";
|
||||
import { ThemeTable } from "components/table";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import gql from "graphql-tag";
|
||||
import type { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "generated/graphql";
|
||||
import { TextLink } from "components/text/TextLink";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
import { uniqBy } from "lodash-es";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ThemeTable } from "@/components/table/ThemeTable";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Collapse } from "@/components/utils/Collapse";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import theme from "@/theme";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledThemeContainerInline = styled.div`
|
||||
@@ -30,7 +32,7 @@ const StyledThemeContainerInline = styled.div`
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
|
||||
user-select: none;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
@@ -38,7 +40,7 @@ const StyledThemeContainerInline = styled.div`
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
@@ -58,39 +60,65 @@ const StyledThemeGroupContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type AnimeSummaryCardProps = {
|
||||
anime: AnimeSummaryCardAnimeFragment
|
||||
expandable?: false
|
||||
} | {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
|
||||
expandable: true
|
||||
};
|
||||
export const ANIME_SUMMARY_CARD_ANIME = graphql(`
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
slug
|
||||
name
|
||||
year
|
||||
season
|
||||
seasonLocalized
|
||||
mediaFormatLocalized
|
||||
animethemes {
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
|
||||
export const ANIME_SUMMARY_CARD_ANIME_EXPANDABLE = graphql(`
|
||||
fragment AnimeSummaryCardAnimeExpandable on Anime {
|
||||
animethemes {
|
||||
...ThemeTableTheme
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface AnimeSummaryCardProps {
|
||||
anime: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME>;
|
||||
expandable?: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
|
||||
}
|
||||
|
||||
export function AnimeSummaryCard({ anime: animeFragment, expandable, ...props }: AnimeSummaryCardProps) {
|
||||
const anime = getFragmentData(ANIME_SUMMARY_CARD_ANIME, animeFragment);
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { smallCover } = extractImages(anime);
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
const groups = uniq(anime.themes.map((theme) => theme.group));
|
||||
|
||||
const animeLink = `/anime/${anime.slug}`;
|
||||
|
||||
let premiere = String(anime.year);
|
||||
let premiereLink = `/year/${anime.year}`;
|
||||
if (anime.season) {
|
||||
premiere = anime.season + " " + premiere;
|
||||
if (anime.seasonLocalized && anime.season) {
|
||||
premiere = anime.seasonLocalized + " " + premiere;
|
||||
premiereLink += `/${anime.season.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Anime</span>
|
||||
{!!anime.year && (
|
||||
<TextLink href={premiereLink}>
|
||||
{premiere}
|
||||
</TextLink>
|
||||
)}
|
||||
<span>{anime.themes.length} themes</span>
|
||||
<span>{anime.mediaFormatLocalized ?? "Anime"}</span>
|
||||
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
|
||||
<span>{anime.animethemes.length} themes</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
@@ -134,46 +162,36 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
)}
|
||||
</SummaryCard>
|
||||
{expandable ? (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledThemeGroupContainer>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group}>
|
||||
{!!group && (
|
||||
<Text variant="h2">{group}</Text>
|
||||
)}
|
||||
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group === group)}/>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
</Collapse>
|
||||
<AnimeSummaryCardCollapse
|
||||
anime={getFragmentData(ANIME_SUMMARY_CARD_ANIME_EXPANDABLE, expandable)}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
AnimeSummaryCard.fragments = {
|
||||
anime: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
year
|
||||
season
|
||||
themes {
|
||||
group
|
||||
}
|
||||
}
|
||||
`,
|
||||
expandable: gql`
|
||||
${ThemeTable.fragments.theme}
|
||||
interface AnimeSummaryCardCollapseProps {
|
||||
anime: ResultOf<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
fragment AnimeSummaryCardAnimeExpandable on Anime {
|
||||
themes {
|
||||
...ThemeTableTheme
|
||||
group
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
function AnimeSummaryCardCollapse({ anime, isExpanded }: AnimeSummaryCardCollapseProps) {
|
||||
const groups = uniqBy(
|
||||
anime.animethemes.map((theme) => theme.group),
|
||||
(group) => group?.slug,
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledThemeGroupContainer>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group?.slug}>
|
||||
{!!group && <Text variant="h2">{group.name}</Text>}
|
||||
<ThemeTable themes={anime.animethemes.filter((theme) => theme.group?.slug === group?.slug)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
|
||||
import { AnimeAwardsNowAvailable } from "@/components/event/AnimeAwardsNowAvailable";
|
||||
import { Markdown } from "@/components/markdown/Markdown";
|
||||
|
||||
interface AnnouncementCardProps {
|
||||
announcementSource: MDXRemoteSerializeResult;
|
||||
}
|
||||
|
||||
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
|
||||
return <Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />;
|
||||
}
|
||||
@@ -1,44 +1,37 @@
|
||||
import extractImages from "utils/extractImages";
|
||||
import { SummaryCard } from "components/card";
|
||||
import gql from "graphql-tag";
|
||||
import type { ArtistSummaryCardArtistFragment } from "generated/graphql";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
export const ARTIST_SUMMARY_CARD_ARTIST = graphql(`
|
||||
fragment ArtistSummaryCardArtist on Artist {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtistSummaryCardProps = {
|
||||
artist: ArtistSummaryCardArtistFragment
|
||||
as?: string | null
|
||||
artist: FragmentType<typeof ARTIST_SUMMARY_CARD_ARTIST>;
|
||||
as?: string | null;
|
||||
};
|
||||
|
||||
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
const { smallCover } = extractImages(artist);
|
||||
export function ArtistSummaryCard({ artist: artistFragment, as }: ArtistSummaryCardProps) {
|
||||
const artist = getFragmentData(ARTIST_SUMMARY_CARD_ARTIST, artistFragment);
|
||||
|
||||
const { smallCover } = extractImages(artist.images.nodes);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Artist</span>
|
||||
{!!as && (
|
||||
<span>As {as}</span>
|
||||
)}
|
||||
{!!as && <span>As {as}</span>}
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={artist.name}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={`/artist/${artist.slug}`}
|
||||
/>
|
||||
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
|
||||
);
|
||||
}
|
||||
|
||||
ArtistSummaryCard.fragments = {
|
||||
artist: gql`
|
||||
fragment ArtistSummaryCardArtist on Artist {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
+17
-14
@@ -1,16 +1,17 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Solid } from "components/box";
|
||||
import theme from "theme";
|
||||
import { withHover } from "styles/mixins";
|
||||
import type { Colors } from "theme/colors";
|
||||
|
||||
import { Solid } from "@/components/box/Solid";
|
||||
import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
import type { Colors } from "@/theme/colors";
|
||||
|
||||
export const Card = styled(Solid)<{
|
||||
hoverable?: boolean
|
||||
color?: keyof Colors
|
||||
$hoverable?: boolean;
|
||||
$color?: keyof Colors;
|
||||
}>`
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
|
||||
padding: 16px 24px 16px 28px;
|
||||
border-radius: ${theme.scalars.borderRadiusCard};
|
||||
overflow: hidden;
|
||||
@@ -21,14 +22,16 @@ export const Card = styled(Solid)<{
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
|
||||
${(props) => props.hoverable && css`
|
||||
cursor: pointer;
|
||||
${(props) =>
|
||||
props.$hoverable &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
|
||||
${withHover(css`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`)}
|
||||
`}
|
||||
|
||||
`}
|
||||
`}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: block;
|
||||
@@ -37,6 +40,6 @@ export const Card = styled(Solid)<{
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
|
||||
background-color: ${(props) => (props.$color ? theme.colors[props.$color] : theme.colors["text-primary"])};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Card } from "components/card";
|
||||
import { Row } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { faExclamation } from "@fortawesome/pro-solid-svg-icons";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Icon } from "components/icon";
|
||||
|
||||
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
display: flex;
|
||||
@@ -19,21 +21,24 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
|
||||
`;
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: any
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export function ErrorCard({ error }: ErrorCardProps) {
|
||||
return (
|
||||
<StyledCard color="text-warning">
|
||||
<StyledCard $color="text-warning">
|
||||
<Row style={{ "--gap": "1rem" }}>
|
||||
<Text color="text-warning">
|
||||
<Icon icon={faExclamation}/>
|
||||
<Icon icon={faExclamation} />
|
||||
</Text>
|
||||
<Text block>
|
||||
An error occurred while searching! Help improving the site by sending us the following error
|
||||
message:
|
||||
</Text>
|
||||
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
|
||||
</Row>
|
||||
<pre>
|
||||
<StyledErrorMessage>
|
||||
{JSON.stringify(error, null, 2)}
|
||||
{error instanceof Error ? error.message : JSON.stringify(error, null, 2)}
|
||||
</StyledErrorMessage>
|
||||
</pre>
|
||||
</StyledCard>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const PLAYLIST_SUMMARY_CARD_PLAYLIST = graphql(`
|
||||
fragment PlaylistSummaryCardPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibilityLocalized
|
||||
tracksCount
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER = graphql(`
|
||||
fragment PlaylistSummaryCardPlaylistWithOwner on Playlist {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistSummaryCardProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST>;
|
||||
playlistWithOwner?: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER>;
|
||||
menu?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PlaylistSummaryCard({
|
||||
playlist: playlistFragment,
|
||||
playlistWithOwner: playlistWithOwnerFragment,
|
||||
children,
|
||||
menu,
|
||||
...props
|
||||
}: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
const playlist = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST, playlistFragment);
|
||||
const playlistWithOwner = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER, playlistWithOwnerFragment);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Playlist</span>
|
||||
{playlistWithOwner ? (
|
||||
<Text link>{playlistWithOwner.user?.name}</Text>
|
||||
) : (
|
||||
<span>{playlist.visibilityLocalized}</span>
|
||||
)}
|
||||
<span>
|
||||
{playlist.tracksCount} theme{playlist.tracksCount !== 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractBackgroundColor from "@/utils/extractBackgroundColor";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
export const STUDIO_SUMMARY_CARD_STUDIO = graphql(`
|
||||
fragment StudioSummaryCardStudio on Studio {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface StudioSummaryCardProps {
|
||||
studio: FragmentType<typeof STUDIO_SUMMARY_CARD_STUDIO>;
|
||||
}
|
||||
|
||||
export function StudioSummaryCard({ studio: studioFragment }: StudioSummaryCardProps) {
|
||||
const studio = getFragmentData(STUDIO_SUMMARY_CARD_STUDIO, studioFragment);
|
||||
|
||||
const [backgroundColor, setBackgroundColor] = useState<Property.Background>();
|
||||
|
||||
function handleLoad(event: SyntheticEvent<HTMLImageElement>) {
|
||||
const image = event.currentTarget;
|
||||
const color = extractBackgroundColor(image);
|
||||
if (color) {
|
||||
setBackgroundColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
key={studio.slug}
|
||||
title={studio.name}
|
||||
description="Studio"
|
||||
to={`/studio/${studio.slug}`}
|
||||
image={extractImages(studio.images.nodes).largeCover}
|
||||
imageProps={{
|
||||
$objectFit: "contain",
|
||||
$backgroundColor: backgroundColor,
|
||||
onLoad: handleLoad,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import Link from "next/link";
|
||||
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 { TextLink } from "components/text/TextLink";
|
||||
import { ConditionalWrapper } from "components/utils/ConditionalWrapper";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { ConditionalWrapper } from "@/components/utils/ConditionalWrapper";
|
||||
import { loadingAnimation } from "@/styles/mixins";
|
||||
import withBasePath from "@/utils/withBasePath";
|
||||
|
||||
const StyledSummaryCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -22,65 +24,59 @@ const StyledSummaryCard = styled(Card)`
|
||||
`;
|
||||
|
||||
const StyledCover = styled.img.attrs({
|
||||
loading: "lazy"
|
||||
loading: "lazy",
|
||||
})<{
|
||||
objectFit?: Property.ObjectFit
|
||||
backgroundColor?: Property.Background
|
||||
isLoading?: boolean
|
||||
isPlaceholder?: boolean
|
||||
$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)}
|
||||
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> & {
|
||||
title: string | ReactNode
|
||||
description?: string | ReactNode
|
||||
image?: string
|
||||
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>
|
||||
to?: string
|
||||
children?: ReactNode
|
||||
};
|
||||
interface SummaryCardProps extends Omit<ComponentPropsWithoutRef<typeof StyledSummaryCard>, "title"> {
|
||||
title: string | ReactNode;
|
||||
description?: string | ReactNode;
|
||||
image?: string;
|
||||
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>;
|
||||
to?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageProps,
|
||||
to,
|
||||
children,
|
||||
...props
|
||||
}: SummaryCardProps) {
|
||||
const [ imageNotFound, setImageNotFound ] = useState(false);
|
||||
const [ imageLoading, setImageLoading ] = useState(true);
|
||||
export function SummaryCard({ title, description, image, imageProps, to, children, ...props }: SummaryCardProps) {
|
||||
const [imageNotFound, setImageNotFound] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<StyledSummaryCard {...props}>
|
||||
<ConditionalWrapper
|
||||
condition={!!to}
|
||||
wrap={(children) => <Link href={to as string}>{children}</Link>}
|
||||
>
|
||||
<ConditionalWrapper condition={!!to} wrap={(children) => <Link href={to as string}>{children}</Link>}>
|
||||
<StyledCover
|
||||
alt="Cover"
|
||||
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
|
||||
isLoading={imageLoading}
|
||||
isPlaceholder={!image || imageNotFound}
|
||||
$isLoading={imageLoading}
|
||||
$isPlaceholder={!image || imageNotFound}
|
||||
loading="lazy"
|
||||
{...imageProps}
|
||||
onLoad={(event) => {
|
||||
@@ -89,28 +85,26 @@ export function SummaryCard({
|
||||
imageProps.onLoad(event);
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
onError={(event) => {
|
||||
setImageNotFound(true);
|
||||
setImageLoading(false);
|
||||
if (imageProps?.onError) {
|
||||
imageProps.onError();
|
||||
imageProps.onError(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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}>
|
||||
{typeof description === "string" ? (
|
||||
<SummaryCard.Description>
|
||||
{[description]}
|
||||
</SummaryCard.Description>
|
||||
) : description}
|
||||
<SummaryCard.Description>{[description]}</SummaryCard.Description>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</StyledBody>
|
||||
@@ -120,20 +114,20 @@ export function SummaryCard({
|
||||
}
|
||||
|
||||
type SummaryCardDescriptionProps = {
|
||||
children: Array<ReactNode>
|
||||
children: Array<ReactNode>;
|
||||
};
|
||||
|
||||
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
|
||||
return (
|
||||
<>
|
||||
{children.filter((child) => child).map((child, index, { length }) => (
|
||||
<Text key={index} color="text-muted">
|
||||
<span>{child}</span>
|
||||
{index < length - 1 && (
|
||||
<span> • </span>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{children
|
||||
.filter((child) => child)
|
||||
.map((child, index, { length }) => (
|
||||
<Text key={index} color="text-muted">
|
||||
<span>{child}</span>
|
||||
{index < length - 1 && <span> • </span>}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { loadingAnimation } from "@/styles/mixins";
|
||||
import withBasePath from "@/utils/withBasePath";
|
||||
|
||||
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 }) => (
|
||||
<Fragment key={index}>
|
||||
{child}
|
||||
{index < length - 1 && <span> • </span>}
|
||||
</Fragment>
|
||||
))}
|
||||
</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={(event) => {
|
||||
setImageNotFound(true);
|
||||
setImageLoading(false);
|
||||
if (props?.onError) {
|
||||
props.onError(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +1,29 @@
|
||||
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 { VideoTags } from "components/tag/VideoTags";
|
||||
import gql from "graphql-tag";
|
||||
import type { ThemeDetailCardThemeFragment } from "generated/graphql";
|
||||
import styled from "styled-components";
|
||||
import { Card } from "components/card/Card";
|
||||
import theme from "theme";
|
||||
import { Row } from "components/box";
|
||||
import { entryVersionComparator } from "utils/comparators";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { VideoButton } from "@/components/button/VideoButton";
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { ThemeMenu } from "@/components/menu/ThemeMenu";
|
||||
import { ThemeEntryTags } from "@/components/tag/ThemeEntryTags";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
import { entryVersionComparator } from "@/utils/comparators";
|
||||
|
||||
const StyledThemeCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
grid-template-columns: 4ch 1fr auto;
|
||||
align-items: baseline;
|
||||
|
||||
|
||||
grid-gap: 1rem;
|
||||
`;
|
||||
|
||||
@@ -36,17 +36,52 @@ const StyledVideoListContainer = styled.div`
|
||||
const StyledVideoList = styled(Row)`
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
|
||||
@media (min-width: 721px) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
|
||||
export const THEME_DETAIL_CARD_THEME = graphql(`
|
||||
fragment ThemeDetailCardTheme on AnimeTheme {
|
||||
...ThemeMenuTheme
|
||||
...VideoButtonTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...VideoButtonAnime
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
animethemeentries {
|
||||
...ThemeEntryTagsEntry
|
||||
...VideoButtonEntry
|
||||
version
|
||||
videos {
|
||||
nodes {
|
||||
...VideoButtonVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface ThemeDetailCardProps {
|
||||
theme: ThemeDetailCardThemeFragment
|
||||
theme: FragmentType<typeof THEME_DETAIL_CARD_THEME>;
|
||||
}
|
||||
|
||||
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
export function ThemeDetailCard({ theme: themeFragment }: ThemeDetailCardProps) {
|
||||
const theme = getFragmentData(THEME_DETAIL_CARD_THEME, themeFragment);
|
||||
const { anime } = theme;
|
||||
|
||||
if (!anime) {
|
||||
@@ -56,30 +91,29 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
return (
|
||||
<StyledThemeCard>
|
||||
<StyledRow>
|
||||
<Text variant="small" color="text">{theme.type}{theme.sequence || null}</Text>
|
||||
<Text>
|
||||
<SongTitle song={theme.song}/>
|
||||
<Performances song={theme.song} expandable/>
|
||||
<Text variant="small" color="text">
|
||||
{theme.type}
|
||||
{theme.sequence || null}
|
||||
</Text>
|
||||
<ThemeMenu theme={theme}/>
|
||||
<Text>
|
||||
<SongTitle song={theme.song} />
|
||||
<Performances song={theme.song} expandable />
|
||||
</Text>
|
||||
<ThemeMenu theme={theme} />
|
||||
</StyledRow>
|
||||
{theme.entries.sort(entryVersionComparator).map(entry => (
|
||||
<StyledRow key={entry.version || 0}>
|
||||
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
|
||||
{[...theme.animethemeentries].sort(entryVersionComparator).map((entry) => (
|
||||
<StyledRow key={entry.version ?? 0}>
|
||||
<Text variant="small" color="text-muted">
|
||||
v{entry.version ?? 1}
|
||||
</Text>
|
||||
<Text color="text-muted">
|
||||
<ThemeEntryTags entry={entry}/>
|
||||
<ThemeEntryTags entry={entry} />
|
||||
</Text>
|
||||
<StyledVideoListContainer>
|
||||
{!!entry.videos && (
|
||||
<StyledVideoList>
|
||||
{entry.videos.map((video, index) => (
|
||||
<VideoButton
|
||||
key={index}
|
||||
anime={anime}
|
||||
theme={theme}
|
||||
entry={entry}
|
||||
video={video}
|
||||
/>
|
||||
{entry.videos.nodes.map((video, index) => (
|
||||
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
|
||||
))}
|
||||
</StyledVideoList>
|
||||
)}
|
||||
@@ -89,43 +123,3 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
</StyledThemeCard>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeDetailCard.fragments = {
|
||||
theme: gql`
|
||||
${ThemeMenu.fragments.theme}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment ThemeDetailCardTheme on Theme {
|
||||
...ThemeMenuTheme
|
||||
slug
|
||||
type
|
||||
sequence
|
||||
group
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
title
|
||||
performances {
|
||||
as
|
||||
artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
entries {
|
||||
version
|
||||
episodes
|
||||
spoiler
|
||||
nsfw
|
||||
videos {
|
||||
...VideoTagsVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Collapse, 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 gql from "graphql-tag";
|
||||
import { fetchDataClient } from "lib/client";
|
||||
import { Icon } from "components/icon";
|
||||
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import useToggle from "hooks/useToggle";
|
||||
import type { MouseEvent, PropsWithChildren } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Table, ThemeTable } from "components/table";
|
||||
import theme from "theme";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import type {
|
||||
ThemeSummaryCardArtistFragment,
|
||||
ThemeSummaryCardQuery,
|
||||
ThemeSummaryCardThemeExpandableFragment,
|
||||
ThemeSummaryCardThemeFragment
|
||||
} from "generated/graphql";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "components/table/Table";
|
||||
import { TextLink } from "components/text/TextLink";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ThemeMenu } from "@/components/menu/ThemeMenu";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "@/components/table/Table";
|
||||
import { ThemeTable } from "@/components/table/ThemeTable";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Collapse } from "@/components/utils/Collapse";
|
||||
import { getDisplayedArtistName, Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
@@ -35,7 +32,7 @@ const StyledOverlayButtons = styled.div`
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
@@ -60,64 +57,157 @@ const StyledPerformedWith = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
export const THEME_SUMMARY_CARD_THEME = graphql(`
|
||||
fragment ThemeSummaryCardTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
__typename
|
||||
... on Artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
... on Membership {
|
||||
group {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const THEME_SUMMARY_CARD_ARTIST = graphql(`
|
||||
fragment ThemeSummaryCardArtist on Artist {
|
||||
...PerformancesArtist
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
export const THEME_SUMMARY_CARD_THEME_EXPANDABLE = graphql(`
|
||||
fragment ThemeSummaryCardThemeExpandable on AnimeTheme {
|
||||
...ThemeTableTheme
|
||||
}
|
||||
`);
|
||||
|
||||
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
type ThemeSummaryCardProps = {
|
||||
theme: ThemeSummaryCardThemeFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable?: false
|
||||
} | {
|
||||
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable: true
|
||||
};
|
||||
interface ThemeSummaryCardProps {
|
||||
theme: FragmentType<typeof THEME_SUMMARY_CARD_THEME>;
|
||||
artist?: FragmentType<typeof THEME_SUMMARY_CARD_ARTIST>;
|
||||
expandable?: FragmentType<typeof THEME_SUMMARY_CARD_THEME_EXPANDABLE>;
|
||||
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: themeFragment,
|
||||
artist: artistFragment,
|
||||
children,
|
||||
expandable: expandableFragment,
|
||||
onPlay,
|
||||
...props
|
||||
}: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
const theme = getFragmentData(THEME_SUMMARY_CARD_THEME, themeFragment);
|
||||
const ownerArtist = artistFragment ? getFragmentData(THEME_SUMMARY_CARD_ARTIST, artistFragment) : undefined;
|
||||
const expandable = expandableFragment
|
||||
? getFragmentData(THEME_SUMMARY_CARD_THEME_EXPANDABLE, expandableFragment)
|
||||
: undefined;
|
||||
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const anime = theme.anime;
|
||||
const entry = theme.entries[0];
|
||||
const video = entry?.videos[0];
|
||||
const entry = theme.animethemeentries[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
|
||||
if (!anime || !entry || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const to = `/anime/${anime.slug}/${videoSlug}`;
|
||||
const href = `/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>
|
||||
);
|
||||
|
||||
function handleToggleExpand(event: MouseEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement && event.target.href) {
|
||||
event.stopPropagation();
|
||||
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
|
||||
if (filterLinks && isLink(event.target)) {
|
||||
return;
|
||||
} 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);
|
||||
}
|
||||
|
||||
const performances = (theme.song?.performances ?? [])
|
||||
.map((performance) => ({
|
||||
artist: performance.artist.__typename === "Artist" ? performance.artist : performance.artist.group,
|
||||
performance,
|
||||
}))
|
||||
.filter(({ artist }) => artist.slug !== ownerArtist?.slug)
|
||||
.sort(({ artist: a }, { artist: b }) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard
|
||||
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={to}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
>
|
||||
<SummaryCard {...props}>
|
||||
<Link href={href} onClick={() => onPlay?.()}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
</Link>
|
||||
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
|
||||
<Performances song={theme.song} artist={ownerArtist} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>
|
||||
{theme.type}
|
||||
{theme.sequence || null}
|
||||
{theme.group && ` (${theme.group.name})`}
|
||||
</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{children}
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
<ThemeMenu theme={theme}/>
|
||||
<StyledOverlayButtons>
|
||||
<ThemeMenu theme={theme} />
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
variant="silent"
|
||||
@@ -137,31 +227,25 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
|
||||
{expandable && (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledPerformedWith>
|
||||
<ThemeTable themes={[theme]}/>
|
||||
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
|
||||
<ThemeTable
|
||||
themes={[expandable]}
|
||||
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
|
||||
/>
|
||||
{performances.length > 0 && (
|
||||
<Table style={{ "--columns": "1fr" }}>
|
||||
<TableHead>
|
||||
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
|
||||
<TableHeadCell>Performed {ownerArtist ? "With" : "By"}</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{theme.song?.performances
|
||||
?.filter((performance) => performance.artist.slug !== artist?.slug)
|
||||
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
|
||||
.map((performance) => (
|
||||
<Link
|
||||
key={performance.artist.slug}
|
||||
href={`/artist/${performance.artist.slug}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<TableRow as="a">
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Link>
|
||||
))}
|
||||
{performances.map(({ artist, performance }) => (
|
||||
<TableRow key={artist.slug} as={Link} href={`/artist/${artist.slug}`}>
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{getDisplayedArtistName({ ...performance, artist })}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
@@ -171,69 +255,3 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeSummaryCard.fragments = {
|
||||
theme: gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
${ThemeMenu.fragments.theme}
|
||||
|
||||
fragment ThemeSummaryCardTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
...ThemeMenuTheme
|
||||
slug
|
||||
type
|
||||
sequence
|
||||
group
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
entries {
|
||||
...createVideoSlugEntry
|
||||
videos {
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
artist: gql`
|
||||
${SongTitleWithArtists.fragments.artist}
|
||||
|
||||
fragment ThemeSummaryCardArtist on Artist {
|
||||
...SongTitleWithArtistsArtist
|
||||
}
|
||||
`,
|
||||
expandable: gql`
|
||||
${ThemeTable.fragments.theme}
|
||||
|
||||
fragment ThemeSummaryCardThemeExpandable on Theme {
|
||||
...ThemeTableTheme
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
|
||||
|
||||
export const fetchThemeSummaryCardData = async function (id: number): Promise<FetchThemeSummaryCardData> {
|
||||
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
query ThemeSummaryCard($themeId: Int!) {
|
||||
theme(id: $themeId) {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
year
|
||||
season
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { themeId: id }).then((result) => result.data?.theme ?? null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type ReactNode, type Ref } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import theme from "@/theme";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
${StyledWrapper}:hover &, &:has([data-state="open"]) {
|
||||
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);
|
||||
`;
|
||||
|
||||
export const VIDEO_SUMMARY_CARD_VIDEO = graphql(`
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_SUMMARY_CARD_ENTRY = graphql(`
|
||||
fragment VideoSummaryCardEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleSong
|
||||
...PerformancesSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoSummaryCardProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
video: FragmentType<typeof VIDEO_SUMMARY_CARD_VIDEO>;
|
||||
entry: FragmentType<typeof VIDEO_SUMMARY_CARD_ENTRY>;
|
||||
menu?: ReactNode;
|
||||
append?: ReactNode;
|
||||
onPlay?(): void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export function VideoSummaryCard({
|
||||
ref,
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
menu,
|
||||
append,
|
||||
onPlay,
|
||||
isPlaying,
|
||||
...props
|
||||
}: VideoSummaryCardProps) {
|
||||
const video = getFragmentData(VIDEO_SUMMARY_CARD_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(VIDEO_SUMMARY_CARD_ENTRY, entryFragment);
|
||||
const theme = entry.animetheme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime.images.nodes);
|
||||
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} href={href} onClick={onPlay} />
|
||||
<Performances song={theme.song} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>
|
||||
{videoSlug}
|
||||
{theme.group && ` (${theme.group.name})`}
|
||||
</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu !== null ? <StyledOverlayButtons>{menu}</StyledOverlayButtons> : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { Card } from "./Card";
|
||||
export { SummaryCard } from "./SummaryCard";
|
||||
export { AnimeSummaryCard } from "./AnimeSummaryCard";
|
||||
export { ThemeSummaryCard } from "./ThemeSummaryCard";
|
||||
export { ArtistSummaryCard } from "./ArtistSummaryCard";
|
||||
export { ThemeDetailCard } from "./ThemeDetailCard";
|
||||
export { ErrorCard } from "./ErrorCard";
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
|
||||
import theme from "@/theme";
|
||||
|
||||
export const SidebarContainer = styled.div`
|
||||
display: grid;
|
||||
@@ -9,4 +10,9 @@ export const SidebarContainer = styled.div`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
// This will prevent columns from overflowing
|
||||
& > * {
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Container } from "./Container";
|
||||
export { SidebarContainer } from "./SidebarContainer";
|
||||
@@ -1,6 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
import { Text } from "components/text";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
const StyledDescriptionList = styled.dl`
|
||||
display: flex;
|
||||
@@ -21,27 +22,25 @@ const StyledValue = styled.dd`
|
||||
`;
|
||||
|
||||
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DescriptionList({ children, ...props }: DescriptionListProps) {
|
||||
return (
|
||||
<StyledDescriptionList {...props}>
|
||||
{children}
|
||||
</StyledDescriptionList>
|
||||
);
|
||||
return <StyledDescriptionList {...props}>{children}</StyledDescriptionList>;
|
||||
}
|
||||
|
||||
interface DescriptionListItemProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
|
||||
return (
|
||||
<>
|
||||
<StyledKey>
|
||||
<Text as="span" variant="h2">{title}</Text>
|
||||
<Text as="span" variant="h2">
|
||||
{title}
|
||||
</Text>
|
||||
</StyledKey>
|
||||
<StyledValue>{children}</StyledValue>
|
||||
</>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { DescriptionList } from "./DescriptionList";
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { RefObject } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
import * as RadixDialog from "@radix-ui/react-dialog";
|
||||
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
|
||||
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(Text).attrs({ variant: "h2", as: RadixDialog.Title })`
|
||||
margin: -24px -24px 24px -24px;
|
||||
padding: 16px 24px;
|
||||
background-color: ${theme.colors["background"]};
|
||||
`;
|
||||
|
||||
interface DialogContentProps extends RadixDialog.DialogContentProps {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function DialogContent({ ref, title, children, ...props }: DialogContentProps) {
|
||||
return (
|
||||
<RadixDialog.Portal>
|
||||
<StyledOverlay>
|
||||
<RadixDialog.Content asChild {...props} ref={ref}>
|
||||
<StyledDialogCard>
|
||||
{title ? <StyledHeader>{title}</StyledHeader> : null}
|
||||
{children}
|
||||
</StyledDialogCard>
|
||||
</RadixDialog.Content>
|
||||
</StyledOverlay>
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export const Dialog = RadixDialog.Root;
|
||||
|
||||
export const DialogTrigger = RadixDialog.Trigger;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
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 { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { ForgotPasswordDialog } from "@/components/dialog/ForgotPasswordDialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import Switch from "@/components/form/Switch";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import useAuth, { type LoginErrors } from "@/hooks/useAuth";
|
||||
|
||||
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<LoginErrors>({});
|
||||
|
||||
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
|
||||
? 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,
|
||||
}}
|
||||
/>
|
||||
<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,151 @@
|
||||
import { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import axios from "@/lib/client/axios";
|
||||
import { AUTH_PATH } from "@/utils/config";
|
||||
|
||||
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(`${AUTH_PATH}/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,155 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
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: (Array.isArray(token) ? token[0] : 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,128 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
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<PlaylistVisibility>("PUBLIC");
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistAdd($name: String!, $visibility: PlaylistVisibility!) {
|
||||
CreatePlaylist(name: $name, visibility: $visibility) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => onSuccess(),
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
await mutate({
|
||||
variables: {
|
||||
name: title,
|
||||
visibility,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
|
||||
>
|
||||
<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 || loading}>
|
||||
<Busy isBusy={loading}>Create Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be created: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_EDIT_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistEditDialogPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistEditDialogProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistEditDialog({ playlist: playlistFragment, trigger }: PlaylistEditDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_EDIT_DIALOG_PLAYLIST, playlistFragment);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: ResultOf<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
|
||||
const [title, setTitle] = useState(playlist.name);
|
||||
const [visibility, setVisibility] = useState<PlaylistVisibility>(playlist.visibility);
|
||||
|
||||
const isValid = title !== "";
|
||||
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistEdit($id: String!, $name: String, $visibility: PlaylistVisibility) {
|
||||
UpdatePlaylist(id: $id, name: $name, visibility: $visibility) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => onSuccess(),
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
await mutate({
|
||||
variables: {
|
||||
id: playlist.id,
|
||||
name: title,
|
||||
visibility,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
|
||||
>
|
||||
<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 || loading}>
|
||||
<Busy isBusy={loading}>Update Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be updated: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { PlaylistRemoveToast } from "@/components/toast/PlaylistRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_REMOVE_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistRemoveDialogProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistRemoveDialog({ playlist: playlistFragment, trigger }: PlaylistRemoveDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_REMOVE_DIALOG_PLAYLIST, playlistFragment);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaylistRemoveFormProps {
|
||||
playlist: ResultOf<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistRemove($id: String!) {
|
||||
DeletePlaylist(id: $id) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function removePlaylist() {
|
||||
await mutate({
|
||||
variables: {
|
||||
id: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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={loading} onClick={removePlaylist}>
|
||||
<Busy isBusy={loading}>Delete playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be removed: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { faArrowDown, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { PlaylistTrackAddToast } from "@/components/toast/PlaylistTrackAddToast";
|
||||
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
import { PROFILE_PAGE } from "@/pages/profile";
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_DIALOG_VIDEO = graphql(`
|
||||
fragment PlaylistTrackAddDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_ADD_DIALOG_ENTRY = graphql(`
|
||||
fragment PlaylistTrackAddDialogEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackAddToastEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddDialogProps {
|
||||
video: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackAddDialog({
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
trigger,
|
||||
}: PlaylistTrackAddDialogProps) {
|
||||
const video = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_ENTRY, entryFragment);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPlus} 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} entry={entry} onCancel={() => setOpen(false)} />
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYLIST_TRACK_ADD_FORM_PLAYLIST = graphql(`
|
||||
query PlaylistTrackAddFormPlaylist($entryId: Mixed!, $videoId: Mixed!) {
|
||||
me {
|
||||
playlists {
|
||||
...PlaylistTrackAddCardPlaylist
|
||||
id
|
||||
tracks(where: [{ field: ENTRY_ID, value: $entryId }, { field: VIDEO_ID, value: $videoId }]) {
|
||||
...PlaylistTrackAddCardTrack
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddFormProps {
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormProps) {
|
||||
const { data } = useQuery(PLAYLIST_TRACK_ADD_FORM_PLAYLIST, {
|
||||
variables: {
|
||||
entryId: entry.id,
|
||||
videoId: video.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
<Skeleton variant="summary-card" />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const playlists = data.me?.playlists ?? [];
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<VideoSummaryCard video={video} entry={entry} menu={null} />
|
||||
<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}
|
||||
track={playlist.tracks[0] ?? null}
|
||||
video={video}
|
||||
entry={entry}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYLIST_TRACK_ADD_CARD_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackAddCardPlaylist on Playlist {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistTrackAddToastPlaylist
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
const PLAYLIST_TRACK_ADD_CARD_TRACK = graphql(`
|
||||
fragment PlaylistTrackAddCardTrack on PlaylistTrack {
|
||||
id
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackAddCardProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_PLAYLIST>;
|
||||
track: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_TRACK> | null;
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
|
||||
}
|
||||
|
||||
function PlaylistTrackAddCard({
|
||||
playlist: playlistFragment,
|
||||
track: trackFragment,
|
||||
video,
|
||||
entry,
|
||||
}: PlaylistTrackAddCardProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_ADD_CARD_PLAYLIST, playlistFragment);
|
||||
const track = getFragmentData(PLAYLIST_TRACK_ADD_CARD_TRACK, trackFragment);
|
||||
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [mutateAddTrack, { loading: loadingAddTrack, error: errorAddTrack }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackAdd($playlistId: String!, $entryId: Int!, $videoId: Int!) {
|
||||
CreatePlaylistTrack(playlist: $playlistId, entryId: $entryId, videoId: $videoId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(
|
||||
`playlist-add-track-${playlist.id}-${video.id}`,
|
||||
<PlaylistTrackAddToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the list in the dialog above
|
||||
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [mutateRemoveTrack, { loading: loadingRemoveTrack, error: errorRemoveTrack }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
|
||||
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
if (track === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${track.id}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the profile page because it includes a list of the user's playlists
|
||||
PROFILE_PAGE,
|
||||
// Update the list in the dialog above
|
||||
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const loading = loadingAddTrack || loadingRemoveTrack;
|
||||
|
||||
async function addTrackToPlaylist() {
|
||||
await mutateAddTrack({
|
||||
variables: {
|
||||
playlistId: playlist.id,
|
||||
videoId: video.id,
|
||||
entryId: entry.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateRemoveTrack({
|
||||
variables: {
|
||||
id: track.id,
|
||||
playlistId: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
|
||||
{track === null ? (
|
||||
<IconTextButton icon={faPlus} disabled={loading} onClick={addTrackToPlaylist}>
|
||||
<Busy isBusy={loading}>Add</Busy>
|
||||
</IconTextButton>
|
||||
) : (
|
||||
<IconTextButton icon={faMinus} disabled={loading} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={loading}>Remove</Busy>
|
||||
</IconTextButton>
|
||||
)}
|
||||
</PlaylistSummaryCard>
|
||||
{errorAddTrack ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be added to the playlist: </strong>
|
||||
{errorAddTrack.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{errorRemoveTrack ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be removed from the playlist: </strong>
|
||||
{errorRemoveTrack.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { LoginGate } from "@/components/auth/LoginGate";
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
|
||||
...PlaylistTrackRemoveToastPlaylist
|
||||
id
|
||||
name
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
`);
|
||||
|
||||
export const PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY = graphql(`
|
||||
fragment PlaylistTrackRemoveDialogEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...PlaylistTrackRemoveToastEntry
|
||||
}
|
||||
`);
|
||||
|
||||
interface PlaylistTrackRemoveDialogProps {
|
||||
playlist: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
|
||||
trackId: string;
|
||||
video: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
|
||||
entry: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function PlaylistTrackRemoveDialog({
|
||||
playlist: playlistFragment,
|
||||
trackId,
|
||||
video: videoFragment,
|
||||
entry: entryFragment,
|
||||
trigger,
|
||||
}: PlaylistTrackRemoveDialogProps) {
|
||||
const playlist = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST, playlistFragment);
|
||||
const video = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO, videoFragment);
|
||||
const entry = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY, entryFragment);
|
||||
|
||||
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}
|
||||
entry={entry}
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaylistTrackRemoveFormProps {
|
||||
playlist: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
|
||||
trackId: string;
|
||||
video: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
|
||||
entry: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function PlaylistTrackRemoveForm({
|
||||
playlist,
|
||||
trackId,
|
||||
video,
|
||||
entry,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: PlaylistTrackRemoveFormProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [mutate, { loading, error }] = useMutation(
|
||||
graphql(`
|
||||
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
|
||||
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
|
||||
message
|
||||
}
|
||||
}
|
||||
`),
|
||||
{
|
||||
onCompleted: () => {
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${trackId}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
refetchQueries: [
|
||||
// Update the playlist page in case the user is editing from there
|
||||
PLAYLIST_DETAIL_PAGE_PLAYLIST,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
async function removeTrackFromPlaylist() {
|
||||
await mutate({
|
||||
variables: {
|
||||
id: trackId,
|
||||
playlistId: playlist.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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} entry={entry} menu={null} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="warning" disabled={loading} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={loading}>Remove from playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning">
|
||||
<strong>The theme could not be removed from the playlist: </strong>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import Switch from "@/components/form/Switch";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import useAuth, { type RegisterErrors } from "@/hooks/useAuth";
|
||||
|
||||
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<RegisterErrors>({});
|
||||
|
||||
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,115 @@
|
||||
import { type FormEvent, type ReactNode, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
|
||||
import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilterMediaFormat";
|
||||
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import type { AnimeMediaFormat, ThemeType } from "@/graphql/generated/graphql";
|
||||
import useRandomThemes from "@/hooks/useRandomThemes";
|
||||
|
||||
interface ShuffleDialogProps {
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent title="Shuffle">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? <ShuffleForm onCancel={() => setOpen(false)} onSuccess={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
`;
|
||||
|
||||
interface ShuffleFormProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [filterThemeType, setFilterThemeType] = useState<ThemeType | null>(null);
|
||||
const [filterMediaFormat, setFilterMediaFormat] = useState<AnimeMediaFormat | null>(null);
|
||||
const [filterAnimeYearMin, setFilterAnimeYearMin] = useState("");
|
||||
const [filterAnimeYearMax, setFilterAnimeYearMax] = useState("");
|
||||
|
||||
const { playRandomThemes } = useRandomThemes();
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await playRandomThemes({
|
||||
themeType: filterThemeType ?? undefined,
|
||||
mediaFormat: filterMediaFormat ?? undefined,
|
||||
animeYearMin: parseInt(filterAnimeYearMin),
|
||||
animeYearMax: parseInt(filterAnimeYearMax),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={submit}>
|
||||
<SearchFilterGroup>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Theme Type</Text>
|
||||
<Switcher selectedItem={filterThemeType} onChange={setFilterThemeType}>
|
||||
<SwitcherOption value="">Any</SwitcherOption>
|
||||
<SwitcherOption value="OP">Openings</SwitcherOption>
|
||||
<SwitcherOption value="ED">Endings</SwitcherOption>
|
||||
</Switcher>
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Premiered After</Text>
|
||||
<Input
|
||||
value={filterAnimeYearMin}
|
||||
onChange={setFilterAnimeYearMin}
|
||||
inputProps={{ type: "number", placeholder: "1900" }}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Premiered Before</Text>
|
||||
<Input
|
||||
value={filterAnimeYearMax}
|
||||
onChange={setFilterAnimeYearMax}
|
||||
inputProps={{ type: "number", placeholder: "2100" }}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilterMediaFormat value={filterMediaFormat} setValue={setFilterMediaFormat} />
|
||||
</SearchFilterGroup>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isBusy}>
|
||||
<Busy isBusy={isBusy}>Start Shuffle</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</StyledForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/form/Input";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
export function UserInformationDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Change User Information</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Change User Information">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? <UserInformationForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface UserInformationFormProps {
|
||||
onSuccess(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps) {
|
||||
const { me, updateUserInformation } = useAuth();
|
||||
const { dispatchToast } = useToasts();
|
||||
|
||||
const [username, setUsername] = useState(me?.name ?? "");
|
||||
const [email, setEmail] = useState(me?.email ?? "");
|
||||
|
||||
const isValid = username && email;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string[];
|
||||
email?: string[];
|
||||
}>({});
|
||||
|
||||
async function performChangeUserInformation(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await updateUserInformation({ setErrors, name: username, email });
|
||||
|
||||
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledForm onSubmit={performChangeUserInformation}>
|
||||
<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 Address</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>
|
||||
<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</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faArrowRight, faTrophy } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
|
||||
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 />
|
||||
<EventButton asChild>
|
||||
<Link href="/event/anime-awards">
|
||||
<EventIcon icon={faTrophy} />
|
||||
<Text>
|
||||
<Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!
|
||||
</Text>
|
||||
<Icon icon={faArrowRight} color="text-primary" />
|
||||
</Link>
|
||||
</EventButton>
|
||||
</EventButtonContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { faChevronCircleRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Text } from "components/text";
|
||||
import { Icon } from "components/icon";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
|
||||
href?: string | null
|
||||
children: ReactNode
|
||||
href?: string | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
|
||||
@@ -14,7 +16,7 @@ export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
|
||||
<Text>{children}</Text>
|
||||
<Text noWrap>
|
||||
|
||||
<Icon icon={faChevronCircleRight}/>
|
||||
<Icon icon={faChevronCircleRight} />
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ExternalLink } from "./ExternalLink";
|
||||
@@ -1,17 +1,20 @@
|
||||
import { FeaturedThemePreview } from "utils/settings";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import theme from "theme";
|
||||
import { ThemeSummaryCard } from "components/card";
|
||||
import extractImages from "utils/extractImages";
|
||||
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 type { ReactNode } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import Link from "next/link";
|
||||
import { VIDEO_URL } from "utils/config";
|
||||
import gql from "graphql-tag";
|
||||
import type { FeaturedThemeThemeFragment } from "generated/graphql";
|
||||
|
||||
import type { ResultOf } from "@graphql-typed-document-node/core";
|
||||
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import useCompatability from "@/hooks/useCompatability";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { fetchRandomGrill } from "@/lib/client/randomGrill";
|
||||
import theme from "@/theme";
|
||||
import { VIDEO_URL } from "@/utils/config";
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
import { FeaturedThemePreview } from "@/utils/settings";
|
||||
|
||||
const slowPan = keyframes`
|
||||
from {
|
||||
@@ -35,11 +38,11 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
|
||||
height: 200px;
|
||||
|
||||
|
||||
position: relative;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: -16px;
|
||||
@@ -47,7 +50,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOverflowHidden = styled.a`
|
||||
const StyledOverflowHidden = styled(Link)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
@@ -60,7 +63,7 @@ const StyledOverflowHidden = styled.a`
|
||||
|
||||
const StyledCenter = styled.div`
|
||||
position: absolute;
|
||||
|
||||
|
||||
width: 400px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
@@ -88,12 +91,12 @@ const StyledCover = styled.img`
|
||||
|
||||
const StyledGrillContainer = styled.div`
|
||||
position: absolute;
|
||||
|
||||
|
||||
height: 130%;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
right: 0;
|
||||
}
|
||||
@@ -105,10 +108,10 @@ const StyledGrill = styled.img`
|
||||
object-fit: contain;
|
||||
object-position: bottom;
|
||||
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
|
||||
transition: transform 1s;
|
||||
transform: translateY(10%);
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
@@ -116,38 +119,74 @@ const StyledGrill = styled.img`
|
||||
|
||||
const Box = styled.div``;
|
||||
|
||||
export const FEATURED_THEME_ENTRY = graphql(`
|
||||
fragment FeaturedThemeEntry on AnimeThemeEntry {
|
||||
...VideoSummaryCardEntry
|
||||
...createVideoSlugEntry
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
anime {
|
||||
slug
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const FEATURED_THEME_VIDEO = graphql(`
|
||||
fragment FeaturedThemeVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...createVideoSlugVideo
|
||||
basename
|
||||
}
|
||||
`);
|
||||
|
||||
interface FeaturedThemeProps {
|
||||
theme: FeaturedThemeThemeFragment
|
||||
entry: FragmentType<typeof FEATURED_THEME_ENTRY>;
|
||||
video: FragmentType<typeof FEATURED_THEME_VIDEO>;
|
||||
hasGrill?: boolean;
|
||||
card?: ReactNode;
|
||||
onPlay?(): void;
|
||||
}
|
||||
|
||||
export function FeaturedTheme({ theme }: FeaturedThemeProps) {
|
||||
const [ grill, setGrill ] = useState<string | null>(null);
|
||||
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
|
||||
export function FeaturedTheme({
|
||||
entry: entryFragment,
|
||||
video: videoFragment,
|
||||
hasGrill = true,
|
||||
card,
|
||||
onPlay,
|
||||
}: FeaturedThemeProps) {
|
||||
const entry = getFragmentData(FEATURED_THEME_ENTRY, entryFragment);
|
||||
const video = getFragmentData(FEATURED_THEME_VIDEO, videoFragment);
|
||||
|
||||
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
|
||||
: Box;
|
||||
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED ? StyledWrapper : Box;
|
||||
|
||||
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
|
||||
? (
|
||||
<StyledCenter>
|
||||
<ThemeSummaryCard theme={theme}/>
|
||||
</StyledCenter>
|
||||
)
|
||||
: (
|
||||
<ThemeSummaryCard theme={theme}/>
|
||||
const featuredThemeSummaryCard =
|
||||
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
|
||||
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
|
||||
) : (
|
||||
(card ?? <VideoSummaryCard entry={entry} video={video} />)
|
||||
);
|
||||
|
||||
return (
|
||||
<FeaturedThemeWrapper>
|
||||
<FeaturedThemeBackground theme={theme}/>
|
||||
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
|
||||
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
|
||||
<StyledGrillContainer>
|
||||
<StyledGrill src={grill}/>
|
||||
<StyledGrill src={grill} />
|
||||
</StyledGrillContainer>
|
||||
)}
|
||||
{featuredThemeSummaryCard}
|
||||
@@ -155,76 +194,37 @@ export function FeaturedTheme({ theme }: FeaturedThemeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
|
||||
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
|
||||
interface FeaturedThemeBackgroundProps {
|
||||
entry: ResultOf<typeof FEATURED_THEME_ENTRY>;
|
||||
video: ResultOf<typeof FEATURED_THEME_VIDEO>;
|
||||
onPlay?(): void;
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeBackgroundProps) {
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
const { canPlayVideo } = useCompatability();
|
||||
const [ fallbackToCover, setFallbackToCover ] = useState(false);
|
||||
const { smallCover: featuredCover } = extractImages(theme.anime);
|
||||
const [fallbackToCover, setFallbackToCover] = useState(false);
|
||||
const { smallCover: featuredCover } = extractImages(entry.animetheme.anime.images.nodes);
|
||||
|
||||
if (!theme.anime || !theme.entries.length) {
|
||||
return null;
|
||||
}
|
||||
const videoSlug = createVideoSlug(entry.animetheme, entry, video);
|
||||
|
||||
const entry = theme.entries[0];
|
||||
|
||||
if (!entry.videos.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
const linkProps = {
|
||||
href: `/anime/${theme.anime.slug}/${videoSlug}`,
|
||||
passHref: true,
|
||||
};
|
||||
const href = `/anime/${entry.animetheme.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} onClick={onPlay}>
|
||||
<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} onClick={onPlay}>
|
||||
<StyledCover src={featuredCover} />
|
||||
</StyledOverflowHidden>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
FeaturedTheme.fragments = {
|
||||
theme: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment FeaturedThemeTheme on Theme {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
entries {
|
||||
videos {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FeaturedTheme } from "./FeaturedTheme";
|
||||
@@ -1,89 +1,132 @@
|
||||
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 { Column, Row } from "@/components/box/Flex";
|
||||
import { ThemeDetailCard } from "@/components/card/ThemeDetailCard";
|
||||
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
|
||||
|
||||
export const ANIME_THEME_FILTER_THEME = graphql(`
|
||||
fragment AnimeThemeFilterTheme on AnimeTheme {
|
||||
...ThemeDetailCardTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface AnimeThemeFilterProps {
|
||||
themes: Array<AnimeThemeFilterThemeFragment>
|
||||
themes: Array<FragmentType<typeof ANIME_THEME_FILTER_THEME>>;
|
||||
}
|
||||
|
||||
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
function AnimeThemeFilterInternal({ themes: themesFragment }: AnimeThemeFilterProps) {
|
||||
const themes = getFragmentData(ANIME_THEME_FILTER_THEME, themesFragment);
|
||||
|
||||
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
|
||||
const filteredThemes = themes
|
||||
.filter((theme) => !filterType || theme.type === filterType)
|
||||
.sort(either(themeGroupComparator).or(themeTypeComparator).or(themeIndexComparator).chain());
|
||||
|
||||
const groups = useMemo(
|
||||
() => themes.reduce<{
|
||||
name: string,
|
||||
themes: typeof themes
|
||||
}[]>(
|
||||
(groups, theme) => {
|
||||
const groupName = theme.group || "Original";
|
||||
() =>
|
||||
filteredThemes.reduce<
|
||||
{
|
||||
name: string;
|
||||
slug: string;
|
||||
themes: typeof themes;
|
||||
}[]
|
||||
>((groups, theme) => {
|
||||
const groupName = theme.group?.name || "Original";
|
||||
const groupSlug = theme.group?.slug || "original";
|
||||
const group = groups.find((group) => group.name === groupName);
|
||||
if (!group) {
|
||||
groups.push({
|
||||
name: groupName,
|
||||
slug: groupSlug,
|
||||
themes: [theme],
|
||||
});
|
||||
} else {
|
||||
group.themes.push(theme);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
[]
|
||||
),
|
||||
[ themes ]
|
||||
}, []),
|
||||
[filteredThemes],
|
||||
);
|
||||
|
||||
const [ activeGroup, setActiveGroup ] = useState(groups[0]?.name);
|
||||
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 filteredThemes = activeThemes
|
||||
.filter((theme) => !filterType || theme.type === filterType)
|
||||
.sort(either(themeTypeComparator).or(themeIndexComparator).chain());
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const activeGroupThemes = groups.find((group) => group.slug === activeGroup)?.themes;
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
{(groups.length > 1 || hasMultipleTypes) && (
|
||||
<HorizontalScroll fixShadows>
|
||||
<HorizontalScroll $fixShadows>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && (
|
||||
<Listbox value={activeGroup} onChange={setActiveGroup}>
|
||||
<Listbox
|
||||
value={activeGroup}
|
||||
onValueChange={setActiveGroup}
|
||||
defaultValue={null}
|
||||
resettable
|
||||
nullable
|
||||
highlightNonDefault
|
||||
>
|
||||
<ListboxOption value={null} hidden>
|
||||
All Groups
|
||||
</ListboxOption>
|
||||
{groups.map((group) => (
|
||||
<Listbox.Option key={group.name} value={group.name}>{group.name}</Listbox.Option>
|
||||
<ListboxOption key={group.slug} value={group.slug}>
|
||||
{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>
|
||||
</HorizontalScroll>
|
||||
)}
|
||||
{filteredThemes.map((theme, index) => (
|
||||
<ThemeDetailCard key={index} theme={theme} />
|
||||
))}
|
||||
<Column style={{ "--gap": "48px" }}>
|
||||
{activeGroupThemes ? (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{activeGroupThemes.map((theme) => (
|
||||
<ThemeDetailCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Column>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Column key={group.slug} style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && <Text variant="h3">{group.name}</Text>}
|
||||
{group.themes.map((theme) => (
|
||||
<ThemeDetailCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Column>
|
||||
))
|
||||
)}
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
AnimeThemeFilterInternal.fragments = {
|
||||
theme: gql`
|
||||
${ThemeDetailCard.fragments.theme}
|
||||
|
||||
fragment AnimeThemeFilterTheme on Theme {
|
||||
...ThemeDetailCardTheme
|
||||
type
|
||||
group
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AnimeThemeFilter } from "./AnimeThemeFilter";
|
||||
@@ -1,14 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { Column, Row, Solid } from "components/box";
|
||||
import { Container } from "components/container";
|
||||
import { Text } from "components/text";
|
||||
import { Icon } from "components/icon";
|
||||
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||
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";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||
|
||||
import { Column, Row } from "@/components/box/Flex";
|
||||
import { Solid } from "@/components/box/Solid";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Container } from "@/components/container/Container";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledFooter = styled(Solid)`
|
||||
margin-top: auto;
|
||||
@@ -35,57 +36,64 @@ const StyledSocialList = styled(Row)`
|
||||
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 })`
|
||||
font-size: 1.2rem;
|
||||
`;
|
||||
|
||||
const FooterLink = styled(Text).attrs({ as: Link })``;
|
||||
|
||||
const FooterTextLink = styled(FooterLink).attrs({
|
||||
link: true,
|
||||
block: true,
|
||||
color: "text-muted",
|
||||
noWrap: true,
|
||||
})``;
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<StyledFooter>
|
||||
<StyledContainer>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href={`${APP_URL}/transparency`}>
|
||||
Transparency
|
||||
</FooterTextLink>
|
||||
<FooterTextLink forwardedAs={Link} href="/wiki/donate" target="_self">
|
||||
Donate
|
||||
</FooterTextLink>
|
||||
<FooterTextLink forwardedAs={Link} href="/wiki/faq" target="_self">
|
||||
FAQ
|
||||
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
|
||||
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
|
||||
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
|
||||
<FooterTextLink as="a" href="https://api-docs.animethemes.moe" target="_blank">
|
||||
API Documentation
|
||||
</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href={`${APP_URL}/terms-of-service`}>
|
||||
Terms of Service
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href={`${APP_URL}/privacy-policy`}>
|
||||
Privacy Policy
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="mailto:admin@animethemes.moe">
|
||||
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
|
||||
<FooterTextLink href="/about/privacy-policy">Privacy Policy</FooterTextLink>
|
||||
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
|
||||
Contact
|
||||
</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
<StyledSocialList>
|
||||
<FooterLink href="https://reddit.com/r/AnimeThemes">
|
||||
<StyledSocialButton title="Reddit">
|
||||
<Icon icon={faReddit}/>
|
||||
<Icon icon={faReddit} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://discordapp.com/invite/m9zbVyQ">
|
||||
<StyledSocialButton title="Discord">
|
||||
<Icon icon={faDiscord}/>
|
||||
<Icon icon={faDiscord} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://twitter.com/AnimeThemesMoe">
|
||||
<StyledSocialButton title="Twitter">
|
||||
<Icon icon={faTwitter}/>
|
||||
<Icon icon={faTwitter} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://github.com/AnimeThemes">
|
||||
<StyledSocialButton title="GitHub">
|
||||
<Icon icon={faGithub}/>
|
||||
<Icon icon={faGithub} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
</StyledSocialList>
|
||||
@@ -93,15 +101,3 @@ export function Footer() {
|
||||
</StyledFooter>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
|
||||
return (
|
||||
<Text as="a" target="_blank" rel="noopener" {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
|
||||
return (
|
||||
<FooterLink link block color="text-muted" noWrap {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Footer } from "./Footer";
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { withColorTheme } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledSearchInput = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
gap: 8px;
|
||||
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${withColorTheme("dark")`
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
`}
|
||||
}
|
||||
`;
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
|
||||
margin: -8px;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface InputProps extends Omit<ComponentPropsWithoutRef<typeof StyledSearchInput>, "onChange"> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
resettable?: boolean;
|
||||
icon?: IconDefinition;
|
||||
inputProps?: ComponentPropsWithRef<typeof StyledInput>;
|
||||
}
|
||||
|
||||
export function Input({ value, onChange, resettable = false, icon, inputProps = {}, ...props }: InputProps) {
|
||||
return (
|
||||
<StyledSearchInput {...props}>
|
||||
{icon && <Icon icon={icon} color="text-disabled" />}
|
||||
<StyledInput
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange && onChange(event.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
{resettable && !!value && (
|
||||
<StyledResetButton>
|
||||
<Icon icon={faXmark} onClick={() => onChange && onChange("")} />
|
||||
</StyledResetButton>
|
||||
)}
|
||||
</StyledSearchInput>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||
|
||||
import { withColorTheme } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
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,24 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import { withColorTheme } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
export const TextArea = styled.textarea`
|
||||
display: block;
|
||||
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
|
||||
resize: vertical;
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
scrollbar-color: ${theme.colors["gray-800"]} transparent;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${withColorTheme("dark")`
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
`}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { Input } from "@/components/form/Input";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function MostPopularEntries() {
|
||||
const { data: mostPopular = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageTrending"],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageMostPopular {
|
||||
animethemeentryPagination(sort: [TRACKS_COUNT_DESC], first: 10) {
|
||||
data {
|
||||
...VideoSummaryCardEntry
|
||||
videos(first: 1) {
|
||||
nodes {
|
||||
...VideoSummaryCardVideo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
return data.animethemeentryPagination.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{mostPopular.map((entry, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{entry ? <VideoSummaryCard video={entry.videos.nodes[0]} entry={entry} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function RecentlyAddedPlaylists() {
|
||||
const { data: recentlyAddedPlaylists = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageRecentlyAddedPlaylists"],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageRecentlyAddedPlaylists {
|
||||
playlistPagination(sort: [CREATED_AT_DESC], first: 10) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardPlaylistWithOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
return data.playlistPagination.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{recentlyAddedPlaylists?.map((playlist, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{playlist ? <PlaylistSummaryCard playlist={playlist} playlistWithOwner={playlist} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { range } from "lodash-es";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
|
||||
import { Skeleton } from "@/components/skeleton/Skeleton";
|
||||
import { client } from "@/graphql/client";
|
||||
import { graphql } from "@/graphql/generated";
|
||||
|
||||
export function RecentlyAddedVideos() {
|
||||
const { data: recentlyAdded = range(10).map(() => null) } = useQuery({
|
||||
queryKey: ["HomePageRecentlyAdded"],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.query({
|
||||
query: graphql(`
|
||||
query HomePageRecentlyAdded {
|
||||
videoPagination(sort: [ID_DESC], first: 10) {
|
||||
data {
|
||||
...VideoSummaryCardVideo
|
||||
animethemeentries(first: 1) {
|
||||
nodes {
|
||||
...VideoSummaryCardEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
});
|
||||
|
||||
return data.videoPagination.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{recentlyAdded.map((video, index) => (
|
||||
<Skeleton key={index} variant="summary-card" delay={index * 100}>
|
||||
{video ? <VideoSummaryCard video={video} entry={video.animethemeentries.nodes[0]} /> : null}
|
||||
</Skeleton>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import styled from "styled-components";
|
||||
import { Icon } from "components/icon/Icon";
|
||||
import theme from "theme";
|
||||
|
||||
export const CornerIcon = styled(Icon).attrs({
|
||||
size: "2x"
|
||||
})`
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import theme from "@/theme";
|
||||
|
||||
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);
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
|
||||
icon: IconDefinition
|
||||
title?: string
|
||||
}
|
||||
icon: IconDefinition;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const xmlns = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -26,17 +27,15 @@ export function FontAwesomeIcon(props: FontAwesomeIconProps) {
|
||||
className={`${className} svg-inline--fa fa-fw`}
|
||||
{...rest}
|
||||
>
|
||||
{title ? (
|
||||
<title>{title}</title>
|
||||
) : null}
|
||||
{title ? <title>{title}</title> : null}
|
||||
{children}
|
||||
{Array.isArray(svgPathData) ? (
|
||||
<g>
|
||||
<path d={svgPathData[0]}/>
|
||||
<path d={svgPathData[1]}/>
|
||||
<path d={svgPathData[0]} />
|
||||
<path d={svgPathData[1]} />
|
||||
</g>
|
||||
) : (
|
||||
<path fill="currentColor" d={svgPathData}/>
|
||||
<path fill="currentColor" d={svgPathData} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { FontAwesomeIcon } from "components/icon/FontAwesomeIcon";
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import type { Property } from "csstype";
|
||||
import type { Colors } from "theme/colors";
|
||||
|
||||
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors, transition?: Property.Transition }>`
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import { FontAwesomeIcon } from "@/components/icon/FontAwesomeIcon";
|
||||
import theme from "@/theme";
|
||||
import type { Colors } from "@/theme/colors";
|
||||
|
||||
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors; transition?: Property.Transition }>`
|
||||
color: ${(props) => props.color && theme.colors[props.color]};
|
||||
|
||||
transition: ${(props) => props.transition}
|
||||
transition: ${(props) => props.transition};
|
||||
`;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Icon } from "./Icon";
|
||||
@@ -1,30 +1,22 @@
|
||||
import extractImages from "utils/extractImages";
|
||||
import { AspectRatio } from "components/utils";
|
||||
import { FullWidthImage } from "components/image";
|
||||
import gql from "graphql-tag";
|
||||
import type { CoverImageResourceWithImagesFragment } from "generated/graphql";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { FullWidthImage } from "@/components/image/FullWidthImage";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
|
||||
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
resourceWithImages: CoverImageResourceWithImagesFragment
|
||||
smallCover: string | undefined;
|
||||
largeCover: string | undefined;
|
||||
}
|
||||
|
||||
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
const { smallCover, largeCover } = extractImages(resourceWithImages);
|
||||
|
||||
export function CoverImage({ smallCover, largeCover, ...props }: CoverImageProps) {
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
|
||||
<AspectRatio $ratio={2 / 3}>
|
||||
<FullWidthImage
|
||||
key={largeCover}
|
||||
src={largeCover}
|
||||
style={{ backgroundImage: `url(${smallCover})` }}
|
||||
{...props}
|
||||
/>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
CoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment CoverImageResourceWithImages on ResourceWithImages {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
|
||||
import theme from "@/theme";
|
||||
|
||||
export const FullWidthImage = styled.img`
|
||||
width: 100%;
|
||||
@@ -7,7 +8,7 @@ export const FullWidthImage = styled.img`
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: ${theme.shadows.medium};
|
||||
|
||||
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
`;
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { SVGAttributes } from "react";
|
||||
export function Logo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
|
||||
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
|
||||
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
|
||||
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48" />
|
||||
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import styled from "styled-components";
|
||||
import theme from "theme";
|
||||
import { Logo } from "components/image/Logo";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Logo } from "@/components/image/Logo";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledPlaceholder = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
|
||||
|
||||
background: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
|
||||
|
||||
& svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -21,7 +22,7 @@ const StyledPlaceholder = styled.div`
|
||||
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
|
||||
return (
|
||||
<StyledPlaceholder {...props}>
|
||||
<Logo/>
|
||||
<Logo />
|
||||
</StyledPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import extractImages from "utils/extractImages";
|
||||
import { AspectRatio } from "components/utils";
|
||||
import styled, { css } from "styled-components";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { MultiCoverImageResourceWithImagesFragment } from "generated/graphql";
|
||||
import gql from "graphql-tag";
|
||||
import { LogoPlaceholder } from "components/image/LogoPlaceholder";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import { LogoPlaceholder } from "@/components/image/LogoPlaceholder";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
|
||||
function getTranslationX(item: number, itemCount: number) {
|
||||
switch (itemCount) {
|
||||
case 4:
|
||||
switch (item) {
|
||||
case 1: return -33;
|
||||
case 2: return -16.5;
|
||||
case 3: return 16.5;
|
||||
case 4: return 33;
|
||||
case 1:
|
||||
return -33;
|
||||
case 2:
|
||||
return -16.5;
|
||||
case 3:
|
||||
return 16.5;
|
||||
case 4:
|
||||
return 33;
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
switch (item) {
|
||||
case 1: return -25;
|
||||
case 2: return 0;
|
||||
case 3: return 25;
|
||||
case 1:
|
||||
return -25;
|
||||
case 2:
|
||||
return 0;
|
||||
case 3:
|
||||
return 25;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -32,6 +37,7 @@ const StyledCoverContainer = styled.div`
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
`;
|
||||
const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
|
||||
position: absolute;
|
||||
@@ -40,58 +46,52 @@ const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
${(props) => props.$itemCount > 1 && css`
|
||||
&:nth-child(1) {
|
||||
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
0 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
${(props) =>
|
||||
props.$itemCount > 1 &&
|
||||
css`
|
||||
&:nth-child(1) {
|
||||
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
|
||||
clip-path: polygon(0 0, calc(100% / (${props.$itemCount} - 1)) 0, 0 100%, 0 100%);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(2) {
|
||||
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
100% 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(3) {
|
||||
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
100% 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
100% 0,
|
||||
100% 0,
|
||||
100% 100%,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(4) {
|
||||
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
|
||||
clip-path: polygon(100% 0, 100% 0, 100% 100%, calc(100% / (${props.$itemCount} - 1) * 2) 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
transition: clip-path 250ms;
|
||||
z-index: 2;
|
||||
}
|
||||
&:hover {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
transition: clip-path 250ms;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:not(:hover) {
|
||||
transition: clip-path 500ms, z-index 1000ms;
|
||||
z-index: 0;
|
||||
}
|
||||
`}
|
||||
&:not(:hover) {
|
||||
transition:
|
||||
clip-path 500ms,
|
||||
z-index 1000ms;
|
||||
z-index: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
const StyledCover = styled.img`
|
||||
width: 100%;
|
||||
@@ -102,7 +102,7 @@ const StyledCover = styled.img`
|
||||
background-position: center;
|
||||
transition: transform 500ms;
|
||||
transform: translateX(var(--translate-x));
|
||||
|
||||
|
||||
&:hover {
|
||||
transition: transform 250ms;
|
||||
transform: scale(1.1);
|
||||
@@ -110,60 +110,37 @@ const StyledCover = styled.img`
|
||||
`;
|
||||
|
||||
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
|
||||
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
|
||||
items: Array<{
|
||||
largeCover?: string;
|
||||
smallCover?: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
export function MultiCoverImage({ items, ...props }: MultiCoverImageProps) {
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
<AspectRatio $ratio={2 / 3}>
|
||||
<StyledCoverContainer>
|
||||
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
|
||||
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={largeCover}
|
||||
alt={`Cover image of ${resource.name}`}
|
||||
title={resource.name}
|
||||
style={{ backgroundImage: `url(${smallCover})` }}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
)) : (
|
||||
<LogoPlaceholder {...props}/>
|
||||
{items.length ? (
|
||||
items
|
||||
.filter(({ largeCover }) => !!largeCover)
|
||||
.slice(0, 4)
|
||||
.map(({ largeCover, smallCover, name }, _, slicedItems) => (
|
||||
<StyledCoverItemContainer key={largeCover} $itemCount={slicedItems.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={largeCover}
|
||||
alt={`Cover image of ${name}`}
|
||||
title={name}
|
||||
style={smallCover ? { backgroundImage: `url(${smallCover})` } : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
))
|
||||
) : (
|
||||
<LogoPlaceholder {...props} />
|
||||
)}
|
||||
</StyledCoverContainer>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
MultiCoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment MultiCoverImageResourceWithImages on ResourceWithImages {
|
||||
... on Anime {
|
||||
name
|
||||
}
|
||||
... on Artist {
|
||||
name
|
||||
}
|
||||
... on Studio {
|
||||
name
|
||||
}
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import md5 from "md5";
|
||||
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const PROFILE_IMAGE_USER = graphql(`
|
||||
fragment ProfileImageUser on Me {
|
||||
name
|
||||
email
|
||||
}
|
||||
`);
|
||||
|
||||
interface ProfileImageProps {
|
||||
user: FragmentType<typeof PROFILE_IMAGE_USER>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ProfileImage({ user: userFragment, size = 80, ...props }: ProfileImageProps) {
|
||||
const user = getFragmentData(PROFILE_IMAGE_USER, userFragment);
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,50 @@
|
||||
import extractImages from "utils/extractImages";
|
||||
import { AspectRatio } from "components/utils";
|
||||
import { FullWidthImage, MultiCoverImage } from "components/image";
|
||||
import gql from "graphql-tag";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { useState } from "react";
|
||||
import type { StudioCoverImageStudioFragment } from "generated/graphql";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { FullWidthImage } from "@/components/image/FullWidthImage";
|
||||
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
|
||||
import { AspectRatio } from "@/components/utils/AspectRatio";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledImage = styled(FullWidthImage)`
|
||||
object-fit: contain;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
export const STUDIO_COVER_IMAGE_STUDIO = graphql(`
|
||||
fragment StudioCoverImageStudio on Studio {
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
anime {
|
||||
nodes {
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
studio: StudioCoverImageStudioFragment
|
||||
studio: FragmentType<typeof STUDIO_COVER_IMAGE_STUDIO>;
|
||||
}
|
||||
|
||||
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
const { largeCover } = extractImages(studio);
|
||||
export function StudioCoverImage({ studio: studioFragment, ...props }: StudioCoverImageProps) {
|
||||
const studio = getFragmentData(STUDIO_COVER_IMAGE_STUDIO, studioFragment);
|
||||
const { largeCover } = extractImages(studio.images.nodes);
|
||||
|
||||
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
|
||||
const [imageNotFound, setImageNotFound] = useState(!largeCover);
|
||||
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
<AspectRatio $ratio={2 / 3}>
|
||||
{!imageNotFound ? (
|
||||
<StyledImage
|
||||
key={largeCover}
|
||||
@@ -32,26 +54,14 @@ export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
|
||||
<MultiCoverImage
|
||||
items={studio.anime.nodes.map((anime) => ({
|
||||
...extractImages(anime.images.nodes),
|
||||
name: anime.name,
|
||||
}))}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
StudioCoverImage.fragments = {
|
||||
studio: gql`
|
||||
fragment StudioCoverImageStudio on Studio {
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
anime {
|
||||
name
|
||||
images {
|
||||
link
|
||||
facet
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { FullWidthImage } from "./FullWidthImage";
|
||||
export { CoverImage } from "./CoverImage";
|
||||
export { MultiCoverImage } from "./MultiCoverImage";
|
||||
export { Logo } from "./Logo";
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { groupBy } from "lodash-es";
|
||||
import { Text } from "components/text";
|
||||
import type { ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { groupBy } from "lodash-es";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
const StyledLetterList = styled.div`
|
||||
display: flex;
|
||||
@@ -23,45 +25,41 @@ const StyledItemGrid = styled.div`
|
||||
type AlphabeticalIndexItem = { name: string };
|
||||
|
||||
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
|
||||
items: Array<T>
|
||||
children: (item: T) => ReactNode
|
||||
items: Array<T>;
|
||||
children: (item: T) => ReactNode;
|
||||
};
|
||||
|
||||
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
|
||||
const itemsByFirstLetter = Object.entries(
|
||||
groupBy(
|
||||
items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
(item) => {
|
||||
const firstLetter = item.name[0].toLowerCase();
|
||||
if (firstLetter.match(/[a-z]/)) {
|
||||
return firstLetter;
|
||||
}
|
||||
return "0-9";
|
||||
}
|
||||
)
|
||||
)
|
||||
.sort(([ a ], [ b ]) => a.localeCompare(b));
|
||||
},
|
||||
),
|
||||
).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return <>
|
||||
<StyledLetterList>
|
||||
{itemsByFirstLetter.map(([ firstLetter ]) => (
|
||||
<Link
|
||||
key={firstLetter}
|
||||
href={`#${firstLetter}`}
|
||||
passHref
|
||||
prefetch={false}
|
||||
legacyBehavior>
|
||||
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
|
||||
</Link>
|
||||
return (
|
||||
<>
|
||||
<StyledLetterList>
|
||||
{itemsByFirstLetter.map(([firstLetter]) => (
|
||||
<Text key={firstLetter} as={Link} href={`#${firstLetter}`} link>
|
||||
{firstLetter.toUpperCase()}{" "}
|
||||
</Text>
|
||||
))}
|
||||
</StyledLetterList>
|
||||
{itemsByFirstLetter.map(([firstLetter, itemsWithFirstLetter]) => (
|
||||
<React.Fragment key={firstLetter}>
|
||||
<Text id={firstLetter} variant="h2">
|
||||
{firstLetter}
|
||||
</Text>
|
||||
<StyledItemGrid>{itemsWithFirstLetter.map((item) => children(item))}</StyledItemGrid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</StyledLetterList>
|
||||
{itemsByFirstLetter.map(([ firstLetter, itemsWithFirstLetter ]) => (
|
||||
<React.Fragment key={firstLetter}>
|
||||
<Text id={firstLetter} variant="h2">{firstLetter}</Text>
|
||||
<StyledItemGrid>
|
||||
{itemsWithFirstLetter.map((item) => children(item))}
|
||||
</StyledItemGrid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AlphabeticalIndex } from "./AlphabeticalIndex";
|
||||
@@ -1,88 +0,0 @@
|
||||
import styled, { css } 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 { withColorTheme } from "styles/mixins";
|
||||
|
||||
const StyledSearchInput = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
gap: 8px;
|
||||
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
${withColorTheme("dark", css`
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
`)}
|
||||
}
|
||||
`;
|
||||
const StyledForm = styled.form`
|
||||
flex: 1;
|
||||
`;
|
||||
const StyledInput = styled.input`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
|
||||
margin: -8px;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
resettable = false,
|
||||
icon,
|
||||
inputProps = {},
|
||||
...props
|
||||
}: InputProps) {
|
||||
return (
|
||||
<StyledSearchInput {...props}>
|
||||
{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>
|
||||
{resettable && !!value && (
|
||||
<StyledResetButton>
|
||||
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
|
||||
</StyledResetButton>
|
||||
)}
|
||||
</StyledSearchInput>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { Input } from "components/input/Input";
|
||||
@@ -1,84 +1,178 @@
|
||||
import { ListboxCustom, ListboxNative } from "components/listbox";
|
||||
import theme from "theme";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import useMediaQuery from "hooks/useMediaQuery";
|
||||
import type { RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const ListboxContext = createContext({
|
||||
isMobile: false
|
||||
});
|
||||
import { faCheck, faSort, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import * as RadixSelect from "@radix-ui/react-select";
|
||||
|
||||
export interface ListboxProps<T extends string | null> {
|
||||
children: ReactNode
|
||||
value: T
|
||||
onChange: (newValue: T) => void
|
||||
resettable?: boolean
|
||||
defaultValue?: T | null
|
||||
highlightNonDefault?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { flipDown } from "@/styles/animations";
|
||||
import theme from "@/theme";
|
||||
|
||||
export function Listbox<T extends string | null>({
|
||||
value,
|
||||
onChange,
|
||||
// Radix' listbox can't handle null values, so we are giving it a fake null value.
|
||||
const NULL_VALUE = "__NULL__";
|
||||
|
||||
// Adds a reset button to the list box once a non-default value has been selected.
|
||||
resettable = false,
|
||||
const StyledListboxButton = styled(Button)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
// The value to return to after a reset.
|
||||
defaultValue = null,
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
|
||||
// Highlights the listbox if an option other than defaultValue is selected.
|
||||
highlightNonDefault = false,
|
||||
&: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;
|
||||
|
||||
disabled = false,
|
||||
...props
|
||||
}: ListboxProps<T>) {
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow:
|
||||
0 0 0 2px ${theme.colors["text-primary"]},
|
||||
${theme.shadows.high};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ListboxContext.Provider value={{ isMobile }}>
|
||||
<ListboxNative
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
resettable={resettable}
|
||||
defaultValue={defaultValue}
|
||||
highlightNonDefault={highlightNonDefault}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</ListboxContext.Provider>
|
||||
);
|
||||
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) & {
|
||||
ref?: RefObject<HTMLButtonElement>;
|
||||
resettable?: boolean;
|
||||
highlightNonDefault?: boolean;
|
||||
};
|
||||
|
||||
interface PropsNullable {
|
||||
nullable: true;
|
||||
value: string | null;
|
||||
onValueChange(newValue: string | null): void;
|
||||
defaultValue?: string | null;
|
||||
}
|
||||
|
||||
interface PropsNotNullable {
|
||||
nullable?: false;
|
||||
value: string;
|
||||
onValueChange(newValue: string): void;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export function Listbox({
|
||||
ref,
|
||||
value,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
nullable,
|
||||
resettable,
|
||||
highlightNonDefault,
|
||||
children,
|
||||
...props
|
||||
}: ListboxProps) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<ListboxContext.Provider value={{ isMobile }}>
|
||||
<ListboxCustom
|
||||
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={faXmark} />
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort} />
|
||||
)}
|
||||
</RadixSelect.Icon>
|
||||
</StyledListboxButton>
|
||||
</RadixSelect.Trigger>
|
||||
<StyledListboxPopover>
|
||||
<StyledListboxList
|
||||
position="popper"
|
||||
sideOffset={8}
|
||||
collisionBoundary={typeof document !== "undefined" ? document.body : []}
|
||||
>
|
||||
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
|
||||
</StyledListboxList>
|
||||
</StyledListboxPopover>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ListboxOptionProps {
|
||||
value?: string | null
|
||||
children: string
|
||||
hidden?: boolean
|
||||
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
Listbox.Option = function ListboxOption(props: ListboxOptionProps) {
|
||||
const { isMobile } = useContext(ListboxContext);
|
||||
export const ListboxOption = function ListboxOption({ ref, value, children, ...props }: ListboxOptionProps) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
|
||||
if (isMobile) {
|
||||
return <ListboxNative.Option {...props}/>;
|
||||
}
|
||||
|
||||
return <ListboxCustom.Option {...props}/>;
|
||||
return (
|
||||
<StyledListboxOption value={radixValue} {...props} ref={ref}>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Icon icon={faCheck} />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</StyledListboxOption>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,159 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import { MDXRemote } from "next-mdx-remote";
|
||||
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
|
||||
import { Card } from "@/components/card/Card";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import theme from "@/theme";
|
||||
|
||||
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,
|
||||
& ul,
|
||||
& ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& ul ul,
|
||||
& ol ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& 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,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { m } from "motion/react";
|
||||
|
||||
import { Text } from "@/components/text/Text";
|
||||
import theme from "@/theme";
|
||||
import type { Heading } from "@/utils/rehypeExtractHeadings";
|
||||
|
||||
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,87 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Menu as ReachMenu, MenuButton, MenuItem, MenuItems, MenuPopover, } from "@reach/menu-button";
|
||||
import { Button } from "components/button";
|
||||
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";
|
||||
import type { RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const StyledMenuItems = styled(MenuItems)`
|
||||
z-index: ${theme.zIndices.menuPopover};
|
||||
import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu";
|
||||
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import { flipDown } from "@/styles/animations";
|
||||
import theme from "@/theme";
|
||||
|
||||
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};
|
||||
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;
|
||||
|
||||
interface MenuContentProps extends DropdownMenuContentProps {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function MenuContent({ ref, children, ...props }: MenuContentProps) {
|
||||
return (
|
||||
<RadixMenu.Portal>
|
||||
<StyledMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
collisionBoundary={typeof document !== "undefined" ? document.body : []}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
font-size: 0.8rem;
|
||||
`;
|
||||
|
||||
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;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { faShare } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Toast } from "@/components/toast/Toast";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import useSetting from "@/hooks/useSetting";
|
||||
import { BASE_PATH } from "@/utils/config";
|
||||
import { AudioMode } from "@/utils/settings";
|
||||
|
||||
interface ShareMenuProps {
|
||||
pagePath: string;
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuProps) {
|
||||
const { dispatchToast } = useToasts();
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
|
||||
function saveToClipboard(url: string) {
|
||||
navigator.clipboard.writeText(url).then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">
|
||||
Share
|
||||
</IconTextButton>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>
|
||||
Copy URL to this Page
|
||||
</MenuItem>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<>
|
||||
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
|
||||
<a href={`${audioUrl}?download`}>
|
||||
<MenuItem>Download Audio</MenuItem>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onSelect={() => saveToClipboard(videoUrl)}>Copy URL to Embeddable Video</MenuItem>
|
||||
<a href={`${videoUrl}?download`}>
|
||||
<MenuItem>Download Video</MenuItem>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +1,108 @@
|
||||
import { Icon } from "components/icon";
|
||||
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Text } from "components/text";
|
||||
import { Menu } from "components/menu";
|
||||
import useLocalPlaylist from "hooks/useLocalPlaylist";
|
||||
import gql from "graphql-tag";
|
||||
import type { ThemeMenuThemeFragment } from "generated/graphql";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const THEME_MENU_THEME = graphql(`
|
||||
fragment ThemeMenuTheme on AnimeTheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
animethemeentries {
|
||||
...createVideoSlugEntry
|
||||
id
|
||||
videos {
|
||||
nodes {
|
||||
...createVideoSlugVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface ThemeMenuProps {
|
||||
theme: ThemeMenuThemeFragment
|
||||
theme: FragmentType<typeof THEME_MENU_THEME>;
|
||||
}
|
||||
|
||||
export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
|
||||
export function ThemeMenu({ theme: themeFragment }: ThemeMenuProps) {
|
||||
const theme = getFragmentData(THEME_MENU_THEME, themeFragment);
|
||||
|
||||
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);
|
||||
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
|
||||
|
||||
if (!options.length) {
|
||||
const entry = theme.animethemeentries[0];
|
||||
const video = entry?.videos.nodes[0];
|
||||
|
||||
if (!entry || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flip the structure on it's head, because we need entry as the root object here.
|
||||
const entryFlipped = {
|
||||
...entry,
|
||||
theme,
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
{options}
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={entryFlipped}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Add to Playlist</Text>
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
{/*{watchList.length ? (*/}
|
||||
{/* <>*/}
|
||||
{/* <MenuSeparator />*/}
|
||||
{/* <MenuItem onSelect={() => addWatchListItem(video, entryFlipped)}>*/}
|
||||
{/* <Icon icon={faArrowTurnDown} color="text-disabled" />*/}
|
||||
{/* <Text>Add to Watch List</Text>*/}
|
||||
{/* </MenuItem>*/}
|
||||
{/* <MenuItem onSelect={() => addWatchListItemNext(video, entryFlipped)}>*/}
|
||||
{/* <Icon icon={faArrowTurnUp} color="text-disabled" />*/}
|
||||
{/* <Text>Play Next</Text>*/}
|
||||
{/* </MenuItem>*/}
|
||||
{/* </>*/}
|
||||
{/*) : null}*/}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMenu.fragments = {
|
||||
theme: gql`
|
||||
fragment ThemeMenuTheme on Theme {
|
||||
id
|
||||
# Hidden inside local playlist context
|
||||
song {
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import PlayerContext from "@/context/playerContext";
|
||||
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
|
||||
|
||||
export const VIDEO_MENU_ENTRY = graphql(`
|
||||
fragment VideoMenuEntry on AnimeThemeEntry {
|
||||
...createVideoSlugEntry
|
||||
...PlaylistTrackAddDialogEntry
|
||||
id
|
||||
animetheme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
images {
|
||||
nodes {
|
||||
...extractImagesImage
|
||||
}
|
||||
}
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const VIDEO_MENU_VIDEO = graphql(`
|
||||
fragment VideoMenuVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...PlaylistTrackAddDialogVideo
|
||||
id
|
||||
basename
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
interface VideoMenuProps {
|
||||
entry: FragmentType<typeof VIDEO_MENU_ENTRY>;
|
||||
video: FragmentType<typeof VIDEO_MENU_VIDEO>;
|
||||
}
|
||||
|
||||
export function VideoMenu({ entry: entryFragment, video: videoFragment }: VideoMenuProps) {
|
||||
const entry = getFragmentData(VIDEO_MENU_ENTRY, entryFragment);
|
||||
const video = getFragmentData(VIDEO_MENU_VIDEO, videoFragment);
|
||||
|
||||
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
|
||||
|
||||
return (
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
<Button variant="silent" isCircle>
|
||||
<Icon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<PlaylistTrackAddDialog
|
||||
video={video}
|
||||
entry={entry}
|
||||
trigger={
|
||||
<MenuItem onSelect={(event) => event.preventDefault()}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Add to Playlist</Text>
|
||||
</MenuItem>
|
||||
}
|
||||
/>
|
||||
{watchList.length ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={() => addWatchListItem(video, entry)}>
|
||||
<Icon icon={faArrowTurnDown} color="text-disabled" />
|
||||
<Text>Add to Watch List</Text>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => addWatchListItemNext(video, entry)}>
|
||||
<Icon icon={faArrowTurnUp} color="text-disabled" />
|
||||
<Text>Play Next</Text>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Menu } from "components/menu/Menu";
|
||||
export { ThemeMenu } from "./ThemeMenu";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user