Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4958f5d462 | |||
| 6394e50232 | |||
| 67c2f2180d | |||
| 91f7be77d1 | |||
| 47daa23e9c | |||
| d6caa6487a | |||
| 530c11c858 | |||
| b195c9d667 | |||
| d7329f73b7 | |||
| 38675ab9ad | |||
| 385a1419ba | |||
| 76db2186dd | |||
| 6d39859138 | |||
| 19ccfedf4a | |||
| 5b950d8869 | |||
| 940c9b12a0 | |||
| 15c20d161e | |||
| 02c7eed23c | |||
| 391a5cd9cb | |||
| 859e0c35f1 | |||
| d17fb278ce | |||
| c5849a051d | |||
| 643d1e8827 | |||
| 55e6149d74 | |||
| bff350179b | |||
| 7edf147c35 | |||
| a2bc1e694f | |||
| c5d40b75d2 | |||
| d86d0a04b5 | |||
| eee86f80cd | |||
| d801654c56 | |||
| bcbd1ffceb | |||
| eea6b93db3 | |||
| 214b3b7a3c | |||
| 986b77562b | |||
| b136b3881f | |||
| 0a4a654586 | |||
| dc9593dc5e | |||
| 12d843c32f | |||
| dce43e3bcc | |||
| dd3a208ec2 | |||
| 0319a5ccf1 | |||
| 23f8071a80 | |||
| 50b544932b | |||
| d1684302e0 | |||
| 223be0c2b8 | |||
| f4110a4c6c | |||
| 0df159d9aa | |||
| 31aee782b0 |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"babel-preset-gatsby",
|
||||
{
|
||||
"reactRuntime": "automatic"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"prismjs",
|
||||
{
|
||||
"languages": [
|
||||
"jsx"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# NPM
|
||||
/node_modules
|
||||
|
||||
# Gatsby
|
||||
/.cache
|
||||
/public
|
||||
|
||||
# IDE (WebStorm)
|
||||
*.iml
|
||||
/.idea
|
||||
@@ -1,46 +1,43 @@
|
||||
{
|
||||
"globals": {
|
||||
"__PATH_PREFIX__": true
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
},
|
||||
"extends": [
|
||||
"react-app",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"ignorePatterns": ["src/generated"],
|
||||
"rules": {
|
||||
"quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"curly": [
|
||||
"error",
|
||||
"all"
|
||||
],
|
||||
"object-curly-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"indent": [
|
||||
"error",
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"no-duplicate-imports": "error",
|
||||
"no-restricted-imports": [
|
||||
// Sort imports
|
||||
"simple-import-sort/imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
"components/*/*"
|
||||
"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"]
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": "error",
|
||||
// For now we don't habe prop-types validation
|
||||
"react/prop-types": "off"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: github pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: test123
|
||||
MYSQL_DATABASE: animethemes
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Import database dump
|
||||
env:
|
||||
PORT: ${{ job.services.mariadb.ports[3306] }}
|
||||
run: |
|
||||
sudo /etc/init.d/mysql start
|
||||
curl -L https://github.com/AnimeThemes/animethemes-db-dump/raw/main/mysql/animethemes-db-dump-create-tables.sql | mysql --user=root --password=test123 --host=127.0.0.1 --port="$PORT" animethemes
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2.1.2
|
||||
with:
|
||||
node-version: '12.x'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Gatsby pages
|
||||
run: npm run build-with-paths
|
||||
env:
|
||||
GATSBY_PATH_PREFIX: /animethemes-web
|
||||
GATSBY_CI: true
|
||||
SOURCE_PLUGIN: DB
|
||||
DB_USER: root
|
||||
DB_PASSWORD: test123
|
||||
DB_PORT: ${{ job.services.mariadb.ports[3306] }}
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./public
|
||||
@@ -1,14 +1,43 @@
|
||||
# General
|
||||
.env
|
||||
.env.*
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# NPM
|
||||
node_modules
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Gatsby
|
||||
/.cache
|
||||
/public
|
||||
/schema.json
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.npmrc
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
next.config.mjs
|
||||
|
||||
# IDE (WebStorm)
|
||||
*.iml
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"schemaPath": "schema.json"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 paranarimasu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,46 +1,93 @@
|
||||
# AnimeThemes Web Client
|
||||
|
||||
1. [Configuration](#configuration)
|
||||
2. [Development](#development)
|
||||
3. [Deployment](#deployment)
|
||||
4. [Used technologies](#used-technologies)
|
||||
|
||||
## Configuration
|
||||
|
||||
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
|
||||
root directory. 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 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
|
||||
```
|
||||
|
||||
To get to use all features of the client, you may configure some additional variables.
|
||||
|
||||
This is a list of all available options:
|
||||
|
||||
```ini
|
||||
; ===== Server-side =====
|
||||
|
||||
; The URL to the AnimeThemes API which will be used on the server.
|
||||
ANIMETHEMES_API_URL=http://localhost
|
||||
|
||||
; 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
|
||||
|
||||
; ===== Server-side + Client-side =====
|
||||
|
||||
; The base path the app should be hosted on.
|
||||
NEXT_PUBLIC_BASE_PATH=/wiki
|
||||
|
||||
; The URL to the AnimeThemes API which will be used on the browser.
|
||||
NEXT_PUBLIC_API_URL=https://api.animethemes.moe
|
||||
|
||||
; The URL from which video files should be served.
|
||||
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
|
||||
|
||||
; The URL 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.
|
||||
; 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).
|
||||
|
||||
## Development
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run develop`
|
||||
2. `npm run dev`
|
||||
|
||||
## Deployment
|
||||
|
||||
### Hosting on root path
|
||||
|
||||
1. `npm run build`
|
||||
2. Upload the contents of `public` to your server.
|
||||
|
||||
### Hosting on sub-path
|
||||
|
||||
1. Adjust `pathPrefix` in `gatsby-config.js` to your server environment.
|
||||
2. `npm run build-with-paths`
|
||||
3. Upload the contents of `public` to your server.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure certain settings by creating a `.env.development` (for development) or `.env.production` (for deployment)
|
||||
in the client root directory and include the following:
|
||||
|
||||
```ini
|
||||
; The URL to the AnimeThemes API to use.
|
||||
; If not specified, "https://staging.animethemes.moe" is used as a default.
|
||||
GATSBY_API_URL = http://localhost
|
||||
```
|
||||
|
||||
### Apache Configuration
|
||||
|
||||
`.htaccess` files can be placed in `static` and will be copied to the `public` folder during build.
|
||||
1. `npm install` (If not done already.)
|
||||
2. `npm run build` generates all static pages and produces a production ready bundle.
|
||||
3. `npm run start` starts the server.
|
||||
4. The server is now listening on port 3000 for incoming requests.
|
||||
|
||||
## Used technologies
|
||||
|
||||
- [Gatsby](https://www.gatsbyjs.com/)
|
||||
- [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://staging.animethemes.moe/api/docs/)
|
||||
- [AnimeThemes API](https://api-docs.animethemes.moe/)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
schema: [
|
||||
"src/lib/common/animethemes/type-defs.ts",
|
||||
"src/lib/server/animebracket/type-defs.ts",
|
||||
"src/lib/client/search.ts",
|
||||
],
|
||||
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
|
||||
generates: {
|
||||
"src/generated/graphql.ts": {
|
||||
plugins: ["typescript", "typescript-operations"],
|
||||
config: {
|
||||
avoidOptionals: {
|
||||
object: true,
|
||||
field: true,
|
||||
inputValue: false,
|
||||
},
|
||||
enumsAsTypes: true,
|
||||
skipTypename: true,
|
||||
useTypeImports: true,
|
||||
},
|
||||
},
|
||||
"src/generated/graphql-resolvers.ts": {
|
||||
plugins: ["typescript", "typescript-resolvers"],
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
// This fixes an issue with the Maybe type, leading to unclear error messages.
|
||||
// Taken from here: https://github.com/dotansimha/graphql-code-generator/issues/3174#issuecomment-595398571
|
||||
maybeValue: "T extends PromiseLike<infer U> ? Promise<U | null> : T | null",
|
||||
mappers: {
|
||||
Anime: "@/lib/common/animethemes/types#ApiAnime",
|
||||
Announcement: "@/lib/common/animethemes/types#ApiAnnouncement",
|
||||
Artist: "@/lib/common/animethemes/types#ApiArtist",
|
||||
Audio: "@/lib/common/animethemes/types#ApiAudio",
|
||||
Dump: "@/lib/common/animethemes/types#ApiDump",
|
||||
Entry: "@/lib/common/animethemes/types#ApiEntry",
|
||||
FeaturedTheme: "@/lib/common/animethemes/types#ApiFeaturedTheme",
|
||||
Group: "@/lib/common/animethemes/types#ApiGroup",
|
||||
Page: "@/lib/common/animethemes/types#ApiPage",
|
||||
Performance: "@/lib/common/animethemes/types#ApiPerformance",
|
||||
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
|
||||
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
|
||||
Season: "@/lib/common/animethemes/types#ApiSeason",
|
||||
Series: "@/lib/common/animethemes/types#ApiSeries",
|
||||
Song: "@/lib/common/animethemes/types#ApiSong",
|
||||
Studio: "@/lib/common/animethemes/types#ApiStudio",
|
||||
Synonym: "@/lib/common/animethemes/types#ApiSynonym",
|
||||
Theme: "@/lib/common/animethemes/types#ApiTheme",
|
||||
UserAuth: "@/lib/common/animethemes/types#ApiUser",
|
||||
UserPublic: "@/lib/common/animethemes/types#ApiUser",
|
||||
UserRole: "@/lib/common/animethemes/types#ApiUserRole",
|
||||
UserScopedQuery: "{}",
|
||||
Video: "@/lib/common/animethemes/types#ApiVideo",
|
||||
VideoOverlap: "string",
|
||||
VideoScript: "@/lib/common/animethemes/types#ApiVideoScript",
|
||||
Year: "@/lib/common/animethemes/types#ApiYear",
|
||||
Bracket: "@/lib/server/animebracket/resolvers#ModelBracket",
|
||||
BracketRound: "@/lib/server/animebracket/resolvers#ModelBracketRound",
|
||||
BracketPairing: "@/lib/server/animebracket/resolvers#ModelBracketPairing",
|
||||
BracketCharacter: "@/lib/server/animebracket/resolvers#ModelBracketCharacter",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
afterAllFileWrite: ["prettier --write"],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,7 @@
|
||||
import "react";
|
||||
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function shouldUpdateScroll({
|
||||
routerProps,
|
||||
prevRouterProps
|
||||
}) {
|
||||
return routerProps?.location?.pathname !== prevRouterProps?.location?.pathname;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import siteConfig from "./site-config";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment configuration
|
||||
dotenv.config({
|
||||
path: `.env.${process.env.NODE_ENV}`,
|
||||
});
|
||||
|
||||
// Map every path in "src" to its directory name so we can import files like this:
|
||||
//
|
||||
// import Button from "components/button";
|
||||
//
|
||||
// Otherwise we would have to provide relative paths which makes refactoring harder.
|
||||
const aliases = fs.readdirSync(path.join(__dirname, "src")).reduce((obj, directory) => {
|
||||
obj[directory] = path.join(__dirname, "src", directory);
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// If Gatsby already set up a path prefix, use that instead
|
||||
const pathPrefix = process.env.GATSBY_PATH_PREFIX || siteConfig.pathPrefix;
|
||||
|
||||
const config = {
|
||||
pathPrefix: pathPrefix,
|
||||
siteMetadata: {
|
||||
lang: siteConfig.siteLanguage,
|
||||
siteName: siteConfig.siteName,
|
||||
description: siteConfig.description,
|
||||
titleTemplate: `%s · ${siteConfig.siteName}`,
|
||||
siteUrl: path.posix.join((process.env.SITE_URL || siteConfig.rootUrl), pathPrefix)
|
||||
},
|
||||
plugins: [
|
||||
process.env.SOURCE_PLUGIN === "DB"
|
||||
? "gatsby-source-animethemes-db"
|
||||
: "gatsby-source-animethemes",
|
||||
"gatsby-source-animebracket",
|
||||
"gatsby-plugin-styled-components",
|
||||
"gatsby-plugin-react-helmet",
|
||||
{
|
||||
resolve: "gatsby-plugin-root-import",
|
||||
options: aliases
|
||||
},
|
||||
{
|
||||
resolve: "gatsby-plugin-layout",
|
||||
options: {
|
||||
component: require.resolve("./src/layout/index.js"),
|
||||
}
|
||||
},
|
||||
"gatsby-plugin-fontawesome-css",
|
||||
{
|
||||
resolve: "gatsby-plugin-manifest",
|
||||
options: {
|
||||
icon: "src/images/icon.png"
|
||||
}
|
||||
},
|
||||
"gatsby-plugin-eslint",
|
||||
"gatsby-plugin-sass"
|
||||
]
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
config.plugins.push("gatsby-plugin-extract-schema");
|
||||
}
|
||||
|
||||
export default config;
|
||||
@@ -1,3 +0,0 @@
|
||||
// eslint-disable-next-line no-native-reassign
|
||||
require = require("esm")(module);
|
||||
module.exports = require("./gatsby-config.esm.js");
|
||||
@@ -1,142 +0,0 @@
|
||||
import createVideoSlug from "./src/utils/createVideoSlug";
|
||||
|
||||
const seasonOrder = [ "Winter", "Spring", "Summer", "Fall" ];
|
||||
|
||||
export async function createPages({ actions: { createPage }, graphql }) {
|
||||
const { data } = await graphql(`
|
||||
query {
|
||||
allAnime {
|
||||
nodes {
|
||||
slug
|
||||
themes {
|
||||
# We can't use fragments in gatsby-node.js,
|
||||
# so this is mostly a copy of the fragment found in
|
||||
# createVideoSlug.fragment.js.
|
||||
# (https://stackoverflow.com/a/49351248)
|
||||
slug
|
||||
entries {
|
||||
id
|
||||
version
|
||||
videos {
|
||||
id
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupedByYear: group(field: year) {
|
||||
year: fieldValue
|
||||
nodes {
|
||||
season
|
||||
}
|
||||
}
|
||||
}
|
||||
allSeries {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
allStudio {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
allArtist {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
allBracket {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
for (const { slug, themes } of data.allAnime.nodes) {
|
||||
createPage({
|
||||
path: `/anime/${slug}`,
|
||||
component: require.resolve("./src/templates/anime.js"),
|
||||
context: { slug }
|
||||
});
|
||||
|
||||
for (const theme of themes) {
|
||||
for (const entry of theme.entries) {
|
||||
for (const video of entry.videos) {
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
createPage({
|
||||
path: `/anime/${slug}/${videoSlug}`,
|
||||
component: require.resolve("./src/templates/video.js"),
|
||||
context: { id: video.id, entryId: entry.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { year, nodes } of data.allAnime.groupedByYear) {
|
||||
const yearNumeric = +year;
|
||||
|
||||
// Get unique seasons
|
||||
const seasonList = nodes
|
||||
.map((node) => node.season)
|
||||
.filter((value, index, self) => self.indexOf(value) === index)
|
||||
.sort((a, b) => seasonOrder.indexOf(a) - seasonOrder.indexOf(b));
|
||||
|
||||
createPage({
|
||||
path: `/year/${year}`,
|
||||
component: require.resolve("./src/templates/year.js"),
|
||||
context: { year: yearNumeric, seasonList }
|
||||
});
|
||||
|
||||
for (const season of seasonList) {
|
||||
createPage({
|
||||
path: `/year/${year}/${season.toLowerCase()}`,
|
||||
component: require.resolve("./src/templates/season.js"),
|
||||
context: { year: yearNumeric, season, seasonList }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { slug } of data.allSeries.nodes) {
|
||||
createPage({
|
||||
path: `/series/${slug}`,
|
||||
component: require.resolve("./src/templates/series.js"),
|
||||
context: { slug }
|
||||
});
|
||||
}
|
||||
|
||||
for (const { slug } of data.allStudio.nodes) {
|
||||
createPage({
|
||||
path: `/studio/${slug}`,
|
||||
component: require.resolve("./src/templates/studio.js"),
|
||||
context: { slug }
|
||||
});
|
||||
}
|
||||
|
||||
for (const { slug } of data.allArtist.nodes) {
|
||||
createPage({
|
||||
path: `/artist/${slug}`,
|
||||
component: require.resolve("./src/templates/artist.js"),
|
||||
context: { slug }
|
||||
});
|
||||
}
|
||||
|
||||
for (const { slug } of data.allBracket.nodes) {
|
||||
createPage({
|
||||
path: `/bracket/${slug}`,
|
||||
component: require.resolve("./src/templates/bracket.js"),
|
||||
context: { slug }
|
||||
});
|
||||
}
|
||||
|
||||
for (const entity of [ null, "anime", "theme", "artist", "series", "studio" ]) {
|
||||
createPage({
|
||||
path: `/search${entity ? `/${entity}` : ``}`,
|
||||
component: require.resolve("./src/templates/search.js"),
|
||||
context: { entity }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// eslint-disable-next-line no-native-reassign
|
||||
require = require("esm")(module);
|
||||
module.exports = require("./gatsby-node.esm.js");
|
||||
@@ -1,24 +0,0 @@
|
||||
const ThemeInjection = () => {
|
||||
// language=JavaScript
|
||||
const injectTheme = `
|
||||
(function() {
|
||||
const body = document.body;
|
||||
const theme = window.localStorage.getItem("theme");
|
||||
|
||||
if (theme) {
|
||||
body.setAttribute("theme", theme);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: injectTheme }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function onRenderBody({ setBodyAttributes, setPreBodyComponents }) {
|
||||
setBodyAttributes({ theme: "dark" });
|
||||
setPreBodyComponents(<ThemeInjection />);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
|
||||
@@ -1,57 +1,92 @@
|
||||
{
|
||||
"name": "anime-themes",
|
||||
"version": "1.0.0",
|
||||
"name": "animethemes-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"develop": "gatsby develop",
|
||||
"build": "gatsby build",
|
||||
"build-with-paths": "gatsby build --prefix-paths"
|
||||
"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",
|
||||
"prepare": "husky",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/nunito": "^4.5.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"@reach/listbox": "^0.16.1",
|
||||
"@react-hook/resize-observer": "^1.2.2",
|
||||
"babel-plugin-prismjs": "^2.0.1",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-preset-gatsby": "^1.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@graphql-tools/merge": "^9.0.16",
|
||||
"@graphql-tools/schema": "^10.0.15",
|
||||
"@graphql-tools/utils": "^10.7.1",
|
||||
"@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.2.2",
|
||||
"common-tags": "^1.8.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"esm": "^3.2.25",
|
||||
"framer-motion": "^4.1.3",
|
||||
"gatsby": "^3.13.0",
|
||||
"gatsby-plugin-fontawesome-css": "^1.1.0",
|
||||
"gatsby-plugin-layout": "^2.3.0",
|
||||
"gatsby-plugin-manifest": "^3.3.0",
|
||||
"gatsby-plugin-react-helmet": "^4.3.0",
|
||||
"gatsby-plugin-root-import": "^2.0.6",
|
||||
"gatsby-plugin-sass": "^4.3.0",
|
||||
"gatsby-plugin-styled-components": "^4.3.0",
|
||||
"gatsby-source-animethemes": "file:plugins/gatsby-source-animethemes",
|
||||
"gatsby-source-animethemes-db": "file:plugins/gatsby-source-animethemes-db",
|
||||
"luxon": "^2.0.2",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prismjs": "^1.24.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-query": "^3.18.1",
|
||||
"sass": "^1.32.8",
|
||||
"styled-components": "^5.2.0",
|
||||
"styled-system": "^5.1.5",
|
||||
"use-debounce": "^6.0.1",
|
||||
"use-media": "^1.4.0"
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-parse-resolve-info": "^4.13.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"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.21",
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.3.0",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"p-limit": "^3.1.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",
|
||||
"swr": "^2.3.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-local-storage-state": "^19.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hot-loader/react-dom": "^17.0.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-config-react-app": "^6.0.0",
|
||||
"eslint-loader": "^4.0.2",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"gatsby-plugin-eslint": "^3.0.0",
|
||||
"gatsby-plugin-extract-schema": "^0.1.0"
|
||||
"@graphql-codegen/cli": "^5.0.2",
|
||||
"@graphql-codegen/typescript": "^4.0.7",
|
||||
"@graphql-codegen/typescript-operations": "^4.2.1",
|
||||
"@graphql-codegen/typescript-resolvers": "^4.1.0",
|
||||
"@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.0",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"husky": "^9.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"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,26 @@
|
||||
diff --git a/node_modules/graphql-parse-resolve-info/build-turbo/index.js b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
index b10297b..6a56538 100644
|
||||
--- a/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
+++ b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
|
||||
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
|
||||
const assert = require("assert");
|
||||
const graphql_1 = require("graphql");
|
||||
-const values_1 = require("graphql/execution/values");
|
||||
+const values_1 = require("graphql");
|
||||
const debugFactory = require("debug");
|
||||
const debug = debugFactory("graphql-parse-resolve-info");
|
||||
const DEBUG_ENABLED = debug.enabled;
|
||||
diff --git a/node_modules/graphql-parse-resolve-info/node8plus/index.js b/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
index 4c3d55d..0e2feb3 100644
|
||||
--- a/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
+++ b/node_modules/graphql-parse-resolve-info/node8plus/index.js
|
||||
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
|
||||
const assert = require("assert");
|
||||
const graphql_1 = require("graphql");
|
||||
-const values_1 = require("graphql/execution/values");
|
||||
+const values_1 = require("graphql");
|
||||
const debugFactory = require("debug");
|
||||
const debug = debugFactory("graphql-parse-resolve-info");
|
||||
const DEBUG_ENABLED = debug.enabled;
|
||||
@@ -1,157 +0,0 @@
|
||||
const fetch = require("node-fetch");
|
||||
const fs = require("fs");
|
||||
|
||||
const mappings = JSON.parse(fs.readFileSync("./plugins/gatsby-source-animebracket/mappings.json", "utf8"));
|
||||
|
||||
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animebracket");
|
||||
|
||||
exports.createSchemaCustomization = ({ actions }) => {
|
||||
const { createTypes } = actions;
|
||||
createTypes(`
|
||||
type Bracket implements Node {
|
||||
slug: String!
|
||||
name: String!
|
||||
currentRound: BracketRound @link(by: "id")
|
||||
currentGroup: Int
|
||||
rounds: [BracketRound] @link(by: "bracket.id", from: "id")
|
||||
}
|
||||
|
||||
type BracketRound implements Node {
|
||||
bracket: Bracket @link(by: "id")
|
||||
tier: Int!
|
||||
name: String
|
||||
pairings: [BracketPairing] @link(by: "round.id", from: "id")
|
||||
}
|
||||
|
||||
type BracketPairing implements Node {
|
||||
round: BracketRound @link(by: "id")
|
||||
order: Int!
|
||||
group: Int!
|
||||
characterA: BracketCharacter @link(by: "id")
|
||||
characterB: BracketCharacter @link(by: "id")
|
||||
votesA: Int
|
||||
votesB: Int
|
||||
}
|
||||
|
||||
type BracketCharacter implements Node {
|
||||
pairing: BracketPairing @link(by: "id")
|
||||
idRaw: Int!
|
||||
seed: Int!
|
||||
name: String!
|
||||
source: String!
|
||||
theme: Theme @link(by: "idRaw")
|
||||
}
|
||||
`);
|
||||
};
|
||||
|
||||
exports.sourceNodes = async ({ actions, createNodeId, createContentDigest }) => {
|
||||
const helpers = {
|
||||
...actions,
|
||||
createNodeId,
|
||||
createContentDigest
|
||||
};
|
||||
|
||||
const perma = "best-anime-opening-ix-salty-arrow";
|
||||
const bracketResponse = await fetch(`https://animebracket.com/api/bracket/${perma}`);
|
||||
const bracket = await bracketResponse.json();
|
||||
const roundsResponse = await fetch(`https://animebracket.com/api/results/${perma}`)
|
||||
const rounds = await roundsResponse.json();
|
||||
const currentRoundResponse = await fetch(`https://animebracket.com/api/rounds/${perma}`);
|
||||
const currentRound = await currentRoundResponse.json();
|
||||
|
||||
const bracketNode = createNodeFromData({
|
||||
id: bracket.id,
|
||||
slug: perma,
|
||||
name: bracket.name,
|
||||
currentRound: currentRound ? createNodeId(`BracketRound-${bracket.id}-${currentRound[0].tier}`) : null,
|
||||
currentGroup: currentRound ? currentRound[0].group : null,
|
||||
rounds: rounds.map((round) => createNodeId(`BracketRound-${bracket.id}-${round[0].tier}`))
|
||||
}, "Bracket", helpers);
|
||||
|
||||
for (const round of rounds) {
|
||||
sourceRound(round);
|
||||
}
|
||||
|
||||
if (currentRound) {
|
||||
sourceRound(currentRound);
|
||||
}
|
||||
|
||||
function sourceRound(round) {
|
||||
const tier = +round[0].tier;
|
||||
|
||||
const roundNode = createNodeFromData({
|
||||
id: `${bracket.id}-${tier}`,
|
||||
bracket: bracketNode.id,
|
||||
tier: tier,
|
||||
pairings: round.map((pairing) => createNodeId(`BracketPairing-${pairing.id}`))
|
||||
}, "BracketRound", helpers);
|
||||
|
||||
for (const pairing of round) {
|
||||
if (pairing.filler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairingNode = createNodeFromData({
|
||||
id: pairing.id,
|
||||
round: roundNode.id,
|
||||
order: +pairing.order,
|
||||
group: +pairing.group,
|
||||
characterA: createNodeId(`BracketCharacter-${pairing.character1.id}`),
|
||||
characterB: createNodeId(`BracketCharacter-${pairing.character2.id}`),
|
||||
votesA: pairing.character1.votes || null,
|
||||
votesB: pairing.character2.votes || null
|
||||
}, "BracketPairing", helpers);
|
||||
|
||||
for (const character of [ pairing.character1, pairing.character2 ]) {
|
||||
createNodeFromData({
|
||||
id: character.id,
|
||||
idRaw: character.id,
|
||||
pairing: pairingNode.id,
|
||||
seed: character.seed,
|
||||
name: character.name,
|
||||
source: character.source,
|
||||
theme: mappings[character.id]
|
||||
}, "BracketCharacter", helpers);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.createResolvers = ({ createResolvers }) => {
|
||||
createResolvers({
|
||||
BracketRound: {
|
||||
name: {
|
||||
resolve(source) {
|
||||
switch (source.pairings.length) {
|
||||
case 1:
|
||||
return "Finals";
|
||||
case 2:
|
||||
return "Semifinals";
|
||||
case 4:
|
||||
return "Quarterfinals";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function createNodeFromData(item, nodeType, helpers) {
|
||||
const node = {
|
||||
...item,
|
||||
id: helpers.createNodeId(`${nodeType}-${item.id}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: nodeType,
|
||||
content: JSON.stringify(item),
|
||||
contentDigest: helpers.createContentDigest(item)
|
||||
}
|
||||
};
|
||||
|
||||
helpers.createNode(node)
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
{
|
||||
"214755": 6846,
|
||||
"214730": 9335,
|
||||
"215383": 4526,
|
||||
"215092": 8131,
|
||||
"215213": 8082,
|
||||
"215355": 1796,
|
||||
"215447": 9113,
|
||||
"214861": 4940,
|
||||
"214734": 6549,
|
||||
"214896": 4207,
|
||||
"214951": 7861,
|
||||
"215344": 1790,
|
||||
"214927": 2021,
|
||||
"215176": 8471,
|
||||
"215157": 7221,
|
||||
"214973": 4485,
|
||||
"215408": 2870,
|
||||
"214907": 7239,
|
||||
"214983": 2531,
|
||||
"214822": 955,
|
||||
"214903": 6326,
|
||||
"215178": 6173,
|
||||
"215634": 673,
|
||||
"215544": 6878,
|
||||
"215255": 7108,
|
||||
"214922": 3460,
|
||||
"215024": 239,
|
||||
"215164": 9114,
|
||||
"215538": 6237,
|
||||
"215122": 5915,
|
||||
"215001": 4966,
|
||||
"214814": 2262,
|
||||
"214969": 3888,
|
||||
"214964": 4397,
|
||||
"214943": 7062,
|
||||
"214885": 7006,
|
||||
"215209": 4663,
|
||||
"215166": 9054,
|
||||
"215258": 4958,
|
||||
"214757": 7546,
|
||||
"214953": 7562,
|
||||
"214862": 3990,
|
||||
"215494": 65,
|
||||
"215046": 8403,
|
||||
"215540": 9282,
|
||||
"215033": 2057,
|
||||
"215471": 6354,
|
||||
"215361": 727,
|
||||
"214735": 1069,
|
||||
"214773": 9980,
|
||||
"215174": 5424,
|
||||
"215026": 9373,
|
||||
"215398": 7328,
|
||||
"214766": 8210,
|
||||
"215076": 7138,
|
||||
"214895": 8504,
|
||||
"214855": 8530,
|
||||
"214774": 8015,
|
||||
"215186": 10072,
|
||||
"214923": 7202,
|
||||
"214847": 8641,
|
||||
"215057": 6055,
|
||||
"215270": 3699,
|
||||
"214750": 7512,
|
||||
"215593": 8656,
|
||||
"214872": 3834,
|
||||
"215019": 1479,
|
||||
"215078": 3436,
|
||||
"215055": 8939,
|
||||
"215162": 9301,
|
||||
"214759": 5930,
|
||||
"215179": 2014,
|
||||
"215120": 8172,
|
||||
"214809": 7742,
|
||||
"214882": 7009,
|
||||
"214876": 7215,
|
||||
"214935": 2692,
|
||||
"214763": 9139,
|
||||
"214934": 1126,
|
||||
"215378": 1477,
|
||||
"214775": 6453,
|
||||
"215437": 6363,
|
||||
"214834": 7357,
|
||||
"215085": 3730,
|
||||
"214832": 2478,
|
||||
"215132": 6645,
|
||||
"214932": 5519,
|
||||
"214924": 6765,
|
||||
"215145": 2264,
|
||||
"214926": 9126,
|
||||
"214867": 7678,
|
||||
"215144": 3435,
|
||||
"214941": 4273,
|
||||
"215505": 4272,
|
||||
"215177": 8175,
|
||||
"215148": 6054,
|
||||
"215238": 4000,
|
||||
"215359": 5445,
|
||||
"215264": 5687,
|
||||
"214984": 10007,
|
||||
"215472": 5774,
|
||||
"215015": 8948,
|
||||
"215521": 4664,
|
||||
"215023": 3633,
|
||||
"215236": 9129,
|
||||
"214894": 10004,
|
||||
"215020": 7936,
|
||||
"215273": 6467,
|
||||
"215004": 5280,
|
||||
"215474": 7218,
|
||||
"214915": 7015,
|
||||
"214900": 9281,
|
||||
"215107": 2263,
|
||||
"214999": 7557,
|
||||
"214836": 9278,
|
||||
"215127": 6429,
|
||||
"215395": 4534,
|
||||
"214955": 5608,
|
||||
"215212": 8125,
|
||||
"214993": 8063,
|
||||
"214768": 9080,
|
||||
"214732": 8566,
|
||||
"215219": 6457,
|
||||
"214820": 4028,
|
||||
"214908": 4947,
|
||||
"214831": 7308,
|
||||
"215154": 6095,
|
||||
"215300": 10009,
|
||||
"215237": 7013,
|
||||
"215223": 5481,
|
||||
"215208": 3362,
|
||||
"215609": 5577,
|
||||
"215039": 1928,
|
||||
"215192": 9311,
|
||||
"214978": 4511,
|
||||
"214843": 8577,
|
||||
"214860": 4852,
|
||||
"215518": 5748,
|
||||
"215139": 6114,
|
||||
"215138": 8949,
|
||||
"214930": 2605,
|
||||
"215443": 4711,
|
||||
"215093": 7872,
|
||||
"215600": 8188,
|
||||
"215165": 3392,
|
||||
"214869": 525,
|
||||
"214972": 2783,
|
||||
"214958": 5116,
|
||||
"215060": 9999,
|
||||
"214745": 9053,
|
||||
"215227": 3984,
|
||||
"214795": 6663,
|
||||
"214970": 7318,
|
||||
"214997": 3795,
|
||||
"215305": 8088,
|
||||
"215111": 4973,
|
||||
"214904": 5520,
|
||||
"215009": 2701,
|
||||
"215167": 3110,
|
||||
"215116": 3981,
|
||||
"215086": 8325,
|
||||
"215226": 8943,
|
||||
"215514": 5833,
|
||||
"214956": 3361,
|
||||
"215550": 8922,
|
||||
"215108": 4527,
|
||||
"215199": 7213,
|
||||
"214886": 3164,
|
||||
"215417": 7054,
|
||||
"215484": 7638,
|
||||
"215200": 3437,
|
||||
"215232": 9987,
|
||||
"214981": 4853,
|
||||
"215636": 1035,
|
||||
"215029": 1034,
|
||||
"215022": 8787,
|
||||
"215331": 2446,
|
||||
"215452": 3040,
|
||||
"215141": 7107,
|
||||
"215007": 3438,
|
||||
"215041": 6081,
|
||||
"214846": 1802,
|
||||
"214859": 7114,
|
||||
"214744": 1654,
|
||||
"215269": 2438,
|
||||
"215489": 8262,
|
||||
"214902": 7053,
|
||||
"215419": 8083,
|
||||
"215084": 3163,
|
||||
"214748": 7980,
|
||||
"214760": 9372,
|
||||
"215480": 1794,
|
||||
"214737": 2509,
|
||||
"214782": 4466,
|
||||
"215433": 6854,
|
||||
"215486": 8940,
|
||||
"214912": 6550,
|
||||
"215562": 9197,
|
||||
"215317": 6466,
|
||||
"215027": 6633,
|
||||
"215547": 7743,
|
||||
"214812": 9264,
|
||||
"215002": 8726,
|
||||
"214728": 7871,
|
||||
"215173": 7773,
|
||||
"215440": 1799,
|
||||
"214906": 9214,
|
||||
"215038": 2873,
|
||||
"215135": 2883,
|
||||
"215438": 3041,
|
||||
"215052": 8719,
|
||||
"214959": 4939,
|
||||
"215169": 5287,
|
||||
"215557": 5576,
|
||||
"214889": 1147,
|
||||
"215529": 4484,
|
||||
"215421": 7639,
|
||||
"214917": 2840,
|
||||
"215306": 6650,
|
||||
"215261": 4457,
|
||||
"215137": 3373,
|
||||
"215409": 7127,
|
||||
"215012": 3504,
|
||||
"215294": 4088,
|
||||
"215231": 3229,
|
||||
"215151": 5837,
|
||||
"215124": 523,
|
||||
"215283": 6972,
|
||||
"215274": 3985,
|
||||
"215032": 8705,
|
||||
"215080": 6097,
|
||||
"214740": 7588,
|
||||
"215088": 2279,
|
||||
"214946": 5832,
|
||||
"214793": 8883,
|
||||
"214950": 4702,
|
||||
"214919": 2953,
|
||||
"215351": 7863,
|
||||
"214940": 9392,
|
||||
"214749": 7392,
|
||||
"215402": 7561,
|
||||
"215476": 8078,
|
||||
"215289": 3887,
|
||||
"215077": 3835,
|
||||
"215036": 8245,
|
||||
"215125": 950,
|
||||
"214758": 7203,
|
||||
"214837": 9972,
|
||||
"214789": 2280,
|
||||
"214742": 8781,
|
||||
"215528": 3111,
|
||||
"215215": 4710,
|
||||
"215262": 3976,
|
||||
"215394": 5293,
|
||||
"215109": 6820,
|
||||
"215405": 5208
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-source-animebracket",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animethemes-db");
|
||||
|
||||
exports.createSchemaCustomization = require("./src/create-schema-customization");
|
||||
|
||||
exports.sourceNodes = require("./src/source-nodes");
|
||||
|
||||
exports.createResolvers = require("./src/create-resolvers");
|
||||
@@ -1,113 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-source-animethemes-db",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"denque": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
|
||||
"integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
|
||||
},
|
||||
"generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"requires": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz",
|
||||
"integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==",
|
||||
"requires": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
}
|
||||
},
|
||||
"is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ="
|
||||
},
|
||||
"long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"requires": {
|
||||
"yallist": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"mysql2": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.2.5.tgz",
|
||||
"integrity": "sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g==",
|
||||
"requires": {
|
||||
"denque": "^1.4.1",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.6.2",
|
||||
"long": "^4.0.0",
|
||||
"lru-cache": "^6.0.0",
|
||||
"named-placeholders": "^1.1.2",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"sqlstring": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz",
|
||||
"integrity": "sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"named-placeholders": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz",
|
||||
"integrity": "sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA==",
|
||||
"requires": {
|
||||
"lru-cache": "^4.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"lru-cache": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
|
||||
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
|
||||
"requires": {
|
||||
"pseudomap": "^1.0.2",
|
||||
"yallist": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"yallist": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
|
||||
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
|
||||
}
|
||||
}
|
||||
},
|
||||
"pseudomap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
|
||||
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-source-animethemes-db",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"mysql2": "^2.2.5"
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
const { animeSeason, videoSource, imageFacet, resourceSite, videoOverlap, themeType } = require("./enums");
|
||||
|
||||
module.exports = ({ createResolvers }) => {
|
||||
createResolvers({
|
||||
Anime: {
|
||||
...enumResolver({
|
||||
season: animeSeason
|
||||
}),
|
||||
...pivotResolver({
|
||||
series: { type: "AnimeSeries", from: "anime", to: "series" },
|
||||
resources: { type: "AnimeResource", from: "anime", to: "resource" },
|
||||
images: { type: "AnimeImage", from: "anime", to: "image" },
|
||||
studios: { type: "AnimeStudio", from: "anime", to: "studio" }
|
||||
})
|
||||
},
|
||||
Artist: {
|
||||
...pivotResolver({
|
||||
resources: { type: "ArtistResource", from: "artist", to: "resource" },
|
||||
images: { type: "ArtistImage", from: "artist", to: "image" }
|
||||
})
|
||||
},
|
||||
Entry: {
|
||||
...pivotResolver({
|
||||
videos: { type: "EntryVideo", from: "entry", to: "video" }
|
||||
})
|
||||
},
|
||||
Image: {
|
||||
...enumResolver({
|
||||
facet: imageFacet
|
||||
}),
|
||||
link: {
|
||||
resolve(source) {
|
||||
return `https://staging.animethemes.moe/image/${source.idRaw}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
Resource: {
|
||||
...enumResolver({
|
||||
site: resourceSite
|
||||
})
|
||||
},
|
||||
Series: {
|
||||
...pivotResolver({
|
||||
anime: { type: "AnimeSeries", from: "series", to: "anime" }
|
||||
})
|
||||
},
|
||||
Studio: {
|
||||
...pivotResolver({
|
||||
anime: { type: "AnimeStudio", from: "studio", to: "anime" },
|
||||
resources: { type: "StudioResource", from: "studio", to: "resource" }
|
||||
})
|
||||
},
|
||||
Theme: {
|
||||
...enumResolver({
|
||||
type: themeType
|
||||
})
|
||||
},
|
||||
Video: {
|
||||
...enumResolver({
|
||||
overlap: videoOverlap,
|
||||
source: videoSource
|
||||
}),
|
||||
...pivotResolver({
|
||||
entries: { type: "EntryVideo", from: "video", to: "entry" }
|
||||
}),
|
||||
tags: {
|
||||
resolve(source) {
|
||||
const tags = [];
|
||||
|
||||
if (source.nc) {
|
||||
tags.push("NC");
|
||||
}
|
||||
if (source.source === 2 || source.source === 3) {
|
||||
tags.push(videoSource.get(source.source));
|
||||
}
|
||||
if (source.resolution && source.resolution !== 720) {
|
||||
tags.push(source.resolution);
|
||||
}
|
||||
|
||||
if (source.subbed) {
|
||||
tags.push("Subbed");
|
||||
} else if (source.lyrics) {
|
||||
tags.push("Lyrics");
|
||||
}
|
||||
|
||||
return tags.join("");
|
||||
}
|
||||
},
|
||||
link: {
|
||||
resolve(source) {
|
||||
return `https://v.animethemes.moe/${source.basename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function enumResolver(propertyToEnumMap) {
|
||||
return Object.fromEntries(Object.entries(propertyToEnumMap).map(([ property, enumMap ]) => {
|
||||
return [
|
||||
property,
|
||||
{
|
||||
resolve(source) {
|
||||
return enumMap.get(source[property]);
|
||||
}
|
||||
}
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
function pivotResolver(propertyToPivotConfig) {
|
||||
return Object.fromEntries(Object.entries(propertyToPivotConfig).map(([ property, pivotConfig ]) => {
|
||||
return [
|
||||
property,
|
||||
{
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getAllNodes({ type: pivotConfig.type })
|
||||
.filter((node) => node[pivotConfig.from] === source.id)
|
||||
.map((node) => context.nodeModel.getNodeById({ id: node[pivotConfig.to] }));
|
||||
}
|
||||
}
|
||||
];
|
||||
}));
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
module.exports = ({ actions }) => {
|
||||
const { createTypes } = actions;
|
||||
createTypes(`
|
||||
interface Entity {
|
||||
idRaw: Int
|
||||
}
|
||||
|
||||
type Anime implements Node & Entity {
|
||||
idRaw: Int
|
||||
name: String!
|
||||
slug: String!
|
||||
year: Int!
|
||||
season: String
|
||||
synopsis: String
|
||||
synonyms: [Synonym] @link(by: "anime.id", from: "id")
|
||||
themes: [Theme] @link(by: "anime.id", from: "id")
|
||||
series: [Series]
|
||||
resources: [Resource]
|
||||
images: [Image]
|
||||
studios: [Studio]
|
||||
}
|
||||
|
||||
type Synonym implements Node & Entity {
|
||||
idRaw: Int
|
||||
text: String!
|
||||
anime: Anime! @link(by: "id")
|
||||
}
|
||||
|
||||
type Theme implements Node & Entity {
|
||||
idRaw: Int
|
||||
slug: String!
|
||||
type: String!
|
||||
sequence: Int!
|
||||
group: String
|
||||
song: Song! @link(by: "id")
|
||||
anime: Anime! @link(by: "id")
|
||||
entries: [Entry] @link(by: "theme.id", from: "id")
|
||||
}
|
||||
|
||||
type Entry implements Node & Entity {
|
||||
idRaw: Int
|
||||
version: Int
|
||||
episodes: String
|
||||
nsfw: Boolean!
|
||||
spoiler: Boolean!
|
||||
theme: Theme! @link(by: "id")
|
||||
videos: [Video]
|
||||
}
|
||||
|
||||
type Video implements Node & Entity {
|
||||
idRaw: Int
|
||||
filename: String!
|
||||
basename: String!
|
||||
link: String
|
||||
resolution: Int
|
||||
nc: Boolean!
|
||||
subbed: Boolean!
|
||||
lyrics: Boolean!
|
||||
uncen: Boolean!
|
||||
source: String
|
||||
overlap: String
|
||||
tags: String
|
||||
entries: [Entry]
|
||||
}
|
||||
|
||||
type Song implements Node & Entity {
|
||||
idRaw: Int
|
||||
title: String!
|
||||
themes: [Theme] @link(by: "song.id", from: "id")
|
||||
performances: [Performance] @link(by: "song.id", from: "id")
|
||||
}
|
||||
|
||||
type Performance implements Node {
|
||||
song: Song! @link(by: "id")
|
||||
artist: Artist! @link(by: "id")
|
||||
as: String
|
||||
}
|
||||
|
||||
type Artist implements Node & Entity {
|
||||
idRaw: Int
|
||||
slug: String!
|
||||
name: String!
|
||||
performances: [Performance] @link(by: "artist.id", from: "id")
|
||||
members: [ArtistMembership] @link(by: "group.id", from: "id")
|
||||
groups: [ArtistMembership] @link(by: "member.id", from: "id")
|
||||
resources: [Resource]
|
||||
images: [Image]
|
||||
}
|
||||
|
||||
type ArtistMembership implements Node {
|
||||
group: Artist! @link(by: "id")
|
||||
member: Artist! @link(by: "id")
|
||||
as: String
|
||||
}
|
||||
|
||||
type Series implements Node & Entity {
|
||||
idRaw: Int
|
||||
slug: String!
|
||||
name: String!
|
||||
anime: [Anime]
|
||||
}
|
||||
|
||||
type Resource implements Node & Entity {
|
||||
idRaw: Int
|
||||
link: String!
|
||||
site: String!
|
||||
}
|
||||
|
||||
type Image implements Node & Entity {
|
||||
idRaw: Int
|
||||
facet: String!
|
||||
link: String
|
||||
}
|
||||
|
||||
type Studio implements Node & Entity {
|
||||
idRaw: Int
|
||||
slug: String!
|
||||
name: String!
|
||||
anime: [Anime]
|
||||
resources: [Resource]
|
||||
}
|
||||
|
||||
type AnimeSeries implements Node {
|
||||
anime: Anime! @link(by: "id")
|
||||
series: Series! @link(by: "id")
|
||||
}
|
||||
|
||||
type EntryVideo implements Node {
|
||||
entry: Entry! @link(by: "id")
|
||||
video: Video! @link(by: "id")
|
||||
}
|
||||
|
||||
type AnimeResource implements Node {
|
||||
anime: Anime! @link(by: "id")
|
||||
resource: Resource! @link(by: "id")
|
||||
}
|
||||
|
||||
type ArtistResource implements Node {
|
||||
artist: Artist! @link(by: "id")
|
||||
resource: Resource! @link(by: "id")
|
||||
}
|
||||
|
||||
type AnimeImage implements Node {
|
||||
anime: Anime! @link(by: "id")
|
||||
image: Image! @link(by: "id")
|
||||
}
|
||||
|
||||
type ArtistImage implements Node {
|
||||
artist: Artist! @link(by: "id")
|
||||
image: Image! @link(by: "id")
|
||||
}
|
||||
|
||||
type AnimeStudio implements Node {
|
||||
anime: Anime! @link(by: "id")
|
||||
studio: Studio! @link(by: "id")
|
||||
}
|
||||
|
||||
type StudioResource implements Node {
|
||||
studio: Studio! @link(by: "id")
|
||||
resource: Resource! @link(by: "id")
|
||||
}
|
||||
|
||||
type Announcement implements Node & Entity {
|
||||
idRaw: Int
|
||||
content: String!
|
||||
}
|
||||
`);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
exports.animeSeason = new Map([
|
||||
[ 0, "Winter" ],
|
||||
[ 1, "Spring" ],
|
||||
[ 2, "Summer" ],
|
||||
[ 3, "Fall" ]
|
||||
]);
|
||||
|
||||
exports.videoSource = new Map([
|
||||
[ 0, "WEB" ],
|
||||
[ 1, "RAW" ],
|
||||
[ 2, "BD" ],
|
||||
[ 3, "DVD" ],
|
||||
[ 4, "VHS" ],
|
||||
[ 5, "LD" ]
|
||||
]);
|
||||
|
||||
exports.videoOverlap = new Map([
|
||||
[ 0, "None" ],
|
||||
[ 1, "Transition" ],
|
||||
[ 2, "Over" ]
|
||||
]);
|
||||
|
||||
exports.imageFacet = new Map([
|
||||
[ 0, "Small Cover" ],
|
||||
[ 1, "Large Cover" ]
|
||||
]);
|
||||
|
||||
exports.resourceSite = new Map([
|
||||
[ 0, "Official Website" ],
|
||||
[ 1, "Twitter" ],
|
||||
[ 2, "aniDB" ],
|
||||
[ 3, "AniList" ],
|
||||
[ 4, "Anime-Planet" ],
|
||||
[ 5, "Anime News Network" ],
|
||||
[ 6, "Kitsu" ],
|
||||
[ 7, "MyAnimeList" ],
|
||||
[ 8, "Wiki" ]
|
||||
]);
|
||||
|
||||
exports.themeType = new Map([
|
||||
[ 0, "OP" ],
|
||||
[ 1, "ED" ]
|
||||
]);
|
||||
@@ -1,330 +0,0 @@
|
||||
const mysql = require("mysql2");
|
||||
|
||||
module.exports = async ({ actions, createNodeId, createContentDigest, reporter }) => {
|
||||
const helpers = {
|
||||
...actions,
|
||||
createNodeId,
|
||||
createContentDigest
|
||||
};
|
||||
|
||||
// TODO: Implement incremental data fetching when pivots have timestamps
|
||||
// const lastFetched = await cache.get("last-fetched");
|
||||
|
||||
// getNodes().filter(isPluginNode).forEach((node) => helpers.touchNode(node));
|
||||
|
||||
// const now = new Date();
|
||||
|
||||
const connection = mysql.createConnection({
|
||||
host: process.env.DB_HOST || "127.0.0.1",
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE || "animethemes"
|
||||
});
|
||||
|
||||
async function query(sql) {
|
||||
reporter.info(`Executing query: ${sql}`);
|
||||
return new Promise((resolve, reject) => connection.query(sql, (error, results) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
reporter.info(`Got ${results.length} results`);
|
||||
resolve(results);
|
||||
}));
|
||||
}
|
||||
|
||||
async function selectAllFrom(table, isPivot = false, tableToA, columnFromA, columnToA, tableToB, columnFromB, columnToB) {
|
||||
let sql = `SELECT ${table}.* FROM ${table}`;
|
||||
|
||||
if (!isPivot) {
|
||||
sql += " WHERE deleted_at IS NULL";
|
||||
} else {
|
||||
sql += ` INNER JOIN ${tableToA} AS a ON (${table}.${columnFromA} = a.${columnToA})
|
||||
INNER JOIN ${tableToB} AS b ON (${table}.${columnFromB} = b.${columnToB})
|
||||
WHERE a.deleted_at IS NULL AND b.deleted_at IS NULL`;
|
||||
}
|
||||
|
||||
return await query(sql);
|
||||
}
|
||||
|
||||
for (const anime of await selectAllFrom("anime")) {
|
||||
createNodeFromData({
|
||||
id: anime.anime_id,
|
||||
idRaw: anime.anime_id,
|
||||
name: anime.name,
|
||||
slug: anime.slug,
|
||||
year: anime.year,
|
||||
season: anime.season,
|
||||
synopsis: anime.synopsis,
|
||||
resources: [],
|
||||
images: []
|
||||
}, "Anime", helpers);
|
||||
}
|
||||
|
||||
for (const synonym of await selectAllFrom("anime_synonyms")) {
|
||||
createNodeFromData({
|
||||
id: synonym.synonym_id,
|
||||
idRaw: synonym.synonym_id,
|
||||
text: synonym.text,
|
||||
anime: createNodeId(`Anime-${synonym.anime_id}`)
|
||||
}, "Synonym", helpers);
|
||||
}
|
||||
|
||||
for (const theme of await selectAllFrom("anime_themes")) {
|
||||
createNodeFromData({
|
||||
id: theme.theme_id,
|
||||
idRaw: theme.theme_id,
|
||||
slug: theme.slug,
|
||||
type: theme.type,
|
||||
sequence: theme.sequence || 0,
|
||||
group: theme.group,
|
||||
song: createNodeId(`Song-${theme.song_id}`),
|
||||
anime: createNodeId(`Anime-${theme.anime_id}`)
|
||||
}, "Theme", helpers);
|
||||
}
|
||||
|
||||
for (const entry of await selectAllFrom("anime_theme_entries")) {
|
||||
createNodeFromData({
|
||||
id: entry.entry_id,
|
||||
idRaw: entry.entry_id,
|
||||
version: entry.version || 1,
|
||||
episodes: entry.episodes,
|
||||
nsfw: entry.nsfw,
|
||||
spoiler: entry.spoiler,
|
||||
theme: createNodeId(`Theme-${entry.theme_id}`),
|
||||
videos: []
|
||||
}, "Entry", helpers);
|
||||
}
|
||||
|
||||
for (const video of await selectAllFrom("videos")) {
|
||||
createNodeFromData({
|
||||
id: video.video_id,
|
||||
idRaw: video.video_id,
|
||||
filename: video.filename,
|
||||
basename: video.basename,
|
||||
link: video.link,
|
||||
resolution: video.resolution,
|
||||
nc: video.nc,
|
||||
subbed: video.subbed,
|
||||
lyrics: video.lyrics,
|
||||
uncen: video.uncen,
|
||||
source: video.source,
|
||||
overlap: video.overlap,
|
||||
entries: []
|
||||
}, "Video", helpers);
|
||||
}
|
||||
|
||||
for (const song of await selectAllFrom("songs")) {
|
||||
createNodeFromData({
|
||||
id: song.song_id,
|
||||
idRaw: song.song_id,
|
||||
title: song.title
|
||||
}, "Song", helpers);
|
||||
}
|
||||
|
||||
for (const artist of await selectAllFrom("artists")) {
|
||||
createNodeFromData({
|
||||
id: artist.artist_id,
|
||||
idRaw: artist.artist_id,
|
||||
slug: artist.slug,
|
||||
name: artist.name,
|
||||
resources: [],
|
||||
images: []
|
||||
}, "Artist", helpers);
|
||||
}
|
||||
|
||||
for (const series of await selectAllFrom("series")) {
|
||||
createNodeFromData({
|
||||
id: series.series_id,
|
||||
idRaw: series.series_id,
|
||||
slug: series.slug,
|
||||
name: series.name
|
||||
}, "Series", helpers);
|
||||
}
|
||||
|
||||
for (const resource of await selectAllFrom("resources")) {
|
||||
createNodeFromData({
|
||||
id: resource.resource_id,
|
||||
idRaw: resource.resource_id,
|
||||
link: resource.link,
|
||||
site: resource.site
|
||||
}, "Resource", helpers);
|
||||
}
|
||||
|
||||
for (const image of await selectAllFrom("images")) {
|
||||
createNodeFromData({
|
||||
id: image.image_id,
|
||||
idRaw: image.image_id,
|
||||
facet: image.facet
|
||||
}, "Image", helpers);
|
||||
}
|
||||
|
||||
for (const studio of await selectAllFrom("studios")) {
|
||||
createNodeFromData({
|
||||
id: studio.studio_id,
|
||||
idRaw: studio.studio_id,
|
||||
slug: studio.slug,
|
||||
name: studio.name
|
||||
}, "Studio", helpers);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const announcement of await selectAllFrom("announcements")) {
|
||||
createNodeFromData({
|
||||
id: announcement.announcement_id,
|
||||
idRaw: announcement.announcement_id,
|
||||
content: announcement.content
|
||||
}, "Announcement", helpers);
|
||||
}
|
||||
} catch (e) {
|
||||
reporter.warn("Error while getting announcements. This should only happen on GitHub pages.");
|
||||
}
|
||||
|
||||
// Pivot types
|
||||
|
||||
for (const performance of await selectAllFrom(
|
||||
"artist_song", true,
|
||||
"artists", "artist_id", "artist_id",
|
||||
"songs", "song_id", "song_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${performance.song_id}-${performance.artist_id}`,
|
||||
song: createNodeId(`Song-${performance.song_id}`),
|
||||
artist: createNodeId(`Artist-${performance.artist_id}`),
|
||||
as: performance.as
|
||||
}, "Performance", helpers);
|
||||
}
|
||||
|
||||
for (const artistMember of await selectAllFrom(
|
||||
"artist_member", true,
|
||||
"artists", "artist_id", "artist_id",
|
||||
"artists", "member_id", "artist_id",
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${artistMember.artist_id}-${artistMember.member_id}`,
|
||||
group: createNodeId(`Artist-${artistMember.artist_id}`),
|
||||
member: createNodeId(`Artist-${artistMember.member_id}`),
|
||||
as: artistMember.as
|
||||
}, "ArtistMembership", helpers);
|
||||
}
|
||||
|
||||
for (const animeSeries of await selectAllFrom(
|
||||
"anime_series", true,
|
||||
"anime", "anime_id", "anime_id",
|
||||
"series", "series_id", "series_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${animeSeries.anime_id}-${animeSeries.series_id}`,
|
||||
anime: createNodeId(`Anime-${animeSeries.anime_id}`),
|
||||
series: createNodeId(`Series-${animeSeries.series_id}`)
|
||||
}, "AnimeSeries", helpers);
|
||||
}
|
||||
|
||||
for (const animeResource of await selectAllFrom(
|
||||
"anime_resource", true,
|
||||
"anime", "anime_id", "anime_id",
|
||||
"resources", "resource_id", "resource_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${animeResource.anime_id}-${animeResource.resource_id}`,
|
||||
anime: createNodeId(`Anime-${animeResource.anime_id}`),
|
||||
resource: createNodeId(`Resource-${animeResource.resource_id}`)
|
||||
}, "AnimeResource", helpers);
|
||||
}
|
||||
|
||||
for (const animeImage of await selectAllFrom(
|
||||
"anime_image", true,
|
||||
"anime", "anime_id", "anime_id",
|
||||
"images", "image_id", "image_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${animeImage.anime_id}-${animeImage.image_id}`,
|
||||
anime: createNodeId(`Anime-${animeImage.anime_id}`),
|
||||
image: createNodeId(`Image-${animeImage.image_id}`)
|
||||
}, "AnimeImage", helpers);
|
||||
}
|
||||
|
||||
for (const artistResource of await selectAllFrom(
|
||||
"artist_resource", true,
|
||||
"artists", "artist_id", "artist_id",
|
||||
"resources", "resource_id", "resource_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${artistResource.artist_id}-${artistResource.resource_id}`,
|
||||
artist: createNodeId(`Artist-${artistResource.artist_id}`),
|
||||
resource: createNodeId(`Resource-${artistResource.resource_id}`)
|
||||
}, "ArtistResource", helpers);
|
||||
}
|
||||
|
||||
for (const artistImage of await selectAllFrom(
|
||||
"artist_image", true,
|
||||
"artists", "artist_id", "artist_id",
|
||||
"images", "image_id", "image_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${artistImage.artist_id}-${artistImage.image_id}`,
|
||||
artist: createNodeId(`Artist-${artistImage.artist_id}`),
|
||||
image: createNodeId(`Image-${artistImage.image_id}`)
|
||||
}, "ArtistImage", helpers);
|
||||
}
|
||||
|
||||
for (const entryVideo of await selectAllFrom(
|
||||
"anime_theme_entry_video", true,
|
||||
"anime_theme_entries", "entry_id", "entry_id",
|
||||
"videos", "video_id", "video_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${entryVideo.entry_id}-${entryVideo.video_id}`,
|
||||
entry: createNodeId(`Entry-${entryVideo.entry_id}`),
|
||||
video: createNodeId(`Video-${entryVideo.video_id}`)
|
||||
}, "EntryVideo", helpers);
|
||||
}
|
||||
|
||||
for (const animeStudio of await selectAllFrom(
|
||||
"anime_studio", true,
|
||||
"anime", "anime_id", "anime_id",
|
||||
"studios", "studio_id", "studio_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${animeStudio.anime_id}-${animeStudio.studio_id}`,
|
||||
anime: createNodeId(`Anime-${animeStudio.anime_id}`),
|
||||
studio: createNodeId(`Studio-${animeStudio.studio_id}`)
|
||||
}, "AnimeStudio", helpers);
|
||||
}
|
||||
|
||||
for (const studioResource of await selectAllFrom(
|
||||
"studio_resource", true,
|
||||
"studios", "studio_id", "studio_id",
|
||||
"resources", "resource_id", "resource_id"
|
||||
)) {
|
||||
createNodeFromData({
|
||||
id: `${studioResource.studio_id}-${studioResource.resource_id}`,
|
||||
studio: createNodeId(`Studio-${studioResource.studio_id}`),
|
||||
resource: createNodeId(`Resource-${studioResource.resource_id}`)
|
||||
}, "StudioResource", helpers);
|
||||
}
|
||||
|
||||
// await cache.set("last-fetched", now);
|
||||
};
|
||||
|
||||
function createNodeFromData(item, nodeType, helpers) {
|
||||
const node = {
|
||||
...item,
|
||||
id: helpers.createNodeId(`${nodeType}-${item.id}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: nodeType,
|
||||
content: JSON.stringify(item),
|
||||
contentDigest: helpers.createContentDigest(item)
|
||||
}
|
||||
};
|
||||
|
||||
helpers.createNode(node)
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// function isPluginNode(node) {
|
||||
// return node.internal.owner === "gatsby-source-animethemes";
|
||||
// }
|
||||
@@ -1,371 +0,0 @@
|
||||
const { fetchAnimeList } = require("./src/anime");
|
||||
const { fetchSynonymList } = require("./src/synonym");
|
||||
const { fetchThemeList } = require("./src/theme");
|
||||
const { fetchEntryList } = require("./src/entry");
|
||||
const { fetchVideoList } = require("./src/video");
|
||||
const { fetchSongList } = require("./src/song");
|
||||
const { fetchArtistList } = require("./src/artist");
|
||||
const { fetchSeriesList } = require("./src/series");
|
||||
const { fetchResourceList } = require("./src/resource");
|
||||
const { fetchImageList } = require("./src/image");
|
||||
const { fetchAnnouncements } = require("./src/announcement");
|
||||
|
||||
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animethemes");
|
||||
|
||||
exports.createSchemaCustomization = ({ actions }) => {
|
||||
const { createTypes } = actions;
|
||||
createTypes(`
|
||||
type Anime implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
slug: String!
|
||||
year: Int!
|
||||
season: String
|
||||
synopsis: String
|
||||
synonyms: [Synonym] @link(by: "anime.id", from: "id")
|
||||
themes: [Theme] @link(by: "anime.id", from: "id")
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
series: [Series]
|
||||
resources: [Resource]
|
||||
images: [Image]
|
||||
}
|
||||
|
||||
type Synonym implements Node {
|
||||
id: ID!
|
||||
text: String!
|
||||
anime: Anime! @link(by: "id")
|
||||
}
|
||||
|
||||
type Theme implements Node {
|
||||
id: ID!
|
||||
slug: String!
|
||||
group: String
|
||||
song: Song! @link(by: "id")
|
||||
anime: Anime! @link(by: "id")
|
||||
entries: [Entry] @link(by: "theme.id", from: "id")
|
||||
}
|
||||
|
||||
type Entry implements Node {
|
||||
id: ID!
|
||||
version: Int
|
||||
episodes: String
|
||||
nsfw: Boolean!
|
||||
spoiler: Boolean!
|
||||
theme: Theme! @link(by: "id")
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
videos: [Video]
|
||||
}
|
||||
|
||||
type Video implements Node {
|
||||
id: ID!
|
||||
filename: String!
|
||||
basename: String!
|
||||
link: String!
|
||||
resolution: Int
|
||||
nc: Boolean!
|
||||
subbed: Boolean!
|
||||
lyrics: Boolean!
|
||||
uncen: Boolean!
|
||||
source: String
|
||||
overlap: String
|
||||
tags: String
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
entries: [Entry]
|
||||
}
|
||||
|
||||
type Song implements Node {
|
||||
id: ID!
|
||||
title: String!
|
||||
themes: [Theme] @link(by: "song.id", from: "id")
|
||||
performances: [Performance] @link(by: "song.id", from: "id")
|
||||
}
|
||||
|
||||
type Performance implements Node {
|
||||
id: ID!
|
||||
song: Song! @link(by: "id")
|
||||
artist: Artist! @link(by: "id")
|
||||
as: String
|
||||
}
|
||||
|
||||
type Artist implements Node {
|
||||
id: ID!
|
||||
slug: String!
|
||||
name: String!
|
||||
performances: [Performance] @link(by: "artist.id", from: "id")
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
resources: [Resource]
|
||||
images: [Image]
|
||||
}
|
||||
|
||||
type Series implements Node {
|
||||
id: ID!
|
||||
slug: String!
|
||||
name: String!
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
anime: [Anime]
|
||||
}
|
||||
|
||||
type Resource implements Node {
|
||||
link: String!
|
||||
site: String!
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
anime: [Anime]
|
||||
artists: [Artist]
|
||||
}
|
||||
|
||||
type Image implements Node {
|
||||
facet: String!
|
||||
link: String!
|
||||
|
||||
# See custom resolver below (n-to-n relations cannot be resolved with @link)
|
||||
anime: [Anime]
|
||||
artists: [Artist]
|
||||
}
|
||||
|
||||
type Announcement implements Node {
|
||||
id: ID!
|
||||
content: String!
|
||||
}
|
||||
`);
|
||||
};
|
||||
|
||||
exports.sourceNodes = async ({ cache, actions, getNodes, createNodeId, createContentDigest, reporter }) => {
|
||||
const helpers = {
|
||||
...actions,
|
||||
createNodeId,
|
||||
createContentDigest
|
||||
};
|
||||
|
||||
// const lastFetched = await cache.get("last-fetched");
|
||||
//
|
||||
// getNodes().filter(isPluginNode).forEach((node) => helpers.touchNode(node));
|
||||
//
|
||||
// const now = new Date().toISOString().replace("Z", "000");
|
||||
|
||||
const animeList = await fetchAnimeList({ reporter });
|
||||
const synonymList = await fetchSynonymList({ reporter });
|
||||
const themeList = await fetchThemeList({ reporter });
|
||||
const entryList = await fetchEntryList({ reporter });
|
||||
const videoList = await fetchVideoList({ reporter });
|
||||
const songList = await fetchSongList({ reporter });
|
||||
const artistList = await fetchArtistList({ reporter });
|
||||
const seriesList = await fetchSeriesList({ reporter });
|
||||
const resourceList = await fetchResourceList({ reporter });
|
||||
const imageList = await fetchImageList({ reporter });
|
||||
|
||||
for (const anime of animeList) {
|
||||
createNodeFromData({
|
||||
id: anime.id,
|
||||
name: anime.name,
|
||||
slug: anime.slug,
|
||||
year: anime.year,
|
||||
season: anime.season,
|
||||
synopsis: anime.synopsis,
|
||||
series: anime.series.map((series) => createNodeId(`Series-${series.id}`)),
|
||||
themes: anime.themes.map((theme) => createNodeId(`Theme-${theme.id}`)),
|
||||
resources: anime.resources.map((resource) => createNodeId(`Resource-${resource.id}`)),
|
||||
images: anime.images.map((image) => createNodeId(`Image-${image.id}`))
|
||||
}, "Anime", helpers);
|
||||
}
|
||||
|
||||
for (const synonym of synonymList) {
|
||||
createNodeFromData({
|
||||
id: synonym.id,
|
||||
text: synonym.text,
|
||||
anime: createNodeId(`Anime-${synonym.anime.id}`)
|
||||
}, "Synonym", helpers);
|
||||
}
|
||||
|
||||
for (const theme of themeList) {
|
||||
createNodeFromData({
|
||||
id: theme.id,
|
||||
slug: theme.slug,
|
||||
group: theme.group,
|
||||
song: createNodeId(`Song-${theme.song.id}`),
|
||||
anime: createNodeId(`Anime-${theme.anime.id}`),
|
||||
entries: theme.entries.map((entry) => createNodeId(`Entry-${entry.id}`))
|
||||
}, "Theme", helpers);
|
||||
}
|
||||
|
||||
for (const entry of entryList) {
|
||||
createNodeFromData({
|
||||
id: entry.id,
|
||||
version: entry.version || 1,
|
||||
episodes: entry.episodes,
|
||||
nsfw: entry.nsfw,
|
||||
spoiler: entry.spoiler,
|
||||
theme: createNodeId(`Theme-${entry.theme.id}`),
|
||||
videos: entry.videos.map((video) => createNodeId(`Video-${video.id}`))
|
||||
}, "Entry", helpers);
|
||||
}
|
||||
|
||||
for (const video of videoList) {
|
||||
createNodeFromData({
|
||||
id: video.id,
|
||||
filename: video.filename,
|
||||
basename: video.basename,
|
||||
link: video.link,
|
||||
resolution: video.resolution,
|
||||
nc: video.nc,
|
||||
subbed: video.subbed,
|
||||
lyrics: video.lyrics,
|
||||
uncen: video.uncen,
|
||||
source: video.source,
|
||||
overlap: video.overlap,
|
||||
tags: video.tags,
|
||||
entries: video.entries.map((entry) => createNodeId(`Entry-${entry.id}`))
|
||||
}, "Video", helpers);
|
||||
}
|
||||
|
||||
for (const song of songList) {
|
||||
createNodeFromData({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
themes: song.themes.map((theme) => createNodeId(`Theme-${theme.id}`)),
|
||||
performances: song.artists.map((artist) => createNodeId(`Performance-${song.id}-${artist.id}`))
|
||||
}, "Song", helpers);
|
||||
|
||||
for (const artist of song.artists) {
|
||||
createNodeFromData({
|
||||
id: `${song.id}-${artist.id}`,
|
||||
song: createNodeId(`Song-${song.id}`),
|
||||
artist: createNodeId(`Artist-${artist.id}`),
|
||||
as: artist.as
|
||||
}, "Performance", helpers);
|
||||
}
|
||||
}
|
||||
|
||||
for (const artist of artistList) {
|
||||
createNodeFromData({
|
||||
id: artist.id,
|
||||
slug: artist.slug,
|
||||
name: artist.name,
|
||||
performances: artist.songs.map((song) => createNodeId(`Performance-${song.id}-${artist.id}`)),
|
||||
resources: artist.resources.map((resource) => createNodeId(`Resource-${resource.id}`)),
|
||||
images: artist.images.map((image) => createNodeId(`Image-${image.id}`))
|
||||
}, "Artist", helpers);
|
||||
}
|
||||
|
||||
for (const series of seriesList) {
|
||||
createNodeFromData({
|
||||
id: series.id,
|
||||
slug: series.slug,
|
||||
name: series.name,
|
||||
anime: series.anime.map((anime) => createNodeId(`Anime-${anime.id}`))
|
||||
}, "Series", helpers);
|
||||
}
|
||||
|
||||
for (const resource of resourceList) {
|
||||
createNodeFromData({
|
||||
id: resource.id,
|
||||
link: resource.link,
|
||||
site: resource.site,
|
||||
anime: resource.anime.map((anime) => createNodeId(`Anime-${anime.id}`)),
|
||||
artists: resource.artists.map((artist) => createNodeId(`Artist-${artist.id}`))
|
||||
}, "Resource", helpers);
|
||||
}
|
||||
|
||||
for (const image of imageList) {
|
||||
createNodeFromData({
|
||||
id: image.id,
|
||||
facet: image.facet,
|
||||
link: image.link,
|
||||
anime: image.anime.map((anime) => createNodeId(`Anime-${anime.id}`)),
|
||||
artists: image.artists.map((artist) => createNodeId(`Artist-${artist.id}`))
|
||||
}, "Image", helpers);
|
||||
}
|
||||
|
||||
const announcements = await fetchAnnouncements();
|
||||
|
||||
for (const announcement of announcements) {
|
||||
createNodeFromData({
|
||||
id: announcement.id,
|
||||
content: announcement.content
|
||||
}, "Announcement", helpers);
|
||||
}
|
||||
|
||||
// await cache.set("last-fetched", now);
|
||||
};
|
||||
|
||||
exports.createResolvers = ({ createResolvers }) => {
|
||||
createResolvers({
|
||||
Anime: {
|
||||
series: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getNodesByIds({ ids: source.series, type: "Series" });
|
||||
}
|
||||
},
|
||||
images: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getAllNodes({ type: "Image" }).filter((node) => node.anime.includes(source.id));
|
||||
}
|
||||
},
|
||||
resources: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.anime.includes(source.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
Artist: {
|
||||
images: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.artists.includes(source.id));
|
||||
}
|
||||
},
|
||||
resources: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.artists.includes(source.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
Series: {
|
||||
anime: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getNodesByIds({ ids: source.anime, type: "Anime" });
|
||||
}
|
||||
}
|
||||
},
|
||||
Entry: {
|
||||
videos: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getNodesByIds({ ids: source.videos, type: "Video" });
|
||||
}
|
||||
}
|
||||
},
|
||||
Video: {
|
||||
entries: {
|
||||
resolve(source, args, context) {
|
||||
return context.nodeModel.getNodesByIds({ ids: source.entries, type: "Entry" });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function createNodeFromData(item, nodeType, helpers) {
|
||||
const node = {
|
||||
...item,
|
||||
id: helpers.createNodeId(`${nodeType}-${item.id}`),
|
||||
parent: null,
|
||||
children: [],
|
||||
internal: {
|
||||
type: nodeType,
|
||||
content: JSON.stringify(item),
|
||||
contentDigest: helpers.createContentDigest(item)
|
||||
}
|
||||
};
|
||||
|
||||
helpers.createNode(node)
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// function isPluginNode(node) {
|
||||
// return node.internal.owner === "gatsby-source-animethemes";
|
||||
// }
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "gatsby-source-animethemes",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
anime: [ "id", "name", "slug", "year", "season", "synopsis" ],
|
||||
image: [ "id" ],
|
||||
theme: [ "id" ],
|
||||
series: [ "id" ],
|
||||
resource: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchAnimeList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching anime list");
|
||||
activity.start();
|
||||
|
||||
const animeList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/anime?page[size]=100&sort=year,season,name&${fields}&include=images,themes,series,externalResources&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.anime,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} anime fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return animeList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAnimeList
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
const { baseUrl, fetchJson } = require("./index");
|
||||
|
||||
function fetchAnnouncements() {
|
||||
return fetchJson(`${baseUrl}/api/announcement`)
|
||||
.then((json) => json.announcements);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAnnouncements
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
artist: [ "id", "slug", "name" ],
|
||||
song: [ "id" ],
|
||||
resource: [ "id" ],
|
||||
image: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchArtistList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching artist list");
|
||||
activity.start();
|
||||
|
||||
const artistList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/artist?page[size]=100&sort=name&${fields}&include=songs,externalResources,images&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.artists,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} artists fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return artistList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchArtistList
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
entry: [ "id", "version", "episodes", "nsfw", "spoiler" ],
|
||||
theme: [ "id" ],
|
||||
video: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchEntryList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching entry list");
|
||||
activity.start();
|
||||
|
||||
const entryList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/entry?page[size]=100&${fields}&include=theme,videos&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.entries,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} entries fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return entryList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchEntryList
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
image: [ "id", "facet", "link" ],
|
||||
anime: [ "id" ],
|
||||
artist: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchImageList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching image list");
|
||||
activity.start();
|
||||
|
||||
const imageList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/image?page[size]=100&${fields}&include=anime,artists&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.images,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} images fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return imageList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchImageList
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
const baseUrl = process.env.GATSBY_API_URL || "https://staging.animethemes.moe";
|
||||
|
||||
const requestCooldown = 1500;
|
||||
let lastRequest;
|
||||
|
||||
async function fetchJson(url, init) {
|
||||
// Debounce, we should only request once every second
|
||||
if (lastRequest) {
|
||||
const expiredTime = Date.now() - lastRequest;
|
||||
if (expiredTime < requestCooldown) {
|
||||
await sleep(requestCooldown - expiredTime);
|
||||
}
|
||||
}
|
||||
|
||||
lastRequest = Date.now();
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function fetchJsonPaginated(url, { reducer = (page) => page, flatten, onProgress, ...init }) {
|
||||
const results = [];
|
||||
let nextUrl = url;
|
||||
|
||||
console.log(`URL schema: ${nextUrl}`);
|
||||
|
||||
while (nextUrl) {
|
||||
const page = await fetchJson(nextUrl, init);
|
||||
const pageResults = reducer(page);
|
||||
|
||||
if (flatten) {
|
||||
results.push(...pageResults);
|
||||
} else {
|
||||
results.push(pageResults);
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(results);
|
||||
}
|
||||
|
||||
nextUrl = page.links.next;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function createFieldParams(fields) {
|
||||
return Object.entries(fields)
|
||||
.map(([ key, values ]) => `fields[${key}]=${values.join()}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
function sleep(millis) {
|
||||
return new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
baseUrl,
|
||||
fetchJson,
|
||||
fetchJsonPaginated,
|
||||
createFieldParams
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
resource: [ "id", "link", "site" ],
|
||||
anime: [ "id" ],
|
||||
artist: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchResourceList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching resource list");
|
||||
activity.start();
|
||||
|
||||
const resourceList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/resource?page[size]=100&${fields}&include=anime,artists&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.resources,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} resources fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return resourceList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchResourceList
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
series: [ "id", "slug", "name" ],
|
||||
anime: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchSeriesList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching series list");
|
||||
activity.start();
|
||||
|
||||
const seriesList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/series?page[size]=100&sort=name&${fields}&include=anime&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.series,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} series fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return seriesList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSeriesList
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
song: [ "id", "title" ],
|
||||
theme: [ "id" ],
|
||||
artist: [ "id", "as" ]
|
||||
});
|
||||
|
||||
async function fetchSongList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching song list");
|
||||
activity.start();
|
||||
|
||||
const songList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/song?page[size]=100&${fields}&include=themes,artists&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.songs,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} songs fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return songList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSongList
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
synonym: [ "id", "text" ],
|
||||
anime: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchSynonymList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching synonym list");
|
||||
activity.start();
|
||||
|
||||
const synonymList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/synonym?page[size]=100&${fields}&include=anime&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.synonyms,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} synonyms fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return synonymList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSynonymList
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
theme: [ "id", "slug", "group" ],
|
||||
anime: [ "id" ],
|
||||
song: [ "id" ],
|
||||
entry: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchThemeList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching theme list");
|
||||
activity.start();
|
||||
|
||||
const themeList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/theme?page[size]=100&${fields}&include=anime,song,entries&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.themes,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} themes fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return themeList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchThemeList
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
|
||||
|
||||
const fields = createFieldParams({
|
||||
video: [ "id", "filename", "basename", "link", "resolution", "nc", "subbed", "lyrics", "uncen", "source", "overlap", "tags" ],
|
||||
entry: [ "id" ]
|
||||
});
|
||||
|
||||
async function fetchVideoList({ reporter, lastFetched }) {
|
||||
const activity = reporter.activityTimer("Fetching video list");
|
||||
activity.start();
|
||||
|
||||
const videoList = await fetchJsonPaginated(
|
||||
`${baseUrl}/api/video?page[size]=100&${fields}&include=entries&filter[updated_at][gte]=${lastFetched}`,
|
||||
{
|
||||
reducer: (page) => page.videos,
|
||||
flatten: true,
|
||||
onProgress: (results) => activity.setStatus(`${results.length} videos fetched`)
|
||||
}
|
||||
);
|
||||
|
||||
activity.end();
|
||||
|
||||
return videoList;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchVideoList
|
||||
};
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 625 B |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="Ebene_1" fill="currentColor" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 86.6"><title>logo</title><polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/><polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1000.000000pt" height="1000.000000pt" viewBox="0 0 1000.000000 1000.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1000.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2953 6087 c-1030 -595 -1873 -1084 -1873 -1087 0 -3 210 -126 467
|
||||
-274 l467 -269 30 17 c17 9 438 252 936 539 l905 523 3 -1081 2 -1081 454
|
||||
-262 c249 -144 460 -264 470 -268 15 -6 16 152 16 2160 0 1191 -1 2166 -2
|
||||
2165 -2 0 -846 -487 -1875 -1082z"/>
|
||||
<path d="M5330 4999 c0 -1353 4 -2159 9 -2157 6 2 217 122 470 268 l461 265 0
|
||||
1078 c0 592 2 1077 5 1077 3 0 421 -239 927 -532 507 -293 929 -532 938 -532
|
||||
18 0 930 523 930 534 0 7 -3723 2160 -3734 2160 -3 0 -6 -972 -6 -2161z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1004 B |
@@ -1,12 +0,0 @@
|
||||
module.exports = {
|
||||
// Basic site metadata
|
||||
siteName: "AnimeThemes",
|
||||
description: "AnimeThemes is a simple and consistent repository of anime opening and ending themes.",
|
||||
siteLanguage: "en",
|
||||
|
||||
// Prefix for all links (for production builds)
|
||||
pathPrefix: "/animethemes",
|
||||
|
||||
// Site URL without pathPrefix
|
||||
rootUrl: "https://animethemes.github.io"
|
||||
}
|
||||
@@ -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) {
|
||||
// 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,8 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { color, compose, flexbox, grid, layout, space, typography, position, border } from "styled-system";
|
||||
import { gaps } from "utils/gaps";
|
||||
|
||||
export const Box = styled.div`
|
||||
${compose(color, space, layout, typography, position, flexbox, grid, border)}
|
||||
${gaps}
|
||||
`;
|
||||
@@ -1,6 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { Box } from "components/box";
|
||||
|
||||
export const Flex = styled(Box)`
|
||||
display: ${(props) => props.display || (props.flexInline ? "inline-flex" : "flex")};
|
||||
`;
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
};
|
||||
}>`
|
||||
--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);
|
||||
`;
|
||||
|
||||
export const Row = styled(Flex)`
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
export const Column = styled(Flex)`
|
||||
flex-direction: column;
|
||||
`;
|
||||
@@ -1,6 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { Box } from "components/box";
|
||||
|
||||
export const Grid = styled(Box)`
|
||||
display: ${(props) => props.gridInline ? "inline-grid" : "grid"};
|
||||
`;
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import theme from "@/theme";
|
||||
|
||||
export const Solid = styled.div`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
`;
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Box } from "./Box";
|
||||
export { Flex } from "./Flex";
|
||||
export { Grid } from "./Grid";
|
||||
@@ -0,0 +1,237 @@
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faDiagramProject } from "@fortawesome/free-solid-svg-icons";
|
||||
import { m } from "motion/react";
|
||||
|
||||
import { BracketThemeSummaryCard } from "@/components/bracket/BracketThemeSummaryCard";
|
||||
import { Button } from "@/components/button/Button";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { BracketPageQuery } from "@/generated/graphql";
|
||||
import theme from "@/theme";
|
||||
import type { RequiredNonNullable } from "@/utils/types";
|
||||
|
||||
const StyledBracketContainer = styled.div`
|
||||
flex: 1 1 0;
|
||||
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:fullscreen {
|
||||
background-color: ${theme.colors["background"]};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledBracket = styled(m.div)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
width: min-content;
|
||||
gap: 128px;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const StyledCanvas = styled.canvas`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledRound = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 64px;
|
||||
`;
|
||||
|
||||
const StyledPairing = styled.div`
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
|
||||
width: 384px;
|
||||
`;
|
||||
|
||||
type BracketChartProps = RequiredNonNullable<BracketPageQuery>;
|
||||
|
||||
export function BracketChart({ bracket }: BracketChartProps) {
|
||||
const [showBracketChart, setShowBracketChart] = useState(false);
|
||||
const bracketRef = useRef<HTMLDivElement>(undefined);
|
||||
|
||||
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
|
||||
bracketRef.current = bracket;
|
||||
|
||||
if (bracket) {
|
||||
bracket.requestFullscreen();
|
||||
|
||||
bracket.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement) {
|
||||
setShowBracketChart(false);
|
||||
bracket.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onCanvasInit = useCallback((canvas: HTMLCanvasElement) => {
|
||||
if (!canvas?.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentRect = (canvas.parentNode as HTMLDivElement).getBoundingClientRect();
|
||||
|
||||
canvas.width = parentRect.width;
|
||||
canvas.height = parentRect.height;
|
||||
|
||||
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
ctx.strokeStyle = "#75ead4";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.translate(-parentRect.left, -parentRect.top);
|
||||
|
||||
const cards = document.querySelectorAll<HTMLDivElement>("[data-theme]");
|
||||
const cardsById = [...cards].reduce<Record<string, Array<HTMLDivElement>>>((map, card) => {
|
||||
const id = card.dataset.theme;
|
||||
if (id) {
|
||||
if (!map[id]) {
|
||||
map[id] = [];
|
||||
}
|
||||
map[id].push(card);
|
||||
}
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
for (const cards of Object.values(cardsById)) {
|
||||
for (let i = 0; i < cards.length - 1; i++) {
|
||||
const current = cards[i];
|
||||
const currentRect = current.getBoundingClientRect();
|
||||
const next = cards[i + 1];
|
||||
const nextRect = next.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(currentRect.left + currentRect.width, currentRect.top + currentRect.height / 2);
|
||||
ctx.bezierCurveTo(
|
||||
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
|
||||
currentRect.top + currentRect.height / 2,
|
||||
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
|
||||
nextRect.top + nextRect.height / 2,
|
||||
nextRect.left,
|
||||
nextRect.top + nextRect.height / 2,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faDiagramProject} />
|
||||
<span>Open Bracket Chart</span>
|
||||
</Button>
|
||||
{showBracketChart ? (
|
||||
<StyledBracketContainer ref={onBracketInit}>
|
||||
<StyledBracket drag dragConstraints={bracketRef 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BracketRoundProps {
|
||||
round: BracketChartProps["bracket"]["rounds"][number];
|
||||
}
|
||||
|
||||
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
if (!round.pairings || !round.pairings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRound>
|
||||
<Text>{round.name}</Text>
|
||||
<BracketPairings pairings={round.pairings} />
|
||||
</StyledRound>
|
||||
);
|
||||
});
|
||||
|
||||
interface BracketPairingsProps {
|
||||
pairings: BracketRoundProps["round"]["pairings"];
|
||||
}
|
||||
|
||||
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>
|
||||
));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
|
||||
const isVoted = !!contestantVotes && !!opponentVotes;
|
||||
const isWinner =
|
||||
isVoted &&
|
||||
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
|
||||
|
||||
return (
|
||||
<StyledBracketThemeSummaryCard
|
||||
contestant={contestant}
|
||||
isVoted={isVoted}
|
||||
isWinner={isWinner}
|
||||
seed={contestant.seed}
|
||||
votes={contestantVotes}
|
||||
data-theme={contestant.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faAward, faSeedling, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { Column } from "@/components/box/Flex";
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
|
||||
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 { BracketThemeSummaryCardConstestantFragment } from "@/generated/graphql";
|
||||
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);
|
||||
`;
|
||||
|
||||
const StyledRank = styled(Text)`
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
|
||||
contestant: BracketThemeSummaryCardConstestantFragment;
|
||||
isVoted: boolean;
|
||||
isWinner: boolean;
|
||||
seed: number | null;
|
||||
votes: number | null;
|
||||
}
|
||||
|
||||
export function BracketThemeSummaryCard({
|
||||
contestant,
|
||||
isVoted,
|
||||
isWinner,
|
||||
seed,
|
||||
votes,
|
||||
...props
|
||||
}: BracketThemeSummaryCardProps) {
|
||||
const theme = contestant.theme;
|
||||
const { smallCover } = extractImages(theme?.anime);
|
||||
|
||||
let to;
|
||||
let description: ReactNode = contestant.source;
|
||||
|
||||
if (theme?.anime) {
|
||||
const entry = theme.entries?.[0];
|
||||
const video = entry?.videos?.[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
to = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
|
||||
description = (
|
||||
<SummaryCard.Description>
|
||||
<span>{theme.type + (theme.sequence || "")}</span>
|
||||
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSummaryCardWrapper {...props}>
|
||||
<StyledSummaryCard
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : contestant.name}
|
||||
description={description}
|
||||
image={smallCover ?? contestant.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} />
|
||||
<StyledRank> {seed}</StyledRank>
|
||||
</Text>
|
||||
{isVoted && (
|
||||
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
|
||||
<Icon icon={faUsers} />
|
||||
<StyledRank> {votes}</StyledRank>
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
</StyledSummaryCard>
|
||||
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
|
||||
</StyledSummaryCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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,83 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { gaps } from "utils/gaps";
|
||||
import { Icon } from "components/icon";
|
||||
import theme from "theme";
|
||||
import { flexbox } from "styled-system";
|
||||
import { withHover } from "styles/mixins";
|
||||
|
||||
export const Button = styled.button.attrs(({ children, circle, title }) => ({
|
||||
circle: (typeof children === "object" && "type" in children && children.type === Icon) || circle,
|
||||
"aria-label": title
|
||||
}))`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
|
||||
background-color: ${getBackgroundColor};
|
||||
color: ${getColor};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
border-radius: 999px;
|
||||
padding: ${(props) => props.circle ? "0.5rem" : "0.5rem 1rem"};
|
||||
|
||||
${withHover(css`
|
||||
background-color: ${(props) => getBackgroundColor(props, true)};
|
||||
color: ${(props) => getColor(props, true)};
|
||||
`)}
|
||||
|
||||
& > &:first-child {
|
||||
margin: -0.5rem 0.5rem -0.5rem -1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
${(props) => props.circle && css`
|
||||
aspect-ratio: 1 / 1;
|
||||
`}
|
||||
|
||||
${(props) => props.silent && css`
|
||||
&:not(:hover) {
|
||||
box-shadow: none;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) => props.disabled && css`
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
`}
|
||||
|
||||
${flexbox}
|
||||
${gaps}
|
||||
`;
|
||||
|
||||
function getColor({ variant, theme }, hover = false) {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return theme.colors["text-on-primary"];
|
||||
case "on-card":
|
||||
case "default":
|
||||
default:
|
||||
return hover ? theme.colors["text"] : theme.colors["text-muted"];
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundColor({ variant, silent, theme }, hover = false) {
|
||||
if (silent && !hover) {
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return theme.colors["solid-primary"];
|
||||
case "on-card":
|
||||
return theme.colors["solid-on-card"];
|
||||
case "default":
|
||||
default:
|
||||
return theme.colors["solid"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { type ComponentPropsWithRef } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Solid } from "@/components/box/Solid";
|
||||
import { NestableSlot } from "@/components/utils/NestableSlot";
|
||||
import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
interface ButtonProps extends ComponentPropsWithRef<typeof BaseButton> {
|
||||
asChild?: boolean;
|
||||
variant?: "solid" | "primary" | "warning" | "silent";
|
||||
isCircle?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
ref,
|
||||
asChild,
|
||||
children,
|
||||
variant = "solid",
|
||||
isCircle = false,
|
||||
disabled = false,
|
||||
title,
|
||||
...props
|
||||
}: 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 {
|
||||
throw new Error(`Unknown button variant "${variant}"!`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
as={asChild ? NestableSlot : "button"}
|
||||
$isCircle={isCircle}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
const BaseButton = styled.button<{ $isCircle?: boolean }>`
|
||||
--gap: 0;
|
||||
--focus-ring-color: ${theme.colors["text-primary"]};
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
||||
pointer-events: ${(props) => props.disabled && "none"};
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
|
||||
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`
|
||||
color: ${theme.colors["text"]};
|
||||
`}
|
||||
|
||||
${Solid} & {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
`;
|
||||
|
||||
const SilentButton = styled(BaseButton)`
|
||||
background-color: transparent;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text"]};
|
||||
`}
|
||||
|
||||
&:not(:hover)&:not(:focus:focus-visible) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
${Solid} & {
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`}
|
||||
}
|
||||
|
||||
${Solid} ${Solid} & {
|
||||
${withHover`
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Icon } from "components/icon";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Text } from "components/text";
|
||||
import { Button } from "components/button";
|
||||
import { useMedia } from "use-media";
|
||||
|
||||
export function FilterToggleButton(props) {
|
||||
const isMobile = useMedia({ maxWidth: "720px" });
|
||||
|
||||
return (
|
||||
<Button silent gapsRow="0.5rem" circle={isMobile} {...props}>
|
||||
<Icon icon={faFilter} color="text-disabled"/>
|
||||
<Text display={[ "none", "inline" ]}>Filter</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
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<{ $collapseBreakpoint: string }>`
|
||||
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
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({ 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 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,25 +0,0 @@
|
||||
import { VideoTags } from "components/utils";
|
||||
import { useContext } from "react";
|
||||
import PlayerContext from "context/playerContext";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { Link } from "gatsby";
|
||||
import { Icon } from "components/icon";
|
||||
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }) {
|
||||
const { currentVideo } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentVideo && currentVideo.filename === video.filename;
|
||||
|
||||
return (
|
||||
<Link to={`/anime/${anime.slug}/${videoSlug}`}>
|
||||
<Button {...props}>
|
||||
<Button as="span" variant="primary">
|
||||
<Icon icon={isPlaying ? faCompactDisc : faPlay} spin={isPlaying}/>
|
||||
</Button>
|
||||
<VideoTags video={video} hideTextOnMobile/>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useContext } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
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 {
|
||||
VideoButtonAnimeFragment,
|
||||
VideoButtonEntryFragment,
|
||||
VideoButtonThemeFragment,
|
||||
VideoButtonVideoFragment,
|
||||
} from "@/generated/graphql";
|
||||
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
|
||||
anime: VideoButtonAnimeFragment;
|
||||
theme: VideoButtonThemeFragment;
|
||||
entry: VideoButtonEntryFragment;
|
||||
video: VideoButtonVideoFragment;
|
||||
}
|
||||
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
|
||||
const { currentWatchListItem } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentWatchListItem
|
||||
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
|
||||
: false;
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
VideoButton.fragments = {
|
||||
anime: gql`
|
||||
fragment VideoButtonAnime on Anime {
|
||||
slug
|
||||
}
|
||||
`,
|
||||
theme: gql`
|
||||
${createVideoSlug.fragments.theme}
|
||||
|
||||
fragment VideoButtonTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
}
|
||||
`,
|
||||
entry: gql`
|
||||
${createVideoSlug.fragments.entry}
|
||||
|
||||
fragment VideoButtonEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
}
|
||||
`,
|
||||
video: gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment VideoButtonVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
id
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Button } from "./Button";
|
||||
export { VideoButton } from "./VideoButton"
|
||||
export { FilterToggleButton } from "./FilterToggleButton";
|
||||
@@ -1,105 +0,0 @@
|
||||
import { graphql, Link } from "gatsby";
|
||||
import { faEllipsisH, faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "components/button";
|
||||
import { Text } from "components/text";
|
||||
import useImage from "hooks/useImage";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { Flex } from "components/box";
|
||||
import { Icon } from "components/icon";
|
||||
import { SummaryCard } from "components/card";
|
||||
import { chain, themeSequenceComparator, themeTypeComparator } from "utils/comparators";
|
||||
|
||||
export function AnimeSummaryCard({ anime, hideThemes = false, maxThemes = 4 }) {
|
||||
const { smallCover } = useImage(anime);
|
||||
|
||||
const animeLink = `/anime/${anime.slug}`;
|
||||
|
||||
let premiere = String(anime.year);
|
||||
let premiereLink = `/year/${anime.year}`;
|
||||
if (anime.season) {
|
||||
premiere = anime.season + " " + premiere;
|
||||
premiereLink += `/${anime.season.toLowerCase()}`;
|
||||
}
|
||||
|
||||
let description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Anime</span>
|
||||
{!!anime.year && (
|
||||
<Link to={premiereLink}>
|
||||
<Text link>{premiere}</Text>
|
||||
</Link>
|
||||
)}
|
||||
<span>{anime.themes.length} themes</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={anime.name}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={animeLink}
|
||||
>
|
||||
{!hideThemes && (
|
||||
<Flex display={[ "none", "flex" ]} flexWrap="wrap" gapsBoth="0.75rem">
|
||||
{anime.themes
|
||||
.filter((theme) => "entries" in theme && theme.entries.length && theme.entries[0].videos.length)
|
||||
.sort(chain(themeTypeComparator, themeSequenceComparator))
|
||||
.slice(0, maxThemes)
|
||||
.map((theme) => {
|
||||
const entry = theme.entries[0];
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
|
||||
return (
|
||||
<Link key={theme.slug} to={`/anime/${anime.slug}/${videoSlug}`}>
|
||||
<Button variant="on-card">
|
||||
<Button as="span" variant="primary">
|
||||
<Icon icon={faPlay}/>
|
||||
</Button>
|
||||
{theme.slug}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
}
|
||||
{anime.themes.length > 4 && (
|
||||
<Link to={animeLink}>
|
||||
<Button variant="on-card" title="Show all themes">
|
||||
<Icon icon={faEllipsisH}/>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const query = graphql`
|
||||
fragment AnimeCard on Anime {
|
||||
name
|
||||
slug
|
||||
year
|
||||
season
|
||||
themes {
|
||||
type
|
||||
sequence
|
||||
slug
|
||||
}
|
||||
resources {
|
||||
site
|
||||
link
|
||||
}
|
||||
images {
|
||||
facet
|
||||
link
|
||||
}
|
||||
}
|
||||
|
||||
fragment AnimeCardThemes on Anime {
|
||||
themes {
|
||||
...VideoSlug
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Fragment, type MouseEvent } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
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 { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "@/generated/graphql";
|
||||
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;
|
||||
`;
|
||||
|
||||
const StyledThemeContainerInline = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
user-select: none;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledExpandButton = styled(Button)`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledThemeGroupContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type AnimeSummaryCardProps =
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment;
|
||||
expandable?: false;
|
||||
}
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
|
||||
expandable: true;
|
||||
};
|
||||
|
||||
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { smallCover } = extractImages(anime);
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
const groups = uniqBy(
|
||||
anime.themes.map((theme) => theme.group),
|
||||
(group) => group?.slug,
|
||||
);
|
||||
|
||||
const animeLink = `/anime/${anime.slug}`;
|
||||
|
||||
let premiere = String(anime.year);
|
||||
let premiereLink = `/year/${anime.year}`;
|
||||
if (anime.season) {
|
||||
premiere = anime.season + " " + premiere;
|
||||
premiereLink += `/${anime.season.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>{anime.media_format ?? "Anime"}</span>
|
||||
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
|
||||
<span>{anime.themes.length} themes</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
function handleToggleExpand(event: MouseEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement && event.target.href) {
|
||||
event.stopPropagation();
|
||||
} else if (expandable && !isMobile) {
|
||||
toggleExpanded();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard
|
||||
title={anime.name}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={animeLink}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
>
|
||||
{/* TODO: Remove the following line once the context menu is there. */}
|
||||
{expandable && (
|
||||
<StyledThemeContainerInline onClick={(event) => event.stopPropagation()}>
|
||||
{/* TODO: Context Menu */}
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
variant="silent"
|
||||
isCircle
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
<Icon
|
||||
icon={faChevronDown}
|
||||
className={isExpanded ? "fa-rotate-180" : undefined}
|
||||
style={{ transition: "transform 400ms" }}
|
||||
/>
|
||||
</StyledExpandButton>
|
||||
)}
|
||||
</StyledThemeContainerInline>
|
||||
)}
|
||||
</SummaryCard>
|
||||
{expandable ? (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledThemeGroupContainer>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group?.slug}>
|
||||
{!!group && <Text variant="h2">{group.name}</Text>}
|
||||
<ThemeTable
|
||||
themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter(
|
||||
(theme) => theme.group?.slug === group?.slug,
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
</Collapse>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
AnimeSummaryCard.fragments = {
|
||||
anime: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
year
|
||||
season
|
||||
media_format
|
||||
themes {
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expandable: gql`
|
||||
${ThemeTable.fragments.theme}
|
||||
|
||||
fragment AnimeSummaryCardAnimeExpandable on Anime {
|
||||
themes {
|
||||
...ThemeTableTheme
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -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,27 +0,0 @@
|
||||
import useImage from "hooks/useImage";
|
||||
import { SummaryCard } from "components/card";
|
||||
|
||||
export function ArtistSummaryCard({ artist, as }) {
|
||||
const { smallCover } = useImage(artist);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Artist</span>
|
||||
{!!artist.performances && (
|
||||
<span>{artist.performances.length} performance{artist.performances.length === 1 ? "" : "s"}</span>
|
||||
)}
|
||||
{!!as && (
|
||||
<span>As {as}</span>
|
||||
)}
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={artist.name}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={`/artist/${artist.slug}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import type { ArtistSummaryCardArtistFragment } from "@/generated/graphql";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
type ArtistSummaryCardProps = {
|
||||
artist: ArtistSummaryCardArtistFragment;
|
||||
as?: string | null;
|
||||
};
|
||||
|
||||
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
const { smallCover } = extractImages(artist);
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Artist</span>
|
||||
{!!as && <span>As {as}</span>}
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<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
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Box } from "components/box";
|
||||
import theme from "theme";
|
||||
|
||||
export const Card = styled(Box).attrs((props) => ({
|
||||
p: props.p ?? "1rem 1.5rem",
|
||||
bg: props.bg || props.backgroundColor || theme.colors["solid"],
|
||||
borderColor: props.borderColor || theme.colors["text-primary"]
|
||||
}))`
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
|
||||
box-shadow: ${theme.shadows.medium};
|
||||
|
||||
${(props) => props.hoverable && css`
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
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;
|
||||
}>`
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
padding: 16px 24px 16px 28px;
|
||||
border-radius: ${theme.scalars.borderRadiusCard};
|
||||
overflow: hidden;
|
||||
|
||||
box-shadow: ${theme.shadows.medium};
|
||||
|
||||
${Solid} & {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$hoverable &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`}
|
||||
`}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: ${(props) => (props.$color ? theme.colors[props.$color] : theme.colors["text-primary"])};
|
||||
}
|
||||
`;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Card } from "components/card";
|
||||
import { Flex } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export function ErrorCard({ error }) {
|
||||
return (
|
||||
<Card gapsColumn="1.5rem" borderColor="text-warning">
|
||||
<Flex gapsRow="1rem">
|
||||
<Text color="text-warning">
|
||||
<FontAwesomeIcon icon={faExclamation}/>
|
||||
</Text>
|
||||
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
|
||||
</Flex>
|
||||
<pre>
|
||||
<Text variant="code" block bg="solid-on-card" overflow="auto">
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</Text>
|
||||
</pre>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
|
||||
display: block;
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export function ErrorCard({ error }: ErrorCardProps) {
|
||||
return (
|
||||
<StyledCard $color="text-warning">
|
||||
<Row style={{ "--gap": "1rem" }}>
|
||||
<Text color="text-warning">
|
||||
<Icon icon={faExclamation} />
|
||||
</Text>
|
||||
<Text block>
|
||||
An error occurred while searching! Help improving the site by sending us the following error
|
||||
message:
|
||||
</Text>
|
||||
</Row>
|
||||
<pre>
|
||||
<StyledErrorMessage>
|
||||
{error instanceof Error ? error.message : JSON.stringify(error, null, 2)}
|
||||
</StyledErrorMessage>
|
||||
</pre>
|
||||
</StyledCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "@/generated/graphql";
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
type PlaylistSummaryCardProps =
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner?: false;
|
||||
}
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner: true;
|
||||
};
|
||||
|
||||
export default function PlaylistSummaryCard({
|
||||
playlist,
|
||||
children,
|
||||
menu,
|
||||
showOwner,
|
||||
...props
|
||||
}: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Playlist</span>
|
||||
{showOwner ? <Text link>{playlist.user.name}</Text> : <span>{playlist.visibility}</span>}
|
||||
<span>
|
||||
{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}
|
||||
</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
|
||||
{children}
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
|
||||
) : null}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
PlaylistSummaryCard.fragments = {
|
||||
playlist: gql`
|
||||
fragment PlaylistSummaryCardPlaylist on Playlist {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
tracks_count
|
||||
}
|
||||
`,
|
||||
showOwner: gql`
|
||||
fragment PlaylistSummaryCardShowOwner on Playlist {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
import type { Property } from "csstype";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard";
|
||||
import type { StudioSummaryCardStudioFragment } from "@/generated/graphql";
|
||||
import extractBackgroundColor from "@/utils/extractBackgroundColor";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
interface StudioSummaryCardProps {
|
||||
studio: StudioSummaryCardStudioFragment;
|
||||
}
|
||||
|
||||
export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
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).largeCover}
|
||||
imageProps={{
|
||||
$objectFit: "contain",
|
||||
$backgroundColor: backgroundColor,
|
||||
onLoad: handleLoad,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
StudioSummaryCard.fragments = {
|
||||
studio: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
fragment StudioSummaryCardStudio on Studio {
|
||||
slug
|
||||
name
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Link, withPrefix } from "gatsby";
|
||||
import { Text } from "components/text";
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
import { Card } from "components/card";
|
||||
import { Flex } from "components/box";
|
||||
|
||||
const loadingAnimation = keyframes`
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 100% 100%; }
|
||||
`;
|
||||
|
||||
const StyledCover = styled.img.attrs({
|
||||
loading: "lazy"
|
||||
})`
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
|
||||
background: radial-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.25)) no-repeat;
|
||||
background-size: 500% 500%;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: ${loadingAnimation} 2s infinite alternate linear;
|
||||
}
|
||||
|
||||
${(props) => props.placeholder && css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
`}
|
||||
`;
|
||||
|
||||
export function SummaryCard({ title, description, image, to, children, ...props }) {
|
||||
return (
|
||||
<Card display="flex" flexDirection="row" alignItems="center" p={0} pr="1rem" height="64px" {...props}>
|
||||
<Link to={to}>
|
||||
<StyledCover alt="Cover" src={image || withPrefix("/img/logo.svg")} placeholder={!image} loading="lazy"/>
|
||||
</Link>
|
||||
<Flex flex={1} flexDirection="column" justifyContent="center" gapsColumn="0.25rem" px="1rem">
|
||||
<Text fontWeight="600" maxLines={1}>
|
||||
{typeof title === "string" ? (
|
||||
<Link to={to}>
|
||||
<Text link>{title}</Text>
|
||||
</Link>
|
||||
) : title}
|
||||
</Text>
|
||||
<Text variant="small" maxLines={1}>
|
||||
{typeof description === "string" ? (
|
||||
<SummaryCard.Description>
|
||||
{[ description ]}
|
||||
</SummaryCard.Description>
|
||||
) : description}
|
||||
</Text>
|
||||
</Flex>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
SummaryCard.Description = function SummaryCardDescription({ children }) {
|
||||
return (
|
||||
<>
|
||||
{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,133 @@
|
||||
import { useState } from "react";
|
||||
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;
|
||||
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;
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<StyledSummaryCard {...props}>
|
||||
<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}
|
||||
loading="lazy"
|
||||
{...imageProps}
|
||||
onLoad={(event) => {
|
||||
setImageLoading(false);
|
||||
if (imageProps?.onLoad) {
|
||||
imageProps.onLoad(event);
|
||||
}
|
||||
}}
|
||||
onError={(event) => {
|
||||
setImageNotFound(true);
|
||||
setImageLoading(false);
|
||||
if (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}
|
||||
</Text>
|
||||
{!!description && (
|
||||
<Text variant="small" maxLines={1}>
|
||||
{typeof description === "string" ? (
|
||||
<SummaryCard.Description>{[description]}</SummaryCard.Description>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</StyledBody>
|
||||
{children}
|
||||
</StyledSummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryCardDescriptionProps = {
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,51 +0,0 @@
|
||||
import {
|
||||
StyledHeader,
|
||||
StyledRow,
|
||||
StyledSequence,
|
||||
StyledThemeCard,
|
||||
StyledVersion,
|
||||
StyledVideoList,
|
||||
StyledVideoListContainer
|
||||
} from "./ThemeDetailCard.style";
|
||||
import { VideoButton } from "components/button";
|
||||
import { SongTitleWithArtists, ThemeEntryTags } from "components/utils";
|
||||
import { Text } from "components/text";
|
||||
|
||||
export function ThemeDetailCard({ theme }) {
|
||||
return (
|
||||
<StyledThemeCard>
|
||||
<StyledRow>
|
||||
<StyledSequence>{theme.slug}</StyledSequence>
|
||||
<StyledHeader>
|
||||
<SongTitleWithArtists song={theme.song}/>
|
||||
</StyledHeader>
|
||||
</StyledRow>
|
||||
{theme.entries.map(entry => (
|
||||
<StyledVersion key={entry.version || 0}>
|
||||
<StyledRow>
|
||||
<StyledSequence secondary>{!!entry.version && `v${entry.version}`}</StyledSequence>
|
||||
<Text color="text-muted">
|
||||
<ThemeEntryTags entry={entry}/>
|
||||
</Text>
|
||||
</StyledRow>
|
||||
{!!entry.videos && (
|
||||
<StyledVideoListContainer>
|
||||
<StyledVideoList>
|
||||
{entry.videos.map((video, index) => (
|
||||
<VideoButton
|
||||
key={index}
|
||||
anime={theme.anime}
|
||||
theme={theme}
|
||||
entry={entry}
|
||||
video={video}
|
||||
variant="on-card"
|
||||
/>
|
||||
))}
|
||||
</StyledVideoList>
|
||||
</StyledVideoListContainer>
|
||||
)}
|
||||
</StyledVersion>
|
||||
))}
|
||||
</StyledThemeCard>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { Flex } from "components/box";
|
||||
import { Text } from "components/text";
|
||||
import { gapsColumn } from "styles/mixins";
|
||||
import { Card } from "components/card";
|
||||
|
||||
export const StyledThemeCard = styled(Card)`
|
||||
${gapsColumn()}
|
||||
`;
|
||||
|
||||
export const StyledRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const StyledSequence = styled(Text).attrs((props) => ({
|
||||
variant: "small",
|
||||
color: props.secondary ? "text-muted" : "text"
|
||||
}))`
|
||||
display: flex;
|
||||
|
||||
width: 2.5rem;
|
||||
|
||||
${(props) => props.secondary && css`
|
||||
@media (max-width: 720px) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const StyledHeader = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const StyledVersion = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 720px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
${gapsColumn()}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledVideoListContainer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const StyledVideoList = styled(Flex).attrs({
|
||||
flexWrap: "wrap",
|
||||
gapsBoth: "0.75rem"
|
||||
})`
|
||||
@media (min-width: 721px) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
import gql from "graphql-tag";
|
||||
|
||||
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 { VideoTags } from "@/components/tag/VideoTags";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import type { ThemeDetailCardThemeFragment } from "@/generated/graphql";
|
||||
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: 4ch 1fr auto;
|
||||
align-items: baseline;
|
||||
|
||||
grid-gap: 1rem;
|
||||
`;
|
||||
|
||||
const StyledVideoListContainer = styled.div`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledVideoList = styled(Row)`
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (min-width: 721px) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThemeDetailCardProps {
|
||||
theme: ThemeDetailCardThemeFragment;
|
||||
}
|
||||
|
||||
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
const { anime } = theme;
|
||||
|
||||
if (!anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<Text color="text-muted">
|
||||
<ThemeEntryTags entry={entry} />
|
||||
</Text>
|
||||
<StyledVideoListContainer>
|
||||
{!!entry.videos && (
|
||||
<StyledVideoList>
|
||||
{entry.videos.map((video, index) => (
|
||||
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
|
||||
))}
|
||||
</StyledVideoList>
|
||||
)}
|
||||
</StyledVideoListContainer>
|
||||
</StyledRow>
|
||||
))}
|
||||
</StyledThemeCard>
|
||||
);
|
||||
}
|
||||
|
||||
ThemeDetailCard.fragments = {
|
||||
theme: gql`
|
||||
${ThemeMenu.fragments.theme}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
fragment ThemeDetailCardTheme on Theme {
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
title
|
||||
performances {
|
||||
alias
|
||||
as
|
||||
artist {
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
entries {
|
||||
version
|
||||
episodes
|
||||
spoiler
|
||||
nsfw
|
||||
videos {
|
||||
...VideoTagsVideo
|
||||
filename
|
||||
tags
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Link } from "gatsby";
|
||||
import { SongTitleWithArtists } from "components/utils";
|
||||
import { Text } from "components/text";
|
||||
import useImage from "hooks/useImage";
|
||||
import createVideoSlug from "utils/createVideoSlug";
|
||||
import { SummaryCard } from "components/card";
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function ThemeSummaryCard({ theme, artist }) {
|
||||
const { smallCover } = useImage(theme.anime);
|
||||
|
||||
if (!theme.entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = theme.entries[0];
|
||||
|
||||
if (!entry.videos.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = entry.videos[0];
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
|
||||
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Theme</span>
|
||||
<span>{theme.slug}</span>
|
||||
<Link to={`/anime/${theme.anime.slug}`}>
|
||||
<Text link>{theme.anime.name}</Text>
|
||||
</Link>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={to}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { MouseEvent, PropsWithChildren } from "react";
|
||||
import styled from "styled-components";
|
||||
import Link from "next/link";
|
||||
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
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 { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type {
|
||||
ThemeSummaryCardArtistFragment,
|
||||
ThemeSummaryCardQuery,
|
||||
ThemeSummaryCardThemeExpandableFragment,
|
||||
ThemeSummaryCardThemeFragment,
|
||||
} from "@/generated/graphql";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
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 StyledExpandButton = styled(Button)`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPerformedWith = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
type ThemeSummaryCardProps =
|
||||
| {
|
||||
theme: ThemeSummaryCardThemeFragment;
|
||||
artist?: ThemeSummaryCardArtistFragment;
|
||||
expandable?: false;
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void;
|
||||
}
|
||||
| {
|
||||
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment;
|
||||
artist?: ThemeSummaryCardArtistFragment;
|
||||
expandable: true;
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void;
|
||||
};
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function ThemeSummaryCard({
|
||||
theme,
|
||||
artist,
|
||||
children,
|
||||
expandable,
|
||||
onPlay,
|
||||
...props
|
||||
}: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const anime = theme.anime;
|
||||
const entry = theme.entries[0];
|
||||
const video = entry?.videos[0];
|
||||
|
||||
if (!anime || !entry || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<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={artist} />
|
||||
</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>
|
||||
<ThemeMenu theme={theme} />
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
variant="silent"
|
||||
isCircle
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
<Icon
|
||||
icon={faChevronDown}
|
||||
className={isExpanded ? "fa-rotate-180" : undefined}
|
||||
style={{ transition: "transform 400ms" }}
|
||||
/>
|
||||
</StyledExpandButton>
|
||||
)}
|
||||
</StyledOverlayButtons>
|
||||
</SummaryCard>
|
||||
{expandable && (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledPerformedWith>
|
||||
<ThemeTable
|
||||
themes={[theme]}
|
||||
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
|
||||
/>
|
||||
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
|
||||
<Table style={{ "--columns": "1fr" }}>
|
||||
<TableHead>
|
||||
<TableHeadCell>Performed {artist ? "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) => (
|
||||
<TableRow
|
||||
key={performance.artist.slug}
|
||||
as={Link}
|
||||
href={`/artist/${performance.artist.slug}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{getDisplayedArtistName(performance)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</StyledPerformedWith>
|
||||
</Collapse>
|
||||
)}
|
||||
</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
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
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,166 @@
|
||||
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 gql from "graphql-tag";
|
||||
|
||||
import { SummaryCard } from "@/components/card/SummaryCard2";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { VideoMenu } from "@/components/menu/VideoMenu";
|
||||
import { TextLink } from "@/components/text/TextLink";
|
||||
import { Performances } from "@/components/utils/Performances";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
|
||||
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
|
||||
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);
|
||||
`;
|
||||
|
||||
interface VideoSummaryCardProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
video: VideoSummaryCardVideoFragment;
|
||||
entry: VideoSummaryCardEntryFragment;
|
||||
menu?: ReactNode;
|
||||
append?: ReactNode;
|
||||
onPlay?(): void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export function VideoSummaryCard({
|
||||
ref,
|
||||
video,
|
||||
entry,
|
||||
menu,
|
||||
append,
|
||||
onPlay,
|
||||
isPlaying,
|
||||
...props
|
||||
}: VideoSummaryCardProps) {
|
||||
const theme = entry.theme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={ref}>
|
||||
<SummaryCard {...props}>
|
||||
<StyledCoverLink href={href} onClick={onPlay}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
{isPlaying ? (
|
||||
<StyledCoverOverlay>
|
||||
<Icon icon={faPlay} />
|
||||
</StyledCoverOverlay>
|
||||
) : null}
|
||||
</StyledCoverLink>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} 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 ?? <VideoMenu entry={entry} video={video} />}</StyledOverlayButtons>
|
||||
) : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const VideoSummaryCardFragmentVideo = gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
${VideoMenu.fragments.video}
|
||||
|
||||
fragment VideoSummaryCardVideo on Video {
|
||||
id
|
||||
basename
|
||||
...createVideoSlugVideo
|
||||
...VideoMenuVideo
|
||||
audio {
|
||||
basename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const VideoSummaryCardFragmentEntry = gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${VideoMenu.fragments.entry}
|
||||
|
||||
fragment VideoSummaryCardEntry on Entry {
|
||||
...createVideoSlugEntry
|
||||
...VideoMenuEntry
|
||||
id
|
||||
theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
type
|
||||
sequence
|
||||
group {
|
||||
name
|
||||
slug
|
||||
}
|
||||
anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
name
|
||||
}
|
||||
song {
|
||||
...SongTitleWithArtistsSong
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
export { Card } from "./Card";
|
||||
export { AnimeSummaryCard } from "./AnimeSummaryCard";
|
||||
export { ThemeSummaryCard } from "./ThemeSummaryCard";
|
||||
export { ArtistSummaryCard } from "./ArtistSummaryCard";
|
||||
export { SummaryCard } from "./SummaryCard";
|
||||
export { ThemeDetailCard } from "./ThemeDetailCard";
|
||||
export { ErrorCard } from "./ErrorCard";
|
||||
@@ -1,11 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
import { space } from "styled-system";
|
||||
|
||||
export const Container = styled.div`
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 24px 16px;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
|
||||
${space}
|
||||
`;
|
||||
@@ -1,11 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export const SidebarContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr;
|
||||
grid-gap: 2rem 1.5rem;
|
||||
|
||||
@media (max-width: 720px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||