63 Commits

Author SHA1 Message Date
Mani 963707c82e feat: Added shuffle feature for playlists (#199)
* Improved page transition when opening video player for the first time.
* Disabled prefetching for index pages.
2024-03-21 22:36:34 +01:00
Mani 5e265b3a92 feat: Added autoplay toggle (#198) 2024-03-21 22:36:34 +01:00
Mani 2a78c4cf56 feat: Added support for new featured theme API (#197) 2024-03-21 22:36:34 +01:00
Mani c8de140bfe chore: Removed unsafe imports from Next.js (#196) 2024-03-21 22:36:34 +01:00
Mani 9f8e4da7cb chore: Bumped Next.js to latest 13.x version (#195)
* Changed next.config.js to be a .mjs file.
* Updated footer links.
* Removed obsolete markdown.
2024-03-21 22:36:34 +01:00
Mani 055bb7bea7 feat: Reworked document pages (#194)
* Moved document pages from /wiki sub-path to root path.
* Added a blog index page.
* Added a page header to document pages.
* Added redirects for the moved pages.
* Removed wiki index page, because it will be a document page in the future.
* Removed terms of service and privacy policy pages, because these will be document pages in the future.
2024-03-21 22:36:34 +01:00
Mani 855443c6c8 clean: Removed anything billing related (#193) 2024-03-21 22:36:34 +01:00
Mani d068dcd422 fix: Fixed scrollbar color and global search includes (#191) 2024-03-21 22:36:34 +01:00
SentFrom8 c4f83e67ce feat: Added group performances to artist page (#188) 2024-03-21 22:36:34 +01:00
Mani 562868e1fa feat: Added support for media format (#187)
* Fixed track count on playlist add track dialog.
* Fixed initial watch list for anime with theme groups.
2024-03-21 22:36:34 +01:00
SentFrom8 02798df253 feat: Added back to overview button (#186) 2024-03-21 22:36:34 +01:00
Mani 8119526e9e fix: Fixed build errors introduced by the previous commit (#185) 2024-03-21 22:36:34 +01:00
MonCaptain 144973ba18 feat: Added watch list support for artist pages (#183) 2024-03-21 22:36:34 +01:00
MonCaptain c22bb00763 fix: increase slideOut distance and use display property (#182)
* fix: increase slideOut distance and use display property

* feat: utilize Framer Motion to animate back to top button
2024-03-21 22:36:34 +01:00
Mani 23998c4c64 feat: Added forgot password function (#181)
* Added ranking mode to playlists.
2024-03-21 22:36:34 +01:00
Mani abd3ef6c17 feat: Added reorder function to playlist page (#180)
* Added volume control to video player.
* Added playlist edit dialog.
* Added slider component.
* Optimized video player for mobile devices.
* Double-clicking the mini player now maximizes the video player.
* Fixed back-to-top button overlapping with video player bar.
* Fixed focus styles appearing on click.
* Fixed multi cover image showing the same image multiple times.
* Fixed progress bar showing 0:60 instead of 1:00.
* Fixed right click on video toggling pause/play.
* Fixed scroll bar being visible while in full screen mode.
2024-03-21 22:36:34 +01:00
Mani 9d2d981632 feat: Added fullscreen mode (#179)
* Added theme entry menu to video player.
* Added overlay to video player.
* Improved video player styles for mobile.
* Fixed button focus ring showing on click.
* Fixed z-index issue with multi cover image.
* Fixed global search.
* Fixed various watch list issues.
2024-03-21 22:36:34 +01:00
Mani 6392ff5967 fix: Added support for new include path (#178)
* Fixed ESLint errors.
2024-03-21 22:36:34 +01:00
Mani 5a69a427c7 feat: Added mini video player back (#177)
* Added shuffle mode.
* Added play next/previous actions to media session.
* Fixed play next/previous actions causing the background player to maximize.
* Added button to close the background player.
* Added revalidation info to video page.
* Fixed z-index issues with list box and dialog.
* Changed default Gravatar image.
* Extract video player sidebar into page component.
* Added support for new pivot handling of the API.
* Added new font optimization from Next.js.
2024-03-21 22:36:34 +01:00
Shehab Mohsen 8e99040807 feat: Add back to top button to index pages (#175) 2024-03-21 22:36:34 +01:00
Mani 62eaff6dc1 feat: Next.js 13 and React 18 (#176)
* Replaced all Reach UI components with Radix UI.
* Improved UX of playlist "Play All".
* Highlight current playing watch list item.
* Added support for hash ids.
* Added menu options for "Play Next" and "Add to Watch List".
* Made "Play Previous" button functional.
* Player buttons now have hover/focus feedback.
* Removed default controls from video element.
* Clicking the video element now toggles the playing state.
* Made the video element bigger, especially on high-res displays.
* Added "Other Entries" to new video player.
* Added support for browser media metadata to new video player.
* Switching between audio and video mode now retains the playback position.
* Removed GraphQL endpoint.
* Disabled custom scroll bar styling for touch devices.
2024-03-21 22:36:34 +01:00
Mani 2995543309 fix: Fixed missing include path for roles (#174) 2024-03-21 22:36:34 +01:00
Mani de0a8e49c4 feat: Started work on the new video player (#173)
* Redesigned the navigation.
  * Removed the mobile navigation toggle.
  * Made the background transparent when page is not scrolled.
  * Made buttons collapse to icons on mobile.
  * Made the logo smaller.
* Added track count to playlist summary card.
* Fixed playlist page only displaying 15 tracks.
* Added option to remember login.
* Added "Most Viewed" section to home page.
2024-03-21 22:36:34 +01:00
Mani 46e390eaed feat: Add support for role color (#172)
* Added role badges on profile page.
* Added special welcome message for new users.
2024-03-21 22:36:34 +01:00
Mani 39efac2233 feat: Add support for searching playlists (#171)
* Added password change dialog.
* Added links to terms of service and privacy policy to register dialog.
* Added notification on profile page when user has not verified their email yet.
* Added action to resend the verification email.
* Adjusted layout of profile page.
* Playlist summary cards can now include the owner.
2024-03-21 22:36:34 +01:00
Mani 7b34229866 feat: Add support for Gravatar (#170)
* Revised layout of profile page.
* Fixed issue where SSR builds used the client user as the authenticated user.
2024-03-21 22:36:34 +01:00
Mani a0c7351249 feat: Various improvements to playlist support (#169)
* Added action to delete playlists.
* Added playlist add action to track add dialog.
* Fixed global search.
* Fixed busy state of register dialog.
* Fixed message that shows when no playlist are available.
* Improved error handling when adding playlists.
2024-03-21 22:36:34 +01:00
Mani 7f5f8ef4fa fix: Fixed two more page build errors (#168) 2024-03-21 22:36:34 +01:00
Mani 02e42fa3c1 fix: Fixed page build errors (#167) 2024-03-21 22:36:34 +01:00
Mani e9b4133490 feat: Added support for playlists (#166)
* Added playlist page.
* Added playlist summary card.
* Added video summary card.
* Added playlist and login/register sections to profile page.
* Changed profile page to SSR mode.
* Added playlist actions to theme menu.
* Removed local playlist actions from theme menu and video page.
* Added dialog component.
* Added dialogs for playlist actions (add playlist, add track to playlist, remove track from playlist).
* Added login dialog.
* Added register dialog.
* Added toasts for playlist actions.
* Added busy component to show in-progress state.
* Added login gate component to protect section from unauthorized access.
* Added focus style to button and listbox.
* Improved hover style for primary button.
* Added warning button variant.
* Added switch (checkbox) component.
* Split transparency page into multiple pages (to allow for links to lead to specific months).
* Fixed table spacing on Markdown pages.
* Removed suffix from embed titles.
* Experimental: Added new implementation of listbox based on Radix.
2024-03-21 22:36:34 +01:00
Manuel S 418a6a3dde feat: Added first auth support (#147)
* APP_URL is now AUTH_URL.
2024-03-21 22:36:34 +01:00
Gaporigo f879241752 feat: Added r/anime award results for 2023 (#192) 2024-03-02 21:27:17 +01:00
Gaporigo 3ba96d7236 feat: Added r/anime award nominations for 2023 (#190) 2024-01-15 15:32:45 +01:00
Mani e6fce90ca7 feat: Add bracket page and mappings for Best Opening X (#184) 2023-08-29 00:51:21 +02:00
Mani f294b1bad2 feat: Added r/anime award results for 2022 (#165) 2023-02-26 03:19:28 +01:00
Mani 753c8d01d0 feat: Replaced Markdown implementation with MDX (#164)
* Replaced announcement toast with MDX component on the home page.
* Added special anime award announcement button.
* Fixed size of corner icons.
* Removed design page.
2023-02-25 04:37:13 +01:00
Mani de45d719bc feat: Add support for Twitter cards (#163) 2023-02-23 23:00:42 +01:00
Mani 2ccd6ee6df feat: Added r/anime award nominations for 2022 (#162) 2023-01-16 23:57:42 +01:00
Mani ef5c9d0325 fix: Fixed download dump button on mobile (#161)
* Improved encoding script download.
2023-01-16 23:54:22 +01:00
Shehab Mohsen 787e14727a chore: Decouple verbose logging from staging (#160)
* Decoupled verbose logs from staging mode.
* Added verbose logs option to README.
2023-01-09 10:24:26 +01:00
Shehab Mohsen 0ef42a79d0 feat: Added audio files url environment variable (#158) 2023-01-03 08:48:59 +01:00
Mani 0edf1daa17 feat: Added allowed include path for videoscripts (#157) 2023-01-01 18:24:00 +01:00
Manuel S 70aab886c1 fix: Reverted concurrent API requests not getting limited, which resulted in timeouts (#156) 2022-12-26 01:06:22 +01:00
Manuel S 350defb4b3 fix: Fixed missing base path from GraphQL endpoint URL (#155) 2022-12-08 06:50:04 +01:00
Manuel S 2168ac6c55 fix: Fixed performance issues with watch history (#154) 2022-12-07 06:57:39 +01:00
Manuel S bfe3edddbf feat: Added support for video scripts (#153)
* Added GraphQL endpoint.
* Experimental: Recently added themes are now fetched via the GraphQL endpoint.
* Optimized build time of bracket pages.
* Fixed circular dependency.
* Fixed some type warnings.
2022-12-07 06:54:23 +01:00
Manuel S c628185bea fix: Fixed GraphQL codegen (#152) 2022-12-06 08:08:33 +01:00
Manuel S 2ebf4437ed feat: Added transparency page (#151)
* Added dump page.
* Added terms of service page.
* Added privacy policy page.
* Removed dev page.
* Added secure headers.
* Updated footer links.
* Fixed some global styles.
* Bumped Next.js to 12.3.
2022-12-06 02:54:06 +01:00
Manuel S 14a25b725a fix: Optimize initial build performance by caching data between pages (#150) 2022-12-06 02:02:00 +01:00
Manuel S 48b8f1628a fix: Fixed share audio (#148) 2022-10-10 20:33:03 +02:00
Manuel S 09b92ef6f6 fix: Fixed too many API calls on the client side (#145)
* Added option to share embeddable audio.
* Fixed text wrapping in multiple places.
* Fixed volume synchronization.
* Fixed switching player mode affecting other tabs.
2022-09-02 01:14:53 +02:00
Manuel S 4958f5d462 feat: Added separate minimal build setting (#144) 2022-08-27 20:06:36 +02:00
Manuel S 6394e50232 fix: Fixed incorrectly spinning icon (#143) 2022-08-27 15:27:14 +02:00
Manuel S 67c2f2180d fix: Improved bundle size (#142)
* Framer Motion is now lazy loaded.
* FontAwesome is now a custom component removing all the bloat we don't need.
* Imports and exports for types now use the proper syntax to get excluded by bundlers.
2022-08-27 03:28:36 +02:00
Manuel S 91f7be77d1 fix: Fixed audio mode for mobile background player (#141)
* Fixed "Play Random".
2022-08-24 23:17:23 +02:00
Manuel S 47daa23e9c feat: Added audio mode (#140)
* Added support for "as" field of artist memberships.
* Bracket pages no longer get prebuilt.
* Fixed wrong order of theme entries.
* Fixed studio cover background on studio page.
* Fixed color theme selectors.
* Fixed wrong alignment of "My Profile" on mobile navigation.
* Fixed very long song titles not wrapping correctly.
* Fixed play random feature.
* Fixed manual rebuilding.
2022-08-19 02:01:25 +02:00
Manuel S d6caa6487a feat: Migrated to TypeScript (#138) 2022-07-23 03:44:41 +02:00
Gaporigo 530c11c858 fix: Fixed grammar on anime page (#137) 2022-06-29 23:53:58 +02:00
Manuel S b195c9d667 feat: Added missing brackets (@theinternetftw) (#136)
* Added bracket chart.
* Added border radius to all sides of cards.
* Added skeletons to home page.
* Added support for system color theme.
* Added code-splitting for GraphQL.
* Added config constants.
* Added warning banner on staging.
* Added URL caching to API resolver (only for non-page request for now).
* Made featured theme load by default.
* Changed static path fetching to only fetch paths if not on staging (except for bracket pages).
* Changed default color theme to system.
* Moved color theme toggle from navigation to profile page.
* Replaced sketchy local storage implementation with external package.
* Fixed wrong fallback for featured theme on devices which don't support our codecs.
* Fixed wrong links in footer.
* Fixed code-splitting of Prism package.
* Fixed "/" character not working in search input.
* Fixed glitching multi cover image on playlist page.
* Updated README.md.
2022-06-08 00:20:46 +02:00
Manuel S d7329f73b7 feat: Upgraded to FontAwesome 6 Pro (#135)
* Replaced various icons with better fitting ones.
* Improved tag styling in general.
* Fixed pages throwing 500 errors instead of 404.
* Fixed manual revalidation.
2022-06-03 02:48:21 +02:00
Manuel S 38675ab9ad fix: Removed more hardcoded /api from URLs (#133) 2022-06-01 05:24:24 +02:00
Manuel S 385a1419ba fix: Removed hardcoded /video from video URL (#132) 2022-06-01 03:38:20 +02:00
Manuel S 76db2186dd fix: Removed hardcoded /api from API URL (#131)
* Fixed recent themes not being ten entries.
* Fixed "Performed As" filter on artist page.
2022-06-01 03:34:42 +02:00
343 changed files with 35279 additions and 11132 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://staging.animethemes.moe
+69 -43
View File
@@ -1,47 +1,73 @@
{
"extends": [
"next/core-web-vitals",
"plugin:react/recommended"
],
"rules": {
"semi": [
"error",
"always"
"extends": [
"next/core-web-vitals",
"plugin:react/recommended",
"eslint:recommended"
],
"quotes": [
"error",
"double",
{
"allowTemplateLiterals": true
}
"ignorePatterns": [
"src/generated"
],
"curly": [
"error",
"all"
],
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-duplicate-imports": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
"components/*/*"
]
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
}
"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"
},
"overrides": [
{
"files": [
"**/*.{ts,tsx}"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"project": [
"./tsconfig.json"
]
},
"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"
}
}
]
}
+5
View File
@@ -29,10 +29,15 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.npmrc
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE (WebStorm)
*.iml
/.idea
+51 -14
View File
@@ -7,29 +7,66 @@
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory.
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or beta URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
```
To get to use all features of the client, you may configure some additional variables.
This is a list of all available options:
```ini
; Back-end API configuration
; These values are required.
; ===== Server-side =====
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; If specified, this API key will be used to make requests to the AnimeThemes API.
; This is used to by-pass rate limiting.
ANIMETHEMES_API_KEY=...
; (Optional) The URL to the AnimeThemes API to use on the front-end.
; For development this is "http://localhost" by default.
; For production this is "https://staging.animethemes.moe" by default.
NEXT_PUBLIC_API_URL=http://localhost
; The token to use for manual revalidation.
REVALIDATE_TOKEN=secret
; (Optional) The URL from which video files should be served.
; By default this is "https://animethemes.moe".
NEXT_PUBLIC_VIDEO_URL=https://animethemes.moe
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
; (Optional) The base path the app should be hosted on.
; By default the app is hosted on the root path (/).
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
; ===== Server-side + Client-side =====
; The base path the app should be hosted on.
NEXT_PUBLIC_BASE_PATH=/wiki
; The URL to the AnimeThemes API which will be used on the browser.
NEXT_PUBLIC_API_URL=https://api.animethemes.moe
; The URL from which video files should be served.
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
; The URL from which audio files should be served.
NEXT_PUBLIC_AUDIO_URL=https://a.animethemes.moe
; The URL to use for authentication API requests.
NEXT_PUBLIC_AUTH_URL=https://app.animethemes.moe
; Set to any truthy value to activate staging mode.
; In staging mode a warning banner is displayed at the top of the page.
NEXT_PUBLIC_STAGING=true
; To enable verbose logging.
NEXT_PUBLIC_VERBOSE_LOGS=true
```
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
@@ -56,4 +93,4 @@ For more information on environment variables see the [Next.js documentation](ht
### APIs
- [AnimeThemes API](https://staging.animethemes.moe/api/docs/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+34
View File
@@ -0,0 +1,34 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: [
"src/lib/common/animethemes/type-defs.ts",
"src/lib/server/animebracket/type-defs.ts",
"src/lib/client/search.ts",
],
documents: [
"src/**/*.js",
"src/**/*.ts",
"src/**/*.tsx",
],
generates: {
"src/generated/graphql.ts": {
plugins: [
"typescript",
"typescript-operations",
],
config: {
avoidOptionals: {
object: true,
field: true,
inputValue: false,
},
enumsAsTypes: true,
skipTypename: true,
},
},
},
};
export default config;
-18
View File
@@ -1,18 +0,0 @@
const { info } = require("next/dist/build/output/log");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
if (process.env.NEXT_PUBLIC_STAGING) {
info("Running animethemes-web in staging mode!");
}
module.exports = withBundleAnalyzer({
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600
});
+89
View File
@@ -0,0 +1,89 @@
// @ts-check
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "./src/utils/config.mjs";
import { error, info } from "./src/utils/log.mjs";
import NextBundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = NextBundleAnalyzer({
enabled: ANALYZE,
});
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600,
experimental: {
// We don't want to multi-thread page building
// to make use of caching between page builds.
workerThreads: false,
cpus: 1,
},
transpilePackages: [
"ahooks"
],
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload"
},
{
key: "X-XSS-Protection",
value: "1; mode=block"
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN"
},
{
key: "X-Content-Type-Options",
value: "nosniff"
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin"
}
]
}
];
},
async redirects() {
return [
{
source: "/wiki/blog/:slug*",
destination: "/blog/:slug*",
permanent: true,
},
{
source: "/wiki/status/:slug*",
destination: "/blog/status_:slug*",
permanent: true,
},
];
}
};
export default withBundleAnalyzer(nextConfig);
+15250 -2266
View File
File diff suppressed because it is too large Load Diff
+46 -19
View File
@@ -1,45 +1,72 @@
{
"name": "animethemes-web",
"version": "2.0.0",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"graphql-codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/pro-solid-svg-icons": "^6.1.1",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@reach/listbox": "^0.16.2",
"@reach/menu-button": "^0.16.2",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-switch": "^1.0.1",
"ahooks": "^3.7.6",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"framer-motion": "^6.2.1",
"graphql": "^15.6.1",
"framer-motion": "^10.12.4",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"marked": "^4.0.12",
"next": "^12.1.6",
"md5": "^2.3.0",
"next": "^13.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"prismjs": "^1.27.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"picocolors": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.29.0",
"rehype-parse": "^8.0.4",
"rehype-react": "^7.0.4",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"unified": "^10.1.2"
"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": "^12.0.8"
"eslint-config-next": "^13.3.0",
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
import useAuth from "hooks/useAuth";
import type { ReactNode } from "react";
import { Text } from "components/text";
import { Column, Row } from "components/box";
import { LoginDialog } from "components/dialog/LoginDialog";
import { RegisterDialog } from "components/dialog/RegisterDialog";
interface LoginGateProps {
children: ReactNode;
}
export function LoginGate({ children }: LoginGateProps) {
const { me } = useAuth();
if (me.user) {
// User is already logged in, so we can safely show the content.
return (
<>
{children}
</>
);
}
return (
<Column style={{ "--gap": "16px" }}>
<Text>You need to log in to continue:</Text>
<Row style={{ "--gap": "16px" }}>
<LoginDialog />
<RegisterDialog />
</Row>
</Column>
);
}
@@ -1,6 +1,14 @@
import styled from "styled-components";
import type { Property } from "csstype";
const Flex = styled.div`
const Flex = styled.div<{
$wrap?: boolean
style?: {
"--justify-content"?: Property.JustifyContent
"--align-items"?: Property.AlignItems
"--gap"?: Property.Gap
}
}>`
--justify-content: initial;
--align-items: initial;
--gap: initial;
+225
View File
@@ -0,0 +1,225 @@
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";
const StyledBracketContainer = styled.div`
flex: 1 1 0;
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
`;
const StyledBracket = styled(m.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
`;
const StyledCanvas = styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
`;
const StyledRound = styled.div`
display: flex;
flex-direction: column;
gap: 64px;
`;
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
`;
const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
width: 384px;
`;
interface BracketChartProps extends RequiredNonNullable<BracketPageQuery> {}
export function BracketChart({ bracket }: BracketChartProps) {
const [showBracketChart, setShowBracketChart] = useState(false);
const bracketRef = useRef<HTMLDivElement>();
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
bracketRef.current = bracket;
if (bracket) {
bracket.requestFullscreen();
bracket.addEventListener("fullscreenchange", onFullscreenChange);
}
function onFullscreenChange() {
if (!document.fullscreenElement) {
setShowBracketChart(false);
bracket.removeEventListener("fullscreenchange", onFullscreenChange);
}
}
}, []);
const onCanvasInit = useCallback((canvas: HTMLCanvasElement) => {
if (!canvas?.parentNode) {
return;
}
const parentRect = (canvas.parentNode as HTMLDivElement).getBoundingClientRect();
canvas.width = parentRect.width;
canvas.height = parentRect.height;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.strokeStyle = "#75ead4";
ctx.lineWidth = 2;
ctx.translate(-parentRect.left, -parentRect.top);
const cards = document.querySelectorAll<HTMLDivElement>("[data-theme]");
const cardsById = [...cards].reduce<Record<string, Array<HTMLDivElement>>>((map, card) => {
const id = card.dataset.theme;
if (id) {
if (!map[id]) {
map[id] = [];
}
map[id].push(card);
}
return map;
}, {});
for (const cards of Object.values(cardsById)) {
for (let i = 0; i < cards.length - 1; i++) {
const current = cards[i];
const currentRect = current.getBoundingClientRect();
const next = cards[i + 1];
const nextRect = next.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(currentRect.left + currentRect.width, currentRect.top + currentRect.height / 2);
ctx.bezierCurveTo(
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
currentRect.top + currentRect.height / 2,
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2
);
ctx.stroke();
}
}
}, []);
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject}/>
<span>Open Bracket Chart</span>
</Button>
{showBracketChart ? (
<StyledBracketContainer ref={onBracketInit}>
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement>}>
<StyledCanvas ref={onCanvasInit}/>
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => (
<BracketRound key={round.tier} round={round}/>
))}
</StyledBracket>
</StyledBracketContainer>
) : null}
</>
);
}
interface BracketRoundProps {
round: BracketChartProps["bracket"]["rounds"][number]
}
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
if (!round.pairings || !round.pairings.length) {
return null;
}
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings}/>
</StyledRound>
);
});
interface BracketPairingsProps {
pairings: BracketRoundProps["round"]["pairings"]
}
function BracketPairings({ pairings }: BracketPairingsProps) {
const sortedPairings = pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
return <>{sortedPairings}</>;
}
interface ContestantCardProps {
contestant: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
opponent: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
contestantVotes: number | null
opponentVotes: number | null
}
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
const isVoted = !!contestantVotes && !!opponentVotes;
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
contestant={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
votes={contestantVotes}
data-theme={contestant.id}
/>
);
}
@@ -0,0 +1,106 @@
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";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
const StyledRank = styled(Text)`
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
`;
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
contestant: BracketThemeSummaryCardConstestantFragment
isVoted: boolean
isWinner: boolean
seed: number | null
votes: number | null
}
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
const theme = contestant.theme;
const { smallCover } = extractImages(theme?.anime);
let to;
let description: ReactNode = contestant.source;
if (theme?.anime) {
const entry = theme.entries?.[0];
const video = entry?.videos?.[0];
const videoSlug = createVideoSlug(theme, entry, video);
to = `/anime/${theme.anime.slug}/${videoSlug}`;
description = (
<SummaryCard.Description>
<span>{theme.slug}</span>
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
</SummaryCard.Description>
);
}
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
description={description}
image={smallCover ?? contestant.image}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling}/>
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers}/>
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && (
<CornerIcon icon={faAward} title="Winner"/>
)}
</StyledSummaryCardWrapper>
);
}
BracketThemeSummaryCard.fragments = {
contestant: gql`
${ThemeSummaryCard.fragments.theme}
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
name
source
image
theme {
...ThemeSummaryCardTheme
}
}
`,
};
+64
View File
@@ -0,0 +1,64 @@
import styled from "styled-components";
import { Button } from "./Button";
import { Icon } from "components/icon";
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import React, { useState, useEffect, useContext } from "react";
import theme from "theme";
import { withHover } from "styles/mixins";
import PlayerContext from "context/playerContext";
import { AnimatePresence, m } from "framer-motion";
const ScrollButton = styled(m(Button))<{ $bottomOffset: number }>`
position: fixed;
right: 16px;
bottom: ${(props) => 16 + props.$bottomOffset}px;
padding: 16px;
${withHover`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-primary"]};
`}
`;
export function BackToTopButton() {
const [isButtonVisible, setIsButtonVisible] = useState(false);
const { currentWatchListItem } = useContext(PlayerContext);
function scrollUp() {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
useEffect(() => {
function handleScroll() {
if (window.scrollY > 2000) {
setIsButtonVisible(true);
} else {
setIsButtonVisible(false);
}
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<AnimatePresence>
{isButtonVisible ? (
<ScrollButton
key={"scrollButton"}
initial={{ y: "150%" }}
animate={{ y: "0" }}
exit={{ y: "450%" }}
variant="primary"
isCircle={true}
onClick={scrollUp}
onMouseDown={(event: React.PointerEvent) => event.preventDefault()}
$bottomOffset={currentWatchListItem ? 76 : 0}
>
<Icon icon={faChevronUp} />
</ScrollButton>
) : null}
</AnimatePresence>
);
}
@@ -1,24 +1,33 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from "react";
import { forwardRef } from "react";
export const Button = forwardRef(ButtonWithRef);
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
children?: ReactNode
variant?: "solid" | "primary" | "warning" | "silent"
isCircle?: boolean
disabled?: boolean
}
function ButtonWithRef({
children,
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}, ref) {
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
let Component;
if (variant === "solid") {
Component = SolidButton;
} else if (variant === "primary") {
Component = PrimaryButton;
} else if (variant === "warning") {
Component = WarningButton;
} else if (variant === "silent") {
Component = SilentButton;
} else {
@@ -33,17 +42,19 @@ function ButtonWithRef({
aria-label={title}
ref={ref}
{...props}
>{children}</Component>
/>
);
}
const BaseButton = styled.button`
const BaseButton = styled.button<{ $isCircle: boolean }>`
--gap: 0;
--focus-ring-color: ${theme.colors["text-primary"]};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
pointer-events: ${(props) => props.disabled && "none"};
font-size: 0.9rem;
font-weight: 700;
@@ -64,20 +75,51 @@ const BaseButton = styled.button`
box-shadow: none;
margin: -8px 8px -8px -16px;
}
&:focus:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-color);
}
`;
const PrimaryButton = styled(BaseButton)`
background-color: ${theme.colors["solid-primary"]};
color: ${theme.colors["text-on-primary"]};
${withHover`
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
}
`;
const WarningButton = styled(BaseButton)`
--focus-ring-color: ${theme.colors["text-warning"]};
background-color: ${theme.colors["solid-warning"]};
color: ${theme.colors["text-on-warning"]};
${withHover`
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
}
`;
const SolidButton = styled(BaseButton)`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
color: ${theme.colors["text"]};
`)}
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
@@ -88,19 +130,26 @@ const SilentButton = styled(BaseButton)`
background-color: transparent;
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text"]};
`)}
`}
&:not(:hover) {
&:not(:hover)&:not(:focus:focus-visible) {
box-shadow: none;
}
${Solid} & {
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
}
${Solid} ${Solid} & {
${withHover`
background-color: transparent;
box-shadow: none;
`}
}
`;
@@ -1,8 +0,0 @@
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "components/button";
export function FilterToggleButton(props) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
);
}
@@ -0,0 +1,9 @@
import { faFilter } from "@fortawesome/pro-solid-svg-icons";
import { IconTextButton } from "components/button";
import type { ComponentPropsWithoutRef } from "react";
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
import styled, { css } from "styled-components";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
const StyledButton = styled(Button)`
gap: 8px;
`;
const StyledText = styled.span`
${(props) => props.collapsible && css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`}
`;
export function IconTextButton({ icon, children, collapsible, ...props }) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const isCollapsed = collapsible && isMobile;
return (
<StyledButton variant="silent" isCircle={isCollapsed} {...props}>
<Icon icon={icon} color="text-disabled"/>
<StyledText collapsible={collapsible}>{children}</StyledText>
</StyledButton>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
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";
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
aspect-ratio: 1 / 1;
padding: 8px;
}
`;
const StyledText = styled.span<{ $collapseBreakpoint: string }>`
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
display: none;
}
`;
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
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";
if (collapsible === true) {
collapseBreakpoint = theme.breakpoints.mobileMax;
} else if (collapsible) {
collapseBreakpoint = theme.breakpoints[collapsible];
}
return (
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
{isIconDefinition(icon) ? (
<Icon icon={icon} color="text-disabled"/>
) : icon}
{(children !== null && children !== undefined) ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
}
);
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
return !!icon && typeof icon === "object" && "icon" in icon;
}
-25
View File
@@ -1,25 +0,0 @@
import { useContext } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import { VideoTags } from "components/utils";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
export function VideoButton({ anime, theme, entry, video, ...props }) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo && currentVideo.filename === video.filename;
return (
<Link href={`/anime/${anime.slug}/${videoSlug}`} passHref prefetch={false}>
<Button as="a" {...props}>
<Button as="span" variant="primary" isCircle>
<Icon icon={isPlaying ? faCompactDisc : faPlay} spin={isPlaying}/>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ComponentPropsWithoutRef } from "react";
import { useContext } 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";
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
anime: VideoButtonAnimeFragment
theme: VideoButtonThemeFragment
entry: VideoButtonEntryFragment
video: VideoButtonVideoFragment
}
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo ? currentVideo.id === video.id : 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>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
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
}
`,
};
@@ -2,3 +2,4 @@ export { Button } from "./Button";
export { VideoButton } from "./VideoButton";
export { FilterToggleButton } from "./FilterToggleButton";
export { IconTextButton } from "./IconTextButton";
export { BackToTopButton } from "./BackToTopButton";
@@ -1,19 +1,20 @@
import { Fragment } from "react";
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import { Icon } from "components/icon";
import { SummaryCard } from "components/card";
import styled from "styled-components";
import useToggle from "hooks/useToggle";
import theme from "theme";
import gql from "graphql-tag";
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";
const StyledWrapper = styled.div`
position: relative
@@ -57,9 +58,17 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
export function AnimeSummaryCard({ anime, previewThemes = false, expandable = false, ...props }) {
type AnimeSummaryCardProps = {
anime: AnimeSummaryCardAnimeFragment
expandable?: false
} | {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
expandable: true
};
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(anime);
const { smallCover } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniq(anime.themes.map((theme) => theme.group));
@@ -73,20 +82,20 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
premiereLink += `/${anime.season.toLowerCase()}`;
}
let description = (
const description = (
<SummaryCard.Description>
<span>Anime</span>
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && (
<Link href={premiereLink} passHref prefetch={false}>
<Text as="a" link>{premiere}</Text>
</Link>
<TextLink href={premiereLink}>
{premiere}
</TextLink>
)}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
@@ -109,19 +118,22 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
{/* TODO: Context Menu */}
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledThemeContainerInline>
)}
</SummaryCard>
{expandable && (
{expandable ? (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
@@ -129,26 +141,27 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
{!!group && (
<Text variant="h2">{group}</Text>
)}
<ThemeTable themes={anime.themes.filter((theme) => theme.group === group)}/>
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group === group)}/>
</Fragment>
))}
</StyledThemeGroupContainer>
</Collapse>
)}
) : null}
</StyledWrapper>
);
}
AnimeSummaryCard.fragments = {
anime: gql`
${useImage.fragment}
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCard_anime on Anime {
...useImage_resourceWithImages
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
name
year
season
media_format
themes {
group
}
@@ -157,9 +170,9 @@ AnimeSummaryCard.fragments = {
expandable: gql`
${ThemeTable.fragments.theme}
fragment AnimeSummaryCard_anime_expandable on Anime {
fragment AnimeSummaryCardAnimeExpandable on Anime {
themes {
...ThemeTable_theme
...ThemeTableTheme
group
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { Markdown } from "components/markdown/Markdown";
import { AnimeAwardsNowAvailable } from "components/event/AnimeAwardsNowAvailable";
interface AnnouncementCardProps {
announcementSource: MDXRemoteSerializeResult;
}
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
return (
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
);
}
-27
View File
@@ -1,27 +0,0 @@
import useImage from "hooks/useImage";
import { SummaryCard } from "components/card";
export function ArtistSummaryCard({ artist, as }) {
const { smallCover } = useImage(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!artist.performances && (
<span>{artist.performances.length} performance{artist.performances.length === 1 ? "" : "s"}</span>
)}
{!!as && (
<span>As {as}</span>
)}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import extractImages from "utils/extractImages";
import { SummaryCard } from "components/card";
import gql from "graphql-tag";
import type { ArtistSummaryCardArtistFragment } from "generated/graphql";
type ArtistSummaryCardProps = {
artist: ArtistSummaryCardArtistFragment
as?: string | null
};
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
const { smallCover } = extractImages(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!as && (
<span>As {as}</span>
)}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
);
}
ArtistSummaryCard.fragments = {
artist: gql`
fragment ArtistSummaryCardArtist on Artist {
slug
name
images {
link
facet
}
}
`
};
-24
View File
@@ -1,24 +0,0 @@
import styled, { css } from "styled-components";
import { Solid } from "components/box";
import theme from "theme";
import { withHover } from "styles/mixins";
export const Card = styled(Solid)`
display: block;
padding: 16px 24px;
border-color: ${(props) => theme.colors[props.color] || props.color || theme.colors["text-primary"]};
border-left-width: 4px;
border-left-style: solid;
border-radius: 0 8px 8px 0;
box-shadow: ${theme.shadows.medium};
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
`;
+42
View File
@@ -0,0 +1,42 @@
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";
export const Card = styled(Solid)<{
hoverable?: boolean
color?: keyof Colors
}>`
display: block;
position: relative;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
box-shadow: ${theme.shadows.medium};
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
${(props) => props.hoverable && css`
cursor: pointer;
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`}
`}
&:before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
}
`;
@@ -1,17 +1,15 @@
import { Card } from "components/card";
import { Row } from "components/box";
import { Text } from "components/text";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
import { faExclamation } from "@fortawesome/pro-solid-svg-icons";
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.colors["text-warning"]};
`;
const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
@@ -20,12 +18,16 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
overflow: auto;
`;
export function ErrorCard({ error }) {
interface ErrorCardProps {
error: unknown
}
export function ErrorCard({ error }: ErrorCardProps) {
return (
<StyledCard>
<StyledCard color="text-warning">
<Row style={{ "--gap": "1rem" }}>
<Text color="text-warning">
<FontAwesomeIcon 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>
</Row>
@@ -36,4 +38,4 @@ export function ErrorCard({ error }) {
</pre>
</StyledCard>
);
}
}
@@ -0,0 +1,84 @@
import { SummaryCard } from "components/card/SummaryCard";
import type { PropsWithChildren } from "react";
import gql from "graphql-tag";
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "generated/graphql";
import type { ReactNode } from "react";
import styled from "styled-components";
import theme from "theme";
import { Text } from "components/text";
const StyledWrapper = styled.div`
position: relative
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
type PlaylistSummaryCardProps = {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
} | {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
const description = (
<SummaryCard.Description>
<span>Playlist</span>
<span>{playlist.visibility}</span>
{showOwner ? (
<Text link>{playlist.user.name}</Text>
) : null}
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
</SummaryCard.Description>
);
return (
<StyledWrapper>
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
{children}
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
PlaylistSummaryCard.fragments = {
playlist: gql`
fragment PlaylistSummaryCardPlaylist on Playlist {
id
name
visibility
tracks_count
}
`,
showOwner: gql`
fragment PlaylistSummaryCardShowOwner on Playlist {
user {
name
}
}
`,
};
-96
View File
@@ -1,96 +0,0 @@
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 { useState } from "react";
import { loadingAnimation } from "styles/mixins";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 0;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})`
width: 48px;
height: 64px;
object-fit: cover;
${loadingAnimation}
${(props) => props.isPlaceholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
export function SummaryCard({ title, description, image, to, children, ...props }) {
const [ imageNotFound, setImageNotFound ] = useState(false);
return (
<StyledSummaryCard {...props}>
<Link href={to} prefetch={false}>
<a>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isPlaceholder={!image || imageNotFound}
loading="lazy"
onError={() => setImageNotFound(true)}
/>
</a>
</Link>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
<Link href={to} passHref prefetch={false}>
<Text as="a" link>{title}</Text>
</Link>
) : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
SummaryCard.Description = function SummaryCardDescription({ children }) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
};
+138
View File
@@ -0,0 +1,138 @@
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 { Property } from "csstype";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
title: string | ReactNode
description?: string | ReactNode
image?: string
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>
to?: string
children?: ReactNode
};
export function SummaryCard({
title,
description,
image,
imageProps,
to,
children,
...props
}: SummaryCardProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
return (
<StyledSummaryCard {...props}>
<ConditionalWrapper
condition={!!to}
wrap={(children) => <Link href={to as string}>{children}</Link>}
>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isLoading={imageLoading}
isPlaceholder={!image || imageNotFound}
loading="lazy"
{...imageProps}
onLoad={(event) => {
setImageLoading(false);
if (imageProps?.onLoad) {
imageProps.onLoad(event);
}
}}
onError={() => {
setImageNotFound(true);
setImageLoading(false);
if (imageProps?.onError) {
imageProps.onError();
}
}}
/>
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" && to ?
<TextLink href={to}>{title}</TextLink> : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
type SummaryCardDescriptionProps = {
children: Array<ReactNode>
};
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
};
+125
View File
@@ -0,0 +1,125 @@
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
import type { Property } from "csstype";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
children?: ReactNode
};
export function SummaryCard({ children, ...props }: SummaryCardProps) {
return (
<StyledSummaryCard {...props}>
{children}
</StyledSummaryCard>
);
}
SummaryCard.Body = StyledBody;
interface SummaryCardTitleProps extends ComponentPropsWithoutRef<typeof Text> {
children: ReactNode;
}
SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCardTitleProps) {
return (
<Text maxLines={1} {...props}>
{children}
</Text>
);
};
interface SummaryCardDescriptionProps {
children: string | Array<ReactNode>
}
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<Text variant="small" maxLines={1} color="text-muted">
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
<>
{child}
{index < length - 1 && (
<span> &bull; </span>
)}
</>
))}
</Text>
);
};
interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCover> {
src?: string;
}
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
return (
<StyledCover
alt="Cover"
src={(!imageNotFound && src) || withBasePath("/img/logo.svg")}
isLoading={imageLoading}
isPlaceholder={!src || imageNotFound}
loading="lazy"
{...props}
onLoad={(event) => {
setImageLoading(false);
if (props?.onLoad) {
props.onLoad(event);
}
}}
onError={() => {
setImageNotFound(true);
setImageLoading(false);
if (props?.onError) {
props.onError();
}
}}
/>
);
};
-49
View File
@@ -1,49 +0,0 @@
import {
StyledRow,
StyledSequence,
StyledThemeCard,
StyledVideoList,
StyledVideoListContainer
} from "./ThemeDetailCard.style";
import { VideoButton } from "components/button";
import { Performances, SongTitle, ThemeEntryTags } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu";
export function ThemeDetailCard({ theme }) {
return (
<StyledThemeCard>
<StyledRow>
<StyledSequence>{theme.type}{theme.sequence || null}</StyledSequence>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
</Text>
<ThemeMenu theme={theme}/>
</StyledRow>
{theme.entries.map(entry => (
<StyledRow key={entry.version || 0}>
<StyledSequence secondary>{!!entry.version && `v${entry.version}`}</StyledSequence>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={theme.anime}
theme={theme}
entry={entry}
video={video}
/>
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
@@ -1,40 +0,0 @@
import styled from "styled-components";
import { Row } from "components/box";
import { Text } from "components/text";
import { Card } from "components/card";
import theme from "theme";
export const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
export const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
export const StyledSequence = styled(Text).attrs((props) => ({
variant: "small",
color: props.secondary ? "text-muted" : "text"
}))``;
export const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
export const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
+131
View File
@@ -0,0 +1,131 @@
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";
const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
interface ThemeDetailCardProps {
theme: ThemeDetailCardThemeFragment
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
const { anime } = theme;
if (!anime) {
return null;
}
return (
<StyledThemeCard>
<StyledRow>
<Text variant="small" color="text">{theme.type}{theme.sequence || null}</Text>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
</Text>
<ThemeMenu theme={theme}/>
</StyledRow>
{theme.entries.sort(entryVersionComparator).map(entry => (
<StyledRow key={entry.version || 0}>
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={anime}
theme={theme}
entry={entry}
video={video}
/>
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
ThemeDetailCard.fragments = {
theme: gql`
${ThemeMenu.fragments.theme}
${VideoTags.fragments.video}
fragment ThemeDetailCardTheme on Theme {
...ThemeMenuTheme
slug
type
sequence
group
anime {
slug
name
}
song {
title
performances {
as
artist {
slug
name
}
}
}
entries {
version
episodes
spoiler
nsfw
videos {
...VideoTagsVideo
filename
tags
}
}
}
`
};
-216
View File
@@ -1,216 +0,0 @@
import Link from "next/link";
import { Collapse, SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
import { ThemeMenu } from "components/menu";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import { Icon } from "components/icon";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import useToggle from "hooks/useToggle";
import styled from "styled-components";
import { Table, ThemeTable } from "components/table";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
const StyledWrapper = styled.div`
position: relative
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledExpandButton = styled(Button)`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const StyledPerformedWith = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
`;
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable = false, ...props }) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(theme.anime);
const isMobile = useIsMobile();
if (!theme.entries.length) {
return null;
}
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref prefetch={false}>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
return (
<StyledWrapper>
<SummaryCard
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
description={description}
image={smallCover}
to={to}
onClick={handleToggleExpand}
{...props}
>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
</StyledExpandButton>
)}
</StyledOverlayButtons>
</SummaryCard>
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]}/>
{theme.song?.performances?.length > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<Table.Head>
<Table.HeadCell>Performed {artist ? "With" : "By"}</Table.HeadCell>
</Table.Head>
<Table.Body>
{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 prefetch={false}>
<Table.Row as="a">
<Table.Cell>
<Text color="text-primary" weight="600">
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
</Text>
</Table.Cell>
</Table.Row>
</Link>
))}
</Table.Body>
</Table>
)}
</StyledPerformedWith>
</Collapse>
)}
</StyledWrapper>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${useImage.fragment}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragment}
fragment ThemeSummaryCard_theme on Theme {
...createVideoSlug_theme
...ThemeMenu_theme
slug
type
sequence
group
anime {
...useImage_resourceWithImages
slug
name
}
song {
...SongTitleWithArtists_song
}
entries {
...createVideoSlug_entry
videos {
...createVideoSlug_video
}
}
}
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCard_artist on Artist {
...SongTitleWithArtists_artist
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment ThemeSummaryCard_theme_expandable on Theme {
...ThemeTable_theme
}
`
};
ThemeSummaryCard.fetchData = async function (id) {
return fetchDataClient(gql`
${ThemeSummaryCard.fragments.theme}
query($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCard_theme
anime {
year
season
}
}
}
`, { themeId: id }).then((result) => result.data?.theme);
};
+248
View File
@@ -0,0 +1,248 @@
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 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";
const StyledWrapper = styled.div`
position: relative
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledExpandButton = styled(Button)`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const StyledPerformedWith = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
`;
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
type ThemeSummaryCardProps = {
theme: ThemeSummaryCardThemeFragment
artist?: ThemeSummaryCardArtistFragment
expandable?: false
onPlay?(entryIndex?: number, videoIndex?: number): void
} | {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
artist?: ThemeSummaryCardArtistFragment
expandable: true
onPlay?(entryIndex?: number, videoIndex?: number): void
};
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
const [isExpanded, toggleExpanded] = useToggle();
const isMobile = useIsMobile();
const anime = theme.anime;
const entry = theme.entries[0];
const video = entry?.videos[0];
if (!anime || !entry || !video) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
function handleToggleExpand(event: MouseEvent) {
if (isLink(event.target)) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
function isLink(element: EventTarget | null): boolean {
if (!element || !(element instanceof HTMLElement)) {
return false;
} else if (element instanceof HTMLAnchorElement && element.href) {
return true;
}
return isLink(element.parentElement);
}
return (
<StyledWrapper>
<SummaryCard onClick={handleToggleExpand} {...props}>
<Link href={href} onClick={() => onPlay?.()}>
<SummaryCard.Cover src={smallCover} />
</Link>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={artist} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
{expandable && (
<StyledExpandButton
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledOverlayButtons>
</SummaryCard>
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<TableHead>
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
</TableHead>
<TableBody>
{theme.song?.performances
?.filter((performance) => performance.artist.slug !== artist?.slug)
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
.map((performance) => (
<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>
))}
</TableBody>
</Table>
)}
</StyledPerformedWith>
</Collapse>
)}
</StyledWrapper>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragments.theme}
fragment ThemeSummaryCardTheme on Theme {
...createVideoSlugTheme
...ThemeMenuTheme
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);
};
+139
View File
@@ -0,0 +1,139 @@
import { Performances, SongTitle, SongTitleWithArtists } from "components/utils";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card/SummaryCard2";
import gql from "graphql-tag";
import styled from "styled-components";
import theme from "theme";
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
import { type ForwardedRef, forwardRef, type ReactNode } from "react";
import { TextLink } from "components/text/TextLink";
import Link from "next/link";
import { Icon } from "components/icon";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledCoverLink = styled(Link)`
position: relative;
`;
const StyledCoverOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
`;
interface VideoSummaryCardProps {
video: VideoSummaryCardVideoFragment;
menu?: ReactNode;
onPlay?(): void;
isPlaying?: boolean;
}
export const VideoSummaryCard = forwardRef(
function VideoSummaryCard({ video, menu, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{videoSlug}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
);
export const VideoSummaryCardFragmentVideo = gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
entries {
...createVideoSlugEntry
theme {
...createVideoSlugTheme
id
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
audio {
basename
}
}
`;
@@ -2,7 +2,7 @@ import styled from "styled-components";
export const Container = styled.div`
margin: 0 auto;
padding: 1.5rem 1rem;
padding: 24px 16px;
width: 100%;
max-width: 1100px;
`;
@@ -1,5 +1,6 @@
import styled from "styled-components";
import { Text } from "components/text";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
const StyledDescriptionList = styled.dl`
display: flex;
@@ -19,7 +20,11 @@ const StyledValue = styled.dd`
}
`;
export function DescriptionList({ children, ...props }) {
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
children: ReactNode
}
export function DescriptionList({ children, ...props }: DescriptionListProps) {
return (
<StyledDescriptionList {...props}>
{children}
@@ -27,7 +32,12 @@ export function DescriptionList({ children, ...props }) {
);
}
DescriptionList.Item = function DescriptionListItem({ title, children }) {
interface DescriptionListItemProps {
title: string
children: ReactNode
}
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
return (
<>
<StyledKey>
+81
View File
@@ -0,0 +1,81 @@
import { forwardRef } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import styled, { keyframes } from "styled-components";
import { Card } from "components/card";
import theme from "theme";
import { Text } from "components/text";
const overlayAnimation = keyframes`
from {
opacity: 0;
}
`;
const contentAnimation = keyframes`
from {
transform: scale(0.9);
opacity: 0;
}
`;
const StyledOverlay = styled(RadixDialog.Overlay)`
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 32px 16px;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
animation: ${overlayAnimation} 250ms;
`;
const StyledDialogCard = styled(Card)`
width: 100%;
max-width: 450px;
animation: ${contentAnimation} 250ms;
margin: auto;
padding: 24px;
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
&:before {
display: none;
}
`;
const StyledHeader = styled.div`
margin: -24px -24px 24px -24px;
padding: 16px 24px;
background-color: ${theme.colors["background"]};
`;
interface DialogContentProps extends RadixDialog.DialogContentProps {
title?: string;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
function DialogContent({ title, children, ...props }, ref) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? (
<StyledHeader>
<Text variant="h2">{title}</Text>
</StyledHeader>
) : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
);
export const Dialog = RadixDialog.Root;
export const DialogTrigger = RadixDialog.Trigger;
@@ -0,0 +1,118 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { isAxiosError } from "axios";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function ForgotPasswordDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Text variant="small" link color="text-muted">Forgot Password?</Text>
</DialogTrigger>
<DialogContent title="Request a Password Reset">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ForgotPasswordForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface ForgotPasswordProps {
onSuccess(): void;
onCancel(): void;
}
function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
const { forgotPassword } = useAuth();
const { dispatchToast } = useToasts();
const [email, setEmail] = useState("");
const isValid = !!email;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
async function performForgotPassword(event: SyntheticEvent) {
event.preventDefault();
event.stopPropagation();
setBusy(true);
setErrors({});
try {
await forgotPassword({
email,
});
dispatchToast(
"forgot-password-sent",
<Toast>We sent you an e-mail with a password reset link.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performForgotPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Submit</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import Switch from "components/form/Switch";
import { ForgotPasswordDialog } from "components/dialog/ForgotPasswordDialog";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function LoginDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Login</Button>
</DialogTrigger>
<DialogContent title="Login">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginForm onCancel={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
interface LoginFormProps {
onCancel(): void;
}
function LoginForm({ onCancel }: LoginFormProps) {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isRemember, setRemember] = useState(false);
const isValid = email && password;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
function performLogin(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
login({
setErrors,
email,
password,
remember: isRemember,
})
.finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performLogin}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
<ForgotPasswordDialog />
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-remember"
isChecked={isRemember}
onCheckedChange={setRemember}
/>
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Login</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,149 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import axios from "lib/client/axios";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
export function PasswordChangeDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Change Password</Button>
</DialogTrigger>
<DialogContent title="Change Password">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<PasswordChangeForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordChangeFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
const { dispatchToast } = useToasts();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const isValid = currentPassword && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
current_password?: string[];
password?: string[];
}>({});
async function performChangePassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await axios.put(
"/user/password",
{
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
}
);
dispatchToast(
"password-change",
<Toast>Password changed successfully.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performChangePassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Current Password</Text>
<Input
value={currentPassword}
onChange={setCurrentPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.current_password ? errors.current_password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Change Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,149 @@
import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
import { useRouter } from "next/router";
import useAuth from "hooks/useAuth";
export function PasswordResetDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => setOpen(true), []);
return (
<Dialog open={open}>
<DialogContent title="Reset Password">
<PasswordResetForm
onSuccess={() => router.push("/profile")}
onCancel={() => router.push("/")}
/>
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordResetFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
const { resetPassword } = useAuth();
const { dispatchToast } = useToasts();
const router = useRouter();
const [email, setEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const token = router.query.token;
const isValid = email && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string[];
password?: string[];
}>({});
async function performResetPassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await resetPassword({
email,
password: newPassword,
password_confirmation: newPasswordConfirmation,
token,
});
dispatchToast(
"password-reset",
<Toast>Password reset successfully.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performResetPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Reset Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
interface PlaylistAddDialogProps {
trigger?: ReactNode;
}
export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Create a new playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistAddForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistAddFormProps {
onSuccess(): void;
onCancel(): void;
}
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState("PUBLIC");
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.post("/api/playlist", {
name: title,
visibility,
});
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="PUBLIC">Public</ListboxOption>
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
<ListboxOption value="PRIVATE">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,140 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPen } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import gql from "graphql-tag";
import type { PlaylistEditDialogPlaylistFragment } from "generated/graphql";
interface PlaylistEditDialogProps {
playlist: PlaylistEditDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Edit playlist details">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistEditForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistEditDialog.fragments = {
playlist: gql`
fragment PlaylistEditDialogPlaylist on Playlist {
id
name
visibility
}
`,
};
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistEditFormProps {
playlist: PlaylistEditDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
const [title, setTitle] = useState(playlist.name);
const [visibility, setVisibility] = useState<string>(playlist.visibility);
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.put(`/api/playlist/${playlist.id}`, {
name: title,
visibility,
});
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="Public">Public</ListboxOption>
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
<ListboxOption value="Private">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,104 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import gql from "graphql-tag";
import { useToasts } from "context/toastContext";
import { PlaylistRemoveToast } from "components/toast/PlaylistRemoveToast";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import type { PlaylistRemoveDialogPlaylistFragment } from "generated/graphql";
interface PlaylistRemoveDialogProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Delete playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistRemoveForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistRemoveDialog.fragments = {
playlist: gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistRemoveToast.fragments.playlist}
fragment PlaylistRemoveDialogPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistRemoveToastPlaylist
id
name
}
`,
};
interface PlaylistRemoveFormProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removePlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}`);
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-${playlist.id}`,
<PlaylistRemoveToast playlist={playlist} />
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<PlaylistSummaryCard playlist={playlist} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
<Busy isBusy={isBusy}>Delete playlist</Busy>
</Button>
</Row>
</Column>
);
}
@@ -0,0 +1,232 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faArrowDown, faListMusic, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import useSWR, { mutate } from "swr";
import { Icon } from "components/icon";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import { useToasts } from "context/toastContext";
import type {
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables
} from "generated/graphql";
import { Text } from "components/text";
import { PlaylistTrackAddToast } from "components/toast/PlaylistTrackAddToast";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { LoginGate } from "components/auth/LoginGate";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import gql from "graphql-tag";
import { Skeleton } from "components/skeleton/Skeleton";
import { Busy } from "components/utils/Busy";
import { fetchDataClient } from "lib/client";
import { PlaylistAddDialog } from "components/dialog/PlaylistAddDialog";
interface PlaylistTrackAddDialogProps {
video: PlaylistTrackAddDialogVideoFragment;
trigger?: ReactNode;
}
export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackAddForm
video={video}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackAddDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
id
}
`,
};
interface PlaylistTrackAddFormProps {
video: PlaylistTrackAddDialogVideoFragment;
onCancel(): void;
}
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
const { data: playlists } = useSWR(
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
async () => {
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
${PlaylistSummaryCard.fragments.playlist}
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
me {
playlistAll {
...PlaylistSummaryCardPlaylist
id
tracks_count
}
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
id
tracks {
id
}
}
}
}
`, { filterVideoId: video.id });
const { playlistAll, playlistAllFiltered } = data.me;
return playlistAll?.map((playlist) => {
return {
...playlist,
...playlistAllFiltered?.find((p) => p.id === playlist.id)
};
}) ?? [];
},
);
if (!playlists) {
return (
<Column style={{ "--gap": "24px" }}>
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
</Column>
);
}
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} />
<Row style={{ "--justify-content": "center" }}>
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
<Column style={{ "--gap": "16px" }}>
{playlists?.length ? playlists.map((playlist) => (
<PlaylistTrackAddCard
key={playlist.id}
playlist={playlist}
video={video}
/>
)) : (
<Text>You have not created a playlist, yet.</Text>
)}
<PlaylistAddDialog trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
} />
</Column>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
</Row>
</Column>
);
}
interface PlaylistTrackAddCardProps {
playlist:
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
video: PlaylistTrackAddDialogVideoFragment;
}
function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function addTrackToPlaylist() {
setBusy(true);
try {
await axios.post(`/api/playlist/${playlist.id}/track`, { video_id: video.id, });
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} video={video} />
);
}
async function removeTrackFromPlaylist() {
const track = playlist.tracks?.[0];
if (!track) {
return;
}
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${track.id}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
}
return (
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{!playlist.tracks?.length ? (
<IconTextButton
icon={faPlus}
disabled={isBusy}
onClick={addTrackToPlaylist}
>
<Busy isBusy={isBusy}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton
icon={faMinus}
disabled={isBusy}
onClick={removeTrackFromPlaylist}
>
<Busy isBusy={isBusy}>Remove</Busy>
</IconTextButton>
)}
</PlaylistSummaryCard>
);
}
@@ -0,0 +1,125 @@
import type {
PlaylistTrackRemoveDialogPlaylistFragment,
PlaylistTrackRemoveDialogVideoFragment
} from "generated/graphql";
import type { ReactNode } from "react";
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { LoginGate } from "components/auth/LoginGate";
import axios from "lib/client/axios";
import { Column, Row } from "components/box";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import { Text } from "components/text";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { useToasts } from "context/toastContext";
import { mutate } from "swr";
import gql from "graphql-tag";
import { Busy } from "components/utils/Busy";
interface PlaylistTrackRemoveDialogProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
trigger?: ReactNode;
}
export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }: PlaylistTrackRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Remove from playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackRemoveForm
playlist={playlist}
trackId={trackId}
video={video}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackRemoveDialog.fragments = {
playlist: gql`
${PlaylistTrackRemoveToast.fragments.playlist}
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
...PlaylistTrackRemoveToastPlaylist
id
name
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
}
`,
};
interface PlaylistTrackRemoveFormProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel }: PlaylistTrackRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removeTrackFromPlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${trackId}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<VideoSummaryCard video={video} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove from playlist</Busy>
</Button>
</Row>
</Column>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import useAuth from "hooks/useAuth";
import styled from "styled-components";
import Switch from "components/form/Switch";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import Link from "next/link";
export function RegisterDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create new account</Button>
</DialogTrigger>
<DialogContent title="Create a new account">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<RegisterForm onCancel={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface RegisterFormProps {
onCancel(): void;
}
function RegisterForm({ onCancel }: RegisterFormProps) {
const { register } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [isTermsAccepted, setTermsAccepted] = useState(false);
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
password?: string[]
}>({});
function performRegister(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
register({
setErrors,
name: username,
email,
password,
password_confirmation: passwordConfirmation,
terms: isTermsAccepted,
})
.finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performRegister}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Username</Text>
<Input
value={username}
onChange={setUsername}
inputProps={{
required: true,
}}
/>
{errors.name ? errors.name.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm Password</Text>
<Input
value={passwordConfirmation}
onChange={setPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-terms"
isChecked={isTermsAccepted}
onCheckedChange={setTermsAccepted}
/>
<Text as="label" htmlFor="input-terms">I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Account</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,84 @@
import styled, { keyframes } from "styled-components";
import theme from "theme";
import Link from "next/link";
import { faArrowRight, faTrophy } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { Button } from "components/button";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
const EventButton = styled(Button)`
position: relative;
justify-content: space-between;
height: 64px;
border-radius: 8px;
gap: 8px;
overflow: hidden;
`;
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
const GradientBorder = styled.div`
position: absolute;
inset: 0;
border-radius: 8px;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
--gradient-angle: 0deg;
}
to {
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
`;
interface AnimeAwardsNowAvailableProps {
year: number;
}
export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps) {
return (
<EventButtonContainer>
<GradientBorder />
<Link href="/event/anime-awards" passHref legacyBehavior>
<EventButton forwardedAs="a">
<EventIcon icon={faTrophy} />
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
<Icon icon={faArrowRight} color="text-primary" />
</EventButton>
</Link>
</EventButtonContainer>
);
}
@@ -1,8 +1,14 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { faChevronCircleRight } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export function ExternalLink({ href, children, ...props }) {
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
href?: string | null
children: ReactNode
}
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
return (
<Text as="a" link href={href} target="_blank" rel="noopener" {...props}>
<Text>{children}</Text>
@@ -2,16 +2,17 @@ import { FeaturedThemePreview } from "utils/settings";
import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import useImage from "hooks/useImage";
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 gql from "graphql-tag";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
import { VIDEO_URL } from "utils/config.mjs";
import gql from "graphql-tag";
import type { FeaturedThemeThemeFragment } from "generated/graphql";
const slowPan = keyframes`
from {
@@ -47,7 +48,7 @@ const StyledWrapper = styled.div`
}
`;
const StyledOverflowHidden = styled.a`
const StyledOverflowHidden = styled(Link)`
width: 100%;
height: 100%;
border-radius: 0.5rem;
@@ -75,6 +76,7 @@ const StyledCenter = styled.div`
const StyledVideo = styled.video`
width: 100%;
filter: blur(5px);
background-color: ${theme.colors["solid-on-card"]};
`;
const StyledCover = styled.img`
@@ -115,13 +117,21 @@ const StyledGrill = styled.img`
const Box = styled.div``;
export function FeaturedTheme({ theme }) {
const [ grill, setGrill ] = useState(null);
interface FeaturedThemeProps {
theme: FeaturedThemeThemeFragment;
hasGrill?: boolean;
card?: ReactNode;
}
export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemeProps) {
const [ grill, setGrill ] = useState<string | null>(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
useEffect(() => {
fetchRandomGrill().then(setGrill);
}, []);
if (hasGrill) {
fetchRandomGrill().then(setGrill);
}
}, [hasGrill]);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
? StyledWrapper
@@ -130,11 +140,11 @@ export function FeaturedTheme({ theme }) {
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
? (
<StyledCenter>
<ThemeSummaryCard theme={theme}/>
{card ?? <ThemeSummaryCard theme={theme}/>}
</StyledCenter>
)
: (
<ThemeSummaryCard theme={theme}/>
card ?? <ThemeSummaryCard theme={theme}/>
);
return (
@@ -150,13 +160,13 @@ export function FeaturedTheme({ theme }) {
);
}
function FeaturedThemeBackground({ theme }) {
function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability({ canPlayVideo: false });
const { canPlayVideo } = useCompatability();
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const { smallCover: featuredCover } = useImage(theme.anime);
const { smallCover: featuredCover } = extractImages(theme.anime);
if (!theme.entries.length) {
if (!theme.anime || !theme.entries.length) {
return null;
}
@@ -169,52 +179,51 @@ function FeaturedThemeBackground({ theme }) {
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const linkProps = {
href: `/anime/${theme.anime.slug}/${videoSlug}`,
passHref: true,
prefetch: false
};
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<Link {...linkProps}>
<StyledOverflowHidden>
<StyledVideo
src={`${videoBaseUrl}/video/${video.basename}`}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
<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>
</Link>
</StyledVideo>
</StyledOverflowHidden>
);
} else if (featuredThemePreview === FeaturedThemePreview.COVER || fallbackToCover) {
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<Link {...linkProps}>
<StyledOverflowHidden>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
</Link>
<StyledOverflowHidden href={href}>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
);
}
return null;
}
FeaturedTheme.fragment = gql`
${ThemeSummaryCard.fragments.theme}
${useImage.fragment}
fragment FeaturedTheme_theme on Theme {
...ThemeSummaryCard_theme
anime {
...useImage_resourceWithImages
}
entries {
videos {
basename
FeaturedTheme.fragments = {
theme: gql`
${ThemeSummaryCard.fragments.theme}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeTheme on Theme {
...ThemeSummaryCardTheme
anime {
...extractImagesResourceWithImages
}
entries {
videos {
basename
}
}
}
}
`;
`
};
-60
View File
@@ -1,60 +0,0 @@
import { memo, useMemo, useState } from "react";
import { Column, Row } from "components/box";
import { ThemeDetailCard } from "components/card";
import { Listbox } from "components/listbox";
import { HorizontalScroll } from "components/utils";
import { chain, themeIndexComparator, themeTypeComparator } from "utils/comparators";
export const AnimeThemeFilter = memo(function AnimeThemeFilter({ themes }) {
const groups = useMemo(() => themes.reduce((groups, theme) => {
const groupName = theme.group || "Original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
}, []), [ themes ]);
const [ activeGroup, setActiveGroup ] = useState(groups[0].name);
const activeThemes = groups.find((group) => group.name === activeGroup).themes;
const hasMultipleTypes = activeThemes.find((theme) => theme.type === "OP") && activeThemes.find((theme) => theme.type === "ED");
const [ filterType, setFilterType ] = useState(null);
const filteredThemes = activeThemes
.filter((theme) => !filterType || theme.type === filterType)
.sort(chain(themeTypeComparator, themeIndexComparator));
return (
<Column style={{ "--gap": "16px" }}>
{(groups.length > 1 || hasMultipleTypes) && (
<HorizontalScroll fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onChange={setActiveGroup}>
{groups.map((group) => (
<Listbox.Option key={group.name} value={group.name}>{group.name}</Listbox.Option>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onChange={setFilterType} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>OP & ED</Listbox.Option>
<Listbox.Option value="OP">OP</Listbox.Option>
<Listbox.Option value="ED">ED</Listbox.Option>
</Listbox>
)}
</Row>
</HorizontalScroll>
)}
{filteredThemes.map((theme, index) => (
<ThemeDetailCard key={index} theme={theme} />
))}
</Column>
);
});
@@ -0,0 +1,89 @@
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";
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>
}
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
const groups = useMemo(
() => themes.reduce<{
name: string,
themes: typeof themes
}[]>(
(groups, theme) => {
const groupName = theme.group || "Original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
},
[]
),
[ themes ]
);
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());
return (
<Column style={{ "--gap": "16px" }}>
{(groups.length > 1 || hasMultipleTypes) && (
<HorizontalScroll fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onValueChange={setActiveGroup}>
{groups.map((group) => (
<ListboxOption key={group.name} value={group.name}>{group.name}</ListboxOption>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
)}
</Row>
</HorizontalScroll>
)}
{filteredThemes.map((theme, index) => (
<ThemeDetailCard key={index} theme={theme} />
))}
</Column>
);
}
AnimeThemeFilterInternal.fragments = {
theme: gql`
${ThemeDetailCard.fragments.theme}
fragment AnimeThemeFilterTheme on Theme {
...ThemeDetailCardTheme
type
group
}
`
};
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
@@ -7,7 +7,7 @@ import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-bran
import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import { forwardRef } from "react";
import type { ComponentPropsWithoutRef } from "react";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -29,11 +29,17 @@ const StyledSocialList = styled(Row)`
justify-content: flex-end;
align-items: flex-end;
gap: 4px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-basis: 100%;
justify-content: flex-start;
}
// To avoid overlap with scroll back to top button as window width gets smaller
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
margin-right: 64px;
}
`;
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
@@ -45,28 +51,24 @@ export function Footer() {
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/transparency">
<FooterTextLink href="/about/transparency">
Transparency
</FooterTextLink>
<Link href="/wiki/donate" passHref>
<FooterTextLink target="_self">
Donate
</FooterTextLink>
</Link>
<Link href="/wiki/faq" passHref>
<FooterTextLink target="_self">
FAQ
</FooterTextLink>
</Link>
<FooterTextLink href="/about/donate">
Donate
</FooterTextLink>
<FooterTextLink href="/about/faq">
FAQ
</FooterTextLink>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/terms-of-service">
<FooterTextLink href="/about/terms-of-service">
Terms of Service
</FooterTextLink>
<FooterTextLink href="https://staging.animethemes.moe/privacy-policy">
<FooterTextLink href="/about/privacy-policy">
Privacy Policy
</FooterTextLink>
<FooterTextLink href="mailto:admin@animethemes.moe">
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
Contact
</FooterTextLink>
</StyledLinkList>
@@ -97,18 +99,14 @@ export function Footer() {
);
}
const FooterLink = forwardRef(function FooterLink({ children, ...props }, ref) {
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
return (
<Text ref={ref} as="a" target="_blank" rel="noopener" {...props}>
{children}
</Text>
<Text as={Link} {...props}/>
);
});
}
const FooterTextLink = forwardRef(function FooterTextLink({ children, ...props }, ref) {
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
return (
<FooterLink ref={ref} link block color="text-muted" noWrap {...props}>
{children}
</FooterLink>
<FooterLink link block color="text-muted" noWrap {...props}/>
);
});
}
@@ -1,8 +1,11 @@
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
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";
const StyledSearchInput = styled.div`
display: flex;
@@ -19,14 +22,11 @@ const StyledSearchInput = styled.div`
&:focus-within {
box-shadow: ${theme.shadows.low};
[theme="dark"] & {
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`}
}
`;
const StyledForm = styled.form`
flex: 1;
`;
const StyledInput = styled.input`
width: 100%;
`;
@@ -39,25 +39,33 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
}
`;
export function Input({ value, onChange, onSubmit, resettable = false, icon = null, inputProps = {}, ...props }) {
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
value: string
onChange: (value: string) => void
resettable?: boolean
icon?: IconDefinition
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
}
export function Input({
value,
onChange,
resettable = false,
icon,
inputProps = {},
...props
}: InputProps) {
return (
<StyledSearchInput {...props}>
{icon && (
<Icon icon={icon} color="text-disabled"/>
)}
<StyledForm onSubmit={(event) => {
event.preventDefault();
if (onSubmit) {
onSubmit(event);
}
}}>
<StyledInput
type="text"
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
</StyledForm>
<StyledInput
type="text"
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
+50
View File
@@ -0,0 +1,50 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import styled from "styled-components";
import theme from "theme";
import { withColorTheme } from "styles/mixins";
const StyledSwitch = styled(RadixSwitch.Root)`
width: 50px;
height: 25px;
background-color: ${theme.colors["solid-on-card"]};
border-radius: 9999px;
position: relative;
box-shadow: 0 2px 10px var(--blackA7);
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
&:focus {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
display: block;
width: 25px;
height: 25px;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
transition: transform 250ms, background-color 250ms;
will-change: transform;
&[data-state='checked'] {
transform: translateX(25px);
background-color: ${theme.colors["text-primary"]};
}
`;
interface Props extends RadixSwitch.SwitchProps {
isChecked: boolean;
onCheckedChange(isChecked: boolean): void;
}
export default function Switch({ isChecked, onCheckedChange, ...props }: Props) {
return (
<StyledSwitch checked={isChecked} onCheckedChange={onCheckedChange} {...props}>
<StyledSwitchThumb />
</StyledSwitch>
);
}
+1
View File
@@ -0,0 +1 @@
export { Input } from "components/form/Input";
+12
View File
@@ -0,0 +1,12 @@
import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
export const CornerIcon = styled(Icon)`
position: absolute;
right: 0;
top: 0;
font-size: 32px;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
+43
View File
@@ -0,0 +1,43 @@
import type { SVGProps } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
icon: IconDefinition
title?: string
}
const xmlns = "http://www.w3.org/2000/svg";
export function FontAwesomeIcon(props: FontAwesomeIconProps) {
const { icon: iconProps, children, className = "", title, ...rest } = props;
const { prefix, iconName, icon } = iconProps;
const [width, height, , , svgPathData] = icon;
const dataFa = `${prefix}-${iconName}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
xmlns={xmlns}
role={"img"}
aria-hidden="true"
data-fa={dataFa}
className={`${className} svg-inline--fa fa-fw`}
{...rest}
>
{title ? (
<title>{title}</title>
) : null}
{children}
{Array.isArray(svgPathData) ? (
<g>
<path d={svgPathData[0]}/>
<path d={svgPathData[1]}/>
</g>
) : (
<path fill="currentColor" d={svgPathData}/>
)}
</svg>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styled, { css } from "styled-components";
import theme from "theme";
export const Icon = styled(FontAwesomeIcon).attrs({
fixedWidth: true
})`
color: ${(props) => theme.colors[props.color] || props.color};
${(props) => props.transition && css`
transition: ${(props) => props.transition};
`}
`;
+11
View File
@@ -0,0 +1,11 @@
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 }>`
color: ${(props) => props.color && theme.colors[props.color]};
transition: ${(props) => props.transition}
`;
-13
View File
@@ -1,13 +0,0 @@
import useImage from "hooks/useImage";
import { AspectRatio } from "components/utils";
import { FullWidthImage } from "components/image";
export function CoverImage({ resourceWithImages, ...props }) {
const { smallCover, largeCover } = useImage(resourceWithImages);
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
</AspectRatio>
);
}
+30
View File
@@ -0,0 +1,30 @@
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";
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
resourceWithImages: CoverImageResourceWithImagesFragment
}
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
const { smallCover, largeCover } = extractImages(resourceWithImages);
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
</AspectRatio>
);
}
CoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment CoverImageResourceWithImages on ResourceWithImages {
...extractImagesResourceWithImages
}
`,
};
@@ -1,4 +1,6 @@
export function Logo(props) {
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"/>
+27
View File
@@ -0,0 +1,27 @@
import styled from "styled-components";
import theme from "theme";
import { Logo } from "components/image/Logo";
import type { ComponentPropsWithoutRef } from "react";
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
return (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
);
}
@@ -1,10 +1,12 @@
import useImage from "hooks/useImage";
import extractImages from "utils/extractImages";
import { AspectRatio } from "components/utils";
import styled, { css } from "styled-components";
import theme from "theme";
import { Logo } from "components/image";
import type { ComponentPropsWithoutRef } from "react";
import type { MultiCoverImageResourceWithImagesFragment } from "generated/graphql";
import gql from "graphql-tag";
import { LogoPlaceholder } from "components/image/LogoPlaceholder";
function getTranslationX(item, itemCount) {
function getTranslationX(item: number, itemCount: number) {
switch (itemCount) {
case 4:
switch (item) {
@@ -30,8 +32,9 @@ const StyledCoverContainer = styled.div`
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
isolation: isolate;
`;
const StyledCoverItemContainer = styled.div`
const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
position: absolute;
top: 0;
left: 0;
@@ -106,34 +109,27 @@ const StyledCover = styled.img`
transform: scale(1.1);
}
`;
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function MultiCoverImage({ resourcesWithImages, ...props }) {
const images = [
[ useImage(resourcesWithImages[0]), resourcesWithImages[0] ],
[ useImage(resourcesWithImages[1]), resourcesWithImages[1] ],
[ useImage(resourcesWithImages[2]), resourcesWithImages[2] ],
[ useImage(resourcesWithImages[3]), resourcesWithImages[3] ]
]
.map(([ images, resource ]) => ({
largeCover: images.largeCover,
smallCover: images.smallCover,
resource
}))
.filter(({ largeCover }) => !!largeCover);
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
}
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);
return (
<AspectRatio ratio={2 / 3}>
@@ -150,11 +146,28 @@ export function MultiCoverImage({ resourcesWithImages, ...props }) {
/>
</StyledCoverItemContainer>
)) : (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
<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
}
`,
};
+31
View File
@@ -0,0 +1,31 @@
import gql from "graphql-tag";
import type { ProfileImageUserFragment } from "generated/graphql";
import md5 from "md5";
interface ProfileImageProps {
user: ProfileImageUserFragment;
size?: number;
}
export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
const hash = md5(user.email.trim().toLowerCase());
return (
<img
src={`https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`}
alt={user.name}
width={size}
height={size}
{...props}
/>
);
}
ProfileImage.fragments = {
user: gql`
fragment ProfileImageUser on UserAuth {
name
email
}
`,
};
+57
View File
@@ -0,0 +1,57 @@
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 styled from "styled-components";
const StyledImage = styled(FullWidthImage)`
object-fit: contain;
background-size: contain;
`;
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
studio: StudioCoverImageStudioFragment
}
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
const { largeCover } = extractImages(studio);
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
return (
<AspectRatio ratio={2 / 3}>
{!imageNotFound ? (
<StyledImage
key={largeCover}
src={largeCover}
style={{ background: `url(${largeCover})`, backgroundSize: "10000%" }}
onError={() => setImageNotFound(true)}
{...props}
/>
) : (
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
)}
</AspectRatio>
);
}
StudioCoverImage.fragments = {
studio: gql`
fragment StudioCoverImageStudio on Studio {
images {
link
facet
}
anime {
name
images {
link
facet
}
}
}
`
};
-55
View File
@@ -1,55 +0,0 @@
import React from "react";
import Link from "next/link";
import { groupBy } from "lodash-es";
import { Text } from "components/text";
import styled from "styled-components";
const StyledLetterList = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
`;
const StyledItemGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
justify-content: flex-start;
grid-gap: 16px;
`;
export function AlphabeticalIndex({ items, children }) {
const itemsByFirstLetter = Object.entries(
groupBy(
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));
return (
<>
<StyledLetterList>
{itemsByFirstLetter.map(([ firstLetter ]) => (
<Link key={firstLetter} href={`#${firstLetter}`} passHref prefetch={false}>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
))}
</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>
))}
</>
);
}
@@ -0,0 +1,66 @@
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 styled from "styled-components";
const StyledLetterList = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
`;
const StyledItemGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
justify-content: flex-start;
justify-items: flex-start;
grid-gap: 16px;
`;
type AlphabeticalIndexItem = { name: string };
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
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)),
(item) => {
const firstLetter = item.name[0].toLowerCase();
if (firstLetter.match(/[a-z]/)) {
return firstLetter;
}
return "0-9";
}
)
)
.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>
))}
</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 { Input } from "./Input";
-65
View File
@@ -1,65 +0,0 @@
import { ListboxCustom, ListboxNative } from "components/listbox";
import theme from "theme";
import { createContext, useContext } from "react";
import useMediaQuery from "hooks/useMediaQuery";
const ListboxContext = createContext();
export function Listbox({
value,
onChange,
// Adds a reset button to the list box once a non-default value has been selected.
resettable = false,
// The value to return to after a reset.
defaultValue = null,
// Highlights the listbox if an option other than defaultValue is selected.
highlightNonDefault = false,
disabled = false,
...props
}) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
if (isMobile) {
return (
<ListboxContext.Provider value={{ isMobile }}>
<ListboxNative
value={value}
onChange={onChange}
resettable={resettable}
defaultValue={defaultValue}
highlightNonDefault={highlightNonDefault}
disabled={disabled}
{...props}
/>
</ListboxContext.Provider>
);
}
return (
<ListboxContext.Provider value={{ isMobile }}>
<ListboxCustom
value={value}
onChange={onChange}
resettable={resettable}
defaultValue={defaultValue}
highlightNonDefault={highlightNonDefault}
disabled={disabled}
{...props}
/>
</ListboxContext.Provider>
);
}
Listbox.Option = function ListboxOption(props) {
const { isMobile } = useContext(ListboxContext);
if (isMobile) {
return <ListboxNative.Option {...props}/>;
}
return <ListboxCustom.Option {...props}/>;
};
+168
View File
@@ -0,0 +1,168 @@
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 * as RadixSelect from "@radix-ui/react-select";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
import { forwardRef } from "react";
// Radix' listbox can't handle null values, so we are giving it a fake null value.
const NULL_VALUE = "__NULL__";
const StyledListboxButton = styled(Button)`
display: inline-flex;
align-items: center;
justify-content: space-between;
white-space: nowrap;
gap: 8px;
&:focus {
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledListboxPopover = styled(RadixSelect.Portal)`
min-width: max-content;
padding: 0;
border-radius: 8px;
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
// Revert CSS rules set by Radix UI which hide the native scrollbar.
[data-radix-select-viewport] {
scrollbar-width: revert;
-ms-overflow-style: revert;
}
[data-radix-select-viewport]::-webkit-scrollbar {
display: revert;
}
`;
const StyledListboxList = styled(RadixSelect.Content)`
width: var(--radix-select-trigger-width);
max-height: calc(var(--radix-select-content-available-height) - 32px);
padding: 8px 0;
`;
const StyledListboxOption = styled(RadixSelect.Item)`
display: ${(props) => props.hidden ? "none" : "flex"};
align-items: center;
justify-content: space-between;
padding: 8px 16px;
gap: 8px;
color: ${theme.colors["text-muted"]};
cursor: pointer;
&[data-highlighted] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
outline: none;
}
&[data-state="checked"] {
color: ${theme.colors["text-primary"]};
}
`;
type ListboxProps =
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
& (PropsNullable | PropsNotNullable)
& {
resettable?: boolean;
highlightNonDefault?: boolean;
};
interface PropsNullable {
nullable: true;
value: string | null;
onValueChange(newValue: string | null): void;
defaultValue?: string | null;
}
interface PropsNotNullable {
nullable?: false;
value: string;
onValueChange(newValue: string): void;
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;
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>
);
}
);
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
value: string | null;
}
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
function ListboxOption({ value, children, ...props }, ref) {
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>
);
}
);
-157
View File
@@ -1,157 +0,0 @@
import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faSort, faTimes } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
import {
ListboxButton,
ListboxInput,
ListboxList,
ListboxOption,
ListboxPopover,
useListboxContext,
} from "@reach/listbox";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
// ReachUI's listbox can't handle null values, so we are giving it a fake null value.
// The caveat is, that we can't use this value normally, thus it's obscure name.
const NULL_VALUE = "__NULL__";
const StyledListbox = styled(ListboxInput)`
display: inline-block;
`;
const StyledListboxButton = styled(Button)`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
white-space: nowrap;
gap: 8px;
&[aria-expanded="true"] {
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledListboxPopover = styled(ListboxPopover)`
z-index: ${theme.zIndices.menuPopover};
min-width: max-content;
margin-top: 0.5rem;
padding: 0;
border-radius: 0.5rem;
overflow: hidden;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
`;
const StyledListboxList = styled(ListboxList)`
max-height: 33vh;
margin: 0;
padding: 0.5rem 0;
list-style: none;
overflow: auto;
`;
const StyledListboxOption = styled(ListboxOption)`
display: ${(props) => props.hidden ? "none" : "flex"};
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
gap: 8px;
color: ${theme.colors["text-muted"]};
cursor: pointer;
&[data-current-nav] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
}
&[data-current-selected] {
color: ${theme.colors["text-primary"]};
}
`;
export function ListboxCustom({ children, value, onChange, resettable, defaultValue, highlightNonDefault, disabled, ...props }) {
function handleChange(newValue) {
if (newValue === NULL_VALUE) {
onChange(null);
} else{
onChange(newValue);
}
}
function handleResetClick() {
if (!disabled) {
onChange(defaultValue);
}
}
function stopPropagation(event) {
event.stopPropagation();
}
return (
<StyledListbox
value={value ?? NULL_VALUE}
onChange={handleChange}
{...props}
>
{({ valueLabel }) => (
<>
<StyledListboxButton
forwardedAs={ListboxButton}
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
disabled={disabled}
>
<Text>{valueLabel}</Text>
{(resettable && value !== defaultValue) ? (
<StyledListboxReset
onClick={handleResetClick}
onMouseDown={stopPropagation}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort}/>
)}
</StyledListboxButton>
<StyledListboxPopover>
<StyledListboxList>
{children}
</StyledListboxList>
</StyledListboxPopover>
</>
)}
</StyledListbox>
);
}
ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden = false }) {
const context = useListboxContext();
if (!context) {
return null;
}
return (
<StyledListboxOption value={value ?? NULL_VALUE} hidden={hidden}>
<Text>{children}</Text>
{value === context.value && (
<FontAwesomeIcon icon={faCheck} fixedWidth/>
)}
</StyledListboxOption>
);
};
-125
View File
@@ -1,125 +0,0 @@
import styled from "styled-components";
import { faSort, faTimes } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Icon } from "components/icon";
const NULL_VALUE = "__NULL__";
const ListboxContext = createContext();
const StyledListbox = styled.div`
display: inline-block;
position: relative;
`;
const StyledListboxButton = styled(Button)`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
white-space: nowrap;
gap: 8px;
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledSelect = styled.select`
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
`;
export function ListboxNative({ children, value, onChange, resettable, defaultValue, highlightNonDefault, disabled, ...props }) {
const [valueLabels, setValueLabels] = useState(() => new Map());
const contextValue = {
setLabel: useCallback((value, label) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
newMap.set(value, label);
return newMap;
});
}, []),
removeLabel: useCallback((value) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
newMap.delete(value);
return newMap;
});
}, [])
};
function handleChange(newValue) {
if (newValue === NULL_VALUE) {
onChange(null);
} else{
onChange(newValue);
}
}
function handleResetClick() {
if (!disabled) {
onChange(defaultValue);
}
}
function stopPropagation(event) {
event.stopPropagation();
}
return (
<StyledListbox {...props}>
<StyledSelect
value={value ?? NULL_VALUE}
onChange={(event) => handleChange(event.target.value)}
>
<ListboxContext.Provider value={contextValue}>
{children}
</ListboxContext.Provider>
</StyledSelect>
<StyledListboxButton
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
disabled={disabled}
>
<Text>{valueLabels.get(value)}</Text>
{(resettable && value !== defaultValue) ? (
<StyledListboxReset
onClick={handleResetClick}
onMouseDown={stopPropagation}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort}/>
)}
</StyledListboxButton>
</StyledListbox>
);
}
ListboxNative.Option = function ListboxNativeOption({ value = null, children }) {
const { setLabel, removeLabel } = useContext(ListboxContext);
useEffect(() => {
setLabel(value, children);
return () => removeLabel(value);
}, [children, removeLabel, setLabel, value]);
return (
<option value={value ?? NULL_VALUE}>
{children}
</option>
);
};
-3
View File
@@ -1,3 +0,0 @@
export { Listbox } from "./Listbox";
export { ListboxCustom } from "./ListboxCustom";
export { ListboxNative } from "./ListboxNative";
+140
View File
@@ -0,0 +1,140 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { MDXRemote } from "next-mdx-remote";
import type { ComponentPropsWithoutRef } from "react";
import { TextLink } from "components/text/TextLink";
import { Text } from "components/text";
import { Card } from "components/card";
import styled from "styled-components";
import theme from "theme";
import type { MDXComponents } from "mdx/types";
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
& h2 {
margin-bottom: 24px;
font-size: 1.2rem;
}
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
margin-top: 32px;
}
& p {
margin-top: 0;
margin-bottom: 16px;
}
& table {
width: 100%;
table-layout: auto;
text-align: left;
border-collapse: collapse;
}
& thead {
border-bottom: 1px solid ${theme.colors["text-muted"]};
& th {
font-weight: 600;
vertical-align: bottom;
padding-left: 8px;
padding-right: 8px;
padding-bottom: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
& tbody tr {
border-bottom: 1px solid ${theme.colors["text-disabled"]};
&:last-child {
border-bottom-width: 0;
}
}
& tbody td {
vertical-align: baseline;
padding: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
& ${Card} {
margin-bottom: 16px;
}
& img {
border-radius: ${theme.scalars.borderRadiusCard};
}
`;
interface MarkdownProps {
source: MDXRemoteSerializeResult;
components?: MDXComponents;
}
export function Markdown({ source, components = {} }: MarkdownProps) {
return (
<StyledMarkdown>
<MDXRemote
{...source}
components={{
a: (props: ComponentPropsWithoutRef<"a">) => {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
}
return <Text as="a" link href={href} {...props}/>;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
Card,
...components,
}}
/>
</StyledMarkdown>
);
}
@@ -0,0 +1,84 @@
import type { Heading } from "utils/rehypeExtractHeadings";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import styled from "styled-components";
import { m } from "framer-motion";
import theme from "theme";
const StyledTableOfContents = styled.ul`
position: sticky;
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
`;
const StyledTableOfContentsHeading = styled.li<{ $depth: number }>`
padding-left: ${(props) => props.$depth === 3 && "16px"};
font-size: ${(props) => props.$depth === 3 && "0.9rem"};
`;
const StyledDot = styled(m.div)`
position: absolute;
left: -16px;
width: 4px;
height: 100%;
border-radius: 4px;
background-color: ${theme.colors["text-primary"]};
`;
export function TableOfContents({ headings }: { headings: Array<Heading> }) {
const [currentSlug, setCurrentSlug] = useState<string | undefined>();
useEffect(() => {
function onScroll() {
const headings = [...document.querySelectorAll<HTMLHeadingElement>("h2, h3")];
let currentHeading = null;
for (const heading of headings) {
if (heading.offsetTop > window.scrollY + window.innerHeight) {
break;
}
currentHeading = heading;
if (heading.offsetTop > window.scrollY) {
break;
}
}
setCurrentSlug(currentHeading?.id);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
);
}
-86
View File
@@ -1,86 +0,0 @@
import styled, { css } from "styled-components";
import { Menu as ReachMenu, MenuButton, MenuItem, MenuItems, MenuPopover, } from "@reach/menu-button";
import { Button } from "components/button";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Icon } from "components/icon";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { fadeIn, flipDown, slideIn } from "styles/animations";
const StyledMenuItems = styled(MenuItems)`
z-index: ${theme.zIndices.menuPopover};
width: max-content;
margin-top: 0.5rem;
padding: 0.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@media (max-width: 720px) {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
border-radius: 1rem 1rem 0 0;
z-index: ${theme.zIndices.toast};
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.high};
animation: ${slideIn()} 200ms both;
}
`;
const StyledMenuItem = styled(MenuItem)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0.5rem 1rem;
cursor: pointer;
color: ${theme.colors["text-muted"]};
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
`)}
`;
const StyledMenuOverlay = styled(MenuPopover)`
@media (max-width: 720px) {
position: fixed !important;
inset: 0 !important;
z-index: ${theme.zIndices.menuOverlay};
background-color: rgba(0, 0, 0, 0.5);
animation: ${fadeIn} 200ms both;
}
`;
export function Menu({ button, children }) {
return (
<ReachMenu>
{button ? button(MenuButton) : (
<Button as={MenuButton} variant="silent" isCircle>
<Icon icon={faEllipsisV}/>
</Button>
)}
<StyledMenuOverlay>
<StyledMenuItems>
{children}
</StyledMenuItems>
</StyledMenuOverlay>
</ReachMenu>
);
}
Menu.Option = function MenuOption({ children, ...props }) {
return (
<StyledMenuItem onSelect={() => {}} {...props}>
{children}
</StyledMenuItem>
);
};
+74
View File
@@ -0,0 +1,74 @@
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";
const StyledMenuContent = styled(RadixMenu.Content)`
width: max-content;
max-height: var(--radix-dropdown-menu-content-available-height);
padding: 8px 0;
border-radius: 8px;
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
`;
export const Menu = RadixMenu.Root;
export const MenuTrigger = RadixMenu.Trigger;
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
function MenuContent({ children, ...props }, forwardedRef) {
return (
<RadixMenu.Portal>
<StyledMenuContent align="start" sideOffset={8} collisionPadding={8} {...props} ref={forwardedRef}>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
}
);
export const MenuItem = styled(RadixMenu.Item)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
cursor: pointer;
color: ${theme.colors["text-muted"]};
&[data-highlighted] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: revert;
}
`;
export const MenuLabel = styled(RadixMenu.Label)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
color: ${theme.colors["text-muted"]};
font-size: 0.8rem;
`;
export const MenuSeparator = styled(RadixMenu.Separator)`
height: 2px;
background-color: ${theme.colors["text-primary"]};
margin: 8px 0;
`;
-44
View File
@@ -1,44 +0,0 @@
import { Icon } from "components/icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { Text } from "components/text";
import { Menu } from "components/menu";
import { useLocalPlaylist } from "context/localPlaylistContext";
import gql from "graphql-tag";
export function ThemeMenu({ theme }) {
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
const options = [
!!theme.id && (isInPlaylist(theme) ? (
<Menu.Option key="add" onSelect={() => removeFromPlaylist(theme)}>
<Icon icon={faMinus}/>
<Text>Remove from Playlist</Text>
</Menu.Option>
) : (
<Menu.Option key="remove" onSelect={() => addToPlaylist(theme)}>
<Icon icon={faPlus}/>
<Text>Add to Playlist</Text>
</Menu.Option>
))
].filter((option) => option);
if (!options.length) {
return null;
}
return (
<Menu>
{options}
</Menu>
);
}
ThemeMenu.fragment = gql`
fragment ThemeMenu_theme on Theme {
id
# Hidden inside local playlist context
song {
title
}
}
`;

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