49 Commits

Author SHA1 Message Date
Maniload b79b8811f0 refactor: Changed all fragments to use the same format 2026-03-21 04:23:31 +01:00
Maniload 37a512ad05 feat: Migrated remaining queries and cleaned up deps 2026-02-26 01:58:31 +01:00
Maniload 77c7a8d82b feat: Migrated playlist, profile and event pages 2026-02-10 00:16:50 +01:00
Maniload 732315b40b feat: Migrated home page 2026-01-26 22:06:19 +01:00
Maniload f86b88bb01 feat: Migrated anime detail page 2026-01-26 21:21:05 +01:00
Maniload 17392046ac feat: Migrated artist page 2026-01-26 20:27:24 +01:00
Maniload be322072cf feat: Started migration to GraphQL backend 2026-01-26 20:26:29 +01:00
Mani 505347face fix: Dialogs can now be correctly closed on mobile (#247) 2026-01-25 05:01:11 +01:00
Mani cdb5f41404 fix: Hide video menu in dialogs (#245) 2026-01-24 03:26:36 +01:00
Kyrch 6c3346897f feat: default video menu (#244) 2026-01-20 11:48:49 +01:00
Kyrch a58a9fb134 feat: most popular entries (#243) 2026-01-20 11:48:25 +01:00
Max Reinartz 74a9affcfb feat: Added various features to the video player (#242)
* Add buffered progress to progress bar.
* Update mute functionality to use setting and make player controls use globalVolume setting.
* Add fullscreen keybind.
* Don't show muted icon when volume is set to zero and changing the volume via slider now unmutes the player.
* Update audio mode to use full area.
* Update video player to be inline and fix iOS fullscreen functionality.
* Add volume icon for when the volume is 0 but unmuted.
* Add a menu which show keybinds in the video overlay and also add a keybind to toggle audio mode.
* Added a button and keybind for PIP.
* Added keybinds for single frame seek functionality.
2026-01-05 03:02:24 +01:00
Max Reinartz 3ab709de25 feat: Implement basic key controls to the video player. (#241) 2025-12-10 22:25:01 +01:00
Kyrch 937d05053a feat: Enhance support for featured theme (#240) 2025-12-10 22:22:16 +01:00
Mani 735704377c feat: Updated Next.js and enabled Turbopack for build (#236) 2025-04-18 12:00:25 +02:00
Kyrch 49f64509ba feat: Added new artist features (#235)
* Added artist information, notes & multiple covers
* Added Markdown for artist information, improved design of artist notes and fixed types.
* Added Markdown for anime synopsis and better error handling.
2025-04-12 08:08:40 +02:00
Mani 2023d85579 fix: Improve build performance of artist pages (#234) 2025-02-02 20:46:59 +01:00
Mani 6e41d6d55f fix: Several improvements to the video player on mobile (#233) 2025-02-01 02:35:56 +01:00
Mani b8a43eeda2 fix: Fixed r/anime award nominations for 2024 (#232) 2025-01-21 23:54:58 +01:00
Mani 0e27c7e1c4 feat: Added r/anime award nominations for 2024 (#231) 2025-01-21 23:18:39 +01:00
Mani 13dd000455 fix: Fixed body not stretching to contain the whole page (#230) 2025-01-20 21:44:17 +01:00
Mani b0724e568a chore: Bumped most of the dependencies to the latest version (#229)
* Bumped Next.js to 15.
* Bumped React to 19.
* Bumped GraphQL to 16.
* Bumped styled-components to 6.
* Bumped Radix components to latest version.
* Migrated Framer Motion to new package.
* Migrated react-query to new package.
* Lots of TypeScript fixes.
* Removed FontAwesome Pro icons.
* Started fading out SWR.
* Dev Server now uses Turbopack.
2025-01-19 02:46:18 +01:00
Mani 88238966fd fix: Fixed potential memory leak caused by recursive playlists (#227) 2024-11-18 01:24:01 +01:00
Kyrch 7a8326706f feat: added artist aliases (#226) 2024-11-04 11:10:56 -03:00
Mani 871e5f5e75 clean: Remove old entry include paths (#225) 2024-08-21 00:39:55 +02:00
Mani 432ca8396b feat: Added repeat toggle for watch lists (#224)
* Added media format filter to shuffle.
* Added missing media formats to filter (for both search and shuffle).
* Improved mouse relax system.
* Fixed media format not displaying on global search.
2024-08-20 00:21:28 +02:00
Mani aa6da02ee0 feat: Added support for entries in playlist tracks (#223)
* Added link to API docs to footer.
* Fixed global search returning wrong theme groups.
2024-08-13 23:08:52 +02:00
Mani b25a505a0f fix: Fixed search page behaving unexpectedly (#222) 2024-07-30 01:01:40 +02:00
Mani d5ccb7d08c fix: Fixed search page hitting the API too often resulting in rate limiting (#221) 2024-07-29 03:50:54 +02:00
Mani f85e21b8c9 feat: Added Prettier (#220)
* Reformated all files with Prettier.
* Updated GraphQL codegen.
* Updated ESLint (including rule config).
* Updated TypeScript.
* Eliminated most ESLint warnings (especially usage of the `any` type).
* Rewrote parts of the API resolver logic to be more type-safe.
* Added IP forwarding for API requests.
2024-06-08 03:30:10 +02:00
Mani b2e68af2ab fix: Fixed video player not changing video in some cases (#219)
* Fixed weird behavior when dragging the mini player.
* Fixed transition between mini and normal player.
* Fixed too many prefetches on search page.
2024-05-29 00:48:04 +02:00
Mani c11baa84f4 fix: Fixed wrapping on very long words (#218) 2024-05-25 23:45:14 +02:00
Mani 633236f347 feat: Add support for playlist descriptions (#217)
* Added watch list actions to theme menu.
* Restructured theme and video menus.
* Changed some icons.
* Improved frontend performance of the playlist page.
2024-05-25 22:07:57 +02:00
Mani 1f0844465c feat: Added proper path alias for imports (#216)
* Added "Share" and "Add to Playlist" buttons to video player overlay.
* Added options to sort playlists in reverse order.
* Added handle for dragging playlist items.
* Added consistent import sorting (with ESLint).
* Removed barrel files.
2024-05-22 17:30:26 +02:00
Mani 2f63982ed6 fix: Fixed playlist performance (#215) 2024-05-19 01:29:51 +02:00
Mani 4b7840ba1b fix: Fixed pagination not working with nullable responses (e.g. 403 errors) (#214) 2024-05-18 01:35:40 +02:00
Mani 8e7fc142eb fix: Fixed pagination not working with limit (#213) 2024-05-17 02:09:59 +02:00
Mani 7919f298e5 fix: Fixed playlists not paginating (#212) 2024-05-15 21:45:17 +02:00
Mani 6336d71122 fix: Fixed video page not loading if the same video was played before (#211)
* Fixed playing indicator on video buttons.
2024-05-06 21:59:56 +02:00
Mani 555d61ccf6 fix: Fixed search request missing a field (#210)
* Added pre-commit checks.
2024-05-03 03:43:03 +02:00
Mani a984ead9a3 fix: Fixed TypeScript errors (#209) 2024-05-03 00:48:24 +02:00
Mani 48294f4eaf feat: Restructured home page (#208)
* Added recently added playlists to home page.
* Added link to blog to home page.
* Added profile picture to "My Profile" button on the home page.
* Changed the order of sections in the "Related" tab on video pages.
* Fixed images not displaying on studio summary cards in the context of video pages.
* Fixed video player not updating correctly when switching between entries with the same video.
* Fixed incorrect watch list creation when clicking on the background of the number one theme in a ranked playlist.
* Fixed playlist summary cards displaying the visibility instead of the owner on video pages.
* Fixed popovers clipping outside the visible area.
2024-05-03 00:27:51 +02:00
Mani 9737e2070e feat: Added support for new theme groups (#207)
* feat: Added support for new theme groups

* Anime pages no show themes from all groups by default.
* Playlist summary cards no longer show the visibility if a user is given.
* Removed usages of theme slug field.
2024-05-01 01:55:38 +02:00
Mani 41936a78b6 fix: Fixed margin of lists on Markdown pages (#206) 2024-04-19 22:06:49 +02:00
Mani d13a22eaa8 fix: Fixed play next not working with disabled autoplay (#205)
* Added back last update date on pages for everyone to see.
2024-04-19 20:56:29 +02:00
Mani 295d93b980 fix: Fixed two small issues (#204) 2024-04-16 19:02:02 +02:00
Mani 17a99b05d4 feat: Added shuffle config dialog (#203)
* Added dialog to change username and e-mail address.
* Added autoplay toggle to shuffle and playlists.
* Added related playlists to video page.
* Changed organization of some sections on video page.
* Changed revalidation to use permission for authorization.
* Removed revalidation token.
* Removed developer mode.
* Removed old video player implementation.
2024-04-16 17:58:02 +02:00
Mani 28b69b7dc3 feat: Added download option to share menu (#202)
* Fixed season order on season and year pages.
* Fixed overflow issue with video scripts.
2024-03-29 01:59:11 +01:00
Maniload 8ce027c22b fix: Fixed auth request in production 2024-03-23 00:53:35 +01:00
258 changed files with 34800 additions and 32319 deletions
+38 -68
View File
@@ -1,73 +1,43 @@
{
"extends": [
"next/core-web-vitals",
"plugin:react/recommended",
"eslint:recommended"
],
"ignorePatterns": [
"src/generated"
],
"rules": {
"semi": [
"error",
"always"
],
"quotes": [
"error",
"double",
{
"allowTemplateLiterals": true
}
],
"curly": [
"error",
"all"
],
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"overrides": [
{
"files": [
"**/*.{ts,tsx}"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"project": [
"./tsconfig.json"
"ignorePatterns": ["src/generated"],
"rules": {
// Sort imports
"simple-import-sort/imports": [
"error",
{
"groups": [
// Packages that are used in almost all files.
["^react$", "^react\\u0000$", "^styled-components", "^next"],
// All other packages.
["^@?\\w"],
// Absolute imports and other imports such as Vue-style `@/foo`.
// Anything not matched in another group.
["^"],
// Side effect imports.
["^\\u0000"]
]
},
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/consistent-type-exports": "error"
}
}
]
],
"simple-import-sort/exports": "error",
// TypeScript rules
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/consistent-type-exports": "error"
}
}
+1
View File
@@ -37,6 +37,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next.config.mjs
# IDE (WebStorm)
*.iml
+4
View File
@@ -0,0 +1,4 @@
npm run graphql-codegen
git add src/generated
npm run lint
npm run type-check
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"printWidth": 120
}
+12 -15
View File
@@ -30,13 +30,6 @@ This is a list of all available options:
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; If specified, this API key will be used to make requests to the AnimeThemes API.
; This is used to by-pass rate limiting.
ANIMETHEMES_API_KEY=...
; The token to use for manual revalidation.
REVALIDATE_TOKEN=secret
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
@@ -58,8 +51,8 @@ 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 URL to use for authentication API requests.
NEXT_PUBLIC_AUTH_URL=https://app.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.
@@ -67,6 +60,10 @@ 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).
@@ -85,12 +82,12 @@ For more information on environment variables see the [Next.js documentation](ht
## Used technologies
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
### APIs
- [AnimeThemes API](https://api-docs.animethemes.moe/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+10 -17
View File
@@ -1,31 +1,24 @@
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",
],
schema: "http://graphql.animethemes.test",
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
ignoreNoDocuments: true,
verbose: true,
generates: {
"src/generated/graphql.ts": {
plugins: [
"typescript",
"typescript-operations",
],
"src/graphql/generated/": {
preset: "client",
presetConfig: {
fragmentMasking: { unmaskFunctionName: "getFragmentData" },
},
config: {
avoidOptionals: {
object: true,
field: true,
inputValue: false,
},
enumsAsTypes: true,
skipTypename: true,
useTypeImports: true,
},
},
},
Vendored
+7
View File
@@ -0,0 +1,7 @@
import "react";
declare module "react" {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
+1
View File
@@ -0,0 +1 @@
schema: http://graphql.animethemes.test
+6 -3
View File
@@ -1,5 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src"
}
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
}
}
+20 -26
View File
@@ -1,13 +1,11 @@
// @ts-check
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "./src/utils/config.mjs";
import { error, info } from "./src/utils/log.mjs";
import type { NextConfig } from "next";
import NextBundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = NextBundleAnalyzer({
enabled: ANALYZE,
});
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...");
@@ -18,14 +16,11 @@ if (STAGING) {
info("Running animethemes-web in staging mode!");
}
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
const nextConfig: NextConfig = {
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
styledComponents: true,
},
staticPageGenerationTimeout: 3600,
experimental: {
@@ -34,9 +29,7 @@ const nextConfig = {
workerThreads: false,
cpus: 1,
},
transpilePackages: [
"ahooks"
],
transpilePackages: ["ahooks"],
async headers() {
return [
{
@@ -44,30 +37,30 @@ const nextConfig = {
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload"
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-XSS-Protection",
value: "1; mode=block"
value: "1; mode=block",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN"
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff"
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin"
}
]
}
value: "origin-when-cross-origin",
},
],
},
];
},
async redirects() {
@@ -83,7 +76,8 @@ const nextConfig = {
permanent: true,
},
];
}
},
allowedDevOrigins: ["animethemes.test", "*.animethemes.test"],
};
export default withBundleAnalyzer(nextConfig);
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
+13736 -20824
View File
File diff suppressed because it is too large Load Diff
+81 -70
View File
@@ -1,72 +1,83 @@
{
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"graphql-codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-switch": "^1.0.1",
"ahooks": "^3.7.6",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"framer-motion": "^10.12.4",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"next": "^13.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"picocolors": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.29.0",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"swr": "^2.2.4",
"unist-util-visit": "^4.1.2",
"use-local-storage-state": "^18.3.0",
"use-session-storage-state": "^18.1.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.9.1",
"@graphql-codegen/typescript": "2.5.0",
"@graphql-codegen/typescript-operations": "2.4.1",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.41",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"eslint": "7.32.0",
"eslint-config-next": "^13.3.0",
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
}
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
"prestart": "npm run compile-config",
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
"lint": "next lint",
"type-check": "tsc",
"graphql-codegen": "graphql-codegen --config codegen.ts",
"graphql-codegen-watch": "graphql-codegen --config codegen.ts --watch",
"prepare": "husky"
},
"dependencies": {
"@apollo/client": "^3.13.8",
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"@fortawesome/free-brands-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@next/bundle-analyzer": "15.3.0",
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.62.15",
"ahooks": "^3.8.4",
"axios": "^1.13.5",
"common-tags": "^1.8.0",
"graphql": "^16.10.0",
"hast-util-has-property": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.1",
"lodash-es": "^4.17.23",
"md5": "^2.3.0",
"motion": "^11.15.0",
"next": "^15.5.12",
"next-mdx-remote": "^6.0.0",
"picocolors": "^1.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"sass": "^1.43.4",
"shiki": "^1.26.2",
"styled-components": "^6.1.14",
"unist-util-visit": "^5.0.0",
"use-local-storage-state": "^19.5.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@parcel/watcher": "^2.5.1",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.5",
"@types/react": "19.1.1",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"esbuild": "^0.27.1",
"eslint": "^8.57.1",
"eslint-config-next": "15.3.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.0.11",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
},
"overrides": {
"ahooks": {
"react": "$react",
"react-dom": "$react-dom"
},
"@types/react": "19.1.1"
}
}
+8 -11
View File
@@ -1,9 +1,10 @@
import useAuth from "hooks/useAuth";
import type { ReactNode } from "react";
import { Text } from "components/text";
import { Column, Row } from "components/box";
import { LoginDialog } from "components/dialog/LoginDialog";
import { RegisterDialog } from "components/dialog/RegisterDialog";
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;
@@ -12,13 +13,9 @@ interface LoginGateProps {
export function LoginGate({ children }: LoginGateProps) {
const { me } = useAuth();
if (me.user) {
if (me) {
// User is already logged in, so we can safely show the content.
return (
<>
{children}
</>
);
return <>{children}</>;
}
return (
+10 -8
View File
@@ -1,22 +1,24 @@
import type { CSSProperties } from "react";
import styled from "styled-components";
import type { Property } from "csstype";
const Flex = styled.div<{
$wrap?: boolean
style?: {
"--justify-content"?: Property.JustifyContent
"--align-items"?: Property.AlignItems
"--gap"?: Property.Gap
}
$wrap?: boolean;
style?: CSSProperties & {
"--justify-content"?: Property.JustifyContent;
"--align-items"?: Property.AlignItems;
"--gap"?: Property.Gap;
};
}>`
--justify-content: initial;
--align-items: initial;
--gap: initial;
display: flex;
flex-wrap: ${(props) => props.$wrap && "wrap"};
justify-content: var(--justify-content);
align-items: var(--align-items);
gap: var(--gap);
+2 -1
View File
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const Solid = styled.div`
background-color: ${theme.colors["solid"]};
-2
View File
@@ -1,2 +0,0 @@
export { Row, Column } from "./Flex";
export { Solid } from "./Solid";
+67 -53
View File
@@ -1,28 +1,34 @@
import styled from "styled-components";
import { Text } from "components/text";
import { faDiagramProject } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import type { RefObject } from "react";
import { memo, useCallback, useRef, useState } from "react";
import theme from "theme";
import { m } from "framer-motion";
import { Button } from "components/button";
import { BracketThemeSummaryCard } from "components/bracket/BracketThemeSummaryCard";
import type { BracketPageQuery } from "generated/graphql";
import type { RequiredNonNullable } from "utils/types";
import type { RefObject } from "react";
import styled from "styled-components";
import { faDiagramProject } from "@fortawesome/free-solid-svg-icons";
import { m } from "motion/react";
import {
type BracketCharacterWithTheme,
type BracketPairingWithThemes,
type BracketRoundWithThemes,
BracketThemeSummaryCard,
type BracketWithThemes,
} from "@/components/bracket/BracketThemeSummaryCard";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const StyledBracketContainer = styled.div`
flex: 1 1 0;
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
@@ -31,7 +37,7 @@ const StyledBracketContainer = styled.div`
const StyledBracket = styled(m.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
@@ -52,7 +58,7 @@ const StyledRound = styled.div`
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
@@ -64,11 +70,13 @@ const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
width: 384px;
`;
interface BracketChartProps extends RequiredNonNullable<BracketPageQuery> {}
interface BracketChartProps {
bracket: BracketWithThemes;
}
export function BracketChart({ bracket }: BracketChartProps) {
const [showBracketChart, setShowBracketChart] = useState(false);
const bracketRef = useRef<HTMLDivElement>();
const bracketRef = useRef<HTMLDivElement>(undefined);
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
bracketRef.current = bracket;
@@ -129,7 +137,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2
nextRect.top + nextRect.height / 2,
);
ctx.stroke();
}
@@ -139,16 +147,18 @@ export function BracketChart({ bracket }: BracketChartProps) {
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject}/>
<Icon icon={faDiagramProject} />
<span>Open Bracket Chart</span>
</Button>
{showBracketChart ? (
<StyledBracketContainer ref={onBracketInit}>
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement>}>
<StyledCanvas ref={onCanvasInit}/>
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => (
<BracketRound key={round.tier} round={round}/>
))}
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement | null>}>
<StyledCanvas ref={onCanvasInit} />
{bracket.rounds
.sort((a, b) => a.tier - b.tier)
.map((round) => (
<BracketRound key={round.tier} round={round} />
))}
</StyledBracket>
</StyledBracketContainer>
) : null}
@@ -157,7 +167,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
}
interface BracketRoundProps {
round: BracketChartProps["bracket"]["rounds"][number]
round: BracketRoundWithThemes;
}
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
@@ -168,53 +178,57 @@ const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings}/>
<BracketPairings pairings={round.pairings} />
</StyledRound>
);
});
interface BracketPairingsProps {
pairings: BracketRoundProps["round"]["pairings"]
pairings: Array<BracketPairingWithThemes>;
}
function BracketPairings({ pairings }: BracketPairingsProps) {
const sortedPairings = pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
const sortedPairings = pairings
.sort((a, b) => a.group - b.group || a.order - b.order)
.map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
return <>{sortedPairings}</>;
}
interface ContestantCardProps {
contestant: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
opponent: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
contestantVotes: number | null
opponentVotes: number | null
contestant: BracketCharacterWithTheme;
opponent: BracketCharacterWithTheme;
contestantVotes: number | null;
opponentVotes: number | null;
}
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
const isVoted = !!contestantVotes && !!opponentVotes;
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
const isWinner =
isVoted &&
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
contestant={contestant}
character={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
@@ -1,27 +1,29 @@
import styled from "styled-components";
import { SummaryCard, ThemeSummaryCard } from "components/card";
import { Text } from "components/text";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SongTitleWithArtists } from "components/utils";
import { Column } from "components/box";
import { Icon } from "components/icon";
import { faAward, faSeedling, faUsers } from "@fortawesome/pro-solid-svg-icons";
import { CornerIcon } from "components/icon/CornerIcon";
import gql from "graphql-tag";
import type { BracketThemeSummaryCardConstestantFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { TextLink } from "components/text/TextLink";
import styled from "styled-components";
import { faAward, faSeedling, faUsers } from "@fortawesome/free-solid-svg-icons";
import { Column } from "@/components/box/Flex";
import { SummaryCard } from "@/components/card/SummaryCard";
import { CornerIcon } from "@/components/icon/CornerIcon";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import type { Bracket, BracketCharacter, BracketPairing, BracketRound } from "@/lib/server/animebracket";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
@@ -30,31 +32,86 @@ const StyledRank = styled(Text)`
letter-spacing: 1px;
`;
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
contestant: BracketThemeSummaryCardConstestantFragment
isVoted: boolean
isWinner: boolean
seed: number | null
votes: number | null
export const BRACKET_THEME_SUMMARY_CARD_THEME = graphql(`
fragment BracketThemeSummaryCardTheme on AnimeTheme {
...createVideoSlugTheme
...ThemeMenuTheme
type
sequence
group {
name
slug
}
anime {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
song {
...SongTitleWithArtistsSong
}
animethemeentries {
...createVideoSlugEntry
videos {
nodes {
...createVideoSlugVideo
}
}
}
}
`);
export interface BracketWithThemes extends Bracket {
currentRound: BracketRoundWithThemes | null;
rounds: Array<BracketRoundWithThemes>;
}
export interface BracketRoundWithThemes extends BracketRound {
pairings: Array<BracketPairingWithThemes>;
}
export interface BracketPairingWithThemes extends BracketPairing {
characterA: BracketCharacterWithTheme;
characterB: BracketCharacterWithTheme;
}
export interface BracketCharacterWithTheme extends BracketCharacter {
theme: FragmentType<typeof BRACKET_THEME_SUMMARY_CARD_THEME> | null;
}
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
const theme = contestant.theme;
const { smallCover } = extractImages(theme?.anime);
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
character: BracketCharacterWithTheme;
isVoted: boolean;
isWinner: boolean;
seed: number | null;
votes: number | null;
}
export function BracketThemeSummaryCard({
character,
isVoted,
isWinner,
seed,
votes,
...props
}: BracketThemeSummaryCardProps) {
const theme = getFragmentData(BRACKET_THEME_SUMMARY_CARD_THEME, character.theme);
const { smallCover } = extractImages(theme?.anime.images.nodes ?? []);
let to;
let description: ReactNode = contestant.source;
let description: ReactNode = character.source;
if (theme?.anime) {
const entry = theme.entries?.[0];
const video = entry?.videos?.[0];
const entry = theme.animethemeentries?.[0];
const video = entry?.videos.nodes[0];
const videoSlug = createVideoSlug(theme, entry, video);
to = `/anime/${theme.anime.slug}/${videoSlug}`;
description = (
<SummaryCard.Description>
<span>{theme.slug}</span>
<span>{theme.type + (theme.sequence || "")}</span>
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
</SummaryCard.Description>
);
@@ -63,44 +120,26 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : character.name}
description={description}
image={smallCover ?? contestant.image}
image={smallCover ?? character.image}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<Icon icon={faSeedling} />
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<Icon icon={faUsers} />
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
</StyledSummaryCardWrapper>
);
}
BracketThemeSummaryCard.fragments = {
contestant: gql`
${ThemeSummaryCard.fragments.theme}
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
name
source
image
theme {
...ThemeSummaryCardTheme
}
}
`,
};
+16 -14
View File
@@ -1,20 +1,22 @@
import React, { useContext, useEffect, useState } from "react";
import styled from "styled-components";
import { Button } from "./Button";
import { Icon } from "components/icon";
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import React, { useState, useEffect, useContext } from "react";
import theme from "theme";
import { withHover } from "styles/mixins";
import PlayerContext from "context/playerContext";
import { AnimatePresence, m } from "framer-motion";
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;
position: fixed;
right: 16px;
bottom: ${(props) => 16 + props.$bottomOffset}px;
padding: 16px;
${withHover`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-primary"]};
`}
@@ -53,7 +55,7 @@ export function BackToTopButton() {
variant="primary"
isCircle={true}
onClick={scrollUp}
onMouseDown={(event: React.PointerEvent) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
$bottomOffset={currentWatchListItem ? 76 : 0}
>
<Icon icon={faChevronUp} />
+32 -28
View File
@@ -1,26 +1,28 @@
import { type ComponentPropsWithRef } from "react";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from "react";
import { forwardRef } from "react";
export const Button = forwardRef(ButtonWithRef);
import { Solid } from "@/components/box/Solid";
import { NestableSlot } from "@/components/utils/NestableSlot";
import { withHover } from "@/styles/mixins";
import theme from "@/theme";
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
children?: ReactNode
variant?: "solid" | "primary" | "warning" | "silent"
isCircle?: boolean
disabled?: boolean
interface ButtonProps extends ComponentPropsWithRef<typeof BaseButton> {
asChild?: boolean;
variant?: "solid" | "primary" | "warning" | "silent";
isCircle?: boolean;
disabled?: boolean;
}
function ButtonWithRef({
export function Button({
ref,
asChild,
children,
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
}: ButtonProps) {
let Component;
if (variant === "solid") {
Component = SolidButton;
@@ -36,46 +38,49 @@ function ButtonWithRef({
return (
<Component
ref={ref}
as={asChild ? NestableSlot : "button"}
$isCircle={isCircle}
disabled={disabled}
title={title}
aria-label={title}
ref={ref}
{...props}
/>
>
{children}
</Component>
);
}
const BaseButton = styled.button<{ $isCircle: boolean }>`
const BaseButton = styled.button<{ $isCircle?: boolean }>`
--gap: 0;
--focus-ring-color: ${theme.colors["text-primary"]};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
pointer-events: ${(props) => props.disabled && "none"};
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1rem;
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
border-radius: 999px;
gap: var(--gap, 0);
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
opacity: ${(props) => props.disabled && "0.5"};
box-shadow: ${theme.shadows.low};
transition: background-color 250ms;
// Buttons within other buttons should have a special margin and no shadow.
& & {
box-shadow: none;
margin: -8px 8px -8px -16px;
}
&:focus:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-color);
}
@@ -89,7 +94,7 @@ const PrimaryButton = styled(BaseButton)`
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"]};
@@ -98,7 +103,7 @@ const PrimaryButton = styled(BaseButton)`
const WarningButton = styled(BaseButton)`
--focus-ring-color: ${theme.colors["text-warning"]};
background-color: ${theme.colors["solid-warning"]};
color: ${theme.colors["text-on-warning"]};
@@ -106,7 +111,7 @@ const WarningButton = styled(BaseButton)`
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"]};
@@ -120,7 +125,7 @@ const SolidButton = styled(BaseButton)`
${withHover`
color: ${theme.colors["text"]};
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
@@ -152,4 +157,3 @@ const SilentButton = styled(BaseButton)`
`}
}
`;
+7 -3
View File
@@ -1,9 +1,13 @@
import { faFilter } from "@fortawesome/pro-solid-svg-icons";
import { IconTextButton } from "components/button";
import type { ComponentPropsWithoutRef } from "react";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "@/components/button/IconTextButton";
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
<IconTextButton icon={faFilter} collapsible {...props}>
Filter
</IconTextButton>
);
}
+36 -31
View File
@@ -1,18 +1,20 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
import styled from "styled-components";
import theme from "theme";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { forwardRef } from "react";
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;
@@ -25,34 +27,37 @@ const StyledText = styled.span<{ $collapseBreakpoint: string }>`
}
`;
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
icon: IconDefinition | ReactNode
children?: ReactNode
collapsible?: true | keyof typeof theme.breakpoints
interface IconTextButtonProps extends Omit<ComponentPropsWithoutRef<typeof StyledButton>, "$collapseBreakpoint"> {
ref?: RefObject<HTMLButtonElement>;
icon: IconDefinition | ReactNode;
children?: ReactNode;
collapsible?: true | keyof typeof theme.breakpoints;
}
export const IconTextButton = forwardRef(
function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) {
let collapseBreakpoint = "0px";
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}>
{isIconDefinition(icon) ? (
<Icon icon={icon} color="text-disabled"/>
) : icon}
{(children !== null && children !== undefined) ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
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;
+65 -58
View File
@@ -1,70 +1,77 @@
import type { ComponentPropsWithoutRef } from "react";
import { useContext } from "react";
import type { ComponentPropsWithoutRef } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/pro-solid-svg-icons";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
import { VideoTags } from "components/tag/VideoTags";
import gql from "graphql-tag";
import type {
VideoButtonAnimeFragment,
VideoButtonEntryFragment,
VideoButtonThemeFragment,
VideoButtonVideoFragment
} from "generated/graphql";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { VideoTags } from "@/components/tag/VideoTags";
import PlayerContext from "@/context/playerContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
export const VIDEO_BUTTON_ANIME = graphql(`
fragment VideoButtonAnime on Anime {
slug
}
`);
export const VIDEO_BUTTON_THEME = graphql(`
fragment VideoButtonTheme on AnimeTheme {
...createVideoSlugTheme
}
`);
export const VIDEO_BUTTON_ENTRY = graphql(`
fragment VideoButtonEntry on AnimeThemeEntry {
...createVideoSlugEntry
}
`);
export const VIDEO_BUTTON_VIDEO = graphql(`
fragment VideoButtonVideo on Video {
...createVideoSlugVideo
...VideoTagsVideo
id
}
`);
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
anime: VideoButtonAnimeFragment
theme: VideoButtonThemeFragment
entry: VideoButtonEntryFragment
video: VideoButtonVideoFragment
anime: FragmentType<typeof VIDEO_BUTTON_ANIME>;
theme: FragmentType<typeof VIDEO_BUTTON_THEME>;
entry: FragmentType<typeof VIDEO_BUTTON_ENTRY>;
video: FragmentType<typeof VIDEO_BUTTON_VIDEO>;
}
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentVideo } = useContext(PlayerContext);
export function VideoButton({
anime: animeFragment,
theme: themeFragment,
entry: entryFragment,
video: videoFragment,
...props
}: VideoButtonProps) {
const anime = getFragmentData(VIDEO_BUTTON_ANIME, animeFragment);
const theme = getFragmentData(VIDEO_BUTTON_THEME, themeFragment);
const entry = getFragmentData(VIDEO_BUTTON_ENTRY, entryFragment);
const video = getFragmentData(VIDEO_BUTTON_VIDEO, videoFragment);
const { currentWatchListItem } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo ? currentVideo.id === video.id : false;
const isPlaying = currentWatchListItem
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
: false;
return (
<Link
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior>
<Button as="a" {...props}>
<Button as="span" variant="primary" isCircle>
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined}/>
<Button asChild {...props}>
<Link href={`/anime/${anime.slug}/${videoSlug}`}>
<Button asChild variant="primary" isCircle>
<span>
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined} />
</span>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
<VideoTags video={video} />
</Link>
</Button>
);
}
VideoButton.fragments = {
anime: gql`
fragment VideoButtonAnime on Anime {
slug
}
`,
theme: gql`
fragment VideoButtonTheme on Theme {
slug
}
`,
entry: gql`
fragment VideoButtonEntry on Entry {
version
}
`,
video: gql`
${VideoTags.fragments.video}
fragment VideoButtonVideo on Video {
...VideoTagsVideo
id
tags
}
`,
};
-5
View File
@@ -1,5 +0,0 @@
export { Button } from "./Button";
export { VideoButton } from "./VideoButton";
export { FilterToggleButton } from "./FilterToggleButton";
export { IconTextButton } from "./IconTextButton";
export { BackToTopButton } from "./BackToTopButton";
+94 -77
View File
@@ -1,23 +1,25 @@
import { Fragment } from "react";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import extractImages from "utils/extractImages";
import { Icon } from "components/icon";
import { SummaryCard } from "components/card";
import { Fragment, type MouseEvent } from "react";
import styled from "styled-components";
import useToggle from "hooks/useToggle";
import theme from "theme";
import { uniq } from "lodash-es";
import { Collapse } from "components/utils";
import { ThemeTable } from "components/table";
import useMediaQuery from "hooks/useMediaQuery";
import gql from "graphql-tag";
import type { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "generated/graphql";
import { TextLink } from "components/text/TextLink";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { uniqBy } from "lodash-es";
import { Button } from "@/components/button/Button";
import { SummaryCard } from "@/components/card/SummaryCard";
import { Icon } from "@/components/icon/Icon";
import { ThemeTable } from "@/components/table/ThemeTable";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { Collapse } from "@/components/utils/Collapse";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import useMediaQuery from "@/hooks/useMediaQuery";
import useToggle from "@/hooks/useToggle";
import theme from "@/theme";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledThemeContainerInline = styled.div`
@@ -30,7 +32,7 @@ const StyledThemeContainerInline = styled.div`
right: 16px;
opacity: 0;
transition-property: opacity;
user-select: none;
${StyledWrapper}:hover & {
@@ -38,7 +40,7 @@ const StyledThemeContainerInline = styled.div`
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
@@ -58,39 +60,65 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
type AnimeSummaryCardProps = {
anime: AnimeSummaryCardAnimeFragment
expandable?: false
} | {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
expandable: true
};
export const ANIME_SUMMARY_CARD_ANIME = graphql(`
fragment AnimeSummaryCardAnime on Anime {
slug
name
year
season
seasonLocalized
mediaFormatLocalized
animethemes {
group {
name
slug
}
}
images {
nodes {
...extractImagesImage
}
}
}
`);
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
export const ANIME_SUMMARY_CARD_ANIME_EXPANDABLE = graphql(`
fragment AnimeSummaryCardAnimeExpandable on Anime {
animethemes {
...ThemeTableTheme
group {
name
slug
}
}
}
`);
interface AnimeSummaryCardProps {
anime: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME>;
expandable?: FragmentType<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
}
export function AnimeSummaryCard({ anime: animeFragment, expandable, ...props }: AnimeSummaryCardProps) {
const anime = getFragmentData(ANIME_SUMMARY_CARD_ANIME, animeFragment);
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = extractImages(anime);
const { smallCover } = extractImages(anime.images.nodes);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniq(anime.themes.map((theme) => theme.group));
const animeLink = `/anime/${anime.slug}`;
let premiere = String(anime.year);
let premiereLink = `/year/${anime.year}`;
if (anime.season) {
premiere = anime.season + " " + premiere;
if (anime.seasonLocalized && anime.season) {
premiere = anime.seasonLocalized + " " + premiere;
premiereLink += `/${anime.season.toLowerCase()}`;
}
const description = (
<SummaryCard.Description>
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && (
<TextLink href={premiereLink}>
{premiere}
</TextLink>
)}
<span>{anime.themes.length} themes</span>
<span>{anime.mediaFormatLocalized ?? "Anime"}</span>
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
<span>{anime.animethemes.length} themes</span>
</SummaryCard.Description>
);
@@ -134,47 +162,36 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
)}
</SummaryCard>
{expandable ? (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
<Fragment key={group}>
{!!group && (
<Text variant="h2">{group}</Text>
)}
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group === group)}/>
</Fragment>
))}
</StyledThemeGroupContainer>
</Collapse>
<AnimeSummaryCardCollapse
anime={getFragmentData(ANIME_SUMMARY_CARD_ANIME_EXPANDABLE, expandable)}
isExpanded={isExpanded}
/>
) : null}
</StyledWrapper>
);
}
AnimeSummaryCard.fragments = {
anime: gql`
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
name
year
season
media_format
themes {
group
}
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
interface AnimeSummaryCardCollapseProps {
anime: ResultOf<typeof ANIME_SUMMARY_CARD_ANIME_EXPANDABLE>;
isExpanded: boolean;
}
fragment AnimeSummaryCardAnimeExpandable on Anime {
themes {
...ThemeTableTheme
group
}
}
`
};
function AnimeSummaryCardCollapse({ anime, isExpanded }: AnimeSummaryCardCollapseProps) {
const groups = uniqBy(
anime.animethemes.map((theme) => theme.group),
(group) => group?.slug,
);
return (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
<Fragment key={group?.slug}>
{!!group && <Text variant="h2">{group.name}</Text>}
<ThemeTable themes={anime.animethemes.filter((theme) => theme.group?.slug === group?.slug)} />
</Fragment>
))}
</StyledThemeGroupContainer>
</Collapse>
);
}
+4 -5
View File
@@ -1,13 +1,12 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { Markdown } from "components/markdown/Markdown";
import { AnimeAwardsNowAvailable } from "components/event/AnimeAwardsNowAvailable";
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 }} />
);
return <Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />;
}
+23 -30
View File
@@ -1,44 +1,37 @@
import extractImages from "utils/extractImages";
import { SummaryCard } from "components/card";
import gql from "graphql-tag";
import type { ArtistSummaryCardArtistFragment } from "generated/graphql";
import { SummaryCard } from "@/components/card/SummaryCard";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import extractImages from "@/utils/extractImages";
export const ARTIST_SUMMARY_CARD_ARTIST = graphql(`
fragment ArtistSummaryCardArtist on Artist {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
`);
type ArtistSummaryCardProps = {
artist: ArtistSummaryCardArtistFragment
as?: string | null
artist: FragmentType<typeof ARTIST_SUMMARY_CARD_ARTIST>;
as?: string | null;
};
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
const { smallCover } = extractImages(artist);
export function ArtistSummaryCard({ artist: artistFragment, as }: ArtistSummaryCardProps) {
const artist = getFragmentData(ARTIST_SUMMARY_CARD_ARTIST, artistFragment);
const { smallCover } = extractImages(artist.images.nodes);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!as && (
<span>As {as}</span>
)}
{!!as && <span>As {as}</span>}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
);
}
ArtistSummaryCard.fragments = {
artist: gql`
fragment ArtistSummaryCardArtist on Artist {
slug
name
images {
link
facet
}
}
`
};
+16 -13
View File
@@ -1,16 +1,17 @@
import styled, { css } from "styled-components";
import { Solid } from "components/box";
import theme from "theme";
import { withHover } from "styles/mixins";
import type { Colors } from "theme/colors";
import { Solid } from "@/components/box/Solid";
import { withHover } from "@/styles/mixins";
import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Card = styled(Solid)<{
hoverable?: boolean
color?: keyof Colors
$hoverable?: boolean;
$color?: keyof Colors;
}>`
display: block;
position: relative;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
@@ -21,14 +22,16 @@ export const Card = styled(Solid)<{
background-color: ${theme.colors["solid-on-card"]};
}
${(props) => props.hoverable && css`
cursor: pointer;
${(props) =>
props.$hoverable &&
css`
cursor: pointer;
${withHover`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`}
`}
`}
&:before {
content: " ";
display: block;
@@ -37,6 +40,6 @@ export const Card = styled(Solid)<{
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
background-color: ${(props) => (props.$color ? theme.colors[props.$color] : theme.colors["text-primary"])};
}
`;
+16 -11
View File
@@ -1,10 +1,12 @@
import { Card } from "components/card";
import { Row } from "components/box";
import { Text } from "components/text";
import { faExclamation } from "@fortawesome/pro-solid-svg-icons";
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
import { Row } from "@/components/box/Flex";
import { Card } from "@/components/card/Card";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const StyledCard = styled(Card)`
display: flex;
@@ -19,21 +21,24 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
`;
interface ErrorCardProps {
error: unknown
error: unknown;
}
export function ErrorCard({ error }: ErrorCardProps) {
return (
<StyledCard color="text-warning">
<StyledCard $color="text-warning">
<Row style={{ "--gap": "1rem" }}>
<Text color="text-warning">
<Icon icon={faExclamation}/>
<Icon icon={faExclamation} />
</Text>
<Text block>
An error occurred while searching! Help improving the site by sending us the following error
message:
</Text>
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
</Row>
<pre>
<StyledErrorMessage>
{JSON.stringify(error, null, 2)}
{error instanceof Error ? error.message : JSON.stringify(error, null, 2)}
</StyledErrorMessage>
</pre>
</StyledCard>
+49 -45
View File
@@ -1,14 +1,13 @@
import { SummaryCard } from "components/card/SummaryCard";
import type { PropsWithChildren } from "react";
import gql from "graphql-tag";
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "generated/graphql";
import type { ReactNode } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import styled from "styled-components";
import theme from "theme";
import { Text } from "components/text";
import { SummaryCard } from "@/components/card/SummaryCard";
import { Text } from "@/components/text/Text";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import theme from "@/theme";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledOverlayButtons = styled.div`
@@ -17,7 +16,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
@@ -29,25 +28,50 @@ const StyledOverlayButtons = styled.div`
}
`;
type PlaylistSummaryCardProps = {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
} | {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
export const PLAYLIST_SUMMARY_CARD_PLAYLIST = graphql(`
fragment PlaylistSummaryCardPlaylist on Playlist {
id
name
visibilityLocalized
tracksCount
}
`);
export const PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER = graphql(`
fragment PlaylistSummaryCardPlaylistWithOwner on Playlist {
user {
name
}
}
`);
interface PlaylistSummaryCardProps {
playlist: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST>;
playlistWithOwner?: FragmentType<typeof PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER>;
menu?: ReactNode;
}
export default function PlaylistSummaryCard({
playlist: playlistFragment,
playlistWithOwner: playlistWithOwnerFragment,
children,
menu,
...props
}: PropsWithChildren<PlaylistSummaryCardProps>) {
const playlist = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST, playlistFragment);
const playlistWithOwner = getFragmentData(PLAYLIST_SUMMARY_CARD_PLAYLIST_WITH_OWNER, playlistWithOwnerFragment);
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
const description = (
<SummaryCard.Description>
<span>Playlist</span>
<span>{playlist.visibility}</span>
{showOwner ? (
<Text link>{playlist.user.name}</Text>
) : null}
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
{playlistWithOwner ? (
<Text link>{playlistWithOwner.user?.name}</Text>
) : (
<span>{playlist.visibilityLocalized}</span>
)}
<span>
{playlist.tracksCount} theme{playlist.tracksCount !== 1 ? "s" : null}
</span>
</SummaryCard.Description>
);
@@ -56,29 +80,9 @@ export default function PlaylistSummaryCard({ playlist, children, menu, showOwne
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
{children}
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
<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
}
}
`,
};
+53
View File
@@ -0,0 +1,53 @@
import { type SyntheticEvent, useState } from "react";
import type { Property } from "csstype";
import { SummaryCard } from "@/components/card/SummaryCard";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import extractBackgroundColor from "@/utils/extractBackgroundColor";
import extractImages from "@/utils/extractImages";
export const STUDIO_SUMMARY_CARD_STUDIO = graphql(`
fragment StudioSummaryCardStudio on Studio {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
`);
interface StudioSummaryCardProps {
studio: FragmentType<typeof STUDIO_SUMMARY_CARD_STUDIO>;
}
export function StudioSummaryCard({ studio: studioFragment }: StudioSummaryCardProps) {
const studio = getFragmentData(STUDIO_SUMMARY_CARD_STUDIO, studioFragment);
const [backgroundColor, setBackgroundColor] = useState<Property.Background>();
function handleLoad(event: SyntheticEvent<HTMLImageElement>) {
const image = event.currentTarget;
const color = extractBackgroundColor(image);
if (color) {
setBackgroundColor(color);
}
}
return (
<SummaryCard
key={studio.slug}
title={studio.name}
description="Studio"
to={`/studio/${studio.slug}`}
image={extractImages(studio.images.nodes).largeCover}
imageProps={{
$objectFit: "contain",
$backgroundColor: backgroundColor,
onLoad: handleLoad,
}}
/>
);
}
+61 -66
View File
@@ -1,16 +1,18 @@
import Link from "next/link";
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
import { TextLink } from "components/text/TextLink";
import { ConditionalWrapper } from "components/utils/ConditionalWrapper";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styled, { css } from "styled-components";
import Link from "next/link";
import type { Property } from "csstype";
import { Column } from "@/components/box/Flex";
import { Card } from "@/components/card/Card";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { ConditionalWrapper } from "@/components/utils/ConditionalWrapper";
import { loadingAnimation } from "@/styles/mixins";
import withBasePath from "@/utils/withBasePath";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
@@ -22,65 +24,59 @@ const StyledSummaryCard = styled(Card)`
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
loading: "lazy",
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
$objectFit?: Property.ObjectFit;
$backgroundColor?: Property.Background;
$isLoading?: boolean;
$isPlaceholder?: boolean;
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
object-fit: ${(props) => props.$objectFit ?? "cover"};
background: ${(props) => props.$backgroundColor};
${(props) =>
props.$isPlaceholder
? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
`
: props.$isLoading
? loadingAnimation
: null}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
title: string | ReactNode
description?: string | ReactNode
image?: string
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>
to?: string
children?: ReactNode
};
interface SummaryCardProps extends Omit<ComponentPropsWithoutRef<typeof StyledSummaryCard>, "title"> {
title: string | ReactNode;
description?: string | ReactNode;
image?: string;
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>;
to?: string;
children?: ReactNode;
}
export function SummaryCard({
title,
description,
image,
imageProps,
to,
children,
...props
}: SummaryCardProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
export function SummaryCard({ title, description, image, imageProps, to, children, ...props }: SummaryCardProps) {
const [imageNotFound, setImageNotFound] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
return (
<StyledSummaryCard {...props}>
<ConditionalWrapper
condition={!!to}
wrap={(children) => <Link href={to as string}>{children}</Link>}
>
<ConditionalWrapper condition={!!to} wrap={(children) => <Link href={to as string}>{children}</Link>}>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isLoading={imageLoading}
isPlaceholder={!image || imageNotFound}
$isLoading={imageLoading}
$isPlaceholder={!image || imageNotFound}
loading="lazy"
{...imageProps}
onLoad={(event) => {
@@ -89,27 +85,26 @@ export function SummaryCard({
imageProps.onLoad(event);
}
}}
onError={() => {
onError={(event) => {
setImageNotFound(true);
setImageLoading(false);
if (imageProps?.onError) {
imageProps.onError();
imageProps.onError(event);
}
}}
/>
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" && to ?
<TextLink href={to}>{title}</TextLink> : title}
{typeof title === "string" && to ? <TextLink href={to}>{title}</TextLink> : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
<SummaryCard.Description>{[description]}</SummaryCard.Description>
) : (
description
)}
</Text>
)}
</StyledBody>
@@ -119,20 +114,20 @@ export function SummaryCard({
}
type SummaryCardDescriptionProps = {
children: Array<ReactNode>
children: Array<ReactNode>;
};
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
{children
.filter((child) => child)
.map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && <span> &bull; </span>}
</Text>
))}
</>
);
};
+47 -42
View File
@@ -1,13 +1,15 @@
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import { Fragment, useState } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
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;
@@ -19,43 +21,44 @@ const StyledSummaryCard = styled(Card)`
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
loading: "lazy",
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
$objectFit?: Property.ObjectFit;
$backgroundColor?: Property.Background;
$isLoading?: boolean;
$isPlaceholder?: boolean;
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
object-fit: ${(props) => props.$objectFit ?? "cover"};
background: ${(props) => props.$backgroundColor};
${(props) =>
props.$isPlaceholder
? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
`
: props.$isLoading
? loadingAnimation
: null}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
children?: ReactNode
children?: ReactNode;
};
export function SummaryCard({ children, ...props }: SummaryCardProps) {
return (
<StyledSummaryCard {...props}>
{children}
</StyledSummaryCard>
);
return <StyledSummaryCard {...props}>{children}</StyledSummaryCard>;
}
SummaryCard.Body = StyledBody;
@@ -73,20 +76,22 @@ SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCar
};
interface SummaryCardDescriptionProps {
children: string | Array<ReactNode>
children: string | Array<ReactNode>;
}
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<Text variant="small" maxLines={1} color="text-muted">
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
<>
{child}
{index < length - 1 && (
<span> &bull; </span>
)}
</>
))}
{typeof children === "string"
? children
: children
.filter((child) => child)
.map((child, index, { length }) => (
<Fragment key={index}>
{child}
{index < length - 1 && <span> &bull; </span>}
</Fragment>
))}
</Text>
);
};
@@ -96,15 +101,15 @@ interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCo
}
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
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}
$isLoading={imageLoading}
$isPlaceholder={!src || imageNotFound}
loading="lazy"
{...props}
onLoad={(event) => {
@@ -113,11 +118,11 @@ SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCove
props.onLoad(event);
}
}}
onError={() => {
onError={(event) => {
setImageNotFound(true);
setImageLoading(false);
if (props?.onError) {
props.onError();
props.onError(event);
}
}}
/>
+69 -75
View File
@@ -1,29 +1,29 @@
import { VideoButton } from "components/button";
import { Performances, SongTitle } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu/ThemeMenu";
import { ThemeEntryTags } from "components/tag/ThemeEntryTags";
import { VideoTags } from "components/tag/VideoTags";
import gql from "graphql-tag";
import type { ThemeDetailCardThemeFragment } from "generated/graphql";
import styled from "styled-components";
import { Card } from "components/card/Card";
import theme from "theme";
import { Row } from "components/box";
import { entryVersionComparator } from "utils/comparators";
import { Row } from "@/components/box/Flex";
import { VideoButton } from "@/components/button/VideoButton";
import { Card } from "@/components/card/Card";
import { ThemeMenu } from "@/components/menu/ThemeMenu";
import { ThemeEntryTags } from "@/components/tag/ThemeEntryTags";
import { Text } from "@/components/text/Text";
import { Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import theme from "@/theme";
import { entryVersionComparator } from "@/utils/comparators";
const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
grid-template-columns: 4ch 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
@@ -36,17 +36,52 @@ const StyledVideoListContainer = styled.div`
const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
export const THEME_DETAIL_CARD_THEME = graphql(`
fragment ThemeDetailCardTheme on AnimeTheme {
...ThemeMenuTheme
...VideoButtonTheme
type
sequence
group {
name
slug
}
anime {
...VideoButtonAnime
slug
name
}
song {
...SongTitleSong
...PerformancesSong
}
animethemeentries {
...ThemeEntryTagsEntry
...VideoButtonEntry
version
videos {
nodes {
...VideoButtonVideo
filename
tags
}
}
}
}
`);
interface ThemeDetailCardProps {
theme: ThemeDetailCardThemeFragment
theme: FragmentType<typeof THEME_DETAIL_CARD_THEME>;
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
export function ThemeDetailCard({ theme: themeFragment }: ThemeDetailCardProps) {
const theme = getFragmentData(THEME_DETAIL_CARD_THEME, themeFragment);
const { anime } = theme;
if (!anime) {
@@ -56,30 +91,29 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
return (
<StyledThemeCard>
<StyledRow>
<Text variant="small" color="text">{theme.type}{theme.sequence || null}</Text>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
<Text variant="small" color="text">
{theme.type}
{theme.sequence || null}
</Text>
<ThemeMenu theme={theme}/>
<Text>
<SongTitle song={theme.song} />
<Performances song={theme.song} expandable />
</Text>
<ThemeMenu theme={theme} />
</StyledRow>
{theme.entries.sort(entryVersionComparator).map(entry => (
<StyledRow key={entry.version || 0}>
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
{[...theme.animethemeentries].sort(entryVersionComparator).map((entry) => (
<StyledRow key={entry.version ?? 0}>
<Text variant="small" color="text-muted">
v{entry.version ?? 1}
</Text>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
<ThemeEntryTags entry={entry} />
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={anime}
theme={theme}
entry={entry}
video={video}
/>
{entry.videos.nodes.map((video, index) => (
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
))}
</StyledVideoList>
)}
@@ -89,43 +123,3 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
</StyledThemeCard>
);
}
ThemeDetailCard.fragments = {
theme: gql`
${ThemeMenu.fragments.theme}
${VideoTags.fragments.video}
fragment ThemeDetailCardTheme on Theme {
...ThemeMenuTheme
slug
type
sequence
group
anime {
slug
name
}
song {
title
performances {
as
artist {
slug
name
}
}
}
entries {
version
episodes
spoiler
nsfw
videos {
...VideoTagsVideo
filename
tags
}
}
}
`
};
+148 -139
View File
@@ -1,32 +1,29 @@
import Link from "next/link";
import { Collapse, Performances, SongTitle, SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card/SummaryCard2";
import { ThemeMenu } from "components/menu/ThemeMenu";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import { Icon } from "components/icon";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import useToggle from "hooks/useToggle";
import type { MouseEvent, PropsWithChildren } from "react";
import styled from "styled-components";
import { Table, ThemeTable } from "components/table";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
import type {
ThemeSummaryCardArtistFragment,
ThemeSummaryCardQuery,
ThemeSummaryCardThemeExpandableFragment,
ThemeSummaryCardThemeFragment
} from "generated/graphql";
import type { PropsWithChildren } from "react";
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "components/table/Table";
import { TextLink } from "components/text/TextLink";
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { SummaryCard } from "@/components/card/SummaryCard2";
import { Icon } from "@/components/icon/Icon";
import { ThemeMenu } from "@/components/menu/ThemeMenu";
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "@/components/table/Table";
import { ThemeTable } from "@/components/table/ThemeTable";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { Collapse } from "@/components/utils/Collapse";
import { getDisplayedArtistName, Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import useMediaQuery from "@/hooks/useMediaQuery";
import useToggle from "@/hooks/useToggle";
import theme from "@/theme";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative
position: relative;
`;
const StyledOverlayButtons = styled.div`
@@ -35,7 +32,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
@@ -60,40 +57,112 @@ const StyledPerformedWith = styled.div`
margin-top: 8px;
`;
export const THEME_SUMMARY_CARD_THEME = graphql(`
fragment ThemeSummaryCardTheme on AnimeTheme {
...createVideoSlugTheme
...ThemeMenuTheme
type
sequence
group {
name
slug
}
anime {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
song {
...SongTitleSong
...PerformancesSong
performances {
alias
as
artist {
__typename
... on Artist {
slug
name
}
... on Membership {
group {
slug
name
}
}
}
}
}
animethemeentries {
...createVideoSlugEntry
videos {
nodes {
...createVideoSlugVideo
}
}
}
}
`);
export const THEME_SUMMARY_CARD_ARTIST = graphql(`
fragment ThemeSummaryCardArtist on Artist {
...PerformancesArtist
slug
}
`);
export const THEME_SUMMARY_CARD_THEME_EXPANDABLE = graphql(`
fragment ThemeSummaryCardThemeExpandable on AnimeTheme {
...ThemeTableTheme
}
`);
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
type ThemeSummaryCardProps = {
theme: ThemeSummaryCardThemeFragment
artist?: ThemeSummaryCardArtistFragment
expandable?: false
onPlay?(entryIndex?: number, videoIndex?: number): void
} | {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
artist?: ThemeSummaryCardArtistFragment
expandable: true
onPlay?(entryIndex?: number, videoIndex?: number): void
};
interface ThemeSummaryCardProps {
theme: FragmentType<typeof THEME_SUMMARY_CARD_THEME>;
artist?: FragmentType<typeof THEME_SUMMARY_CARD_ARTIST>;
expandable?: FragmentType<typeof THEME_SUMMARY_CARD_THEME_EXPANDABLE>;
onPlay?(entryIndex?: number, videoIndex?: number): void;
}
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
export function ThemeSummaryCard({
theme: themeFragment,
artist: artistFragment,
children,
expandable: expandableFragment,
onPlay,
...props
}: PropsWithChildren<ThemeSummaryCardProps>) {
const theme = getFragmentData(THEME_SUMMARY_CARD_THEME, themeFragment);
const ownerArtist = artistFragment ? getFragmentData(THEME_SUMMARY_CARD_ARTIST, artistFragment) : undefined;
const expandable = expandableFragment
? getFragmentData(THEME_SUMMARY_CARD_THEME_EXPANDABLE, expandableFragment)
: undefined;
const [isExpanded, toggleExpanded] = useToggle();
const isMobile = useIsMobile();
const anime = theme.anime;
const entry = theme.entries[0];
const video = entry?.videos[0];
const entry = theme.animethemeentries[0];
const video = entry?.videos.nodes[0];
if (!anime || !entry || !video) {
return null;
}
const { smallCover } = extractImages(anime);
const { smallCover } = extractImages(anime.images.nodes);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
function handleToggleExpand(event: MouseEvent) {
if (isLink(event.target)) {
event.stopPropagation();
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
if (filterLinks && isLink(event.target)) {
return;
} else if (expandable && !isMobile) {
toggleExpanded();
}
@@ -108,25 +177,37 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
return isLink(element.parentElement);
}
const performances = (theme.song?.performances ?? [])
.map((performance) => ({
artist: performance.artist.__typename === "Artist" ? performance.artist : performance.artist.group,
performance,
}))
.filter(({ artist }) => artist.slug !== ownerArtist?.slug)
.sort(({ artist: a }, { artist: b }) => a.name.localeCompare(b.name));
return (
<StyledWrapper>
<SummaryCard onClick={handleToggleExpand} {...props}>
<SummaryCard {...props}>
<Link href={href} onClick={() => onPlay?.()}>
<SummaryCard.Cover src={smallCover} />
</Link>
<SummaryCard.Body>
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={artist} />
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={ownerArtist} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<span>
{theme.type}
{theme.sequence || null}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
<StyledOverlayButtons>
<ThemeMenu theme={theme} />
{expandable && (
<StyledExpandButton
variant="silent"
@@ -146,31 +227,25 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<ThemeTable
themes={[expandable]}
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
/>
{performances.length > 0 && (
<Table style={{ "--columns": "1fr" }}>
<TableHead>
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
<TableHeadCell>Performed {ownerArtist ? "With" : "By"}</TableHeadCell>
</TableHead>
<TableBody>
{theme.song?.performances
?.filter((performance) => performance.artist.slug !== artist?.slug)
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
.map((performance) => (
<Link
key={performance.artist.slug}
href={`/artist/${performance.artist.slug}`}
passHref
legacyBehavior>
<TableRow as="a">
<TableCell>
<Text color="text-primary" weight="600">
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
</Text>
</TableCell>
</TableRow>
</Link>
))}
{performances.map(({ artist, performance }) => (
<TableRow key={artist.slug} as={Link} href={`/artist/${artist.slug}`}>
<TableCell>
<Text color="text-primary" weight="600">
{getDisplayedArtistName({ ...performance, artist })}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
@@ -180,69 +255,3 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
</StyledWrapper>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragments.theme}
fragment ThemeSummaryCardTheme on Theme {
...createVideoSlugTheme
...ThemeMenuTheme
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
entries {
...createVideoSlugEntry
videos {
...createVideoSlugVideo
}
}
}
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCardArtist on Artist {
...SongTitleWithArtistsArtist
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment ThemeSummaryCardThemeExpandable on Theme {
...ThemeTableTheme
}
`
};
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
export const fetchThemeSummaryCardData = async function (id: number): Promise<FetchThemeSummaryCardData> {
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(gql`
${ThemeSummaryCard.fragments.theme}
query ThemeSummaryCard($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCardTheme
anime {
year
season
}
}
}
`, { themeId: id }).then((result) => result.data?.theme ?? null);
};
+108 -93
View File
@@ -1,16 +1,18 @@
import { Performances, SongTitle, SongTitleWithArtists } from "components/utils";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card/SummaryCard2";
import gql from "graphql-tag";
import { type ReactNode, type Ref } from "react";
import styled from "styled-components";
import theme from "theme";
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
import { type ForwardedRef, forwardRef, type ReactNode } from "react";
import { TextLink } from "components/text/TextLink";
import Link from "next/link";
import { Icon } from "components/icon";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
import { faPlay } from "@fortawesome/free-solid-svg-icons";
import { SummaryCard } from "@/components/card/SummaryCard2";
import { Icon } from "@/components/icon/Icon";
import { TextLink } from "@/components/text/TextLink";
import { Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import theme from "@/theme";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative;
@@ -22,7 +24,7 @@ const StyledOverlayButtons = styled.div`
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
@@ -41,99 +43,112 @@ const StyledCoverLink = styled(Link)`
const StyledCoverOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
`;
interface VideoSummaryCardProps {
video: VideoSummaryCardVideoFragment;
menu?: ReactNode;
onPlay?(): void;
isPlaying?: boolean;
}
export const VideoSummaryCard = forwardRef(
function VideoSummaryCard({ video, menu, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{videoSlug}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
);
export const VideoSummaryCardFragmentVideo = gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
export const VIDEO_SUMMARY_CARD_VIDEO = graphql(`
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
entries {
...createVideoSlugEntry
theme {
...createVideoSlugTheme
id
}
`);
export const VIDEO_SUMMARY_CARD_ENTRY = graphql(`
fragment VideoSummaryCardEntry on AnimeThemeEntry {
...createVideoSlugEntry
id
animetheme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
anime {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
}
audio {
basename
song {
...SongTitleSong
...PerformancesSong
}
}
}
`;
`);
interface VideoSummaryCardProps {
ref?: Ref<HTMLDivElement>;
video: FragmentType<typeof VIDEO_SUMMARY_CARD_VIDEO>;
entry: FragmentType<typeof VIDEO_SUMMARY_CARD_ENTRY>;
menu?: ReactNode;
append?: ReactNode;
onPlay?(): void;
isPlaying?: boolean;
}
export function VideoSummaryCard({
ref,
video: videoFragment,
entry: entryFragment,
menu,
append,
onPlay,
isPlaying,
...props
}: VideoSummaryCardProps) {
const video = getFragmentData(VIDEO_SUMMARY_CARD_VIDEO, videoFragment);
const entry = getFragmentData(VIDEO_SUMMARY_CARD_ENTRY, entryFragment);
const theme = entry.animetheme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime.images.nodes);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>
{videoSlug}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu !== null ? <StyledOverlayButtons>{menu}</StyledOverlayButtons> : null}
{append}
</SummaryCard>
</StyledWrapper>
);
}
-7
View File
@@ -1,7 +0,0 @@
export { Card } from "./Card";
export { SummaryCard } from "./SummaryCard";
export { AnimeSummaryCard } from "./AnimeSummaryCard";
export { ThemeSummaryCard } from "./ThemeSummaryCard";
export { ArtistSummaryCard } from "./ArtistSummaryCard";
export { ThemeDetailCard } from "./ThemeDetailCard";
export { ErrorCard } from "./ErrorCard";
+7 -1
View File
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const SidebarContainer = styled.div`
display: grid;
@@ -9,4 +10,9 @@ export const SidebarContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
}
// This will prevent columns from overflowing
& > * {
min-width: 0;
}
`;
-2
View File
@@ -1,2 +0,0 @@
export { Container } from "./Container";
export { SidebarContainer } from "./SidebarContainer";
@@ -1,6 +1,7 @@
import styled from "styled-components";
import { Text } from "components/text";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styled from "styled-components";
import { Text } from "@/components/text/Text";
const StyledDescriptionList = styled.dl`
display: flex;
@@ -21,27 +22,25 @@ const StyledValue = styled.dd`
`;
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
children: ReactNode
children: ReactNode;
}
export function DescriptionList({ children, ...props }: DescriptionListProps) {
return (
<StyledDescriptionList {...props}>
{children}
</StyledDescriptionList>
);
return <StyledDescriptionList {...props}>{children}</StyledDescriptionList>;
}
interface DescriptionListItemProps {
title: string
children: ReactNode
title: string;
children: ReactNode;
}
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
return (
<>
<StyledKey>
<Text as="span" variant="h2">{title}</Text>
<Text as="span" variant="h2">
{title}
</Text>
</StyledKey>
<StyledValue>{children}</StyledValue>
</>
-1
View File
@@ -1 +0,0 @@
export { DescriptionList } from "./DescriptionList";
+25 -28
View File
@@ -1,9 +1,11 @@
import { forwardRef } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import type { RefObject } from "react";
import styled, { keyframes } from "styled-components";
import { Card } from "components/card";
import theme from "theme";
import { Text } from "components/text";
import * as RadixDialog from "@radix-ui/react-dialog";
import { Card } from "@/components/card/Card";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const overlayAnimation = keyframes`
from {
@@ -36,45 +38,40 @@ const StyledDialogCard = styled(Card)`
width: 100%;
max-width: 450px;
animation: ${contentAnimation} 250ms;
margin: auto;
padding: 24px;
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
&:before {
display: none;
}
`;
const StyledHeader = styled.div`
const StyledHeader = styled(Text).attrs({ variant: "h2", as: RadixDialog.Title })`
margin: -24px -24px 24px -24px;
padding: 16px 24px;
background-color: ${theme.colors["background"]};
`;
interface DialogContentProps extends RadixDialog.DialogContentProps {
ref?: RefObject<HTMLDivElement>;
title?: string;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
function DialogContent({ title, children, ...props }, ref) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? (
<StyledHeader>
<Text variant="h2">{title}</Text>
</StyledHeader>
) : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
);
export function DialogContent({ ref, title, children, ...props }: DialogContentProps) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? <StyledHeader>{title}</StyledHeader> : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
export const Dialog = RadixDialog.Root;
+22 -26
View File
@@ -1,18 +1,20 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { isAxiosError } from "axios";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
@@ -25,16 +27,13 @@ export function ForgotPasswordDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Text variant="small" link color="text-muted">Forgot Password?</Text>
<Text variant="small" link color="text-muted">
Forgot Password?
</Text>
</DialogTrigger>
<DialogContent title="Request a Password Reset">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ForgotPasswordForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
{open ? <ForgotPasswordForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -70,10 +69,7 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
email,
});
dispatchToast(
"forgot-password-sent",
<Toast>We sent you an e-mail with a password reset link.</Toast>
);
dispatchToast("forgot-password-sent", <Toast>We sent you an e-mail with a password reset link.</Toast>);
onSuccess();
} catch (error) {
@@ -101,12 +97,12 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
{errors.email ? <Text color="text-warning">{errors.email}</Text> : null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Submit</Busy>
</Button>
+29 -29
View File
@@ -1,16 +1,17 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import Switch from "components/form/Switch";
import { ForgotPasswordDialog } from "components/dialog/ForgotPasswordDialog";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { ForgotPasswordDialog } from "@/components/dialog/ForgotPasswordDialog";
import { Input } from "@/components/form/Input";
import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth, { type LoginErrors } from "@/hooks/useAuth";
const StyledForm = styled.form`
display: flex;
@@ -28,9 +29,7 @@ export function LoginDialog() {
</DialogTrigger>
<DialogContent title="Login">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginForm onCancel={() => setOpen(false)} />
) : null}
{open ? <LoginForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -50,9 +49,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
const isValid = email && password;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
const [errors, setErrors] = useState<LoginErrors>({});
function performLogin(event: SyntheticEvent) {
event.preventDefault();
@@ -64,8 +61,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
email,
password,
remember: isRemember,
})
.finally(() => setBusy(false));
}).finally(() => setBusy(false));
}
return (
@@ -82,9 +78,13 @@ function LoginForm({ onCancel }: LoginFormProps) {
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
@@ -99,15 +99,15 @@ function LoginForm({ onCancel }: LoginFormProps) {
<ForgotPasswordDialog />
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-remember"
isChecked={isRemember}
onCheckedChange={setRemember}
/>
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
<Switch id="input-remember" isChecked={isRemember} onCheckedChange={setRemember} />
<Text as="label" htmlFor="input-remember">
Remember my login on this device.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Login</Busy>
</Button>
+38 -36
View File
@@ -1,17 +1,20 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import axios from "lib/client/axios";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import axios from "@/lib/client/axios";
import { AUTH_PATH } from "@/utils/config";
export function PasswordChangeDialog() {
const [open, setOpen] = useState(false);
@@ -23,12 +26,7 @@ export function PasswordChangeDialog() {
</DialogTrigger>
<DialogContent title="Change Password">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<PasswordChangeForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
{open ? <PasswordChangeForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -67,19 +65,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
setErrors({});
try {
await axios.put(
"/user/password",
{
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
}
);
await axios.put(`${AUTH_PATH}/user/password`, {
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
});
dispatchToast(
"password-change",
<Toast>Password changed successfully.</Toast>
);
dispatchToast("password-change", <Toast>Password changed successfully.</Toast>);
onSuccess();
} catch (error) {
@@ -107,9 +99,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
required: true,
}}
/>
{errors.current_password ? errors.current_password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.current_password
? errors.current_password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
@@ -121,9 +117,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
@@ -137,7 +137,9 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Change Password</Busy>
</Button>
+34 -28
View File
@@ -1,18 +1,20 @@
import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
import { useRouter } from "next/router";
import useAuth from "hooks/useAuth";
import { isAxiosError } from "axios";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
export function PasswordResetDialog() {
const router = useRouter();
@@ -24,10 +26,7 @@ export function PasswordResetDialog() {
return (
<Dialog open={open}>
<DialogContent title="Reset Password">
<PasswordResetForm
onSuccess={() => router.push("/profile")}
onCancel={() => router.push("/")}
/>
<PasswordResetForm onSuccess={() => router.push("/profile")} onCancel={() => router.push("/")} />
</DialogContent>
</Dialog>
);
@@ -73,13 +72,10 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
email,
password: newPassword,
password_confirmation: newPasswordConfirmation,
token,
token: (Array.isArray(token) ? token[0] : token) ?? "",
});
dispatchToast(
"password-reset",
<Toast>Password reset successfully.</Toast>
);
dispatchToast("password-reset", <Toast>Password reset successfully.</Toast>);
onSuccess();
} catch (error) {
@@ -107,9 +103,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
@@ -121,9 +121,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
@@ -137,7 +141,9 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Reset Password</Busy>
</Button>
+57 -49
View File
@@ -1,19 +1,23 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import type { FormEvent, ReactNode } from "react";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import { useMutation } from "@apollo/client";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import { graphql } from "@/graphql/generated";
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
import { PROFILE_PAGE } from "@/pages/profile";
interface PlaylistAddDialogProps {
trigger?: ReactNode;
@@ -26,17 +30,16 @@ export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
<IconTextButton icon={faPlus} collapsible>
New
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Create a new playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistAddForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
<PlaylistAddForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
@@ -55,36 +58,36 @@ interface PlaylistAddFormProps {
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState("PUBLIC");
const [visibility, setVisibility] = useState<PlaylistVisibility>("PUBLIC");
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
const [mutate, { loading, error }] = useMutation(
graphql(`
mutation PlaylistAdd($name: String!, $visibility: PlaylistVisibility!) {
CreatePlaylist(name: $name, visibility: $visibility) {
id
}
}
`),
{
onCompleted: () => onSuccess(),
refetchQueries: [
// Update the profile page because it includes a list of the user's playlists
PROFILE_PAGE,
],
},
);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.post("/api/playlist", {
await mutate({
variables: {
name: title,
visibility,
});
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
},
});
}
return (
@@ -92,27 +95,32 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<Listbox
value={visibility}
onValueChange={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
>
<ListboxOption value="PUBLIC">Public</ListboxOption>
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
<ListboxOption value="PRIVATE">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Playlist</Busy>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || loading}>
<Busy isBusy={loading}>Create Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
<Text color="text-warning">
<strong>The playlist could not be created: </strong>
{error.message}
</Text>
) : null}
</Column>
</StyledForm>
+77 -68
View File
@@ -1,35 +1,51 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPen } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import type { FormEvent, ReactNode } from "react";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import gql from "graphql-tag";
import type { PlaylistEditDialogPlaylistFragment } from "generated/graphql";
import { useMutation } from "@apollo/client";
import { faPen } from "@fortawesome/free-solid-svg-icons";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import type { PlaylistVisibility } from "@/graphql/generated/graphql";
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
import { PROFILE_PAGE } from "@/pages/profile";
export const PLAYLIST_EDIT_DIALOG_PLAYLIST = graphql(`
fragment PlaylistEditDialogPlaylist on Playlist {
id
name
visibility
}
`);
interface PlaylistEditDialogProps {
playlist: PlaylistEditDialogPlaylistFragment;
playlist: FragmentType<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
trigger?: ReactNode;
}
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
export function PlaylistEditDialog({ playlist: playlistFragment, trigger }: PlaylistEditDialogProps) {
const playlist = getFragmentData(PLAYLIST_EDIT_DIALOG_PLAYLIST, playlistFragment);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
<IconTextButton icon={faPen} variant="solid">
Edit Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Edit playlist details">
@@ -48,63 +64,51 @@ export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProp
);
}
PlaylistEditDialog.fragments = {
playlist: gql`
fragment PlaylistEditDialogPlaylist on Playlist {
id
name
visibility
}
`,
};
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistEditFormProps {
playlist: PlaylistEditDialogPlaylistFragment;
playlist: ResultOf<typeof PLAYLIST_EDIT_DIALOG_PLAYLIST>;
onSuccess(): void;
onCancel(): void;
}
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
const [title, setTitle] = useState(playlist.name);
const [visibility, setVisibility] = useState<string>(playlist.visibility);
const [visibility, setVisibility] = useState<PlaylistVisibility>(playlist.visibility);
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
const [mutate, { loading, error }] = useMutation(
graphql(`
mutation PlaylistEdit($id: String!, $name: String, $visibility: PlaylistVisibility) {
UpdatePlaylist(id: $id, name: $name, visibility: $visibility) {
name
}
}
`),
{
onCompleted: () => onSuccess(),
refetchQueries: [
// Update the profile page because it includes a list of the user's playlists
PROFILE_PAGE,
// Update the playlist page in case the user is editing from there
PLAYLIST_DETAIL_PAGE_PLAYLIST,
],
},
);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.put(`/api/playlist/${playlist.id}`, {
await mutate({
variables: {
id: playlist.id,
name: title,
visibility,
});
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
},
});
}
return (
@@ -112,27 +116,32 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="Public">Public</ListboxOption>
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
<ListboxOption value="Private">Private</ListboxOption>
<Listbox
value={visibility}
onValueChange={(newVisibility) => setVisibility(newVisibility as PlaylistVisibility)}
>
<ListboxOption value="PUBLIC">Public</ListboxOption>
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
<ListboxOption value="PRIVATE">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update Playlist</Busy>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || loading}>
<Busy isBusy={loading}>Update Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
<Text color="text-warning">
<strong>The playlist could not be updated: </strong>
{error.message}
</Text>
) : null}
</Column>
</StyledForm>
+78 -53
View File
@@ -1,33 +1,49 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import gql from "graphql-tag";
import { useToasts } from "context/toastContext";
import { PlaylistRemoveToast } from "components/toast/PlaylistRemoveToast";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import type { PlaylistRemoveDialogPlaylistFragment } from "generated/graphql";
import type { ReactNode } from "react";
import { useMutation } from "@apollo/client";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Text } from "@/components/text/Text";
import { PlaylistRemoveToast } from "@/components/toast/PlaylistRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import { PROFILE_PAGE } from "@/pages/profile";
export const PLAYLIST_REMOVE_DIALOG_PLAYLIST = graphql(`
fragment PlaylistRemoveDialogPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistRemoveToastPlaylist
id
name
}
`);
interface PlaylistRemoveDialogProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
playlist: FragmentType<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
trigger?: ReactNode;
}
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
export function PlaylistRemoveDialog({ playlist: playlistFragment, trigger }: PlaylistRemoveDialogProps) {
const playlist = getFragmentData(PLAYLIST_REMOVE_DIALOG_PLAYLIST, playlistFragment);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
<IconTextButton icon={faMinus} variant="solid" collapsible>
Delete playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Delete playlist">
@@ -46,22 +62,8 @@ export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialog
);
}
PlaylistRemoveDialog.fragments = {
playlist: gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistRemoveToast.fragments.playlist}
fragment PlaylistRemoveDialogPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistRemoveToastPlaylist
id
name
}
`,
};
interface PlaylistRemoveFormProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
playlist: ResultOf<typeof PLAYLIST_REMOVE_DIALOG_PLAYLIST>;
onSuccess(): void;
onCancel(): void;
}
@@ -69,36 +71,59 @@ interface PlaylistRemoveFormProps {
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
const [mutate, { loading, error }] = useMutation(
graphql(`
mutation PlaylistRemove($id: String!) {
DeletePlaylist(id: $id) {
message
}
}
`),
{
onCompleted: () => {
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
onSuccess();
},
refetchQueries: [
// Update the profile page because it includes a list of the user's playlists
PROFILE_PAGE,
],
},
);
async function removePlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}`);
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-${playlist.id}`,
<PlaylistRemoveToast playlist={playlist} />
);
onSuccess();
await mutate({
variables: {
id: playlist.id,
},
});
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<Text>
Do you really want to delete{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<PlaylistSummaryCard playlist={playlist} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
<Busy isBusy={isBusy}>Delete playlist</Busy>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={loading} onClick={removePlaylist}>
<Busy isBusy={loading}>Delete playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning">
<strong>The playlist could not be removed: </strong>
{error.message}
</Text>
) : null}
</Column>
);
}
+221 -155
View File
@@ -1,52 +1,75 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faArrowDown, faListMusic, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import useSWR, { mutate } from "swr";
import { Icon } from "components/icon";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import { useToasts } from "context/toastContext";
import type {
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables
} from "generated/graphql";
import { Text } from "components/text";
import { PlaylistTrackAddToast } from "components/toast/PlaylistTrackAddToast";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { LoginGate } from "components/auth/LoginGate";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import gql from "graphql-tag";
import { Skeleton } from "components/skeleton/Skeleton";
import { Busy } from "components/utils/Busy";
import { fetchDataClient } from "lib/client";
import { PlaylistAddDialog } from "components/dialog/PlaylistAddDialog";
import type { ReactNode } from "react";
import { useMutation } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
import { faArrowDown, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Skeleton } from "@/components/skeleton/Skeleton";
import { Text } from "@/components/text/Text";
import { PlaylistTrackAddToast } from "@/components/toast/PlaylistTrackAddToast";
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
import { PROFILE_PAGE } from "@/pages/profile";
export const PLAYLIST_TRACK_ADD_DIALOG_VIDEO = graphql(`
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
id
}
`);
export const PLAYLIST_TRACK_ADD_DIALOG_ENTRY = graphql(`
fragment PlaylistTrackAddDialogEntry on AnimeThemeEntry {
...VideoSummaryCardEntry
...PlaylistTrackAddToastEntry
...PlaylistTrackRemoveToastEntry
id
}
`);
interface PlaylistTrackAddDialogProps {
video: PlaylistTrackAddDialogVideoFragment;
video: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
entry: FragmentType<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
trigger?: ReactNode;
}
export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialogProps) {
export function PlaylistTrackAddDialog({
video: videoFragment,
entry: entryFragment,
trigger,
}: PlaylistTrackAddDialogProps) {
const video = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_VIDEO, videoFragment);
const entry = getFragmentData(PLAYLIST_TRACK_ADD_DIALOG_ENTRY, entryFragment);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
<IconTextButton icon={faPlus} variant="solid" collapsible>
Add to Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackAddForm
video={video}
onCancel={() => setOpen(false)}
/>
<PlaylistTrackAddForm video={video} entry={entry} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
@@ -54,60 +77,35 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
);
}
PlaylistTrackAddDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
id
const PLAYLIST_TRACK_ADD_FORM_PLAYLIST = graphql(`
query PlaylistTrackAddFormPlaylist($entryId: Mixed!, $videoId: Mixed!) {
me {
playlists {
...PlaylistTrackAddCardPlaylist
id
tracks(where: [{ field: ENTRY_ID, value: $entryId }, { field: VIDEO_ID, value: $videoId }]) {
...PlaylistTrackAddCardTrack
}
}
}
`,
};
}
`);
interface PlaylistTrackAddFormProps {
video: PlaylistTrackAddDialogVideoFragment;
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
onCancel(): void;
}
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
const { data: playlists } = useSWR(
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
async () => {
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
${PlaylistSummaryCard.fragments.playlist}
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
me {
playlistAll {
...PlaylistSummaryCardPlaylist
id
tracks_count
}
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
id
tracks {
id
}
}
}
}
`, { filterVideoId: video.id });
const { playlistAll, playlistAllFiltered } = data.me;
return playlistAll?.map((playlist) => {
return {
...playlist,
...playlistAllFiltered?.find((p) => p.id === playlist.id)
};
}) ?? [];
function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormProps) {
const { data } = useQuery(PLAYLIST_TRACK_ADD_FORM_PLAYLIST, {
variables: {
entryId: entry.id,
videoId: video.id,
},
);
});
if (!playlists) {
if (!data) {
return (
<Column style={{ "--gap": "24px" }}>
<Skeleton variant="summary-card" />
@@ -118,115 +116,183 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
);
}
const playlists = data.me?.playlists ?? [];
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} />
<VideoSummaryCard video={video} entry={entry} menu={null} />
<Row style={{ "--justify-content": "center" }}>
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
<Column style={{ "--gap": "16px" }}>
{playlists?.length ? playlists.map((playlist) => (
<PlaylistTrackAddCard
key={playlist.id}
playlist={playlist}
video={video}
/>
)) : (
{playlists?.length ? (
playlists.map((playlist) => (
<PlaylistTrackAddCard
key={playlist.id}
playlist={playlist}
track={playlist.tracks[0] ?? null}
video={video}
entry={entry}
/>
))
) : (
<Text>You have not created a playlist, yet.</Text>
)}
<PlaylistAddDialog trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
} />
<PlaylistAddDialog
trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
}
/>
</Column>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
</Row>
</Column>
);
}
const PLAYLIST_TRACK_ADD_CARD_PLAYLIST = graphql(`
fragment PlaylistTrackAddCardPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistTrackAddToastPlaylist
...PlaylistTrackRemoveToastPlaylist
id
}
`);
const PLAYLIST_TRACK_ADD_CARD_TRACK = graphql(`
fragment PlaylistTrackAddCardTrack on PlaylistTrack {
id
}
`);
interface PlaylistTrackAddCardProps {
playlist:
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
video: PlaylistTrackAddDialogVideoFragment;
playlist: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_PLAYLIST>;
track: FragmentType<typeof PLAYLIST_TRACK_ADD_CARD_TRACK> | null;
video: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_VIDEO>;
entry: ResultOf<typeof PLAYLIST_TRACK_ADD_DIALOG_ENTRY>;
}
function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
function PlaylistTrackAddCard({
playlist: playlistFragment,
track: trackFragment,
video,
entry,
}: PlaylistTrackAddCardProps) {
const playlist = getFragmentData(PLAYLIST_TRACK_ADD_CARD_PLAYLIST, playlistFragment);
const track = getFragmentData(PLAYLIST_TRACK_ADD_CARD_TRACK, trackFragment);
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
const [mutateAddTrack, { loading: loadingAddTrack, error: errorAddTrack }] = useMutation(
graphql(`
mutation PlaylistTrackAdd($playlistId: String!, $entryId: Int!, $videoId: Int!) {
CreatePlaylistTrack(playlist: $playlistId, entryId: $entryId, videoId: $videoId) {
id
}
}
`),
{
onCompleted: () => {
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} entry={entry} />,
);
},
refetchQueries: [
// Update the profile page because it includes a list of the user's playlists
PROFILE_PAGE,
// Update the list in the dialog above
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
],
},
);
const [mutateRemoveTrack, { loading: loadingRemoveTrack, error: errorRemoveTrack }] = useMutation(
graphql(`
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
message
}
}
`),
{
onCompleted: () => {
if (track === null) {
return;
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
);
},
refetchQueries: [
// Update the profile page because it includes a list of the user's playlists
PROFILE_PAGE,
// Update the list in the dialog above
PLAYLIST_TRACK_ADD_FORM_PLAYLIST,
// Update the playlist page in case the user is editing from there
PLAYLIST_DETAIL_PAGE_PLAYLIST,
],
},
);
const loading = loadingAddTrack || loadingRemoveTrack;
async function addTrackToPlaylist() {
setBusy(true);
try {
await axios.post(`/api/playlist/${playlist.id}/track`, { video_id: video.id, });
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} video={video} />
);
await mutateAddTrack({
variables: {
playlistId: playlist.id,
videoId: video.id,
entryId: entry.id,
},
});
}
async function removeTrackFromPlaylist() {
const track = playlist.tracks?.[0];
if (!track) {
return;
}
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${track.id}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
await mutateRemoveTrack({
variables: {
id: track.id,
playlistId: playlist.id,
},
});
}
return (
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{!playlist.tracks?.length ? (
<IconTextButton
icon={faPlus}
disabled={isBusy}
onClick={addTrackToPlaylist}
>
<Busy isBusy={isBusy}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton
icon={faMinus}
disabled={isBusy}
onClick={removeTrackFromPlaylist}
>
<Busy isBusy={isBusy}>Remove</Busy>
</IconTextButton>
)}
</PlaylistSummaryCard>
<>
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{track === null ? (
<IconTextButton icon={faPlus} disabled={loading} onClick={addTrackToPlaylist}>
<Busy isBusy={loading}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton icon={faMinus} disabled={loading} onClick={removeTrackFromPlaylist}>
<Busy isBusy={loading}>Remove</Busy>
</IconTextButton>
)}
</PlaylistSummaryCard>
{errorAddTrack ? (
<Text color="text-warning">
<strong>The theme could not be added to the playlist: </strong>
{errorAddTrack.message}
</Text>
) : null}
{errorRemoveTrack ? (
<Text color="text-warning">
<strong>The theme could not be removed from the playlist: </strong>
{errorRemoveTrack.message}
</Text>
) : null}
</>
);
}
@@ -1,38 +1,72 @@
import type {
PlaylistTrackRemoveDialogPlaylistFragment,
PlaylistTrackRemoveDialogVideoFragment
} from "generated/graphql";
import type { ReactNode } from "react";
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { LoginGate } from "components/auth/LoginGate";
import axios from "lib/client/axios";
import { Column, Row } from "components/box";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import { Text } from "components/text";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { useToasts } from "context/toastContext";
import { mutate } from "swr";
import gql from "graphql-tag";
import { Busy } from "components/utils/Busy";
import type { ReactNode } from "react";
import { useMutation } from "@apollo/client";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Text } from "@/components/text/Text";
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import { PLAYLIST_DETAIL_PAGE_PLAYLIST } from "@/pages/playlist/[playlistId]";
export const PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST = graphql(`
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
...PlaylistTrackRemoveToastPlaylist
id
name
}
`);
export const PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO = graphql(`
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
}
`);
export const PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY = graphql(`
fragment PlaylistTrackRemoveDialogEntry on AnimeThemeEntry {
...VideoSummaryCardEntry
...PlaylistTrackRemoveToastEntry
}
`);
interface PlaylistTrackRemoveDialogProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
playlist: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
video: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
entry: FragmentType<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
trigger?: ReactNode;
}
export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }: PlaylistTrackRemoveDialogProps) {
export function PlaylistTrackRemoveDialog({
playlist: playlistFragment,
trackId,
video: videoFragment,
entry: entryFragment,
trigger,
}: PlaylistTrackRemoveDialogProps) {
const playlist = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST, playlistFragment);
const video = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO, videoFragment);
const entry = getFragmentData(PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY, entryFragment);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
<IconTextButton icon={faMinus} variant="solid" collapsible>
Remove from Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Remove from playlist">
@@ -43,6 +77,7 @@ export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }:
playlist={playlist}
trackId={trackId}
video={video}
entry={entry}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
@@ -53,73 +88,82 @@ export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }:
);
}
PlaylistTrackRemoveDialog.fragments = {
playlist: gql`
${PlaylistTrackRemoveToast.fragments.playlist}
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
...PlaylistTrackRemoveToastPlaylist
id
name
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
}
`,
};
interface PlaylistTrackRemoveFormProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
playlist: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_PLAYLIST>;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
video: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_VIDEO>;
entry: ResultOf<typeof PLAYLIST_TRACK_REMOVE_DIALOG_ENTRY>;
onSuccess(): void;
onCancel(): void;
}
function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel }: PlaylistTrackRemoveFormProps) {
function PlaylistTrackRemoveForm({
playlist,
trackId,
video,
entry,
onSuccess,
onCancel,
}: PlaylistTrackRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
const [mutate, { loading, error }] = useMutation(
graphql(`
mutation PlaylistTrackRemove($id: String!, $playlistId: String!) {
DeletePlaylistTrack(id: $id, playlist: $playlistId) {
message
}
}
`),
{
onCompleted: () => {
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
);
onSuccess();
},
refetchQueries: [
// Update the playlist page in case the user is editing from there
PLAYLIST_DETAIL_PAGE_PLAYLIST,
],
},
);
async function removeTrackFromPlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${trackId}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
onSuccess();
await mutate({
variables: {
id: trackId,
playlistId: playlist.id,
},
});
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<VideoSummaryCard video={video} />
<Text>
Do you really want to remove this video from{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<VideoSummaryCard video={video} entry={entry} menu={null} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove from playlist</Busy>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={loading} onClick={removeTrackFromPlaylist}>
<Busy isBusy={loading}>Remove from playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning">
<strong>The theme could not be removed from the playlist: </strong>
{error.message}
</Text>
) : null}
</Column>
);
}
+50 -39
View File
@@ -1,17 +1,18 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import useAuth from "hooks/useAuth";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import Switch from "components/form/Switch";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import Link from "next/link";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth, { type RegisterErrors } from "@/hooks/useAuth";
export function RegisterDialog() {
const [open, setOpen] = useState(false);
@@ -22,9 +23,7 @@ export function RegisterDialog() {
</DialogTrigger>
<DialogContent title="Create a new account">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<RegisterForm onCancel={() => setOpen(false)} />
) : null}
{open ? <RegisterForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
@@ -52,11 +51,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
password?: string[]
}>({});
const [errors, setErrors] = useState<RegisterErrors>({});
function performRegister(event: SyntheticEvent) {
event.preventDefault();
@@ -70,8 +65,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
password,
password_confirmation: passwordConfirmation,
terms: isTermsAccepted,
})
.finally(() => setBusy(false));
}).finally(() => setBusy(false));
}
return (
@@ -87,9 +81,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.name ? errors.name.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail</Text>
@@ -101,9 +99,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
@@ -115,9 +117,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm Password</Text>
@@ -131,18 +137,23 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
/>
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-terms"
isChecked={isTermsAccepted}
onCheckedChange={setTermsAccepted}
/>
<Text as="label" htmlFor="input-terms">I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
<Switch id="input-terms" isChecked={isTermsAccepted} onCheckedChange={setTermsAccepted} />
<Text as="label" htmlFor="input-terms">
I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>
Terms of Service
</Text>{" "}
and{" "}
<Text as={Link} href="/about/privacy-policy" link>
Privacy Policy
</Text>
.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Account</Busy>
</Button>
@@ -151,4 +162,4 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
</StyledForm>
</>
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { type FormEvent, type ReactNode, useState } from "react";
import styled from "styled-components";
import { Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilterMediaFormat";
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import type { AnimeMediaFormat, ThemeType } from "@/graphql/generated/graphql";
import useRandomThemes from "@/hooks/useRandomThemes";
interface ShuffleDialogProps {
trigger: ReactNode;
}
export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent title="Shuffle">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <ShuffleForm onCancel={() => setOpen(false)} onSuccess={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
display: flex;
flex-direction: column;
gap: 32px;
`;
interface ShuffleFormProps {
onSuccess(): void;
onCancel(): void;
}
function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
const [isBusy, setBusy] = useState(false);
const [filterThemeType, setFilterThemeType] = useState<ThemeType | null>(null);
const [filterMediaFormat, setFilterMediaFormat] = useState<AnimeMediaFormat | null>(null);
const [filterAnimeYearMin, setFilterAnimeYearMin] = useState("");
const [filterAnimeYearMax, setFilterAnimeYearMax] = useState("");
const { playRandomThemes } = useRandomThemes();
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
try {
await playRandomThemes({
themeType: filterThemeType ?? undefined,
mediaFormat: filterMediaFormat ?? undefined,
animeYearMin: parseInt(filterAnimeYearMin),
animeYearMax: parseInt(filterAnimeYearMax),
});
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<SearchFilterGroup>
<SearchFilter>
<Text variant="h2">Theme Type</Text>
<Switcher selectedItem={filterThemeType} onChange={setFilterThemeType}>
<SwitcherOption value="">Any</SwitcherOption>
<SwitcherOption value="OP">Openings</SwitcherOption>
<SwitcherOption value="ED">Endings</SwitcherOption>
</Switcher>
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered After</Text>
<Input
value={filterAnimeYearMin}
onChange={setFilterAnimeYearMin}
inputProps={{ type: "number", placeholder: "1900" }}
/>
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered Before</Text>
<Input
value={filterAnimeYearMax}
onChange={setFilterAnimeYearMax}
inputProps={{ type: "number", placeholder: "2100" }}
/>
</SearchFilter>
<SearchFilterMediaFormat value={filterMediaFormat} setValue={setFilterMediaFormat} />
</SearchFilterGroup>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isBusy}>
<Busy isBusy={isBusy}>Start Shuffle</Busy>
</Button>
</Row>
</StyledForm>
);
}
@@ -0,0 +1,126 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
export function UserInformationDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Change User Information</Button>
</DialogTrigger>
<DialogContent title="Change User Information">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <UserInformationForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface UserInformationFormProps {
onSuccess(): void;
onCancel(): void;
}
function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps) {
const { me, updateUserInformation } = useAuth();
const { dispatchToast } = useToasts();
const [username, setUsername] = useState(me?.name ?? "");
const [email, setEmail] = useState(me?.email ?? "");
const isValid = username && email;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
}>({});
async function performChangeUserInformation(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await updateUserInformation({ setErrors, name: username, email });
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
onSuccess();
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performChangeUserInformation}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Username</Text>
<Input
value={username}
onChange={setUsername}
inputProps={{
required: true,
}}
/>
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail Address</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -1,14 +1,16 @@
import styled, { keyframes } from "styled-components";
import theme from "theme";
import Link from "next/link";
import { faArrowRight, faTrophy } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { Button } from "components/button";
import { faArrowRight, faTrophy } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
@@ -24,7 +26,7 @@ const EventButton = styled(Button)`
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
@@ -37,15 +39,15 @@ const GradientBorder = styled.div`
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: '<angle>';
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
@@ -55,10 +57,10 @@ const GradientBorder = styled.div`
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
@@ -72,13 +74,15 @@ export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps)
return (
<EventButtonContainer>
<GradientBorder />
<Link href="/event/anime-awards" passHref legacyBehavior>
<EventButton forwardedAs="a">
<EventButton asChild>
<Link href="/event/anime-awards">
<EventIcon icon={faTrophy} />
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
<Text>
<Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!
</Text>
<Icon icon={faArrowRight} color="text-primary" />
</EventButton>
</Link>
</Link>
</EventButton>
</EventButtonContainer>
);
}
}
@@ -1,11 +1,13 @@
import { faChevronCircleRight } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
href?: string | null
children: ReactNode
href?: string | null;
children: ReactNode;
}
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
@@ -14,7 +16,7 @@ export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
<Text>{children}</Text>
<Text noWrap>
&nbsp;
<Icon icon={faChevronCircleRight}/>
<Icon icon={faChevronCircleRight} />
</Text>
</Text>
);
-1
View File
@@ -1 +0,0 @@
export { ExternalLink } from "./ExternalLink";
+89 -88
View File
@@ -1,18 +1,20 @@
import { FeaturedThemePreview } from "utils/settings";
import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import extractImages from "utils/extractImages";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import useSetting from "hooks/useSetting";
import useCompatability from "hooks/useCompatability";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import type { ReactNode } from "react";
import styled, { keyframes } from "styled-components";
import Link from "next/link";
import { VIDEO_URL } from "utils/config.mjs";
import gql from "graphql-tag";
import type { FeaturedThemeThemeFragment } from "generated/graphql";
import type { ResultOf } from "@graphql-typed-document-node/core";
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import useCompatability from "@/hooks/useCompatability";
import useSetting from "@/hooks/useSetting";
import { fetchRandomGrill } from "@/lib/client/randomGrill";
import theme from "@/theme";
import { VIDEO_URL } from "@/utils/config";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
import { FeaturedThemePreview } from "@/utils/settings";
const slowPan = keyframes`
from {
@@ -36,11 +38,11 @@ const StyledWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
position: relative;
@media (max-width: ${theme.breakpoints.mobileMax}) {
margin-inline-start: -16px;
margin-inline-end: -16px;
@@ -61,7 +63,7 @@ const StyledOverflowHidden = styled(Link)`
const StyledCenter = styled.div`
position: absolute;
width: 400px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
@@ -89,12 +91,12 @@ const StyledCover = styled.img`
const StyledGrillContainer = styled.div`
position: absolute;
height: 130%;
bottom: 0;
right: 32px;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
right: 0;
}
@@ -106,10 +108,10 @@ const StyledGrill = styled.img`
object-fit: contain;
object-position: bottom;
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 1s;
transform: translateY(10%);
&:hover {
transform: none;
}
@@ -117,15 +119,52 @@ const StyledGrill = styled.img`
const Box = styled.div``;
export const FEATURED_THEME_ENTRY = graphql(`
fragment FeaturedThemeEntry on AnimeThemeEntry {
...VideoSummaryCardEntry
...createVideoSlugEntry
animetheme {
...createVideoSlugTheme
anime {
slug
images {
nodes {
...extractImagesImage
}
}
}
}
}
`);
export const FEATURED_THEME_VIDEO = graphql(`
fragment FeaturedThemeVideo on Video {
...VideoSummaryCardVideo
...createVideoSlugVideo
basename
}
`);
interface FeaturedThemeProps {
theme: FeaturedThemeThemeFragment;
entry: FragmentType<typeof FEATURED_THEME_ENTRY>;
video: FragmentType<typeof FEATURED_THEME_VIDEO>;
hasGrill?: boolean;
card?: ReactNode;
onPlay?(): void;
}
export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemeProps) {
const [ grill, setGrill ] = useState<string | null>(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
export function FeaturedTheme({
entry: entryFragment,
video: videoFragment,
hasGrill = true,
card,
onPlay,
}: FeaturedThemeProps) {
const entry = getFragmentData(FEATURED_THEME_ENTRY, entryFragment);
const video = getFragmentData(FEATURED_THEME_VIDEO, videoFragment);
const [grill, setGrill] = useState<string | null>(null);
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
useEffect(() => {
if (hasGrill) {
@@ -133,26 +172,21 @@ export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemePro
}
}, [hasGrill]);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
? StyledWrapper
: Box;
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED ? StyledWrapper : Box;
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
? (
<StyledCenter>
{card ?? <ThemeSummaryCard theme={theme}/>}
</StyledCenter>
)
: (
card ?? <ThemeSummaryCard theme={theme}/>
const featuredThemeSummaryCard =
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
) : (
(card ?? <VideoSummaryCard entry={entry} video={video} />)
);
return (
<FeaturedThemeWrapper>
<FeaturedThemeBackground theme={theme}/>
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
<StyledGrillContainer>
<StyledGrill src={grill}/>
<StyledGrill src={grill} />
</StyledGrillContainer>
)}
{featuredThemeSummaryCard}
@@ -160,70 +194,37 @@ export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemePro
);
}
function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
interface FeaturedThemeBackgroundProps {
entry: ResultOf<typeof FEATURED_THEME_ENTRY>;
video: ResultOf<typeof FEATURED_THEME_VIDEO>;
onPlay?(): void;
}
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeBackgroundProps) {
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability();
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const { smallCover: featuredCover } = extractImages(theme.anime);
const [fallbackToCover, setFallbackToCover] = useState(false);
const { smallCover: featuredCover } = extractImages(entry.animetheme.anime.images.nodes);
if (!theme.anime || !theme.entries.length) {
return null;
}
const videoSlug = createVideoSlug(entry.animetheme, entry, video);
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
const href = `/anime/${entry.animetheme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<StyledOverflowHidden href={href}>
<StyledVideo
key={video.basename}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledVideo key={video.basename} autoPlay muted loop onError={() => setFallbackToCover(true)}>
<source src={`${VIDEO_URL}/${video.basename}`} type={`video/webm; codecs="vp8, vp9, opus`} />
</StyledVideo>
</StyledOverflowHidden>
);
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<StyledOverflowHidden href={href}>
<StyledCover src={featuredCover}/>
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledCover src={featuredCover} />
</StyledOverflowHidden>
);
}
return null;
}
FeaturedTheme.fragments = {
theme: gql`
${ThemeSummaryCard.fragments.theme}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeTheme on Theme {
...ThemeSummaryCardTheme
anime {
...extractImagesResourceWithImages
}
entries {
videos {
basename
}
}
}
`
};
-1
View File
@@ -1 +0,0 @@
export { FeaturedTheme } from "./FeaturedTheme";
+92 -49
View File
@@ -1,65 +1,105 @@
import { memo, useMemo, useState } from "react";
import { Column, Row } from "components/box";
import { ThemeDetailCard } from "components/card";
import { HorizontalScroll } from "components/utils";
import { either, themeIndexComparator, themeTypeComparator } from "utils/comparators";
import gql from "graphql-tag";
import type { AnimeThemeFilterThemeFragment } from "generated/graphql";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import { Column, Row } from "@/components/box/Flex";
import { ThemeDetailCard } from "@/components/card/ThemeDetailCard";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { Text } from "@/components/text/Text";
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
export const ANIME_THEME_FILTER_THEME = graphql(`
fragment AnimeThemeFilterTheme on AnimeTheme {
...ThemeDetailCardTheme
id
type
sequence
group {
name
slug
}
}
`);
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>
themes: Array<FragmentType<typeof ANIME_THEME_FILTER_THEME>>;
}
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
function AnimeThemeFilterInternal({ themes: themesFragment }: AnimeThemeFilterProps) {
const themes = getFragmentData(ANIME_THEME_FILTER_THEME, themesFragment);
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
const [filterType, setFilterType] = useState<string | null>(null);
const filteredThemes = themes
.filter((theme) => !filterType || theme.type === filterType)
.sort(either(themeGroupComparator).or(themeTypeComparator).or(themeIndexComparator).chain());
const groups = useMemo(
() => themes.reduce<{
name: string,
themes: typeof themes
}[]>(
(groups, theme) => {
const groupName = theme.group || "Original";
() =>
filteredThemes.reduce<
{
name: string;
slug: string;
themes: typeof themes;
}[]
>((groups, theme) => {
const groupName = theme.group?.name || "Original";
const groupSlug = theme.group?.slug || "original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
slug: groupSlug,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
},
[]
),
[ themes ]
}, []),
[filteredThemes],
);
const [ activeGroup, setActiveGroup ] = useState(groups[0]?.name);
const activeThemes = groups.find((group) => group.name === activeGroup)?.themes ?? [];
const hasMultipleTypes = activeThemes.find((theme) => theme.type === "OP") && activeThemes.find((theme) => theme.type === "ED");
const [ filterType, setFilterType ] = useState<string | null>(null);
const filteredThemes = activeThemes
.filter((theme) => !filterType || theme.type === filterType)
.sort(either(themeTypeComparator).or(themeIndexComparator).chain());
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const activeGroupThemes = groups.find((group) => group.slug === activeGroup)?.themes;
return (
<Column style={{ "--gap": "16px" }}>
<Column style={{ "--gap": "24px" }}>
{(groups.length > 1 || hasMultipleTypes) && (
<HorizontalScroll fixShadows>
<HorizontalScroll $fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onValueChange={setActiveGroup}>
<Listbox
value={activeGroup}
onValueChange={setActiveGroup}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
All Groups
</ListboxOption>
{groups.map((group) => (
<ListboxOption key={group.name} value={group.name}>{group.name}</ListboxOption>
<ListboxOption key={group.slug} value={group.slug}>
{group.name}
</ListboxOption>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
<Listbox
value={filterType}
onValueChange={setFilterType}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
OP & ED
</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
@@ -67,23 +107,26 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
</Row>
</HorizontalScroll>
)}
{filteredThemes.map((theme, index) => (
<ThemeDetailCard key={index} theme={theme} />
))}
<Column style={{ "--gap": "48px" }}>
{activeGroupThemes ? (
<Column style={{ "--gap": "16px" }}>
{activeGroupThemes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
) : (
groups.map((group) => (
<Column key={group.slug} style={{ "--gap": "16px" }}>
{groups.length > 1 && <Text variant="h3">{group.name}</Text>}
{group.themes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
))
)}
</Column>
</Column>
);
}
AnimeThemeFilterInternal.fragments = {
theme: gql`
${ThemeDetailCard.fragments.theme}
fragment AnimeThemeFilterTheme on Theme {
...ThemeDetailCardTheme
type
group
}
`
};
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
-1
View File
@@ -1 +0,0 @@
export { AnimeThemeFilter } from "./AnimeThemeFilter";
+33 -42
View File
@@ -1,13 +1,15 @@
import Link from "next/link";
import { Column, Row, Solid } from "components/box";
import { Container } from "components/container";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import type { ComponentPropsWithoutRef } from "react";
import Link from "next/link";
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { Column, Row } from "@/components/box/Flex";
import { Solid } from "@/components/box/Solid";
import { Button } from "@/components/button/Button";
import { Container } from "@/components/container/Container";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -29,45 +31,46 @@ const StyledSocialList = styled(Row)`
justify-content: flex-end;
align-items: flex-end;
gap: 4px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-basis: 100%;
justify-content: flex-start;
}
// To avoid overlap with scroll back to top button as window width gets smaller
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width: ${theme.breakpoints.mobileMax}) {
margin-right: 64px;
}
`;
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
font-size: 1.2rem;
`;
const FooterLink = styled(Text).attrs({ as: Link })``;
const FooterTextLink = styled(FooterLink).attrs({
link: true,
block: true,
color: "text-muted",
noWrap: true,
})``;
export function Footer() {
return (
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="/about/transparency">
Transparency
</FooterTextLink>
<FooterTextLink href="/about/donate">
Donate
</FooterTextLink>
<FooterTextLink href="/about/faq">
FAQ
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
<FooterTextLink as="a" href="https://api-docs.animethemes.moe" target="_blank">
API Documentation
</FooterTextLink>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="/about/terms-of-service">
Terms of Service
</FooterTextLink>
<FooterTextLink href="/about/privacy-policy">
Privacy Policy
</FooterTextLink>
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
<FooterTextLink href="/about/privacy-policy">Privacy Policy</FooterTextLink>
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
Contact
</FooterTextLink>
@@ -75,22 +78,22 @@ export function Footer() {
<StyledSocialList>
<FooterLink href="https://reddit.com/r/AnimeThemes">
<StyledSocialButton title="Reddit">
<Icon icon={faReddit}/>
<Icon icon={faReddit} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://discordapp.com/invite/m9zbVyQ">
<StyledSocialButton title="Discord">
<Icon icon={faDiscord}/>
<Icon icon={faDiscord} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://twitter.com/AnimeThemesMoe">
<StyledSocialButton title="Twitter">
<Icon icon={faTwitter}/>
<Icon icon={faTwitter} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://github.com/AnimeThemes">
<StyledSocialButton title="GitHub">
<Icon icon={faGithub}/>
<Icon icon={faGithub} />
</StyledSocialButton>
</FooterLink>
</StyledSocialList>
@@ -98,15 +101,3 @@ export function Footer() {
</StyledFooter>
);
}
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
return (
<Text as={Link} {...props}/>
);
}
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
return (
<FooterLink link block color="text-muted" noWrap {...props}/>
);
}
-1
View File
@@ -1 +0,0 @@
export { Footer } from "./Footer";
+20 -27
View File
@@ -1,11 +1,13 @@
import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef } from "react";
import { withColorTheme } from "styles/mixins";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
const StyledSearchInput = styled.div`
display: flex;
@@ -15,10 +17,10 @@ const StyledSearchInput = styled.div`
padding: 0.5rem 1rem;
border-radius: 2rem;
gap: 8px;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
&:focus-within {
box-shadow: ${theme.shadows.low};
@@ -32,34 +34,25 @@ const StyledInput = styled.input`
`;
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
margin: -8px;
&:hover {
background-color: transparent;
box-shadow: none;
}
`;
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
value: string
onChange: (value: string) => void
resettable?: boolean
icon?: IconDefinition
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
interface InputProps extends Omit<ComponentPropsWithoutRef<typeof StyledSearchInput>, "onChange"> {
value: string;
onChange: (value: string) => void;
resettable?: boolean;
icon?: IconDefinition;
inputProps?: ComponentPropsWithRef<typeof StyledInput>;
}
export function Input({
value,
onChange,
resettable = false,
icon,
inputProps = {},
...props
}: InputProps) {
export function Input({ value, onChange, resettable = false, icon, inputProps = {}, ...props }: InputProps) {
return (
<StyledSearchInput {...props}>
{icon && (
<Icon icon={icon} color="text-disabled"/>
)}
{icon && <Icon icon={icon} color="text-disabled" />}
<StyledInput
type="text"
value={value}
@@ -68,7 +61,7 @@ export function Input({
/>
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
<Icon icon={faXmark} onClick={() => onChange && onChange("")} />
</StyledResetButton>
)}
</StyledSearchInput>
+10 -6
View File
@@ -1,7 +1,9 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import styled from "styled-components";
import theme from "theme";
import { withColorTheme } from "styles/mixins";
import * as RadixSwitch from "@radix-ui/react-switch";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
const StyledSwitch = styled(RadixSwitch.Root)`
width: 50px;
@@ -27,10 +29,12 @@ const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
height: 25px;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
transition: transform 250ms, background-color 250ms;
transition:
transform 250ms,
background-color 250ms;
will-change: transform;
&[data-state='checked'] {
&[data-state="checked"] {
transform: translateX(25px);
background-color: ${theme.colors["text-primary"]};
}
+24
View File
@@ -0,0 +1,24 @@
import styled from "styled-components";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
export const TextArea = styled.textarea`
display: block;
padding: 8px 16px;
border-radius: 8px;
resize: vertical;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
scrollbar-color: ${theme.colors["gray-800"]} transparent;
&:focus-within {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
+1 -1
View File
@@ -1 +1 @@
export { Input } from "components/form/Input";
export { Input } from "@/components/form/Input";
@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import { client } from "@/graphql/client";
import { graphql } from "@/graphql/generated";
export function MostPopularEntries() {
const { data: mostPopular = range(10).map(() => null) } = useQuery({
queryKey: ["HomePageTrending"],
queryFn: async () => {
const { data } = await client.query({
query: graphql(`
query HomePageMostPopular {
animethemeentryPagination(sort: [TRACKS_COUNT_DESC], first: 10) {
data {
...VideoSummaryCardEntry
videos(first: 1) {
nodes {
...VideoSummaryCardVideo
}
}
}
}
}
`),
});
return data.animethemeentryPagination.data;
},
});
return (
<Column style={{ "--gap": "16px" }}>
{mostPopular.map((entry, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{entry ? <VideoSummaryCard video={entry.videos.nodes[0]} entry={entry} /> : null}
</Skeleton>
))}
</Column>
);
}
@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import { client } from "@/graphql/client";
import { graphql } from "@/graphql/generated";
export function RecentlyAddedPlaylists() {
const { data: recentlyAddedPlaylists = range(10).map(() => null) } = useQuery({
queryKey: ["HomePageRecentlyAddedPlaylists"],
queryFn: async () => {
const { data } = await client.query({
query: graphql(`
query HomePageRecentlyAddedPlaylists {
playlistPagination(sort: [CREATED_AT_DESC], first: 10) {
data {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardPlaylistWithOwner
}
}
}
`),
});
return data.playlistPagination.data;
},
});
return (
<Column style={{ "--gap": "16px" }}>
{recentlyAddedPlaylists?.map((playlist, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{playlist ? <PlaylistSummaryCard playlist={playlist} playlistWithOwner={playlist} /> : null}
</Skeleton>
))}
</Column>
);
}
@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import { VideoSummaryCard } from "@/components/card/VideoSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import { client } from "@/graphql/client";
import { graphql } from "@/graphql/generated";
export function RecentlyAddedVideos() {
const { data: recentlyAdded = range(10).map(() => null) } = useQuery({
queryKey: ["HomePageRecentlyAdded"],
queryFn: async () => {
const { data } = await client.query({
query: graphql(`
query HomePageRecentlyAdded {
videoPagination(sort: [ID_DESC], first: 10) {
data {
...VideoSummaryCardVideo
animethemeentries(first: 1) {
nodes {
...VideoSummaryCardEntry
}
}
}
}
}
`),
});
return data.videoPagination.data;
},
});
return (
<Column style={{ "--gap": "16px" }}>
{recentlyAdded.map((video, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{video ? <VideoSummaryCard video={video} entry={video.animethemeentries.nodes[0]} /> : null}
</Skeleton>
))}
</Column>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
import { Icon } from "@/components/icon/Icon";
import theme from "@/theme";
export const CornerIcon = styled(Icon)`
position: absolute;
+8 -9
View File
@@ -1,10 +1,11 @@
import type { SVGProps } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
icon: IconDefinition
title?: string
}
icon: IconDefinition;
title?: string;
};
const xmlns = "http://www.w3.org/2000/svg";
@@ -26,17 +27,15 @@ export function FontAwesomeIcon(props: FontAwesomeIconProps) {
className={`${className} svg-inline--fa fa-fw`}
{...rest}
>
{title ? (
<title>{title}</title>
) : null}
{title ? <title>{title}</title> : null}
{children}
{Array.isArray(svgPathData) ? (
<g>
<path d={svgPathData[0]}/>
<path d={svgPathData[1]}/>
<path d={svgPathData[0]} />
<path d={svgPathData[1]} />
</g>
) : (
<path fill="currentColor" d={svgPathData}/>
<path fill="currentColor" d={svgPathData} />
)}
</svg>
);
+8 -6
View File
@@ -1,11 +1,13 @@
import { FontAwesomeIcon } from "components/icon/FontAwesomeIcon";
import styled from "styled-components";
import theme from "theme";
import type { Property } from "csstype";
import type { Colors } from "theme/colors";
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors, transition?: Property.Transition }>`
import type { Property } from "csstype";
import { FontAwesomeIcon } from "@/components/icon/FontAwesomeIcon";
import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors; transition?: Property.Transition }>`
color: ${(props) => props.color && theme.colors[props.color]};
transition: ${(props) => props.transition}
transition: ${(props) => props.transition};
`;
-1
View File
@@ -1 +0,0 @@
export { Icon } from "./Icon";
+13 -21
View File
@@ -1,30 +1,22 @@
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import { FullWidthImage } from "components/image";
import gql from "graphql-tag";
import type { CoverImageResourceWithImagesFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef } from "react";
import { FullWidthImage } from "@/components/image/FullWidthImage";
import { AspectRatio } from "@/components/utils/AspectRatio";
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
resourceWithImages: CoverImageResourceWithImagesFragment
smallCover: string | undefined;
largeCover: string | undefined;
}
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
const { smallCover, largeCover } = extractImages(resourceWithImages);
export function CoverImage({ smallCover, largeCover, ...props }: CoverImageProps) {
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
<AspectRatio $ratio={2 / 3}>
<FullWidthImage
key={largeCover}
src={largeCover}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</AspectRatio>
);
}
CoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment CoverImageResourceWithImages on ResourceWithImages {
...extractImagesResourceWithImages
}
`,
};
+3 -2
View File
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const FullWidthImage = styled.img`
width: 100%;
@@ -7,7 +8,7 @@ export const FullWidthImage = styled.img`
object-fit: cover;
border-radius: 0.5rem;
box-shadow: ${theme.shadows.medium};
background-size: cover;
background-position: center;
`;
+2 -2
View File
@@ -3,8 +3,8 @@ import type { SVGAttributes } from "react";
export function Logo(props: SVGAttributes<SVGElement>) {
return (
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48" />
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48" />
</svg>
);
}
+7 -6
View File
@@ -1,16 +1,17 @@
import styled from "styled-components";
import theme from "theme";
import { Logo } from "components/image/Logo";
import type { ComponentPropsWithoutRef } from "react";
import styled from "styled-components";
import { Logo } from "@/components/image/Logo";
import theme from "@/theme";
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
@@ -21,7 +22,7 @@ const StyledPlaceholder = styled.div`
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
return (
<StyledPlaceholder {...props}>
<Logo/>
<Logo />
</StyledPlaceholder>
);
}
+85 -112
View File
@@ -1,26 +1,31 @@
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import styled, { css } from "styled-components";
import type { ComponentPropsWithoutRef } from "react";
import type { MultiCoverImageResourceWithImagesFragment } from "generated/graphql";
import gql from "graphql-tag";
import { LogoPlaceholder } from "components/image/LogoPlaceholder";
import styled, { css } from "styled-components";
import { LogoPlaceholder } from "@/components/image/LogoPlaceholder";
import { AspectRatio } from "@/components/utils/AspectRatio";
function getTranslationX(item: number, itemCount: number) {
switch (itemCount) {
case 4:
switch (item) {
case 1: return -33;
case 2: return -16.5;
case 3: return 16.5;
case 4: return 33;
case 1:
return -33;
case 2:
return -16.5;
case 3:
return 16.5;
case 4:
return 33;
}
break;
case 3:
switch (item) {
case 1: return -25;
case 2: return 0;
case 3: return 25;
case 1:
return -25;
case 2:
return 0;
case 3:
return 25;
}
break;
}
@@ -41,58 +46,52 @@ const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
width: 100%;
height: 100%;
${(props) => props.$itemCount > 1 && css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(
0 0,
calc(100% / (${props.$itemCount} - 1)) 0,
0 100%,
0 100%
);
}
${(props) =>
props.$itemCount > 1 &&
css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(0 0, calc(100% / (${props.$itemCount} - 1)) 0, 0 100%, 0 100%);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(
100% 0,
100% 0,
100% 100%,
calc(100% / (${props.$itemCount} - 1) * 2) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(100% 0, 100% 0, 100% 100%, calc(100% / (${props.$itemCount} - 1) * 2) 100%);
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:not(:hover) {
transition: clip-path 500ms, z-index 1000ms;
z-index: 0;
}
`}
&:not(:hover) {
transition:
clip-path 500ms,
z-index 1000ms;
z-index: 0;
}
`}
`;
const StyledCover = styled.img`
width: 100%;
@@ -103,7 +102,7 @@ const StyledCover = styled.img`
background-position: center;
transition: transform 500ms;
transform: translateX(var(--translate-x));
&:hover {
transition: transform 250ms;
transform: scale(1.1);
@@ -111,63 +110,37 @@ const StyledCover = styled.img`
`;
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
items: Array<{
largeCover?: string;
smallCover?: string;
name: string;
}>;
}
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
const images = resourcesWithImages
.filter((resource, index, list) =>
list.findIndex((r) => r.name === resource.name) === index
)
.map((resource) => {
const { largeCover, smallCover } = extractImages(resource);
return {
largeCover,
smallCover,
resource,
};
})
.filter(({ largeCover }) => !!largeCover)
.slice(0, 4);
export function MultiCoverImage({ items, ...props }: MultiCoverImageProps) {
return (
<AspectRatio ratio={2 / 3}>
<AspectRatio $ratio={2 / 3}>
<StyledCoverContainer>
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
<StyledCover
loading="lazy"
src={largeCover}
alt={`Cover image of ${resource.name}`}
title={resource.name}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</StyledCoverItemContainer>
)) : (
<LogoPlaceholder {...props}/>
{items.length ? (
items
.filter(({ largeCover }) => !!largeCover)
.slice(0, 4)
.map(({ largeCover, smallCover, name }, _, slicedItems) => (
<StyledCoverItemContainer key={largeCover} $itemCount={slicedItems.length}>
<StyledCover
loading="lazy"
src={largeCover}
alt={`Cover image of ${name}`}
title={name}
style={smallCover ? { backgroundImage: `url(${smallCover})` } : undefined}
{...props}
/>
</StyledCoverItemContainer>
))
) : (
<LogoPlaceholder {...props} />
)}
</StyledCoverContainer>
</AspectRatio>
);
}
MultiCoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment MultiCoverImageResourceWithImages on ResourceWithImages {
... on Anime {
name
}
... on Artist {
name
}
... on Studio {
name
}
...extractImagesResourceWithImages
}
`,
};
+13 -13
View File
@@ -1,13 +1,22 @@
import gql from "graphql-tag";
import type { ProfileImageUserFragment } from "generated/graphql";
import md5 from "md5";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
export const PROFILE_IMAGE_USER = graphql(`
fragment ProfileImageUser on Me {
name
email
}
`);
interface ProfileImageProps {
user: ProfileImageUserFragment;
user: FragmentType<typeof PROFILE_IMAGE_USER>;
size?: number;
}
export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
export function ProfileImage({ user: userFragment, size = 80, ...props }: ProfileImageProps) {
const user = getFragmentData(PROFILE_IMAGE_USER, userFragment);
const hash = md5(user.email.trim().toLowerCase());
return (
@@ -20,12 +29,3 @@ export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
/>
);
}
ProfileImage.fragments = {
user: gql`
fragment ProfileImageUser on UserAuth {
name
email
}
`,
};
+40 -30
View File
@@ -1,28 +1,50 @@
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import { FullWidthImage, MultiCoverImage } from "components/image";
import gql from "graphql-tag";
import type { ComponentPropsWithoutRef } from "react";
import { useState } from "react";
import type { StudioCoverImageStudioFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef } from "react";
import styled from "styled-components";
import { FullWidthImage } from "@/components/image/FullWidthImage";
import { MultiCoverImage } from "@/components/image/MultiCoverImage";
import { AspectRatio } from "@/components/utils/AspectRatio";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
import extractImages from "@/utils/extractImages";
const StyledImage = styled(FullWidthImage)`
object-fit: contain;
background-size: contain;
`;
export const STUDIO_COVER_IMAGE_STUDIO = graphql(`
fragment StudioCoverImageStudio on Studio {
images {
nodes {
...extractImagesImage
}
}
anime {
nodes {
name
images {
nodes {
...extractImagesImage
}
}
}
}
}
`);
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
studio: StudioCoverImageStudioFragment
studio: FragmentType<typeof STUDIO_COVER_IMAGE_STUDIO>;
}
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
const { largeCover } = extractImages(studio);
export function StudioCoverImage({ studio: studioFragment, ...props }: StudioCoverImageProps) {
const studio = getFragmentData(STUDIO_COVER_IMAGE_STUDIO, studioFragment);
const { largeCover } = extractImages(studio.images.nodes);
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
const [imageNotFound, setImageNotFound] = useState(!largeCover);
return (
<AspectRatio ratio={2 / 3}>
<AspectRatio $ratio={2 / 3}>
{!imageNotFound ? (
<StyledImage
key={largeCover}
@@ -32,26 +54,14 @@ export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
{...props}
/>
) : (
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
<MultiCoverImage
items={studio.anime.nodes.map((anime) => ({
...extractImages(anime.images.nodes),
name: anime.name,
}))}
{...props}
/>
)}
</AspectRatio>
);
}
StudioCoverImage.fragments = {
studio: gql`
fragment StudioCoverImageStudio on Studio {
images {
link
facet
}
anime {
name
images {
link
facet
}
}
}
`
};
-4
View File
@@ -1,4 +0,0 @@
export { FullWidthImage } from "./FullWidthImage";
export { CoverImage } from "./CoverImage";
export { MultiCoverImage } from "./MultiCoverImage";
export { Logo } from "./Logo";
+30 -31
View File
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import React from "react";
import Link from "next/link";
import { groupBy } from "lodash-es";
import { Text } from "components/text";
import type { ReactNode } from "react";
import styled from "styled-components";
import Link from "next/link";
import { groupBy } from "lodash-es";
import { Text } from "@/components/text/Text";
const StyledLetterList = styled.div`
display: flex;
@@ -23,44 +25,41 @@ const StyledItemGrid = styled.div`
type AlphabeticalIndexItem = { name: string };
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
items: Array<T>
children: (item: T) => ReactNode
items: Array<T>;
children: (item: T) => ReactNode;
};
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
const itemsByFirstLetter = Object.entries(
groupBy(
items.sort((a, b) => a.name.localeCompare(b.name)),
[...items].sort((a, b) => a.name.localeCompare(b.name)),
(item) => {
const firstLetter = item.name[0].toLowerCase();
if (firstLetter.match(/[a-z]/)) {
return firstLetter;
}
return "0-9";
}
)
)
.sort(([ a ], [ b ]) => a.localeCompare(b));
},
),
).sort(([a], [b]) => a.localeCompare(b));
return <>
<StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter ]) => (
<Link
key={firstLetter}
href={`#${firstLetter}`}
passHref
legacyBehavior>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
return (
<>
<StyledLetterList>
{itemsByFirstLetter.map(([firstLetter]) => (
<Text key={firstLetter} as={Link} href={`#${firstLetter}`} link>
{firstLetter.toUpperCase()}{" "}
</Text>
))}
</StyledLetterList>
{itemsByFirstLetter.map(([firstLetter, itemsWithFirstLetter]) => (
<React.Fragment key={firstLetter}>
<Text id={firstLetter} variant="h2">
{firstLetter}
</Text>
<StyledItemGrid>{itemsWithFirstLetter.map((item) => children(item))}</StyledItemGrid>
</React.Fragment>
))}
</StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter, itemsWithFirstLetter ]) => (
<React.Fragment key={firstLetter}>
<Text id={firstLetter} variant="h2">{firstLetter}</Text>
<StyledItemGrid>
{itemsWithFirstLetter.map((item) => children(item))}
</StyledItemGrid>
</React.Fragment>
))}
</>;
</>
);
}
-1
View File
@@ -1 +0,0 @@
export { AlphabeticalIndex } from "./AlphabeticalIndex";
+84 -74
View File
@@ -1,11 +1,13 @@
import type { RefObject } from "react";
import styled from "styled-components";
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import theme from "theme";
import { faCheck, faSort, faXmark } from "@fortawesome/free-solid-svg-icons";
import * as RadixSelect from "@radix-ui/react-select";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
import { forwardRef } from "react";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { flipDown } from "@/styles/animations";
import theme from "@/theme";
// Radix' listbox can't handle null values, so we are giving it a fake null value.
const NULL_VALUE = "__NULL__";
@@ -35,7 +37,9 @@ const StyledListboxPopover = styled(RadixSelect.Portal)`
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
box-shadow:
0 0 0 2px ${theme.colors["text-primary"]},
${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@@ -55,7 +59,7 @@ const StyledListboxList = styled(RadixSelect.Content)`
padding: 8px 0;
`;
const StyledListboxOption = styled(RadixSelect.Item)`
display: ${(props) => props.hidden ? "none" : "flex"};
display: ${(props) => (props.hidden ? "none" : "flex")};
align-items: center;
justify-content: space-between;
@@ -70,16 +74,15 @@ const StyledListboxOption = styled(RadixSelect.Item)`
color: ${theme.colors["text"]};
outline: none;
}
&[data-state="checked"] {
color: ${theme.colors["text-primary"]};
}
`;
type ListboxProps =
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
& (PropsNullable | PropsNotNullable)
& {
type ListboxProps = Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange"> &
(PropsNullable | PropsNotNullable) & {
ref?: RefObject<HTMLButtonElement>;
resettable?: boolean;
highlightNonDefault?: boolean;
};
@@ -98,71 +101,78 @@ interface PropsNotNullable {
defaultValue?: string;
}
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(
function Listbox({
value,
onValueChange,
defaultValue,
nullable,
resettable,
highlightNonDefault,
children,
...props
}, ref) {
const radixValue = value === null ? NULL_VALUE : value;
const radixOnValueChange = (newValue: string) => {
if (nullable) {
onValueChange(newValue === NULL_VALUE ? null : newValue);
} else {
onValueChange(newValue);
}
};
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
export function Listbox({
ref,
value,
onValueChange,
defaultValue,
nullable,
resettable,
highlightNonDefault,
children,
...props
}: ListboxProps) {
const radixValue = value === null ? NULL_VALUE : value;
const radixOnValueChange = (newValue: string) => {
if (nullable) {
onValueChange(newValue === NULL_VALUE ? null : newValue);
} else {
onValueChange(newValue);
}
};
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
return (
<RadixSelect.Root value={radixValue} onValueChange={radixOnValueChange} defaultValue={radixDefaultValue} {...props}>
<RadixSelect.Trigger asChild ref={ref}>
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
<RadixSelect.Value />
<RadixSelect.Icon>
{(resettable && radixDefaultValue && value !== defaultValue) ? (
<StyledListboxReset
onClick={() => radixOnValueChange(radixDefaultValue)}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort} />
)}
</RadixSelect.Icon>
</StyledListboxButton>
</RadixSelect.Trigger>
<StyledListboxPopover>
<StyledListboxList position="popper" sideOffset={8}>
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
</StyledListboxList>
</StyledListboxPopover>
</RadixSelect.Root>
);
}
);
return (
<RadixSelect.Root
value={radixValue}
onValueChange={radixOnValueChange}
defaultValue={radixDefaultValue}
{...props}
>
<RadixSelect.Trigger asChild ref={ref}>
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
<RadixSelect.Value />
<RadixSelect.Icon>
{resettable && radixDefaultValue && value !== defaultValue ? (
<StyledListboxReset
onClick={() => radixOnValueChange(radixDefaultValue)}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon icon={faXmark} />
</StyledListboxReset>
) : (
<Icon icon={faSort} />
)}
</RadixSelect.Icon>
</StyledListboxButton>
</RadixSelect.Trigger>
<StyledListboxPopover>
<StyledListboxList
position="popper"
sideOffset={8}
collisionBoundary={typeof document !== "undefined" ? document.body : []}
>
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
</StyledListboxList>
</StyledListboxPopover>
</RadixSelect.Root>
);
}
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
ref?: RefObject<HTMLDivElement>;
value: string | null;
}
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
function ListboxOption({ value, children, ...props }, ref) {
const radixValue = value === null ? NULL_VALUE : value;
export const ListboxOption = function ListboxOption({ ref, value, children, ...props }: ListboxOptionProps) {
const radixValue = value === null ? NULL_VALUE : value;
return (
<StyledListboxOption value={radixValue} {...props} ref={ref}>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator>
<Icon icon={faCheck} />
</RadixSelect.ItemIndicator>
</StyledListboxOption>
);
}
);
return (
<StyledListboxOption value={radixValue} {...props} ref={ref}>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator>
<Icon icon={faCheck} />
</RadixSelect.ItemIndicator>
</StyledListboxOption>
);
};
+41 -22
View File
@@ -1,17 +1,19 @@
import type { ComponentPropsWithoutRef } from "react";
import styled from "styled-components";
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { MDXRemote } from "next-mdx-remote";
import type { ComponentPropsWithoutRef } from "react";
import { TextLink } from "components/text/TextLink";
import { Text } from "components/text";
import { Card } from "components/card";
import styled from "styled-components";
import theme from "theme";
import type { MDXComponents } from "mdx/types";
import { Card } from "@/components/card/Card";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import theme from "@/theme";
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
@@ -24,20 +26,37 @@ const StyledMarkdown = styled.div`
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
& p + h2,
& ul + h2,
& ol + h2,
& ${Card} + h2,
& pre + h2,
& table + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
& p + h3,
& ul + h3,
& ol + h3,
& ${Card} + h3,
& pre + h3,
& table + h3 {
margin-top: 32px;
}
& p {
& p,
& ul,
& ol {
margin-top: 0;
margin-bottom: 16px;
}
& ul ul,
& ol ol {
margin-bottom: 0;
}
& table {
width: 100%;
table-layout: auto;
@@ -85,23 +104,23 @@ const StyledMarkdown = styled.div`
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
& ${Card} {
margin-bottom: 16px;
}
& img {
border-radius: ${theme.scalars.borderRadiusCard};
}
@@ -122,19 +141,19 @@ export function Markdown({ source, components = {} }: MarkdownProps) {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
return <TextLink href={href} {...props} />;
}
return <Text as="a" link href={href} {...props}/>;
return <Text as="a" link href={href} {...props} />;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props} />,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props} />,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props} />,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props} />,
Card,
...components,
}}
/>
</StyledMarkdown>
);
}
}
+14 -12
View File
@@ -1,16 +1,18 @@
import type { Heading } from "utils/rehypeExtractHeadings";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import styled from "styled-components";
import { m } from "framer-motion";
import theme from "theme";
import { m } from "motion/react";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
import type { Heading } from "@/utils/rehypeExtractHeadings";
const StyledTableOfContents = styled.ul`
position: sticky;
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
@@ -18,13 +20,13 @@ const StyledTableOfContents = styled.ul`
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
@@ -73,10 +75,10 @@ export function TableOfContents({ headings }: { headings: Array<Heading> }) {
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
{slug === currentSlug && <StyledDot layoutId="dot" />}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>
{text}
</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
+30 -17
View File
@@ -1,10 +1,12 @@
import type { RefObject } from "react";
import styled from "styled-components";
import theme from "theme";
import { flipDown } from "styles/animations";
import { forwardRef } from "react";
import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu";
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
import { flipDown } from "@/styles/animations";
import theme from "@/theme";
const StyledMenuContent = styled(RadixMenu.Content)`
width: max-content;
max-height: var(--radix-dropdown-menu-content-available-height);
@@ -13,7 +15,9 @@ const StyledMenuContent = styled(RadixMenu.Content)`
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
box-shadow:
0 0 0 2px ${theme.colors["text-primary"]},
${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@@ -22,17 +26,26 @@ const StyledMenuContent = styled(RadixMenu.Content)`
export const Menu = RadixMenu.Root;
export const MenuTrigger = RadixMenu.Trigger;
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
function MenuContent({ children, ...props }, forwardedRef) {
return (
<RadixMenu.Portal>
<StyledMenuContent align="start" sideOffset={8} collisionPadding={8} {...props} ref={forwardedRef}>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
}
);
interface MenuContentProps extends DropdownMenuContentProps {
ref?: RefObject<HTMLDivElement>;
}
export function MenuContent({ ref, children, ...props }: MenuContentProps) {
return (
<RadixMenu.Portal>
<StyledMenuContent
align="start"
sideOffset={8}
collisionPadding={8}
collisionBoundary={typeof document !== "undefined" ? document.body : []}
{...props}
ref={ref}
>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
}
export const MenuItem = styled(RadixMenu.Item)`
display: flex;
@@ -49,7 +62,7 @@ export const MenuItem = styled(RadixMenu.Item)`
color: ${theme.colors["text"]};
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: revert;
@@ -60,7 +73,7 @@ export const MenuLabel = styled(RadixMenu.Label)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
color: ${theme.colors["text-muted"]};
+59
View File
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { faShare } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu/Menu";
import { Toast } from "@/components/toast/Toast";
import { useToasts } from "@/context/toastContext";
import useSetting from "@/hooks/useSetting";
import { BASE_PATH } from "@/utils/config";
import { AudioMode } from "@/utils/settings";
interface ShareMenuProps {
pagePath: string;
videoUrl: string;
audioUrl: string;
trigger?: ReactNode;
}
export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuProps) {
const { dispatchToast } = useToasts();
const [audioMode] = useSetting(AudioMode, { storageSync: false });
function saveToClipboard(url: string) {
navigator.clipboard.writeText(url).then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
}
return (
<Menu modal={false}>
<MenuTrigger asChild>
{trigger ?? (
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">
Share
</IconTextButton>
)}
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>
Copy URL to this Page
</MenuItem>
{audioMode === AudioMode.ENABLED ? (
<>
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
<a href={`${audioUrl}?download`}>
<MenuItem>Download Audio</MenuItem>
</a>
</>
) : (
<>
<MenuItem onSelect={() => saveToClipboard(videoUrl)}>Copy URL to Embeddable Video</MenuItem>
<a href={`${videoUrl}?download`}>
<MenuItem>Download Video</MenuItem>
</a>
</>
)}
</MenuContent>
</Menu>
);
}
+94 -77
View File
@@ -1,83 +1,42 @@
import { Icon } from "components/icon";
import { faEllipsisV, faListMusic } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import type { ThemeMenuThemeFragment } from "generated/graphql";
import { PlaylistTrackAddDialog } from "components/dialog/PlaylistTrackAddDialog";
import gql from "graphql-tag";
import { SongTitleWithArtists } from "components/utils";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu";
import { Button } from "components/button";
import { useContext } from "react";
interface ThemeMenuProps {
theme: ThemeMenuThemeFragment;
}
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
export function ThemeMenu({ theme }: ThemeMenuProps) {
const entry = theme.entries[0];
const video = entry?.videos[0];
import { Button } from "@/components/button/Button";
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
import { Text } from "@/components/text/Text";
import PlayerContext from "@/context/playerContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
if (!entry || !video) {
return null;
}
return (
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisV} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={{
// Flip the structure on it's head,
// because we need video as the root object here.
...video,
entries: [{
...entry,
theme,
}],
}}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
</MenuContent>
</Menu>
);
}
ThemeMenu.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment ThemeMenuTheme on Theme {
...createVideoSlugTheme
id
export const THEME_MENU_THEME = graphql(`
fragment ThemeMenuTheme on AnimeTheme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
anime {
slug
name
images {
nodes {
...extractImagesImage
}
}
song {
...SongTitleWithArtistsSong
}
entries {
...createVideoSlugEntry
videos {
}
song {
...SongTitleWithArtistsSong
}
animethemeentries {
...createVideoSlugEntry
id
videos {
nodes {
...createVideoSlugVideo
id
basename
@@ -87,5 +46,63 @@ ThemeMenu.fragments = {
}
}
}
`,
};
}
`);
interface ThemeMenuProps {
theme: FragmentType<typeof THEME_MENU_THEME>;
}
export function ThemeMenu({ theme: themeFragment }: ThemeMenuProps) {
const theme = getFragmentData(THEME_MENU_THEME, themeFragment);
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
const entry = theme.animethemeentries[0];
const video = entry?.videos.nodes[0];
if (!entry || !video) {
return null;
}
// Flip the structure on it's head, because we need entry as the root object here.
const entryFlipped = {
...entry,
theme,
};
return (
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisVertical} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={video}
entry={entryFlipped}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faPlus} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{/*{watchList.length ? (*/}
{/* <>*/}
{/* <MenuSeparator />*/}
{/* <MenuItem onSelect={() => addWatchListItem(video, entryFlipped)}>*/}
{/* <Icon icon={faArrowTurnDown} color="text-disabled" />*/}
{/* <Text>Add to Watch List</Text>*/}
{/* </MenuItem>*/}
{/* <MenuItem onSelect={() => addWatchListItemNext(video, entryFlipped)}>*/}
{/* <Icon icon={faArrowTurnUp} color="text-disabled" />*/}
{/* <Text>Play Next</Text>*/}
{/* </MenuItem>*/}
{/* </>*/}
{/*) : null}*/}
</MenuContent>
</Menu>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useContext } from "react";
import { faArrowTurnDown, faArrowTurnUp, faEllipsisVertical, faPlus } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { PlaylistTrackAddDialog } from "@/components/dialog/PlaylistTrackAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/menu/Menu";
import { Text } from "@/components/text/Text";
import PlayerContext from "@/context/playerContext";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
export const VIDEO_MENU_ENTRY = graphql(`
fragment VideoMenuEntry on AnimeThemeEntry {
...createVideoSlugEntry
...PlaylistTrackAddDialogEntry
id
animetheme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
}
anime {
slug
name
images {
nodes {
...extractImagesImage
}
}
}
song {
...SongTitleWithArtistsSong
}
}
}
`);
export const VIDEO_MENU_VIDEO = graphql(`
fragment VideoMenuVideo on Video {
...createVideoSlugVideo
...PlaylistTrackAddDialogVideo
id
basename
audio {
basename
}
}
`);
interface VideoMenuProps {
entry: FragmentType<typeof VIDEO_MENU_ENTRY>;
video: FragmentType<typeof VIDEO_MENU_VIDEO>;
}
export function VideoMenu({ entry: entryFragment, video: videoFragment }: VideoMenuProps) {
const entry = getFragmentData(VIDEO_MENU_ENTRY, entryFragment);
const video = getFragmentData(VIDEO_MENU_VIDEO, videoFragment);
const { watchList, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext);
return (
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisVertical} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={video}
entry={entry}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faPlus} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{watchList.length ? (
<>
<MenuSeparator />
<MenuItem onSelect={() => addWatchListItem(video, entry)}>
<Icon icon={faArrowTurnDown} color="text-disabled" />
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(video, entry)}>
<Icon icon={faArrowTurnUp} color="text-disabled" />
<Text>Play Next</Text>
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
);
}
+15 -12
View File
@@ -1,8 +1,9 @@
import styled, { css } from "styled-components";
import { Container } from "components/container";
import theme from "theme";
import { Logo } from "components/image";
import { ProfileImage } from "components/image/ProfileImage";
import { Container } from "@/components/container/Container";
import { Logo } from "@/components/image/Logo";
import { ProfileImage } from "@/components/image/ProfileImage";
import theme from "@/theme";
export const StyledNavigation = styled.nav<{ $floating: boolean }>`
position: sticky;
@@ -13,13 +14,15 @@ export const StyledNavigation = styled.nav<{ $floating: boolean }>`
transition: 100ms ease;
transition-property: background-color, box-shadow;
${(props) => props.$floating && css`
transition: 500ms ease;
background-color: transparent;
box-shadow: none;
`}
${(props) =>
props.$floating &&
css`
transition: 500ms ease;
background-color: transparent;
box-shadow: none;
`}
[data-fullscreen] & {
display: none;
}
@@ -31,7 +34,7 @@ export const StyledNavigationContainer = styled(Container)`
justify-content: space-between;
align-items: stretch;
gap: 16px;
padding: 8px 16px;
`;
+63 -48
View File
@@ -1,6 +1,11 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { faRandom, faSearch, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
import { useRouter } from "next/router";
import { faMagnifyingGlass, faShuffle, faTv, faUser } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "@/components/button/IconTextButton";
import { ShuffleDialog } from "@/components/dialog/ShuffleDialog";
import {
StyledLogo,
StyledLogoContainer,
@@ -8,24 +13,20 @@ import {
StyledNavigationContainer,
StyledNavigationLinks,
StyledProfileImage,
StyledProfileImageIcon
} from "./Navigation.style";
import { IconTextButton } from "components/button";
import useCurrentSeason from "hooks/useCurrentSeason";
import useRandomThemes from "hooks/useRandomThemes";
import { useRouter } from "next/router";
import useAuth from "hooks/useAuth";
StyledProfileImageIcon,
} from "@/components/navigation/Navigation.style";
import useAuth from "@/hooks/useAuth";
import useCurrentSeason from "@/hooks/useCurrentSeason";
export function Navigation() {
const { me } = useAuth();
const { currentYear, currentSeason } = useCurrentSeason();
const { playRandomThemes } = useRandomThemes();
const router = useRouter();
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
const [prevPathname, setPrevPathname] = useState(router.pathname);
const [ isFloating, setFloating ] = useState(true);
const [isFloating, setFloating] = useState(true);
useEffect(() => {
function onScroll() {
@@ -44,48 +45,62 @@ export function Navigation() {
return null;
}
return <>
<StyledNavigation $floating={isFloating}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref legacyBehavior>
<StyledLogoContainer>
<StyledLogo width="277" height="150"/>
return (
<>
<StyledNavigation $floating={isFloating}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<StyledLogoContainer as={Link} href="/">
<StyledLogo width="277" height="150" />
</StyledLogoContainer>
</Link>
<StyledNavigationLinks>
<Link href="/search" passHref legacyBehavior>
<IconTextButton forwardedAs="a" icon={faSearch} variant="silent" collapsible style={{ "--gap": "8px" }}>
Search
</IconTextButton>
</Link>
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }} onClick={playRandomThemes}>
Shuffle
</IconTextButton>
<Link
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
passHref
legacyBehavior>
<IconTextButton forwardedAs="a" variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
Current Season
</IconTextButton>
</Link>
<Link href="/profile" passHref legacyBehavior>
<StyledNavigationLinks>
<IconTextButton
forwardedAs="a"
asChild
icon={faMagnifyingGlass}
variant="silent"
icon={me.user ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me.user} />
</StyledProfileImageIcon>
) : faUser}
collapsible
style={{ "--gap": "8px" }}
>
<Link href="/search">Search</Link>
</IconTextButton>
<ShuffleDialog
trigger={
<IconTextButton
variant="silent"
icon={faShuffle}
collapsible
style={{ "--gap": "8px" }}
>
Shuffle
</IconTextButton>
}
/>
<IconTextButton asChild variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
<Link href={currentYear && currentSeason ? `/year/${currentYear}/${currentSeason}` : "/"}>
Current Season
</Link>
</IconTextButton>
<IconTextButton
asChild
variant="silent"
icon={
me ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me} />
</StyledProfileImageIcon>
) : (
faUser
)
}
title="My Profile"
collapsible
>
My Profile
<Link href="/profile">My Profile</Link>
</IconTextButton>
</Link>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
</>;
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
</>
);
}
+84 -48
View File
@@ -1,22 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Text } from "components/text";
import { Input } from "components/form";
import { HorizontalScroll } from "components/utils";
import { Switcher } from "components/switcher";
import styled from "styled-components";
import theme from "theme";
import Link from "next/link";
import { useRouter } from "next/router";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { debounce } from "lodash-es";
import { faSearch } from "@fortawesome/pro-solid-svg-icons";
import { SwitcherOption, SwitcherReset } from "components/switcher/Switcher";
import { Input } from "@/components/form/Input";
import { Switcher, SwitcherOption, SwitcherReset } from "@/components/switcher/Switcher";
import { Text } from "@/components/text/Text";
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
import theme from "@/theme";
const StyledSearchOptions = styled.div`
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
grid-gap: 1rem;
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
align-items: stretch;
@@ -27,19 +28,23 @@ const updateSearchQuery = debounce((router, newSearchQuery) => {
// Update URL to maintain the searchQuery on page navigation.
const newUrlParams = {
...router.query,
q: newSearchQuery
q: newSearchQuery,
};
if (!newUrlParams.q) {
delete newUrlParams.q;
}
router.replace({
pathname: router.pathname,
query: newUrlParams
}, null, {
shallow: true
});
router.replace(
{
pathname: router.pathname,
query: newUrlParams,
},
null,
{
shallow: true,
},
);
}, 500);
export function SearchNavigation() {
@@ -48,20 +53,20 @@ export function SearchNavigation() {
const { entity, ...query } = router.query;
const { q: initialSearchQuery = "" } = query;
const [inputSearchQuery, setInputSearchQuery] = useState("");
const [inputSearchQuery, setInputSearchQuery] = useState(initialSearchQuery as string);
const updateInputSearchQuery = (newInputSearchQuery: string) => {
setInputSearchQuery(newInputSearchQuery);
updateSearchQuery(router, newInputSearchQuery);
};
const inputRef = useRef<HTMLInputElement>();
const inputRef = useRef<HTMLInputElement>(undefined);
const onMountInput = useCallback((input: HTMLInputElement) => {
// Only focus the input on desktop devices
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
input?.focus({
preventScroll: true
preventScroll: true,
});
}
@@ -70,10 +75,13 @@ export function SearchNavigation() {
useEffect(() => {
const hotkeyListener = (event: KeyboardEvent) => {
if (inputRef.current !== document.activeElement && ((event.key === "s" && event.ctrlKey) || (event.key === "/"))) {
if (
inputRef.current !== document.activeElement &&
((event.key === "s" && event.ctrlKey) || event.key === "/")
) {
event.preventDefault();
inputRef.current?.focus({
preventScroll: true
preventScroll: true,
});
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -90,31 +98,59 @@ export function SearchNavigation() {
return null;
}
return <>
<Text variant="h1">Search</Text>
<StyledSearchOptions>
<Input
value={inputSearchQuery}
onChange={updateInputSearchQuery}
inputProps={{
ref: onMountInput,
spellCheck: false,
placeholder: "Search"
}}
resettable
icon={faSearch}
/>
<HorizontalScroll fixShadows>
<Switcher selectedItem={entity as string || null}>
<SwitcherReset as={Link} href={{ pathname: "/search", query }}/>
<SwitcherOption as={Link} href={{ pathname: "/search/anime", query }} value="anime">Anime</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/theme", query }} value="theme">Theme</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/playlist", query }} value="playlist">Playlist</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>;
return (
<>
<Text variant="h1">Search</Text>
<StyledSearchOptions>
<Input
value={inputSearchQuery}
onChange={updateInputSearchQuery}
inputProps={{
ref: onMountInput,
spellCheck: false,
placeholder: "Search",
}}
resettable
icon={faMagnifyingGlass}
/>
<HorizontalScroll $fixShadows>
<Switcher selectedItem={(entity as string) || null}>
<SwitcherReset asChild>
<Link href={{ pathname: "/search", query }} prefetch={false} />
</SwitcherReset>
<SwitcherOption asChild value="anime">
<Link href={{ pathname: "/search/anime", query }} prefetch={false}>
Anime
</Link>
</SwitcherOption>
<SwitcherOption asChild value="theme">
<Link href={{ pathname: "/search/theme", query }} prefetch={false}>
Theme
</Link>
</SwitcherOption>
<SwitcherOption asChild value="artist">
<Link href={{ pathname: "/search/artist", query }} prefetch={false}>
Artist
</Link>
</SwitcherOption>
<SwitcherOption asChild value="series">
<Link href={{ pathname: "/search/series", query }} prefetch={false}>
Series
</Link>
</SwitcherOption>
<SwitcherOption asChild value="studio">
<Link href={{ pathname: "/search/studio", query }} prefetch={false}>
Studio
</Link>
</SwitcherOption>
<SwitcherOption asChild value="playlist">
<Link href={{ pathname: "/search/playlist", query }} prefetch={false}>
Playlist
</Link>
</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
</>
);
}
+36 -18
View File
@@ -1,26 +1,44 @@
import { Row } from "components/box";
import { Switcher } from "components/switcher";
import Link from "next/link";
import { HorizontalScroll } from "components/utils";
import type { YearDetailPageProps } from "pages/year/[year]";
import { SwitcherOption } from "components/switcher/Switcher";
import type { SeasonDetailPageProps } from "pages/year/[year]/[season]";
export function SeasonNavigation(props: YearDetailPageProps | SeasonDetailPageProps) {
const { year } = props;
import { Row } from "@/components/box/Flex";
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
export const SEASON_NAVIGATION_YEAR = graphql(`
fragment SeasonNavigationYear on AnimeYear {
year
seasons {
season
seasonLocalized
}
}
`);
export const SEASON_NAVIGATION_SEASON = graphql(`
fragment SeasonNavigationSeason on AnimeYearSeason {
season
}
`);
interface SeasonNavigationProps {
year: FragmentType<typeof SEASON_NAVIGATION_YEAR>;
season?: FragmentType<typeof SEASON_NAVIGATION_SEASON>;
}
export function SeasonNavigation({ year: yearFragment, season: seasonFragment }: SeasonNavigationProps) {
const year = getFragmentData(SEASON_NAVIGATION_YEAR, yearFragment);
const season = seasonFragment ? getFragmentData(SEASON_NAVIGATION_SEASON, seasonFragment) : undefined;
return (
<Row style={{ "--justify-content": "center" }}>
<HorizontalScroll fixShadows>
<Switcher selectedItem={"season" in props ? props.season.value.toLowerCase() : null}>
{year.seasons.map((season) => (
<SwitcherOption
key={season.value}
href={`/year/${year.value}/${season.value.toLowerCase()}`}
as={Link}
value={season.value.toLowerCase()}
>
{season.value}
<HorizontalScroll $fixShadows>
<Switcher selectedItem={season ? season.season : null}>
{year.seasons?.map((season) => (
<SwitcherOption key={season.season} asChild value={season.season}>
<Link href={`/year/${year.year}/${season.season.toLowerCase()}`}>
{season.seasonLocalized}
</Link>
</SwitcherOption>
))}
</Switcher>
+44 -19
View File
@@ -1,9 +1,10 @@
import Link from "next/link";
import { Button } from "components/button";
import styled from "styled-components";
import { Row } from "components/box";
import { Text } from "components/text";
import type { YearDetailPageProps } from "pages/year/[year]";
import Link from "next/link";
import { Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Text } from "@/components/text/Text";
import { type FragmentType, getFragmentData, graphql } from "@/graphql/generated";
const StyledYear = styled.div`
flex: 1;
@@ -21,29 +22,53 @@ const StyledYearNext = styled(StyledYear)`
justify-content: flex-start;
`;
export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
const previousYear = yearAll.find((y) => y.value === year.value - 1)?.value ?? null;
const nextYear = yearAll.find((y) => y.value === year.value + 1)?.value ?? null;
export const YEAR_NAVIGATION_YEAR = graphql(`
fragment YearNavigationYear on AnimeYear {
year
seasons {
season
seasonLocalized
}
}
`);
export const YEAR_NAVIGATION_YEARS = graphql(`
fragment YearNavigationYears on AnimeYear {
year
}
`);
interface YearNavigationProps {
year: FragmentType<typeof YEAR_NAVIGATION_YEAR>;
years: Array<FragmentType<typeof YEAR_NAVIGATION_YEARS>>;
}
export function YearNavigation({ year: yearFragment, years: yearsFragment }: YearNavigationProps) {
const year = getFragmentData(YEAR_NAVIGATION_YEAR, yearFragment);
const years = getFragmentData(YEAR_NAVIGATION_YEARs, yearsFragment);
const previousYear = years.find((y) => y.year === year.year - 1)?.year ?? null;
const nextYear = years.find((y) => y.year === year.year + 1)?.year ?? null;
return (
<Row style={{ "--align-items": "center" }}>
<StyledYearPrevious>
{previousYear && (
<Link href={`/year/${previousYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{previousYear}</Button>
</Link>
<Button asChild variant="silent">
<Link href={`/year/${previousYear}`}>{previousYear}</Link>
</Button>
)}
</StyledYearPrevious>
<Link href={`/year`} passHref legacyBehavior>
<Button as="a" variant="silent">
<Text variant="h1">{year.value}</Text>
</Button>
</Link>
<Button asChild variant="silent">
<Link href={`/year`}>
<Text variant="h1">{year.year}</Text>
</Link>
</Button>
<StyledYearNext>
{nextYear && (
<Link href={`/year/${nextYear}`} passHref legacyBehavior>
<Button as="a" variant="silent">{nextYear}</Button>
</Link>
<Button asChild variant="silent">
<Link href={`/year/${nextYear}`}>{nextYear}</Link>
</Button>
)}
</StyledYearNext>
</Row>
-4
View File
@@ -1,4 +0,0 @@
export { Navigation } from "./Navigation";
export { SeasonNavigation } from "./SeasonNavigation";
export { YearNavigation } from "./YearNavigation";
export { SearchNavigation } from "./SearchNavigation";
@@ -1,12 +1,12 @@
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
const letters = createLetters();
interface SearchFilterFirstLetterProps {
value: string | null
setValue: (newValue: string | null) => void
value: string | null;
setValue: (newValue: string | null) => void;
}
export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLetterProps) {
@@ -14,9 +14,13 @@ export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLe
<SearchFilter>
<Text variant="h2">First Letter</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
{letters.map((letter) => (
<ListboxOption key={letter} value={letter}>{letter}</ListboxOption>
<ListboxOption key={letter} value={letter}>
{letter}
</ListboxOption>
))}
</Listbox>
</SearchFilter>
@@ -1,10 +1,11 @@
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import type { AnimeMediaFormat } from "@/graphql/generated/graphql";
interface SearchFilterMediaFormatProps {
value: string | null
setValue: (newValue: string | null) => void
value: AnimeMediaFormat | null;
setValue: (newValue: AnimeMediaFormat | null) => void;
}
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
@@ -12,10 +13,15 @@ export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFo
<SearchFilter>
<Text variant="h2">Format</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
<ListboxOption value="TV">TV</ListboxOption>
<ListboxOption value="TV Short">TV Short</ListboxOption>
<ListboxOption value="Movie">Movie</ListboxOption>
<ListboxOption value="TV_SHORT">TV Short</ListboxOption>
<ListboxOption value="MOVIE">Movie</ListboxOption>
<ListboxOption value="OVA">OVA</ListboxOption>
<ListboxOption value="ONA">ONA</ListboxOption>
<ListboxOption value="SPECIAL">Special</ListboxOption>
</Listbox>
</SearchFilter>
);
@@ -1,10 +1,11 @@
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import type { AnimeSeason } from "@/graphql/generated/graphql";
interface SearchFilterSeasonProps {
value: string | null
setValue: (newValue: string | null) => void
value: AnimeSeason | null;
setValue: (newValue: AnimeSeason | null) => void;
}
export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps) {
@@ -12,11 +13,13 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
<SearchFilter>
<Text variant="h2">Season</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value="Winter">Winter</ListboxOption>
<ListboxOption value="Spring">Spring</ListboxOption>
<ListboxOption value="Summer">Summer</ListboxOption>
<ListboxOption value="Fall">Fall</ListboxOption>
<ListboxOption value={null} hidden>
Any
</ListboxOption>
<ListboxOption value="WINTER">Winter</ListboxOption>
<ListboxOption value="SPRING">Spring</ListboxOption>
<ListboxOption value="SUMMER">Summer</ListboxOption>
<ListboxOption value="FALL">Fall</ListboxOption>
</Listbox>
</SearchFilter>
);

Some files were not shown because too many files have changed in this diff Show More