100 Commits

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

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

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