142 Commits

Author SHA1 Message Date
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
Manuel S 4958f5d462 feat: Added separate minimal build setting (#144) 2022-08-27 20:06:36 +02:00
Manuel S 6394e50232 fix: Fixed incorrectly spinning icon (#143) 2022-08-27 15:27:14 +02:00
Manuel S 67c2f2180d fix: Improved bundle size (#142)
* Framer Motion is now lazy loaded.
* FontAwesome is now a custom component removing all the bloat we don't need.
* Imports and exports for types now use the proper syntax to get excluded by bundlers.
2022-08-27 03:28:36 +02:00
Manuel S 91f7be77d1 fix: Fixed audio mode for mobile background player (#141)
* Fixed "Play Random".
2022-08-24 23:17:23 +02:00
Manuel S 47daa23e9c feat: Added audio mode (#140)
* Added support for "as" field of artist memberships.
* Bracket pages no longer get prebuilt.
* Fixed wrong order of theme entries.
* Fixed studio cover background on studio page.
* Fixed color theme selectors.
* Fixed wrong alignment of "My Profile" on mobile navigation.
* Fixed very long song titles not wrapping correctly.
* Fixed play random feature.
* Fixed manual rebuilding.
2022-08-19 02:01:25 +02:00
Manuel S d6caa6487a feat: Migrated to TypeScript (#138) 2022-07-23 03:44:41 +02:00
Gaporigo 530c11c858 fix: Fixed grammar on anime page (#137) 2022-06-29 23:53:58 +02:00
Manuel S b195c9d667 feat: Added missing brackets (@theinternetftw) (#136)
* Added bracket chart.
* Added border radius to all sides of cards.
* Added skeletons to home page.
* Added support for system color theme.
* Added code-splitting for GraphQL.
* Added config constants.
* Added warning banner on staging.
* Added URL caching to API resolver (only for non-page request for now).
* Made featured theme load by default.
* Changed static path fetching to only fetch paths if not on staging (except for bracket pages).
* Changed default color theme to system.
* Moved color theme toggle from navigation to profile page.
* Replaced sketchy local storage implementation with external package.
* Fixed wrong fallback for featured theme on devices which don't support our codecs.
* Fixed wrong links in footer.
* Fixed code-splitting of Prism package.
* Fixed "/" character not working in search input.
* Fixed glitching multi cover image on playlist page.
* Updated README.md.
2022-06-08 00:20:46 +02:00
Manuel S d7329f73b7 feat: Upgraded to FontAwesome 6 Pro (#135)
* Replaced various icons with better fitting ones.
* Improved tag styling in general.
* Fixed pages throwing 500 errors instead of 404.
* Fixed manual revalidation.
2022-06-03 02:48:21 +02:00
Manuel S 38675ab9ad fix: Removed more hardcoded /api from URLs (#133) 2022-06-01 05:24:24 +02:00
Manuel S 385a1419ba fix: Removed hardcoded /video from video URL (#132) 2022-06-01 03:38:20 +02:00
Manuel S 76db2186dd fix: Removed hardcoded /api from API URL (#131)
* Fixed recent themes not being ten entries.
* Fixed "Performed As" filter on artist page.
2022-06-01 03:34:42 +02:00
Manuel S 6d39859138 feat: Renamed /page to /wiki (#130) 2022-05-31 00:48:20 +02:00
Manuel S 19ccfedf4a fix: Fixed broken signature of closeToast (#126) 2022-05-30 23:45:42 +02:00
Manuel S 5b950d8869 fix: Fixed build error on design page (#125) 2022-05-30 02:16:44 +02:00
Manuel S 940c9b12a0 feat: Featured theme is now fetched from the API (#124)
* Added theme table to theme summary card.
* Added hotkey to go directly to the search.
* Added artist table to theme summary cards in search context.
* Added global error boundary.
* Added alternative labels for "Add to Playlist" button on mobile.
* Improved performance of filtering on anime pages.
* Improved performance of the mobile navigation.
* Improved performance and stability of search.
* Extracted theme table into its own component.
* Disabled manual re-ordering of local playlist entries when sorting.
* Fixed duplicate toasts.
* Fixed issue with z-index on the video player.
* Fixed sorting of artists on multiple pages.
* Fixed multiple issues when sorting the local playlist.
* Fixed several console warnings.
* Updated Next.js to 12.1.6.
2022-05-30 01:30:24 +02:00
Manuel S 15c20d161e feat: Added table component (#123)
* Replaced list of themes in anime summary card with table.
* Made expandable summary card expand when clicking anywhere on the card.
* Removed inline theme buttons from anime summary card.
* Made summary card buttons only show on hover.
* Made theme summary cards expandable when multiple artists are performing the theme.
* Split theme entry info into "episode count" and "content warning" in theme table.
* Improved featured theme design on mobile displays.
* Added grills to mobile displays.
* Made featured theme background clickable to link to the video page.
* Added support for sorting while searching.
* Fixed wrong text color of switcher items.
* Added global volume setting.
* Made settings sync across browser tabs.
* Revamped settings system (by removing magic strings).
* Added share button to video page.
* Fixed artist performance sort by anime premiere.
* Made horizontal scrollbars visible and styled.
2022-05-26 23:33:30 +02:00
Manuel S 02c7eed23c fix: Fixed searching single resources not working (#122)
* Fixed search filters sharing the same session storage key leading to overlap issues.
* Improved alignment of text inside theme detail cards.
* Made performances in song title expandable.
* Added base path to revalidate path.
2022-05-13 01:14:36 +02:00
Manuel S 391a5cd9cb feat: #120 Pages with a sidebar now stay in the two column layout on tablet devices (#121)
* Improved stability and performance of the search input.
* Changed display of character voices.
* Changed display of title line on video pages.
* Limited number of artists displayed in song titles.
* Fixed video player growing higher than the viewport.
* Added number of API requests used to build a page to dev info.
* Changed default order of themes on artist pages to be by anime.
* Added support for the "as" attribute of external resources.
* Added warning notice to local playlist page.
* Removed use-debounce as we now use the debounce function from lodash.
2022-05-11 23:22:29 +02:00
Manuel S 859e0c35f1 feat: Grills are now fetched from the API (#119)
* Added setting for revalidation token.
* Re-added revalidation interval for anime pages.
2022-05-01 00:14:48 +02:00
Manuel S d17fb278ce fix: Fixed local storage getting filled up with unneeded data (#118)
- Added feature to refresh the local playlist.
- Themes are now re-fetched from the API before being added to the history or playlist.
2022-04-29 00:53:57 +02:00
Manuel S c5849a051d feat: Added revalidate API route (#117)
* Disabled prefetching on all links.
* Increased revalidate interval on all pages.
* Disabled revalidation on year index, dev and home page.
* Removed page counts from dev page.
* Recent themes on the home page are now only fetched on the client-side.
* Added dev mode which displays when a page was last built on the bottom of the page.
2022-04-24 03:25:00 +02:00
Manuel S 643d1e8827 fix: Document pages now hydrate correctly (#116)
- Extracted API resolvers, so they can be used by the client.
- Removed all CommonJS imports and exports (except for next.config.js).
- Fixed wrong set of video pages getting pre-rendered in staging mode.
- Fixed missing cover image on summary cards if title is too long.
- Changed target of index buttons on home page to the actual index pages.
- Added buttons to the home page for series, studio and page index.
- Moved index pages from /index/... to /anime, /artist, /series and /studio.
- Disabled prefetching for links on the index pages.
- Recently added themes are now fetched on the client, so they always show the most up-to-date data.
2022-04-22 01:11:03 +02:00
Manuel S 55e6149d74 feat: Migrated to API resolvers (#115)
- All data is now fetched from the API instead of the database.
- Added staging mode.
- Fixed cover images still showing old images when switching pages.
- Fixed first letter filter not working.
2022-04-16 22:21:23 +02:00
Manuel S bff350179b fix: Fixed artist group and member resolver (#114) 2022-04-06 00:51:48 +02:00
Manuel S 7edf147c35 fix: Songs can now be null (#113)
* Added h3 to table of contents on document pages.
* Fixed missing song titles at multiple places.
* Fixed theme group display on video page.
* Fixed missing resolvers for artist members and groups.
2022-04-06 00:01:19 +02:00
Manuel S a2bc1e694f feat: Anime summary cards can now be expanded to a table (#112)
* All anime summary cards can now be expanded (before only ones with more than two themes could be).
* Anime summary cards now prefer to show the first OP and ED instead of the first two OPs.
* Streamlined collapse animations.
* Video and entry tags are now baseline aligned.
* Document pages: Inline code can now line-wrap.
* Document pages: Images no longer overflow.
* API requests now merge sparse fieldsets to prevent missing fields.
* Fixed wrong order of anime on year pages.
* Fixed issue with null values of listboxes on mobile.
2022-04-02 23:58:13 +02:00
Manuel S c5d40b75d2 fix: Fixed image URLs to use the object storage (#111) 2022-04-02 00:20:14 +02:00
Manuel S d86d0a04b5 fix: Fixed links in TOC of document pages (#110)
* Fixed "FAQ" and "Donate" links in footer.
* Fixed bottom margin of code blocks.
* Invalid language for syntax highlighting now yields a warning instead of crashing the page build.
2022-04-01 22:46:39 +02:00
Manuel S eee86f80cd fix: Fixed markdown parsing of long tables (#109)
* Fixed issue where CSS wasn't injected properly.
* Fixed multi cover image not working with a single image.
* Replaced markdown parsing library.
2022-03-31 03:23:00 +02:00
Manuel S d801654c56 feat: Added document pages (#108)
* ThemeSummaryCard now includes the theme group (if available).
* ThemeDetailCard no longer includes the theme group in the slug.
* FeaturedTheme now falls back to cover if video fails to load.
* Added new multi cover image component.
* Added more generic input component.
* Added back-end code for MAL and AniList integration.
* Added "Also Used As" section to video page.
* Added "Related Themes" section to video page.
* Re-organized video page.
* Re-implemented sort by filter component.
* Fixed bug where list boxes crashed the site on mobile.
* Fixed bug where switcher links would not work.
* Fixed styled-components SSR in development.
* Started incremental implementation of GraphQL fragments.
* Removed use-media as a dependency.
2022-03-26 03:08:55 +01:00
Manuel S bcbd1ffceb fix: Fixed unknown button variant errors (#107) 2022-03-02 03:30:58 +01:00
Manuel S eea6b93db3 fix: Fixed initial setting values being wronly cased (#106) 2022-03-02 02:12:00 +01:00
Manuel S 214b3b7a3c feat: Removed styled-system as a dependency and various other changes (#105)
* Use CSS Variables and styled-components composition instead.
* Added Solid component for automatic recognition if a component is on a solid surface.
* Rewrote Button component.
* Extracted animation keyframes for repeated use.
* Rewrote Listbox and Menu (both are now based on ReachUI).
* Listbox and Menu now have an improved API based on compound components.
* Replaced hardcoded breakpoints with constants.
* Extracted featured theme into its own component.
* Added setting to turn featured theme preview off or in cover-only mode.
* Extracted MultiCoverImage into its own component to reduce repeated logic.
* Rewrote Switcher with better API and fixed invalid motion transitions.
* Added ToastHub which can hold multiple toasts at the same time.
* Added PlaylistAddToast which notifies that a theme was added to the local playlist.
* HeightTransition now longer clips shadows.
* Improved the overlay of the background video player.
* The list of years is now generated statically.
* Fixed motion transitions on /r/anime awards page.
* Settings were moved from the /dev to the /profile page.
* Updated /design page.
* Rearranged buttons on the home page (mobile only).
* Fixed spacing of buttons on the home page.
* Removed headlessui as a dependency.
* Upgraded Next.js to version 12.1.0.
2022-03-02 01:50:36 +01:00
Manuel S 986b77562b feat: Added /r/anime Awards results (#104) 2022-02-20 05:06:06 +01:00
Manuel S b136b3881f feat: Added expandable theme view to anime summary card (#103)
* Added hover text to summary card titles.
* Added event overview page.
* Added "Events" and "My Profile" buttons to home page.
* Moved event pages to new namespace.
* "My Profile" button in navigation now collapses to icon on smaller viewports.
* Sidebar container now switches to one column layout at a wider viewport.
* Fixed switcher animation.
* Replaced lodash with lodash-es for smaller bundle size.
* Bumped some dependencies.
2022-01-23 02:54:24 +01:00
Manuel S 0a4a654586 feat: Added /r/anime Awards page (#101)
* Re-added animation for state transitions to switcher.
2022-01-17 02:18:30 +01:00
Manuel S dc9593dc5e feat: Added theme context menu (#98)
* Added headless-ui as dependency to allow for easier integration of context menus.
* Added generic context menu component.
* Added theme context menus to theme summary and detail cards.
* Added missing pages to /dev and adjusted "NEW" tags.
* Improved stability of local playlist and watch history.
2022-01-16 04:09:29 +01:00
Manuel S 12d843c32f feat: Added local playlist, watch history and profile page (#97)
* Improved alt text and titles for cover images.
* Improved summary card images by having a fallback image on load errors.
* Fixed sidebar links not working on anime page.
* Fixed current season always being the latest instead of the actual current one (it also no longer requires an API request).
* Fixed some links needing a double tab to work on iOS devices.
* Fixed maximizing mini player always navigating to base theme instead of correct entry.
* Fixed links on index pages taking up the full width.
* Updated Framer Motion to version 5 release.
* Removed PWA functionality.
* Removed LazyMotion, because we now need the full Framer Motion kit.
* Removed global transition CSS declarations.
2022-01-16 04:09:29 +01:00
Manuel S dce43e3bcc feat: Added static index pages (#96)
* Fixed order of description lists.
2022-01-16 04:09:29 +01:00
Manuel S dd3a208ec2 fix: Fixed order of themes (#95)
* Testing PWA.
2022-01-16 04:09:29 +01:00
Manuel S 0319a5ccf1 fix: Fixed some soft deleted entries not getting filtered correctly (#94) 2022-01-16 04:09:29 +01:00
Manuel S 23f8071a80 fix: Fixed soft deleted entries still showing up (#93)
* Added setting to disable announcements.
2022-01-16 04:09:29 +01:00
Manuel S 50b544932b fix: Hopefully fixed the loading problem on iOS devices (#92) 2022-01-16 04:09:29 +01:00
Manuel S d1684302e0 feat: Added announcement toast (#91)
* Re-added page count on /dev page.
* Made navigation sticky.
* Added navigation fade in animation on mobile.
* Added style helper for modern gap CSS property.
2022-01-16 04:09:29 +01:00
Manuel S 223be0c2b8 fix: Re-added missing font styles and changed display to fallback (#90) 2022-01-16 04:09:29 +01:00
Manuel S f4110a4c6c fix: Re-added custom font and artist resources (#89)
* Fixed studio resources not showing on mobile.
* Added experimental SEO for videos.
2022-01-16 04:09:29 +01:00
Manuel S 0df159d9aa fix: Re-implement theme hydration & lighthouse findings (#88)
* Also removed something I forgot.
2022-01-16 04:09:29 +01:00
Manuel S 31aee782b0 feat: Migrated to Next.js (#87)
* Rewrote data layer to work on-demand.
* Added several favicon formats.
* Added image for SEO page previews.
* Disabled GitHub pages as it no longer works.
* Several other adjustments.
2022-01-16 04:09:29 +01:00
393 changed files with 40377 additions and 50405 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"presets": [
[
"babel-preset-gatsby",
{
"reactRuntime": "automatic"
}
]
],
"plugins": [
[
"prismjs",
{
"languages": [
"jsx"
]
}
]
]
}
-10
View File
@@ -1,10 +0,0 @@
# NPM
/node_modules
# Gatsby
/.cache
/public
# IDE (WebStorm)
*.iml
/.idea
+33 -36
View File
@@ -1,46 +1,43 @@
{
"globals": {
"__PATH_PREFIX__": true
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"extends": [
"react-app",
"plugin:react/recommended"
],
"ignorePatterns": ["src/generated"],
"rules": {
"quotes": [
"error",
"double",
{
"allowTemplateLiterals": true
}
],
"curly": [
"error",
"all"
],
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-duplicate-imports": "error",
"no-restricted-imports": [
// Sort imports
"simple-import-sort/imports": [
"error",
{
"patterns": [
"components/*/*"
"groups": [
// Packages that are used in almost all files.
["^react$", "^react\\u0000$", "^styled-components", "^next"],
// All other packages.
["^@?\\w"],
// Absolute imports and other imports such as Vue-style `@/foo`.
// Anything not matched in another group.
["^"],
// Side effect imports.
["^\\u0000"]
]
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
"simple-import-sort/exports": "error",
// TypeScript rules
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/consistent-type-exports": "error"
}
}
-60
View File
@@ -1,60 +0,0 @@
name: github pages
on:
push:
branches:
- main
jobs:
deploy:
services:
mariadb:
image: mariadb:latest
env:
MYSQL_ROOT_PASSWORD: test123
MYSQL_DATABASE: animethemes
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Import database dump
env:
PORT: ${{ job.services.mariadb.ports[3306] }}
run: |
sudo /etc/init.d/mysql start
curl -L https://github.com/AnimeThemes/animethemes-db-dump/raw/main/mysql/animethemes-db-dump-create-tables.sql | mysql --user=root --password=test123 --host=127.0.0.1 --port="$PORT" animethemes
- name: Setup Node
uses: actions/setup-node@v2.1.2
with:
node-version: '12.x'
- name: Cache dependencies
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm install
- name: Build Gatsby pages
run: npm run build-with-paths
env:
GATSBY_PATH_PREFIX: /animethemes-web
GATSBY_CI: true
SOURCE_PLUGIN: DB
DB_USER: root
DB_PASSWORD: test123
DB_PORT: ${{ job.services.mariadb.ports[3306] }}
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
+38 -9
View File
@@ -1,14 +1,43 @@
# General
.env
.env.*
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# NPM
node_modules
# dependencies
/node_modules
/.pnp
.pnp.js
# Gatsby
/.cache
/public
/schema.json
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.npmrc
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
next.config.mjs
# IDE (WebStorm)
*.iml
-3
View File
@@ -1,3 +0,0 @@
{
"schemaPath": "schema.json"
}
+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
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2017 paranarimasu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+79 -32
View File
@@ -1,46 +1,93 @@
# AnimeThemes Web Client
1. [Configuration](#configuration)
2. [Development](#development)
3. [Deployment](#deployment)
4. [Used technologies](#used-technologies)
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or beta URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
```
To get to use all features of the client, you may configure some additional variables.
This is a list of all available options:
```ini
; ===== Server-side =====
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
; ===== Server-side + Client-side =====
; The base path the app should be hosted on.
NEXT_PUBLIC_BASE_PATH=/wiki
; The URL to the AnimeThemes API which will be used on the browser.
NEXT_PUBLIC_API_URL=https://api.animethemes.moe
; The URL from which video files should be served.
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
; The URL from which audio files should be served.
NEXT_PUBLIC_AUDIO_URL=https://a.animethemes.moe
; The path to use for authentication API requests. It will be appended to NEXT_PUBLIC_API_URL.
NEXT_PUBLIC_AUTH_PATH=/auth
; Set to any truthy value to activate staging mode.
; In staging mode a warning banner is displayed at the top of the page.
NEXT_PUBLIC_STAGING=true
; To enable verbose logging.
NEXT_PUBLIC_VERBOSE_LOGS=true
; Set the page size to fetch when resolving paginated resources.
; If not set, 25 will be used.
NEXT_PUBLIC_PAGINATION_PAGE_SIZE=10
```
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
## Development
1. `npm install`
2. `npm run develop`
2. `npm run dev`
## Deployment
### Hosting on root path
1. `npm run build`
2. Upload the contents of `public` to your server.
### Hosting on sub-path
1. Adjust `pathPrefix` in `gatsby-config.js` to your server environment.
2. `npm run build-with-paths`
3. Upload the contents of `public` to your server.
## Configuration
You can configure certain settings by creating a `.env.development` (for development) or `.env.production` (for deployment)
in the client root directory and include the following:
```ini
; The URL to the AnimeThemes API to use.
; If not specified, "https://staging.animethemes.moe" is used as a default.
GATSBY_API_URL = http://localhost
```
### Apache Configuration
`.htaccess` files can be placed in `static` and will be copied to the `public` folder during build.
1. `npm install` (If not done already.)
2. `npm run build` generates all static pages and produces a production ready bundle.
3. `npm run start` starts the server.
4. The server is now listening on port 3000 for incoming requests.
## Used technologies
- [Gatsby](https://www.gatsbyjs.com/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
### APIs
- [AnimeThemes API](https://staging.animethemes.moe/api/docs/)
- [AnimeThemes API](https://api-docs.animethemes.moe/)
+72
View File
@@ -0,0 +1,72 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: [
"src/lib/common/animethemes/type-defs.ts",
"src/lib/server/animebracket/type-defs.ts",
"src/lib/client/search.ts",
],
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
generates: {
"src/generated/graphql.ts": {
plugins: ["typescript", "typescript-operations"],
config: {
avoidOptionals: {
object: true,
field: true,
inputValue: false,
},
enumsAsTypes: true,
skipTypename: true,
useTypeImports: true,
},
},
"src/generated/graphql-resolvers.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
useTypeImports: true,
// This fixes an issue with the Maybe type, leading to unclear error messages.
// Taken from here: https://github.com/dotansimha/graphql-code-generator/issues/3174#issuecomment-595398571
maybeValue: "T extends PromiseLike<infer U> ? Promise<U | null> : T | null",
mappers: {
Anime: "@/lib/common/animethemes/types#ApiAnime",
Announcement: "@/lib/common/animethemes/types#ApiAnnouncement",
Artist: "@/lib/common/animethemes/types#ApiArtist",
Audio: "@/lib/common/animethemes/types#ApiAudio",
Dump: "@/lib/common/animethemes/types#ApiDump",
Entry: "@/lib/common/animethemes/types#ApiEntry",
FeaturedTheme: "@/lib/common/animethemes/types#ApiFeaturedTheme",
Group: "@/lib/common/animethemes/types#ApiGroup",
Page: "@/lib/common/animethemes/types#ApiPage",
Performance: "@/lib/common/animethemes/types#ApiPerformance",
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
Season: "@/lib/common/animethemes/types#ApiSeason",
Series: "@/lib/common/animethemes/types#ApiSeries",
Song: "@/lib/common/animethemes/types#ApiSong",
Studio: "@/lib/common/animethemes/types#ApiStudio",
Synonym: "@/lib/common/animethemes/types#ApiSynonym",
Theme: "@/lib/common/animethemes/types#ApiTheme",
UserAuth: "@/lib/common/animethemes/types#ApiUser",
UserPublic: "@/lib/common/animethemes/types#ApiUser",
UserRole: "@/lib/common/animethemes/types#ApiUserRole",
UserScopedQuery: "{}",
Video: "@/lib/common/animethemes/types#ApiVideo",
VideoOverlap: "string",
VideoScript: "@/lib/common/animethemes/types#ApiVideoScript",
Year: "@/lib/common/animethemes/types#ApiYear",
Bracket: "@/lib/server/animebracket/resolvers#ModelBracket",
BracketRound: "@/lib/server/animebracket/resolvers#ModelBracketRound",
BracketPairing: "@/lib/server/animebracket/resolvers#ModelBracketPairing",
BracketCharacter: "@/lib/server/animebracket/resolvers#ModelBracketCharacter",
},
},
},
},
hooks: {
afterAllFileWrite: ["prettier --write"],
},
};
export default config;
Vendored
+7
View File
@@ -0,0 +1,7 @@
import "react";
declare module "react" {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
-6
View File
@@ -1,6 +0,0 @@
export function shouldUpdateScroll({
routerProps,
prevRouterProps
}) {
return routerProps?.location?.pathname !== prevRouterProps?.location?.pathname;
}
-66
View File
@@ -1,66 +0,0 @@
import fs from "fs";
import path from "path";
import siteConfig from "./site-config";
import dotenv from "dotenv";
// Load environment configuration
dotenv.config({
path: `.env.${process.env.NODE_ENV}`,
});
// Map every path in "src" to its directory name so we can import files like this:
//
// import Button from "components/button";
//
// Otherwise we would have to provide relative paths which makes refactoring harder.
const aliases = fs.readdirSync(path.join(__dirname, "src")).reduce((obj, directory) => {
obj[directory] = path.join(__dirname, "src", directory);
return obj;
}, {});
// If Gatsby already set up a path prefix, use that instead
const pathPrefix = process.env.GATSBY_PATH_PREFIX || siteConfig.pathPrefix;
const config = {
pathPrefix: pathPrefix,
siteMetadata: {
lang: siteConfig.siteLanguage,
siteName: siteConfig.siteName,
description: siteConfig.description,
titleTemplate: `%s · ${siteConfig.siteName}`,
siteUrl: path.posix.join((process.env.SITE_URL || siteConfig.rootUrl), pathPrefix)
},
plugins: [
process.env.SOURCE_PLUGIN === "DB"
? "gatsby-source-animethemes-db"
: "gatsby-source-animethemes",
"gatsby-source-animebracket",
"gatsby-plugin-styled-components",
"gatsby-plugin-react-helmet",
{
resolve: "gatsby-plugin-root-import",
options: aliases
},
{
resolve: "gatsby-plugin-layout",
options: {
component: require.resolve("./src/layout/index.js"),
}
},
"gatsby-plugin-fontawesome-css",
{
resolve: "gatsby-plugin-manifest",
options: {
icon: "src/images/icon.png"
}
},
"gatsby-plugin-eslint",
"gatsby-plugin-sass"
]
};
if (process.env.NODE_ENV === "development") {
config.plugins.push("gatsby-plugin-extract-schema");
}
export default config;
-3
View File
@@ -1,3 +0,0 @@
// eslint-disable-next-line no-native-reassign
require = require("esm")(module);
module.exports = require("./gatsby-config.esm.js");
-142
View File
@@ -1,142 +0,0 @@
import createVideoSlug from "./src/utils/createVideoSlug";
const seasonOrder = [ "Winter", "Spring", "Summer", "Fall" ];
export async function createPages({ actions: { createPage }, graphql }) {
const { data } = await graphql(`
query {
allAnime {
nodes {
slug
themes {
# We can't use fragments in gatsby-node.js,
# so this is mostly a copy of the fragment found in
# createVideoSlug.fragment.js.
# (https://stackoverflow.com/a/49351248)
slug
entries {
id
version
videos {
id
tags
}
}
}
}
groupedByYear: group(field: year) {
year: fieldValue
nodes {
season
}
}
}
allSeries {
nodes {
slug
}
}
allStudio {
nodes {
slug
}
}
allArtist {
nodes {
slug
}
}
allBracket {
nodes {
slug
}
}
}
`);
for (const { slug, themes } of data.allAnime.nodes) {
createPage({
path: `/anime/${slug}`,
component: require.resolve("./src/templates/anime.js"),
context: { slug }
});
for (const theme of themes) {
for (const entry of theme.entries) {
for (const video of entry.videos) {
const videoSlug = createVideoSlug(theme, entry, video);
createPage({
path: `/anime/${slug}/${videoSlug}`,
component: require.resolve("./src/templates/video.js"),
context: { id: video.id, entryId: entry.id }
});
}
}
}
}
for (const { year, nodes } of data.allAnime.groupedByYear) {
const yearNumeric = +year;
// Get unique seasons
const seasonList = nodes
.map((node) => node.season)
.filter((value, index, self) => self.indexOf(value) === index)
.sort((a, b) => seasonOrder.indexOf(a) - seasonOrder.indexOf(b));
createPage({
path: `/year/${year}`,
component: require.resolve("./src/templates/year.js"),
context: { year: yearNumeric, seasonList }
});
for (const season of seasonList) {
createPage({
path: `/year/${year}/${season.toLowerCase()}`,
component: require.resolve("./src/templates/season.js"),
context: { year: yearNumeric, season, seasonList }
});
}
}
for (const { slug } of data.allSeries.nodes) {
createPage({
path: `/series/${slug}`,
component: require.resolve("./src/templates/series.js"),
context: { slug }
});
}
for (const { slug } of data.allStudio.nodes) {
createPage({
path: `/studio/${slug}`,
component: require.resolve("./src/templates/studio.js"),
context: { slug }
});
}
for (const { slug } of data.allArtist.nodes) {
createPage({
path: `/artist/${slug}`,
component: require.resolve("./src/templates/artist.js"),
context: { slug }
});
}
for (const { slug } of data.allBracket.nodes) {
createPage({
path: `/bracket/${slug}`,
component: require.resolve("./src/templates/bracket.js"),
context: { slug }
});
}
for (const entity of [ null, "anime", "theme", "artist", "series", "studio" ]) {
createPage({
path: `/search${entity ? `/${entity}` : ``}`,
component: require.resolve("./src/templates/search.js"),
context: { entity }
});
}
}
-3
View File
@@ -1,3 +0,0 @@
// eslint-disable-next-line no-native-reassign
require = require("esm")(module);
module.exports = require("./gatsby-node.esm.js");
-24
View File
@@ -1,24 +0,0 @@
const ThemeInjection = () => {
// language=JavaScript
const injectTheme = `
(function() {
const body = document.body;
const theme = window.localStorage.getItem("theme");
if (theme) {
body.setAttribute("theme", theme);
}
})();
`;
return (
<script
dangerouslySetInnerHTML={{ __html: injectTheme }}
/>
);
};
export function onRenderBody({ setBodyAttributes, setPreBodyComponents }) {
setBodyAttributes({ theme: "dark" });
setPreBodyComponents(<ThemeInjection />);
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { NextConfig } from "next";
import NextBundleAnalyzer from "@next/bundle-analyzer";
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "@/utils/config";
import { error, info } from "@/utils/log";
const withBundleAnalyzer = NextBundleAnalyzer();
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
const nextConfig: NextConfig = {
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true,
},
staticPageGenerationTimeout: 3600,
experimental: {
// We don't want to multi-thread page building
// to make use of caching between page builds.
workerThreads: false,
cpus: 1,
},
transpilePackages: ["ahooks"],
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
],
},
];
},
async redirects() {
return [
{
source: "/wiki/blog/:slug*",
destination: "/blog/:slug*",
permanent: true,
},
{
source: "/wiki/status/:slug*",
destination: "/blog/status_:slug*",
permanent: true,
},
];
},
};
export default ANALYZE ? withBundleAnalyzer(nextConfig) : nextConfig;
+10346 -29025
View File
File diff suppressed because it is too large Load Diff
+83 -48
View File
@@ -1,57 +1,92 @@
{
"name": "anime-themes",
"version": "1.0.0",
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"develop": "gatsby develop",
"build": "gatsby build",
"build-with-paths": "gatsby build --prefix-paths"
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
"prestart": "npm run compile-config",
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
"lint": "next lint",
"type-check": "tsc",
"graphql-codegen": "graphql-codegen --config codegen.ts",
"prepare": "husky",
"postinstall": "patch-package"
},
"dependencies": {
"@fontsource/nunito": "^4.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@reach/listbox": "^0.16.1",
"@react-hook/resize-observer": "^1.2.2",
"babel-plugin-prismjs": "^2.0.1",
"babel-plugin-styled-components": "^1.11.1",
"babel-preset-gatsby": "^1.3.0",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@graphql-tools/merge": "^9.0.16",
"@graphql-tools/schema": "^10.0.15",
"@graphql-tools/utils": "^10.7.1",
"@next/bundle-analyzer": "15.3.0",
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.62.15",
"ahooks": "^3.8.4",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"dotenv": "^8.2.0",
"esm": "^3.2.25",
"framer-motion": "^4.1.3",
"gatsby": "^3.13.0",
"gatsby-plugin-fontawesome-css": "^1.1.0",
"gatsby-plugin-layout": "^2.3.0",
"gatsby-plugin-manifest": "^3.3.0",
"gatsby-plugin-react-helmet": "^4.3.0",
"gatsby-plugin-root-import": "^2.0.6",
"gatsby-plugin-sass": "^4.3.0",
"gatsby-plugin-styled-components": "^4.3.0",
"gatsby-source-animethemes": "file:plugins/gatsby-source-animethemes",
"gatsby-source-animethemes-db": "file:plugins/gatsby-source-animethemes-db",
"luxon": "^2.0.2",
"node-fetch": "^2.6.1",
"prismjs": "^1.24.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-helmet": "^6.1.0",
"react-query": "^3.18.1",
"sass": "^1.32.8",
"styled-components": "^5.2.0",
"styled-system": "^5.1.5",
"use-debounce": "^6.0.1",
"use-media": "^1.4.0"
"graphql": "^16.10.0",
"graphql-parse-resolve-info": "^4.13.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^3.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.1",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"motion": "^11.15.0",
"next": "^15.3.0",
"next-mdx-remote": "^5.0.0",
"p-limit": "^3.1.0",
"picocolors": "^1.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"sass": "^1.43.4",
"shiki": "^1.26.2",
"styled-components": "^6.1.14",
"swr": "^2.3.0",
"unist-util-visit": "^5.0.0",
"use-local-storage-state": "^19.5.0"
},
"devDependencies": {
"@hot-loader/react-dom": "^17.0.1",
"babel-eslint": "^10.1.0",
"eslint": "^7.21.0",
"eslint-config-react-app": "^6.0.0",
"eslint-loader": "^4.0.2",
"eslint-plugin-react": "^7.22.0",
"gatsby-plugin-eslint": "^3.0.0",
"gatsby-plugin-extract-schema": "^0.1.0"
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/typescript": "^4.0.7",
"@graphql-codegen/typescript-operations": "^4.2.1",
"@graphql-codegen/typescript-resolvers": "^4.1.0",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/md5": "^2.3.5",
"@types/node": "^22.10.5",
"@types/react": "19.1.1",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"esbuild": "^0.27.1",
"eslint": "^8.57.0",
"eslint-config-next": "15.3.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.0.11",
"patch-package": "^8.0.0",
"prettier": "^3.4.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
},
"overrides": {
"ahooks": {
"react": "$react",
"react-dom": "$react-dom"
},
"@types/react": "19.1.1"
}
}
@@ -0,0 +1,26 @@
diff --git a/node_modules/graphql-parse-resolve-info/build-turbo/index.js b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
index b10297b..6a56538 100644
--- a/node_modules/graphql-parse-resolve-info/build-turbo/index.js
+++ b/node_modules/graphql-parse-resolve-info/build-turbo/index.js
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
const assert = require("assert");
const graphql_1 = require("graphql");
-const values_1 = require("graphql/execution/values");
+const values_1 = require("graphql");
const debugFactory = require("debug");
const debug = debugFactory("graphql-parse-resolve-info");
const DEBUG_ENABLED = debug.enabled;
diff --git a/node_modules/graphql-parse-resolve-info/node8plus/index.js b/node_modules/graphql-parse-resolve-info/node8plus/index.js
index 4c3d55d..0e2feb3 100644
--- a/node_modules/graphql-parse-resolve-info/node8plus/index.js
+++ b/node_modules/graphql-parse-resolve-info/node8plus/index.js
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.getAlias = exports.simplify = exports.parse = exports.simplifyParsedResolveInfoFragmentWithType = exports.parseResolveInfo = exports.getAliasFromResolveInfo = void 0;
const assert = require("assert");
const graphql_1 = require("graphql");
-const values_1 = require("graphql/execution/values");
+const values_1 = require("graphql");
const debugFactory = require("debug");
const debug = debugFactory("graphql-parse-resolve-info");
const DEBUG_ENABLED = debug.enabled;
@@ -1,157 +0,0 @@
const fetch = require("node-fetch");
const fs = require("fs");
const mappings = JSON.parse(fs.readFileSync("./plugins/gatsby-source-animebracket/mappings.json", "utf8"));
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animebracket");
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions;
createTypes(`
type Bracket implements Node {
slug: String!
name: String!
currentRound: BracketRound @link(by: "id")
currentGroup: Int
rounds: [BracketRound] @link(by: "bracket.id", from: "id")
}
type BracketRound implements Node {
bracket: Bracket @link(by: "id")
tier: Int!
name: String
pairings: [BracketPairing] @link(by: "round.id", from: "id")
}
type BracketPairing implements Node {
round: BracketRound @link(by: "id")
order: Int!
group: Int!
characterA: BracketCharacter @link(by: "id")
characterB: BracketCharacter @link(by: "id")
votesA: Int
votesB: Int
}
type BracketCharacter implements Node {
pairing: BracketPairing @link(by: "id")
idRaw: Int!
seed: Int!
name: String!
source: String!
theme: Theme @link(by: "idRaw")
}
`);
};
exports.sourceNodes = async ({ actions, createNodeId, createContentDigest }) => {
const helpers = {
...actions,
createNodeId,
createContentDigest
};
const perma = "best-anime-opening-ix-salty-arrow";
const bracketResponse = await fetch(`https://animebracket.com/api/bracket/${perma}`);
const bracket = await bracketResponse.json();
const roundsResponse = await fetch(`https://animebracket.com/api/results/${perma}`)
const rounds = await roundsResponse.json();
const currentRoundResponse = await fetch(`https://animebracket.com/api/rounds/${perma}`);
const currentRound = await currentRoundResponse.json();
const bracketNode = createNodeFromData({
id: bracket.id,
slug: perma,
name: bracket.name,
currentRound: currentRound ? createNodeId(`BracketRound-${bracket.id}-${currentRound[0].tier}`) : null,
currentGroup: currentRound ? currentRound[0].group : null,
rounds: rounds.map((round) => createNodeId(`BracketRound-${bracket.id}-${round[0].tier}`))
}, "Bracket", helpers);
for (const round of rounds) {
sourceRound(round);
}
if (currentRound) {
sourceRound(currentRound);
}
function sourceRound(round) {
const tier = +round[0].tier;
const roundNode = createNodeFromData({
id: `${bracket.id}-${tier}`,
bracket: bracketNode.id,
tier: tier,
pairings: round.map((pairing) => createNodeId(`BracketPairing-${pairing.id}`))
}, "BracketRound", helpers);
for (const pairing of round) {
if (pairing.filler) {
continue;
}
const pairingNode = createNodeFromData({
id: pairing.id,
round: roundNode.id,
order: +pairing.order,
group: +pairing.group,
characterA: createNodeId(`BracketCharacter-${pairing.character1.id}`),
characterB: createNodeId(`BracketCharacter-${pairing.character2.id}`),
votesA: pairing.character1.votes || null,
votesB: pairing.character2.votes || null
}, "BracketPairing", helpers);
for (const character of [ pairing.character1, pairing.character2 ]) {
createNodeFromData({
id: character.id,
idRaw: character.id,
pairing: pairingNode.id,
seed: character.seed,
name: character.name,
source: character.source,
theme: mappings[character.id]
}, "BracketCharacter", helpers);
}
}
}
};
exports.createResolvers = ({ createResolvers }) => {
createResolvers({
BracketRound: {
name: {
resolve(source) {
switch (source.pairings.length) {
case 1:
return "Finals";
case 2:
return "Semifinals";
case 4:
return "Quarterfinals";
default:
return null;
}
}
}
}
});
};
function createNodeFromData(item, nodeType, helpers) {
const node = {
...item,
id: helpers.createNodeId(`${nodeType}-${item.id}`),
parent: null,
children: [],
internal: {
type: nodeType,
content: JSON.stringify(item),
contentDigest: helpers.createContentDigest(item)
}
};
helpers.createNode(node)
return node;
}
@@ -1,258 +0,0 @@
{
"214755": 6846,
"214730": 9335,
"215383": 4526,
"215092": 8131,
"215213": 8082,
"215355": 1796,
"215447": 9113,
"214861": 4940,
"214734": 6549,
"214896": 4207,
"214951": 7861,
"215344": 1790,
"214927": 2021,
"215176": 8471,
"215157": 7221,
"214973": 4485,
"215408": 2870,
"214907": 7239,
"214983": 2531,
"214822": 955,
"214903": 6326,
"215178": 6173,
"215634": 673,
"215544": 6878,
"215255": 7108,
"214922": 3460,
"215024": 239,
"215164": 9114,
"215538": 6237,
"215122": 5915,
"215001": 4966,
"214814": 2262,
"214969": 3888,
"214964": 4397,
"214943": 7062,
"214885": 7006,
"215209": 4663,
"215166": 9054,
"215258": 4958,
"214757": 7546,
"214953": 7562,
"214862": 3990,
"215494": 65,
"215046": 8403,
"215540": 9282,
"215033": 2057,
"215471": 6354,
"215361": 727,
"214735": 1069,
"214773": 9980,
"215174": 5424,
"215026": 9373,
"215398": 7328,
"214766": 8210,
"215076": 7138,
"214895": 8504,
"214855": 8530,
"214774": 8015,
"215186": 10072,
"214923": 7202,
"214847": 8641,
"215057": 6055,
"215270": 3699,
"214750": 7512,
"215593": 8656,
"214872": 3834,
"215019": 1479,
"215078": 3436,
"215055": 8939,
"215162": 9301,
"214759": 5930,
"215179": 2014,
"215120": 8172,
"214809": 7742,
"214882": 7009,
"214876": 7215,
"214935": 2692,
"214763": 9139,
"214934": 1126,
"215378": 1477,
"214775": 6453,
"215437": 6363,
"214834": 7357,
"215085": 3730,
"214832": 2478,
"215132": 6645,
"214932": 5519,
"214924": 6765,
"215145": 2264,
"214926": 9126,
"214867": 7678,
"215144": 3435,
"214941": 4273,
"215505": 4272,
"215177": 8175,
"215148": 6054,
"215238": 4000,
"215359": 5445,
"215264": 5687,
"214984": 10007,
"215472": 5774,
"215015": 8948,
"215521": 4664,
"215023": 3633,
"215236": 9129,
"214894": 10004,
"215020": 7936,
"215273": 6467,
"215004": 5280,
"215474": 7218,
"214915": 7015,
"214900": 9281,
"215107": 2263,
"214999": 7557,
"214836": 9278,
"215127": 6429,
"215395": 4534,
"214955": 5608,
"215212": 8125,
"214993": 8063,
"214768": 9080,
"214732": 8566,
"215219": 6457,
"214820": 4028,
"214908": 4947,
"214831": 7308,
"215154": 6095,
"215300": 10009,
"215237": 7013,
"215223": 5481,
"215208": 3362,
"215609": 5577,
"215039": 1928,
"215192": 9311,
"214978": 4511,
"214843": 8577,
"214860": 4852,
"215518": 5748,
"215139": 6114,
"215138": 8949,
"214930": 2605,
"215443": 4711,
"215093": 7872,
"215600": 8188,
"215165": 3392,
"214869": 525,
"214972": 2783,
"214958": 5116,
"215060": 9999,
"214745": 9053,
"215227": 3984,
"214795": 6663,
"214970": 7318,
"214997": 3795,
"215305": 8088,
"215111": 4973,
"214904": 5520,
"215009": 2701,
"215167": 3110,
"215116": 3981,
"215086": 8325,
"215226": 8943,
"215514": 5833,
"214956": 3361,
"215550": 8922,
"215108": 4527,
"215199": 7213,
"214886": 3164,
"215417": 7054,
"215484": 7638,
"215200": 3437,
"215232": 9987,
"214981": 4853,
"215636": 1035,
"215029": 1034,
"215022": 8787,
"215331": 2446,
"215452": 3040,
"215141": 7107,
"215007": 3438,
"215041": 6081,
"214846": 1802,
"214859": 7114,
"214744": 1654,
"215269": 2438,
"215489": 8262,
"214902": 7053,
"215419": 8083,
"215084": 3163,
"214748": 7980,
"214760": 9372,
"215480": 1794,
"214737": 2509,
"214782": 4466,
"215433": 6854,
"215486": 8940,
"214912": 6550,
"215562": 9197,
"215317": 6466,
"215027": 6633,
"215547": 7743,
"214812": 9264,
"215002": 8726,
"214728": 7871,
"215173": 7773,
"215440": 1799,
"214906": 9214,
"215038": 2873,
"215135": 2883,
"215438": 3041,
"215052": 8719,
"214959": 4939,
"215169": 5287,
"215557": 5576,
"214889": 1147,
"215529": 4484,
"215421": 7639,
"214917": 2840,
"215306": 6650,
"215261": 4457,
"215137": 3373,
"215409": 7127,
"215012": 3504,
"215294": 4088,
"215231": 3229,
"215151": 5837,
"215124": 523,
"215283": 6972,
"215274": 3985,
"215032": 8705,
"215080": 6097,
"214740": 7588,
"215088": 2279,
"214946": 5832,
"214793": 8883,
"214950": 4702,
"214919": 2953,
"215351": 7863,
"214940": 9392,
"214749": 7392,
"215402": 7561,
"215476": 8078,
"215289": 3887,
"215077": 3835,
"215036": 8245,
"215125": 950,
"214758": 7203,
"214837": 9972,
"214789": 2280,
"214742": 8781,
"215528": 3111,
"215215": 4710,
"215262": 3976,
"215394": 5293,
"215109": 6820,
"215405": 5208
}
@@ -1,4 +0,0 @@
{
"name": "gatsby-source-animebracket",
"version": "1.0.0"
}
@@ -1,7 +0,0 @@
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animethemes-db");
exports.createSchemaCustomization = require("./src/create-schema-customization");
exports.sourceNodes = require("./src/source-nodes");
exports.createResolvers = require("./src/create-resolvers");
-113
View File
@@ -1,113 +0,0 @@
{
"name": "gatsby-source-animethemes-db",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"denque": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
"integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
},
"generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"requires": {
"is-property": "^1.0.2"
}
},
"iconv-lite": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz",
"integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
},
"is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ="
},
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"mysql2": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.2.5.tgz",
"integrity": "sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g==",
"requires": {
"denque": "^1.4.1",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.2",
"long": "^4.0.0",
"lru-cache": "^6.0.0",
"named-placeholders": "^1.1.2",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"dependencies": {
"sqlstring": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz",
"integrity": "sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg=="
}
}
},
"named-placeholders": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz",
"integrity": "sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA==",
"requires": {
"lru-cache": "^4.1.3"
},
"dependencies": {
"lru-cache": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"requires": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
}
},
"yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
}
}
},
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4="
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}
@@ -1,7 +0,0 @@
{
"name": "gatsby-source-animethemes-db",
"version": "1.0.0",
"dependencies": {
"mysql2": "^2.2.5"
}
}
@@ -1,124 +0,0 @@
const { animeSeason, videoSource, imageFacet, resourceSite, videoOverlap, themeType } = require("./enums");
module.exports = ({ createResolvers }) => {
createResolvers({
Anime: {
...enumResolver({
season: animeSeason
}),
...pivotResolver({
series: { type: "AnimeSeries", from: "anime", to: "series" },
resources: { type: "AnimeResource", from: "anime", to: "resource" },
images: { type: "AnimeImage", from: "anime", to: "image" },
studios: { type: "AnimeStudio", from: "anime", to: "studio" }
})
},
Artist: {
...pivotResolver({
resources: { type: "ArtistResource", from: "artist", to: "resource" },
images: { type: "ArtistImage", from: "artist", to: "image" }
})
},
Entry: {
...pivotResolver({
videos: { type: "EntryVideo", from: "entry", to: "video" }
})
},
Image: {
...enumResolver({
facet: imageFacet
}),
link: {
resolve(source) {
return `https://staging.animethemes.moe/image/${source.idRaw}`;
}
}
},
Resource: {
...enumResolver({
site: resourceSite
})
},
Series: {
...pivotResolver({
anime: { type: "AnimeSeries", from: "series", to: "anime" }
})
},
Studio: {
...pivotResolver({
anime: { type: "AnimeStudio", from: "studio", to: "anime" },
resources: { type: "StudioResource", from: "studio", to: "resource" }
})
},
Theme: {
...enumResolver({
type: themeType
})
},
Video: {
...enumResolver({
overlap: videoOverlap,
source: videoSource
}),
...pivotResolver({
entries: { type: "EntryVideo", from: "video", to: "entry" }
}),
tags: {
resolve(source) {
const tags = [];
if (source.nc) {
tags.push("NC");
}
if (source.source === 2 || source.source === 3) {
tags.push(videoSource.get(source.source));
}
if (source.resolution && source.resolution !== 720) {
tags.push(source.resolution);
}
if (source.subbed) {
tags.push("Subbed");
} else if (source.lyrics) {
tags.push("Lyrics");
}
return tags.join("");
}
},
link: {
resolve(source) {
return `https://v.animethemes.moe/${source.basename}`;
}
}
}
});
};
function enumResolver(propertyToEnumMap) {
return Object.fromEntries(Object.entries(propertyToEnumMap).map(([ property, enumMap ]) => {
return [
property,
{
resolve(source) {
return enumMap.get(source[property]);
}
}
];
}));
}
function pivotResolver(propertyToPivotConfig) {
return Object.fromEntries(Object.entries(propertyToPivotConfig).map(([ property, pivotConfig ]) => {
return [
property,
{
resolve(source, args, context) {
return context.nodeModel.getAllNodes({ type: pivotConfig.type })
.filter((node) => node[pivotConfig.from] === source.id)
.map((node) => context.nodeModel.getNodeById({ id: node[pivotConfig.to] }));
}
}
];
}));
}
@@ -1,168 +0,0 @@
module.exports = ({ actions }) => {
const { createTypes } = actions;
createTypes(`
interface Entity {
idRaw: Int
}
type Anime implements Node & Entity {
idRaw: Int
name: String!
slug: String!
year: Int!
season: String
synopsis: String
synonyms: [Synonym] @link(by: "anime.id", from: "id")
themes: [Theme] @link(by: "anime.id", from: "id")
series: [Series]
resources: [Resource]
images: [Image]
studios: [Studio]
}
type Synonym implements Node & Entity {
idRaw: Int
text: String!
anime: Anime! @link(by: "id")
}
type Theme implements Node & Entity {
idRaw: Int
slug: String!
type: String!
sequence: Int!
group: String
song: Song! @link(by: "id")
anime: Anime! @link(by: "id")
entries: [Entry] @link(by: "theme.id", from: "id")
}
type Entry implements Node & Entity {
idRaw: Int
version: Int
episodes: String
nsfw: Boolean!
spoiler: Boolean!
theme: Theme! @link(by: "id")
videos: [Video]
}
type Video implements Node & Entity {
idRaw: Int
filename: String!
basename: String!
link: String
resolution: Int
nc: Boolean!
subbed: Boolean!
lyrics: Boolean!
uncen: Boolean!
source: String
overlap: String
tags: String
entries: [Entry]
}
type Song implements Node & Entity {
idRaw: Int
title: String!
themes: [Theme] @link(by: "song.id", from: "id")
performances: [Performance] @link(by: "song.id", from: "id")
}
type Performance implements Node {
song: Song! @link(by: "id")
artist: Artist! @link(by: "id")
as: String
}
type Artist implements Node & Entity {
idRaw: Int
slug: String!
name: String!
performances: [Performance] @link(by: "artist.id", from: "id")
members: [ArtistMembership] @link(by: "group.id", from: "id")
groups: [ArtistMembership] @link(by: "member.id", from: "id")
resources: [Resource]
images: [Image]
}
type ArtistMembership implements Node {
group: Artist! @link(by: "id")
member: Artist! @link(by: "id")
as: String
}
type Series implements Node & Entity {
idRaw: Int
slug: String!
name: String!
anime: [Anime]
}
type Resource implements Node & Entity {
idRaw: Int
link: String!
site: String!
}
type Image implements Node & Entity {
idRaw: Int
facet: String!
link: String
}
type Studio implements Node & Entity {
idRaw: Int
slug: String!
name: String!
anime: [Anime]
resources: [Resource]
}
type AnimeSeries implements Node {
anime: Anime! @link(by: "id")
series: Series! @link(by: "id")
}
type EntryVideo implements Node {
entry: Entry! @link(by: "id")
video: Video! @link(by: "id")
}
type AnimeResource implements Node {
anime: Anime! @link(by: "id")
resource: Resource! @link(by: "id")
}
type ArtistResource implements Node {
artist: Artist! @link(by: "id")
resource: Resource! @link(by: "id")
}
type AnimeImage implements Node {
anime: Anime! @link(by: "id")
image: Image! @link(by: "id")
}
type ArtistImage implements Node {
artist: Artist! @link(by: "id")
image: Image! @link(by: "id")
}
type AnimeStudio implements Node {
anime: Anime! @link(by: "id")
studio: Studio! @link(by: "id")
}
type StudioResource implements Node {
studio: Studio! @link(by: "id")
resource: Resource! @link(by: "id")
}
type Announcement implements Node & Entity {
idRaw: Int
content: String!
}
`);
};
@@ -1,43 +0,0 @@
exports.animeSeason = new Map([
[ 0, "Winter" ],
[ 1, "Spring" ],
[ 2, "Summer" ],
[ 3, "Fall" ]
]);
exports.videoSource = new Map([
[ 0, "WEB" ],
[ 1, "RAW" ],
[ 2, "BD" ],
[ 3, "DVD" ],
[ 4, "VHS" ],
[ 5, "LD" ]
]);
exports.videoOverlap = new Map([
[ 0, "None" ],
[ 1, "Transition" ],
[ 2, "Over" ]
]);
exports.imageFacet = new Map([
[ 0, "Small Cover" ],
[ 1, "Large Cover" ]
]);
exports.resourceSite = new Map([
[ 0, "Official Website" ],
[ 1, "Twitter" ],
[ 2, "aniDB" ],
[ 3, "AniList" ],
[ 4, "Anime-Planet" ],
[ 5, "Anime News Network" ],
[ 6, "Kitsu" ],
[ 7, "MyAnimeList" ],
[ 8, "Wiki" ]
]);
exports.themeType = new Map([
[ 0, "OP" ],
[ 1, "ED" ]
]);
@@ -1,330 +0,0 @@
const mysql = require("mysql2");
module.exports = async ({ actions, createNodeId, createContentDigest, reporter }) => {
const helpers = {
...actions,
createNodeId,
createContentDigest
};
// TODO: Implement incremental data fetching when pivots have timestamps
// const lastFetched = await cache.get("last-fetched");
// getNodes().filter(isPluginNode).forEach((node) => helpers.touchNode(node));
// const now = new Date();
const connection = mysql.createConnection({
host: process.env.DB_HOST || "127.0.0.1",
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE || "animethemes"
});
async function query(sql) {
reporter.info(`Executing query: ${sql}`);
return new Promise((resolve, reject) => connection.query(sql, (error, results) => {
if (error) {
return reject(error);
}
reporter.info(`Got ${results.length} results`);
resolve(results);
}));
}
async function selectAllFrom(table, isPivot = false, tableToA, columnFromA, columnToA, tableToB, columnFromB, columnToB) {
let sql = `SELECT ${table}.* FROM ${table}`;
if (!isPivot) {
sql += " WHERE deleted_at IS NULL";
} else {
sql += ` INNER JOIN ${tableToA} AS a ON (${table}.${columnFromA} = a.${columnToA})
INNER JOIN ${tableToB} AS b ON (${table}.${columnFromB} = b.${columnToB})
WHERE a.deleted_at IS NULL AND b.deleted_at IS NULL`;
}
return await query(sql);
}
for (const anime of await selectAllFrom("anime")) {
createNodeFromData({
id: anime.anime_id,
idRaw: anime.anime_id,
name: anime.name,
slug: anime.slug,
year: anime.year,
season: anime.season,
synopsis: anime.synopsis,
resources: [],
images: []
}, "Anime", helpers);
}
for (const synonym of await selectAllFrom("anime_synonyms")) {
createNodeFromData({
id: synonym.synonym_id,
idRaw: synonym.synonym_id,
text: synonym.text,
anime: createNodeId(`Anime-${synonym.anime_id}`)
}, "Synonym", helpers);
}
for (const theme of await selectAllFrom("anime_themes")) {
createNodeFromData({
id: theme.theme_id,
idRaw: theme.theme_id,
slug: theme.slug,
type: theme.type,
sequence: theme.sequence || 0,
group: theme.group,
song: createNodeId(`Song-${theme.song_id}`),
anime: createNodeId(`Anime-${theme.anime_id}`)
}, "Theme", helpers);
}
for (const entry of await selectAllFrom("anime_theme_entries")) {
createNodeFromData({
id: entry.entry_id,
idRaw: entry.entry_id,
version: entry.version || 1,
episodes: entry.episodes,
nsfw: entry.nsfw,
spoiler: entry.spoiler,
theme: createNodeId(`Theme-${entry.theme_id}`),
videos: []
}, "Entry", helpers);
}
for (const video of await selectAllFrom("videos")) {
createNodeFromData({
id: video.video_id,
idRaw: video.video_id,
filename: video.filename,
basename: video.basename,
link: video.link,
resolution: video.resolution,
nc: video.nc,
subbed: video.subbed,
lyrics: video.lyrics,
uncen: video.uncen,
source: video.source,
overlap: video.overlap,
entries: []
}, "Video", helpers);
}
for (const song of await selectAllFrom("songs")) {
createNodeFromData({
id: song.song_id,
idRaw: song.song_id,
title: song.title
}, "Song", helpers);
}
for (const artist of await selectAllFrom("artists")) {
createNodeFromData({
id: artist.artist_id,
idRaw: artist.artist_id,
slug: artist.slug,
name: artist.name,
resources: [],
images: []
}, "Artist", helpers);
}
for (const series of await selectAllFrom("series")) {
createNodeFromData({
id: series.series_id,
idRaw: series.series_id,
slug: series.slug,
name: series.name
}, "Series", helpers);
}
for (const resource of await selectAllFrom("resources")) {
createNodeFromData({
id: resource.resource_id,
idRaw: resource.resource_id,
link: resource.link,
site: resource.site
}, "Resource", helpers);
}
for (const image of await selectAllFrom("images")) {
createNodeFromData({
id: image.image_id,
idRaw: image.image_id,
facet: image.facet
}, "Image", helpers);
}
for (const studio of await selectAllFrom("studios")) {
createNodeFromData({
id: studio.studio_id,
idRaw: studio.studio_id,
slug: studio.slug,
name: studio.name
}, "Studio", helpers);
}
try {
for (const announcement of await selectAllFrom("announcements")) {
createNodeFromData({
id: announcement.announcement_id,
idRaw: announcement.announcement_id,
content: announcement.content
}, "Announcement", helpers);
}
} catch (e) {
reporter.warn("Error while getting announcements. This should only happen on GitHub pages.");
}
// Pivot types
for (const performance of await selectAllFrom(
"artist_song", true,
"artists", "artist_id", "artist_id",
"songs", "song_id", "song_id"
)) {
createNodeFromData({
id: `${performance.song_id}-${performance.artist_id}`,
song: createNodeId(`Song-${performance.song_id}`),
artist: createNodeId(`Artist-${performance.artist_id}`),
as: performance.as
}, "Performance", helpers);
}
for (const artistMember of await selectAllFrom(
"artist_member", true,
"artists", "artist_id", "artist_id",
"artists", "member_id", "artist_id",
)) {
createNodeFromData({
id: `${artistMember.artist_id}-${artistMember.member_id}`,
group: createNodeId(`Artist-${artistMember.artist_id}`),
member: createNodeId(`Artist-${artistMember.member_id}`),
as: artistMember.as
}, "ArtistMembership", helpers);
}
for (const animeSeries of await selectAllFrom(
"anime_series", true,
"anime", "anime_id", "anime_id",
"series", "series_id", "series_id"
)) {
createNodeFromData({
id: `${animeSeries.anime_id}-${animeSeries.series_id}`,
anime: createNodeId(`Anime-${animeSeries.anime_id}`),
series: createNodeId(`Series-${animeSeries.series_id}`)
}, "AnimeSeries", helpers);
}
for (const animeResource of await selectAllFrom(
"anime_resource", true,
"anime", "anime_id", "anime_id",
"resources", "resource_id", "resource_id"
)) {
createNodeFromData({
id: `${animeResource.anime_id}-${animeResource.resource_id}`,
anime: createNodeId(`Anime-${animeResource.anime_id}`),
resource: createNodeId(`Resource-${animeResource.resource_id}`)
}, "AnimeResource", helpers);
}
for (const animeImage of await selectAllFrom(
"anime_image", true,
"anime", "anime_id", "anime_id",
"images", "image_id", "image_id"
)) {
createNodeFromData({
id: `${animeImage.anime_id}-${animeImage.image_id}`,
anime: createNodeId(`Anime-${animeImage.anime_id}`),
image: createNodeId(`Image-${animeImage.image_id}`)
}, "AnimeImage", helpers);
}
for (const artistResource of await selectAllFrom(
"artist_resource", true,
"artists", "artist_id", "artist_id",
"resources", "resource_id", "resource_id"
)) {
createNodeFromData({
id: `${artistResource.artist_id}-${artistResource.resource_id}`,
artist: createNodeId(`Artist-${artistResource.artist_id}`),
resource: createNodeId(`Resource-${artistResource.resource_id}`)
}, "ArtistResource", helpers);
}
for (const artistImage of await selectAllFrom(
"artist_image", true,
"artists", "artist_id", "artist_id",
"images", "image_id", "image_id"
)) {
createNodeFromData({
id: `${artistImage.artist_id}-${artistImage.image_id}`,
artist: createNodeId(`Artist-${artistImage.artist_id}`),
image: createNodeId(`Image-${artistImage.image_id}`)
}, "ArtistImage", helpers);
}
for (const entryVideo of await selectAllFrom(
"anime_theme_entry_video", true,
"anime_theme_entries", "entry_id", "entry_id",
"videos", "video_id", "video_id"
)) {
createNodeFromData({
id: `${entryVideo.entry_id}-${entryVideo.video_id}`,
entry: createNodeId(`Entry-${entryVideo.entry_id}`),
video: createNodeId(`Video-${entryVideo.video_id}`)
}, "EntryVideo", helpers);
}
for (const animeStudio of await selectAllFrom(
"anime_studio", true,
"anime", "anime_id", "anime_id",
"studios", "studio_id", "studio_id"
)) {
createNodeFromData({
id: `${animeStudio.anime_id}-${animeStudio.studio_id}`,
anime: createNodeId(`Anime-${animeStudio.anime_id}`),
studio: createNodeId(`Studio-${animeStudio.studio_id}`)
}, "AnimeStudio", helpers);
}
for (const studioResource of await selectAllFrom(
"studio_resource", true,
"studios", "studio_id", "studio_id",
"resources", "resource_id", "resource_id"
)) {
createNodeFromData({
id: `${studioResource.studio_id}-${studioResource.resource_id}`,
studio: createNodeId(`Studio-${studioResource.studio_id}`),
resource: createNodeId(`Resource-${studioResource.resource_id}`)
}, "StudioResource", helpers);
}
// await cache.set("last-fetched", now);
};
function createNodeFromData(item, nodeType, helpers) {
const node = {
...item,
id: helpers.createNodeId(`${nodeType}-${item.id}`),
parent: null,
children: [],
internal: {
type: nodeType,
content: JSON.stringify(item),
contentDigest: helpers.createContentDigest(item)
}
};
helpers.createNode(node)
return node;
}
// function isPluginNode(node) {
// return node.internal.owner === "gatsby-source-animethemes";
// }
@@ -1,371 +0,0 @@
const { fetchAnimeList } = require("./src/anime");
const { fetchSynonymList } = require("./src/synonym");
const { fetchThemeList } = require("./src/theme");
const { fetchEntryList } = require("./src/entry");
const { fetchVideoList } = require("./src/video");
const { fetchSongList } = require("./src/song");
const { fetchArtistList } = require("./src/artist");
const { fetchSeriesList } = require("./src/series");
const { fetchResourceList } = require("./src/resource");
const { fetchImageList } = require("./src/image");
const { fetchAnnouncements } = require("./src/announcement");
exports.onPreInit = ({ reporter }) => reporter.info("Loaded gatsby-source-animethemes");
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions;
createTypes(`
type Anime implements Node {
id: ID!
name: String!
slug: String!
year: Int!
season: String
synopsis: String
synonyms: [Synonym] @link(by: "anime.id", from: "id")
themes: [Theme] @link(by: "anime.id", from: "id")
# See custom resolver below (n-to-n relations cannot be resolved with @link)
series: [Series]
resources: [Resource]
images: [Image]
}
type Synonym implements Node {
id: ID!
text: String!
anime: Anime! @link(by: "id")
}
type Theme implements Node {
id: ID!
slug: String!
group: String
song: Song! @link(by: "id")
anime: Anime! @link(by: "id")
entries: [Entry] @link(by: "theme.id", from: "id")
}
type Entry implements Node {
id: ID!
version: Int
episodes: String
nsfw: Boolean!
spoiler: Boolean!
theme: Theme! @link(by: "id")
# See custom resolver below (n-to-n relations cannot be resolved with @link)
videos: [Video]
}
type Video implements Node {
id: ID!
filename: String!
basename: String!
link: String!
resolution: Int
nc: Boolean!
subbed: Boolean!
lyrics: Boolean!
uncen: Boolean!
source: String
overlap: String
tags: String
# See custom resolver below (n-to-n relations cannot be resolved with @link)
entries: [Entry]
}
type Song implements Node {
id: ID!
title: String!
themes: [Theme] @link(by: "song.id", from: "id")
performances: [Performance] @link(by: "song.id", from: "id")
}
type Performance implements Node {
id: ID!
song: Song! @link(by: "id")
artist: Artist! @link(by: "id")
as: String
}
type Artist implements Node {
id: ID!
slug: String!
name: String!
performances: [Performance] @link(by: "artist.id", from: "id")
# See custom resolver below (n-to-n relations cannot be resolved with @link)
resources: [Resource]
images: [Image]
}
type Series implements Node {
id: ID!
slug: String!
name: String!
# See custom resolver below (n-to-n relations cannot be resolved with @link)
anime: [Anime]
}
type Resource implements Node {
link: String!
site: String!
# See custom resolver below (n-to-n relations cannot be resolved with @link)
anime: [Anime]
artists: [Artist]
}
type Image implements Node {
facet: String!
link: String!
# See custom resolver below (n-to-n relations cannot be resolved with @link)
anime: [Anime]
artists: [Artist]
}
type Announcement implements Node {
id: ID!
content: String!
}
`);
};
exports.sourceNodes = async ({ cache, actions, getNodes, createNodeId, createContentDigest, reporter }) => {
const helpers = {
...actions,
createNodeId,
createContentDigest
};
// const lastFetched = await cache.get("last-fetched");
//
// getNodes().filter(isPluginNode).forEach((node) => helpers.touchNode(node));
//
// const now = new Date().toISOString().replace("Z", "000");
const animeList = await fetchAnimeList({ reporter });
const synonymList = await fetchSynonymList({ reporter });
const themeList = await fetchThemeList({ reporter });
const entryList = await fetchEntryList({ reporter });
const videoList = await fetchVideoList({ reporter });
const songList = await fetchSongList({ reporter });
const artistList = await fetchArtistList({ reporter });
const seriesList = await fetchSeriesList({ reporter });
const resourceList = await fetchResourceList({ reporter });
const imageList = await fetchImageList({ reporter });
for (const anime of animeList) {
createNodeFromData({
id: anime.id,
name: anime.name,
slug: anime.slug,
year: anime.year,
season: anime.season,
synopsis: anime.synopsis,
series: anime.series.map((series) => createNodeId(`Series-${series.id}`)),
themes: anime.themes.map((theme) => createNodeId(`Theme-${theme.id}`)),
resources: anime.resources.map((resource) => createNodeId(`Resource-${resource.id}`)),
images: anime.images.map((image) => createNodeId(`Image-${image.id}`))
}, "Anime", helpers);
}
for (const synonym of synonymList) {
createNodeFromData({
id: synonym.id,
text: synonym.text,
anime: createNodeId(`Anime-${synonym.anime.id}`)
}, "Synonym", helpers);
}
for (const theme of themeList) {
createNodeFromData({
id: theme.id,
slug: theme.slug,
group: theme.group,
song: createNodeId(`Song-${theme.song.id}`),
anime: createNodeId(`Anime-${theme.anime.id}`),
entries: theme.entries.map((entry) => createNodeId(`Entry-${entry.id}`))
}, "Theme", helpers);
}
for (const entry of entryList) {
createNodeFromData({
id: entry.id,
version: entry.version || 1,
episodes: entry.episodes,
nsfw: entry.nsfw,
spoiler: entry.spoiler,
theme: createNodeId(`Theme-${entry.theme.id}`),
videos: entry.videos.map((video) => createNodeId(`Video-${video.id}`))
}, "Entry", helpers);
}
for (const video of videoList) {
createNodeFromData({
id: video.id,
filename: video.filename,
basename: video.basename,
link: video.link,
resolution: video.resolution,
nc: video.nc,
subbed: video.subbed,
lyrics: video.lyrics,
uncen: video.uncen,
source: video.source,
overlap: video.overlap,
tags: video.tags,
entries: video.entries.map((entry) => createNodeId(`Entry-${entry.id}`))
}, "Video", helpers);
}
for (const song of songList) {
createNodeFromData({
id: song.id,
title: song.title,
themes: song.themes.map((theme) => createNodeId(`Theme-${theme.id}`)),
performances: song.artists.map((artist) => createNodeId(`Performance-${song.id}-${artist.id}`))
}, "Song", helpers);
for (const artist of song.artists) {
createNodeFromData({
id: `${song.id}-${artist.id}`,
song: createNodeId(`Song-${song.id}`),
artist: createNodeId(`Artist-${artist.id}`),
as: artist.as
}, "Performance", helpers);
}
}
for (const artist of artistList) {
createNodeFromData({
id: artist.id,
slug: artist.slug,
name: artist.name,
performances: artist.songs.map((song) => createNodeId(`Performance-${song.id}-${artist.id}`)),
resources: artist.resources.map((resource) => createNodeId(`Resource-${resource.id}`)),
images: artist.images.map((image) => createNodeId(`Image-${image.id}`))
}, "Artist", helpers);
}
for (const series of seriesList) {
createNodeFromData({
id: series.id,
slug: series.slug,
name: series.name,
anime: series.anime.map((anime) => createNodeId(`Anime-${anime.id}`))
}, "Series", helpers);
}
for (const resource of resourceList) {
createNodeFromData({
id: resource.id,
link: resource.link,
site: resource.site,
anime: resource.anime.map((anime) => createNodeId(`Anime-${anime.id}`)),
artists: resource.artists.map((artist) => createNodeId(`Artist-${artist.id}`))
}, "Resource", helpers);
}
for (const image of imageList) {
createNodeFromData({
id: image.id,
facet: image.facet,
link: image.link,
anime: image.anime.map((anime) => createNodeId(`Anime-${anime.id}`)),
artists: image.artists.map((artist) => createNodeId(`Artist-${artist.id}`))
}, "Image", helpers);
}
const announcements = await fetchAnnouncements();
for (const announcement of announcements) {
createNodeFromData({
id: announcement.id,
content: announcement.content
}, "Announcement", helpers);
}
// await cache.set("last-fetched", now);
};
exports.createResolvers = ({ createResolvers }) => {
createResolvers({
Anime: {
series: {
resolve(source, args, context) {
return context.nodeModel.getNodesByIds({ ids: source.series, type: "Series" });
}
},
images: {
resolve(source, args, context) {
return context.nodeModel.getAllNodes({ type: "Image" }).filter((node) => node.anime.includes(source.id));
}
},
resources: {
resolve(source, args, context) {
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.anime.includes(source.id));
}
}
},
Artist: {
images: {
resolve(source, args, context) {
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.artists.includes(source.id));
}
},
resources: {
resolve(source, args, context) {
return context.nodeModel.getAllNodes({ type: "Resource" }).filter((node) => node.artists.includes(source.id));
}
}
},
Series: {
anime: {
resolve(source, args, context) {
return context.nodeModel.getNodesByIds({ ids: source.anime, type: "Anime" });
}
}
},
Entry: {
videos: {
resolve(source, args, context) {
return context.nodeModel.getNodesByIds({ ids: source.videos, type: "Video" });
}
}
},
Video: {
entries: {
resolve(source, args, context) {
return context.nodeModel.getNodesByIds({ ids: source.entries, type: "Entry" });
}
}
}
});
};
function createNodeFromData(item, nodeType, helpers) {
const node = {
...item,
id: helpers.createNodeId(`${nodeType}-${item.id}`),
parent: null,
children: [],
internal: {
type: nodeType,
content: JSON.stringify(item),
contentDigest: helpers.createContentDigest(item)
}
};
helpers.createNode(node)
return node;
}
// function isPluginNode(node) {
// return node.internal.owner === "gatsby-source-animethemes";
// }
@@ -1,4 +0,0 @@
{
"name": "gatsby-source-animethemes",
"version": "1.0.0"
}
@@ -1,31 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
anime: [ "id", "name", "slug", "year", "season", "synopsis" ],
image: [ "id" ],
theme: [ "id" ],
series: [ "id" ],
resource: [ "id" ]
});
async function fetchAnimeList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching anime list");
activity.start();
const animeList = await fetchJsonPaginated(
`${baseUrl}/api/anime?page[size]=100&sort=year,season,name&${fields}&include=images,themes,series,externalResources&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.anime,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} anime fetched`)
}
);
activity.end();
return animeList;
}
module.exports = {
fetchAnimeList
};
@@ -1,10 +0,0 @@
const { baseUrl, fetchJson } = require("./index");
function fetchAnnouncements() {
return fetchJson(`${baseUrl}/api/announcement`)
.then((json) => json.announcements);
}
module.exports = {
fetchAnnouncements
};
@@ -1,30 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
artist: [ "id", "slug", "name" ],
song: [ "id" ],
resource: [ "id" ],
image: [ "id" ]
});
async function fetchArtistList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching artist list");
activity.start();
const artistList = await fetchJsonPaginated(
`${baseUrl}/api/artist?page[size]=100&sort=name&${fields}&include=songs,externalResources,images&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.artists,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} artists fetched`)
}
);
activity.end();
return artistList;
}
module.exports = {
fetchArtistList
};
@@ -1,29 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
entry: [ "id", "version", "episodes", "nsfw", "spoiler" ],
theme: [ "id" ],
video: [ "id" ]
});
async function fetchEntryList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching entry list");
activity.start();
const entryList = await fetchJsonPaginated(
`${baseUrl}/api/entry?page[size]=100&${fields}&include=theme,videos&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.entries,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} entries fetched`)
}
);
activity.end();
return entryList;
}
module.exports = {
fetchEntryList
};
@@ -1,29 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
image: [ "id", "facet", "link" ],
anime: [ "id" ],
artist: [ "id" ]
});
async function fetchImageList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching image list");
activity.start();
const imageList = await fetchJsonPaginated(
`${baseUrl}/api/image?page[size]=100&${fields}&include=anime,artists&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.images,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} images fetched`)
}
);
activity.end();
return imageList;
}
module.exports = {
fetchImageList
};
@@ -1,65 +0,0 @@
const fetch = require("node-fetch");
const baseUrl = process.env.GATSBY_API_URL || "https://staging.animethemes.moe";
const requestCooldown = 1500;
let lastRequest;
async function fetchJson(url, init) {
// Debounce, we should only request once every second
if (lastRequest) {
const expiredTime = Date.now() - lastRequest;
if (expiredTime < requestCooldown) {
await sleep(requestCooldown - expiredTime);
}
}
lastRequest = Date.now();
const response = await fetch(url, init);
return await response.json();
}
async function fetchJsonPaginated(url, { reducer = (page) => page, flatten, onProgress, ...init }) {
const results = [];
let nextUrl = url;
console.log(`URL schema: ${nextUrl}`);
while (nextUrl) {
const page = await fetchJson(nextUrl, init);
const pageResults = reducer(page);
if (flatten) {
results.push(...pageResults);
} else {
results.push(pageResults);
}
if (onProgress) {
onProgress(results);
}
nextUrl = page.links.next;
}
return results;
}
function createFieldParams(fields) {
return Object.entries(fields)
.map(([ key, values ]) => `fields[${key}]=${values.join()}`)
.join("&");
}
function sleep(millis) {
return new Promise((resolve) => setTimeout(resolve, millis));
}
module.exports = {
baseUrl,
fetchJson,
fetchJsonPaginated,
createFieldParams
};
@@ -1,29 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
resource: [ "id", "link", "site" ],
anime: [ "id" ],
artist: [ "id" ]
});
async function fetchResourceList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching resource list");
activity.start();
const resourceList = await fetchJsonPaginated(
`${baseUrl}/api/resource?page[size]=100&${fields}&include=anime,artists&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.resources,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} resources fetched`)
}
);
activity.end();
return resourceList;
}
module.exports = {
fetchResourceList
};
@@ -1,28 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
series: [ "id", "slug", "name" ],
anime: [ "id" ]
});
async function fetchSeriesList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching series list");
activity.start();
const seriesList = await fetchJsonPaginated(
`${baseUrl}/api/series?page[size]=100&sort=name&${fields}&include=anime&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.series,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} series fetched`)
}
);
activity.end();
return seriesList;
}
module.exports = {
fetchSeriesList
};
@@ -1,29 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
song: [ "id", "title" ],
theme: [ "id" ],
artist: [ "id", "as" ]
});
async function fetchSongList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching song list");
activity.start();
const songList = await fetchJsonPaginated(
`${baseUrl}/api/song?page[size]=100&${fields}&include=themes,artists&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.songs,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} songs fetched`)
}
);
activity.end();
return songList;
}
module.exports = {
fetchSongList
};
@@ -1,28 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
synonym: [ "id", "text" ],
anime: [ "id" ]
});
async function fetchSynonymList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching synonym list");
activity.start();
const synonymList = await fetchJsonPaginated(
`${baseUrl}/api/synonym?page[size]=100&${fields}&include=anime&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.synonyms,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} synonyms fetched`)
}
);
activity.end();
return synonymList;
}
module.exports = {
fetchSynonymList
};
@@ -1,30 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
theme: [ "id", "slug", "group" ],
anime: [ "id" ],
song: [ "id" ],
entry: [ "id" ]
});
async function fetchThemeList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching theme list");
activity.start();
const themeList = await fetchJsonPaginated(
`${baseUrl}/api/theme?page[size]=100&${fields}&include=anime,song,entries&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.themes,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} themes fetched`)
}
);
activity.end();
return themeList;
}
module.exports = {
fetchThemeList
};
@@ -1,28 +0,0 @@
const { baseUrl, fetchJsonPaginated, createFieldParams } = require("./index");
const fields = createFieldParams({
video: [ "id", "filename", "basename", "link", "resolution", "nc", "subbed", "lyrics", "uncen", "source", "overlap", "tags" ],
entry: [ "id" ]
});
async function fetchVideoList({ reporter, lastFetched }) {
const activity = reporter.activityTimer("Fetching video list");
activity.start();
const videoList = await fetchJsonPaginated(
`${baseUrl}/api/video?page[size]=100&${fields}&include=entries&filter[updated_at][gte]=${lastFetched}`,
{
reducer: (page) => page.videos,
flatten: true,
onProgress: (results) => activity.setStatus(`${results.length} videos fetched`)
}
);
activity.end();
return videoList;
}
module.exports = {
fetchVideoList
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="Ebene_1" fill="currentColor" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 86.6"><title>logo</title><polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/><polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/></svg>

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1000.000000pt" height="1000.000000pt" viewBox="0 0 1000.000000 1000.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1000.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2953 6087 c-1030 -595 -1873 -1084 -1873 -1087 0 -3 210 -126 467
-274 l467 -269 30 17 c17 9 438 252 936 539 l905 523 3 -1081 2 -1081 454
-262 c249 -144 460 -264 470 -268 15 -6 16 152 16 2160 0 1191 -1 2166 -2
2165 -2 0 -846 -487 -1875 -1082z"/>
<path d="M5330 4999 c0 -1353 4 -2159 9 -2157 6 2 217 122 470 268 l461 265 0
1078 c0 592 2 1077 5 1077 3 0 421 -239 927 -532 507 -293 929 -532 938 -532
18 0 930 523 930 534 0 7 -3723 2160 -3734 2160 -3 0 -6 -972 -6 -2161z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1004 B

-12581
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
module.exports = {
// Basic site metadata
siteName: "AnimeThemes",
description: "AnimeThemes is a simple and consistent repository of anime opening and ending themes.",
siteLanguage: "en",
// Prefix for all links (for production builds)
pathPrefix: "/animethemes",
// Site URL without pathPrefix
rootUrl: "https://animethemes.github.io"
}
+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) {
// 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>
);
}
-8
View File
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { color, compose, flexbox, grid, layout, space, typography, position, border } from "styled-system";
import { gaps } from "utils/gaps";
export const Box = styled.div`
${compose(color, space, layout, typography, position, flexbox, grid, border)}
${gaps}
`;
-6
View File
@@ -1,6 +0,0 @@
import styled from "styled-components";
import { Box } from "components/box";
export const Flex = styled(Box)`
display: ${(props) => props.display || (props.flexInline ? "inline-flex" : "flex")};
`;
+32
View File
@@ -0,0 +1,32 @@
import styled from "styled-components";
import type { Property } from "csstype";
const Flex = styled.div<{
$wrap?: boolean;
style?: {
"--justify-content"?: Property.JustifyContent;
"--align-items"?: Property.AlignItems;
"--gap"?: Property.Gap;
};
}>`
--justify-content: initial;
--align-items: initial;
--gap: initial;
display: flex;
flex-wrap: ${(props) => props.$wrap && "wrap"};
justify-content: var(--justify-content);
align-items: var(--align-items);
gap: var(--gap);
`;
export const Row = styled(Flex)`
flex-direction: row;
`;
export const Column = styled(Flex)`
flex-direction: column;
`;
-6
View File
@@ -1,6 +0,0 @@
import styled from "styled-components";
import { Box } from "components/box";
export const Grid = styled(Box)`
display: ${(props) => props.gridInline ? "inline-grid" : "grid"};
`;
+7
View File
@@ -0,0 +1,7 @@
import styled from "styled-components";
import theme from "@/theme";
export const Solid = styled.div`
background-color: ${theme.colors["solid"]};
`;
-3
View File
@@ -1,3 +0,0 @@
export { Box } from "./Box";
export { Flex } from "./Flex";
export { Grid } from "./Grid";
+237
View File
@@ -0,0 +1,237 @@
import { memo, useCallback, useRef, useState } from "react";
import type { RefObject } from "react";
import styled from "styled-components";
import { faDiagramProject } from "@fortawesome/free-solid-svg-icons";
import { m } from "motion/react";
import { BracketThemeSummaryCard } from "@/components/bracket/BracketThemeSummaryCard";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import type { BracketPageQuery } from "@/generated/graphql";
import theme from "@/theme";
import type { RequiredNonNullable } from "@/utils/types";
const StyledBracketContainer = styled.div`
flex: 1 1 0;
overflow: hidden;
user-select: none;
cursor: grab;
&:active {
cursor: grabbing;
}
&:fullscreen {
background-color: ${theme.colors["background"]};
}
`;
const StyledBracket = styled(m.div)`
display: flex;
position: relative;
width: min-content;
gap: 128px;
padding: 16px;
`;
const StyledCanvas = styled.canvas`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
`;
const StyledRound = styled.div`
display: flex;
flex-direction: column;
gap: 64px;
`;
const StyledPairing = styled.div`
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
`;
const StyledBracketThemeSummaryCard = styled(BracketThemeSummaryCard)`
width: 384px;
`;
type BracketChartProps = RequiredNonNullable<BracketPageQuery>;
export function BracketChart({ bracket }: BracketChartProps) {
const [showBracketChart, setShowBracketChart] = useState(false);
const bracketRef = useRef<HTMLDivElement>(undefined);
const onBracketInit = useCallback((bracket: HTMLDivElement) => {
bracketRef.current = bracket;
if (bracket) {
bracket.requestFullscreen();
bracket.addEventListener("fullscreenchange", onFullscreenChange);
}
function onFullscreenChange() {
if (!document.fullscreenElement) {
setShowBracketChart(false);
bracket.removeEventListener("fullscreenchange", onFullscreenChange);
}
}
}, []);
const onCanvasInit = useCallback((canvas: HTMLCanvasElement) => {
if (!canvas?.parentNode) {
return;
}
const parentRect = (canvas.parentNode as HTMLDivElement).getBoundingClientRect();
canvas.width = parentRect.width;
canvas.height = parentRect.height;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.strokeStyle = "#75ead4";
ctx.lineWidth = 2;
ctx.translate(-parentRect.left, -parentRect.top);
const cards = document.querySelectorAll<HTMLDivElement>("[data-theme]");
const cardsById = [...cards].reduce<Record<string, Array<HTMLDivElement>>>((map, card) => {
const id = card.dataset.theme;
if (id) {
if (!map[id]) {
map[id] = [];
}
map[id].push(card);
}
return map;
}, {});
for (const cards of Object.values(cardsById)) {
for (let i = 0; i < cards.length - 1; i++) {
const current = cards[i];
const currentRect = current.getBoundingClientRect();
const next = cards[i + 1];
const nextRect = next.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(currentRect.left + currentRect.width, currentRect.top + currentRect.height / 2);
ctx.bezierCurveTo(
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
currentRect.top + currentRect.height / 2,
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
nextRect.top + nextRect.height / 2,
nextRect.left,
nextRect.top + nextRect.height / 2,
);
ctx.stroke();
}
}
}, []);
return (
<>
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
<Icon icon={faDiagramProject} />
<span>Open Bracket Chart</span>
</Button>
{showBracketChart ? (
<StyledBracketContainer ref={onBracketInit}>
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement | null>}>
<StyledCanvas ref={onCanvasInit} />
{bracket.rounds
.sort((a, b) => a.tier - b.tier)
.map((round) => (
<BracketRound key={round.tier} round={round} />
))}
</StyledBracket>
</StyledBracketContainer>
) : null}
</>
);
}
interface BracketRoundProps {
round: BracketChartProps["bracket"]["rounds"][number];
}
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
if (!round.pairings || !round.pairings.length) {
return null;
}
return (
<StyledRound>
<Text>{round.name}</Text>
<BracketPairings pairings={round.pairings} />
</StyledRound>
);
});
interface BracketPairingsProps {
pairings: BracketRoundProps["round"]["pairings"];
}
function BracketPairings({ pairings }: BracketPairingsProps) {
const sortedPairings = pairings
.sort((a, b) => a.group - b.group || a.order - b.order)
.map((pairing, index) => (
<StyledPairing key={index}>
<ContestantCard
key={pairing.characterA.id}
contestant={pairing.characterA}
opponent={pairing.characterB}
contestantVotes={pairing.votesA}
opponentVotes={pairing.votesB}
/>
<Text variant="small">VS</Text>
<ContestantCard
key={pairing.characterB.id}
contestant={pairing.characterB}
opponent={pairing.characterA}
contestantVotes={pairing.votesB}
opponentVotes={pairing.votesA}
/>
</StyledPairing>
));
return <>{sortedPairings}</>;
}
interface ContestantCardProps {
contestant:
| BracketPairingsProps["pairings"][number]["characterA"]
| BracketPairingsProps["pairings"][number]["characterB"];
opponent:
| BracketPairingsProps["pairings"][number]["characterA"]
| BracketPairingsProps["pairings"][number]["characterB"];
contestantVotes: number | null;
opponentVotes: number | null;
}
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
const isVoted = !!contestantVotes && !!opponentVotes;
const isWinner =
isVoted &&
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
return (
<StyledBracketThemeSummaryCard
contestant={contestant}
isVoted={isVoted}
isWinner={isWinner}
seed={contestant.seed}
votes={contestantVotes}
data-theme={contestant.id}
/>
);
}
@@ -0,0 +1,113 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styled from "styled-components";
import { faAward, faSeedling, faUsers } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { Column } from "@/components/box/Flex";
import { SummaryCard } from "@/components/card/SummaryCard";
import { ThemeSummaryCard } from "@/components/card/ThemeSummaryCard";
import { CornerIcon } from "@/components/icon/CornerIcon";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
import type { BracketThemeSummaryCardConstestantFragment } from "@/generated/graphql";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledSummaryCardWrapper = styled.div`
position: relative;
justify-self: stretch;
`;
const StyledSummaryCard = styled(SummaryCard)`
padding-inline-end: 24px;
opacity: var(--opacity);
`;
const StyledRank = styled(Text)`
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
`;
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
contestant: BracketThemeSummaryCardConstestantFragment;
isVoted: boolean;
isWinner: boolean;
seed: number | null;
votes: number | null;
}
export function BracketThemeSummaryCard({
contestant,
isVoted,
isWinner,
seed,
votes,
...props
}: BracketThemeSummaryCardProps) {
const theme = contestant.theme;
const { smallCover } = extractImages(theme?.anime);
let to;
let description: ReactNode = contestant.source;
if (theme?.anime) {
const entry = theme.entries?.[0];
const video = entry?.videos?.[0];
const videoSlug = createVideoSlug(theme, entry, video);
to = `/anime/${theme.anime.slug}/${videoSlug}`;
description = (
<SummaryCard.Description>
<span>{theme.type + (theme.sequence || "")}</span>
<TextLink href={`/anime/${theme.anime.slug}`}>{theme.anime.name}</TextLink>
</SummaryCard.Description>
);
}
return (
<StyledSummaryCardWrapper {...props}>
<StyledSummaryCard
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : contestant.name}
description={description}
image={smallCover ?? contestant.image}
to={to}
style={{ "--opacity": !isVoted || isWinner ? 1 : 0.5 }}
>
<Column style={{ "--gap": "8px" }}>
<Text variant="small" color="text-muted" noWrap title="Seed">
<Icon icon={faSeedling} />
<StyledRank> {seed}</StyledRank>
</Text>
{isVoted && (
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
<Icon icon={faUsers} />
<StyledRank> {votes}</StyledRank>
</Text>
)}
</Column>
</StyledSummaryCard>
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
</StyledSummaryCardWrapper>
);
}
BracketThemeSummaryCard.fragments = {
contestant: gql`
${ThemeSummaryCard.fragments.theme}
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
name
source
image
theme {
...ThemeSummaryCardTheme
}
}
`,
};
+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>
);
}
-83
View File
@@ -1,83 +0,0 @@
import styled, { css } from "styled-components";
import { gaps } from "utils/gaps";
import { Icon } from "components/icon";
import theme from "theme";
import { flexbox } from "styled-system";
import { withHover } from "styles/mixins";
export const Button = styled.button.attrs(({ children, circle, title }) => ({
circle: (typeof children === "object" && "type" in children && children.type === Icon) || circle,
"aria-label": title
}))`
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1rem;
background-color: ${getBackgroundColor};
color: ${getColor};
box-shadow: ${theme.shadows.low};
border-radius: 999px;
padding: ${(props) => props.circle ? "0.5rem" : "0.5rem 1rem"};
${withHover(css`
background-color: ${(props) => getBackgroundColor(props, true)};
color: ${(props) => getColor(props, true)};
`)}
& > &:first-child {
margin: -0.5rem 0.5rem -0.5rem -1rem;
box-shadow: none;
}
${(props) => props.circle && css`
aspect-ratio: 1 / 1;
`}
${(props) => props.silent && css`
&:not(:hover) {
box-shadow: none;
}
`}
${(props) => props.disabled && css`
cursor: not-allowed;
opacity: 0.5;
`}
${flexbox}
${gaps}
`;
function getColor({ variant, theme }, hover = false) {
switch (variant) {
case "primary":
return theme.colors["text-on-primary"];
case "on-card":
case "default":
default:
return hover ? theme.colors["text"] : theme.colors["text-muted"];
}
}
function getBackgroundColor({ variant, silent, theme }, hover = false) {
if (silent && !hover) {
return "transparent";
}
switch (variant) {
case "primary":
return theme.colors["solid-primary"];
case "on-card":
return theme.colors["solid-on-card"];
case "default":
default:
return theme.colors["solid"];
}
}
+159
View File
@@ -0,0 +1,159 @@
import { type ComponentPropsWithRef } from "react";
import styled from "styled-components";
import { Solid } from "@/components/box/Solid";
import { NestableSlot } from "@/components/utils/NestableSlot";
import { withHover } from "@/styles/mixins";
import theme from "@/theme";
interface ButtonProps extends ComponentPropsWithRef<typeof BaseButton> {
asChild?: boolean;
variant?: "solid" | "primary" | "warning" | "silent";
isCircle?: boolean;
disabled?: boolean;
}
export function Button({
ref,
asChild,
children,
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}: ButtonProps) {
let Component;
if (variant === "solid") {
Component = SolidButton;
} else if (variant === "primary") {
Component = PrimaryButton;
} else if (variant === "warning") {
Component = WarningButton;
} else if (variant === "silent") {
Component = SilentButton;
} else {
throw new Error(`Unknown button variant "${variant}"!`);
}
return (
<Component
ref={ref}
as={asChild ? NestableSlot : "button"}
$isCircle={isCircle}
disabled={disabled}
title={title}
aria-label={title}
{...props}
>
{children}
</Component>
);
}
const BaseButton = styled.button<{ $isCircle?: boolean }>`
--gap: 0;
--focus-ring-color: ${theme.colors["text-primary"]};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
pointer-events: ${(props) => props.disabled && "none"};
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1rem;
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
border-radius: 999px;
gap: var(--gap, 0);
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
opacity: ${(props) => props.disabled && "0.5"};
box-shadow: ${theme.shadows.low};
transition: background-color 250ms;
// Buttons within other buttons should have a special margin and no shadow.
& & {
box-shadow: none;
margin: -8px 8px -8px -16px;
}
&:focus:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-color);
}
`;
const PrimaryButton = styled(BaseButton)`
background-color: ${theme.colors["solid-primary"]};
color: ${theme.colors["text-on-primary"]};
${withHover`
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
}
`;
const WarningButton = styled(BaseButton)`
--focus-ring-color: ${theme.colors["text-warning"]};
background-color: ${theme.colors["solid-warning"]};
color: ${theme.colors["text-on-warning"]};
${withHover`
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
}
`;
const SolidButton = styled(BaseButton)`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
${withHover`
color: ${theme.colors["text"]};
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
`;
const SilentButton = styled(BaseButton)`
background-color: transparent;
color: ${theme.colors["text-muted"]};
${withHover`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text"]};
`}
&:not(:hover)&:not(:focus:focus-visible) {
box-shadow: none;
}
${Solid} & {
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`}
}
${Solid} ${Solid} & {
${withHover`
background-color: transparent;
box-shadow: none;
`}
}
`;
@@ -1,16 +0,0 @@
import { Icon } from "components/icon";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { Text } from "components/text";
import { Button } from "components/button";
import { useMedia } from "use-media";
export function FilterToggleButton(props) {
const isMobile = useMedia({ maxWidth: "720px" });
return (
<Button silent gapsRow="0.5rem" circle={isMobile} {...props}>
<Icon icon={faFilter} color="text-disabled"/>
<Text display={[ "none", "inline" ]}>Filter</Text>
</Button>
);
}
@@ -0,0 +1,13 @@
import type { ComponentPropsWithoutRef } from "react";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "@/components/button/IconTextButton";
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>
Filter
</IconTextButton>
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
import styled from "styled-components";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { NestableSlottable } from "@/components/utils/NestableSlot";
import theme from "@/theme";
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
aspect-ratio: 1 / 1;
padding: 8px;
}
`;
const StyledText = styled.span<{ $collapseBreakpoint: string }>`
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
display: none;
}
`;
interface IconTextButtonProps extends Omit<ComponentPropsWithoutRef<typeof StyledButton>, "$collapseBreakpoint"> {
ref?: RefObject<HTMLButtonElement>;
icon: IconDefinition | ReactNode;
children?: ReactNode;
collapsible?: true | keyof typeof theme.breakpoints;
}
export function IconTextButton({ ref, icon, children, collapsible, ...props }: IconTextButtonProps) {
let collapseBreakpoint = "0px";
if (collapsible === true) {
collapseBreakpoint = theme.breakpoints.mobileMax;
} else if (collapsible) {
collapseBreakpoint = theme.breakpoints[collapsible];
}
return (
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
<NestableSlottable child={children}>
{(child) => (
<>
{isIconDefinition(icon) ? <Icon icon={icon} color="text-disabled" /> : icon}
{child !== null && child !== undefined ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{child}</StyledText>
) : null}
</>
)}
</NestableSlottable>
</StyledButton>
);
}
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
return !!icon && typeof icon === "object" && "icon" in icon;
}
-25
View File
@@ -1,25 +0,0 @@
import { VideoTags } from "components/utils";
import { useContext } from "react";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Link } from "gatsby";
import { Icon } from "components/icon";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
export function VideoButton({ anime, theme, entry, video, ...props }) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo && currentVideo.filename === video.filename;
return (
<Link to={`/anime/${anime.slug}/${videoSlug}`}>
<Button {...props}>
<Button as="span" variant="primary">
<Icon icon={isPlaying ? faCompactDisc : faPlay} spin={isPlaying}/>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useContext } from "react";
import type { ComponentPropsWithoutRef } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { VideoTags } from "@/components/tag/VideoTags";
import PlayerContext from "@/context/playerContext";
import type {
VideoButtonAnimeFragment,
VideoButtonEntryFragment,
VideoButtonThemeFragment,
VideoButtonVideoFragment,
} from "@/generated/graphql";
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
anime: VideoButtonAnimeFragment;
theme: VideoButtonThemeFragment;
entry: VideoButtonEntryFragment;
video: VideoButtonVideoFragment;
}
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentWatchListItem } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentWatchListItem
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
: false;
return (
<Button asChild {...props}>
<Link href={`/anime/${anime.slug}/${videoSlug}`}>
<Button asChild variant="primary" isCircle>
<span>
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined} />
</span>
</Button>
<VideoTags video={video} />
</Link>
</Button>
);
}
VideoButton.fragments = {
anime: gql`
fragment VideoButtonAnime on Anime {
slug
}
`,
theme: gql`
${createVideoSlug.fragments.theme}
fragment VideoButtonTheme on Theme {
...createVideoSlugTheme
}
`,
entry: gql`
${createVideoSlug.fragments.entry}
fragment VideoButtonEntry on Entry {
...createVideoSlugEntry
}
`,
video: gql`
${createVideoSlug.fragments.video}
${VideoTags.fragments.video}
fragment VideoButtonVideo on Video {
...createVideoSlugVideo
...VideoTagsVideo
id
}
`,
};
-3
View File
@@ -1,3 +0,0 @@
export { Button } from "./Button";
export { VideoButton } from "./VideoButton"
export { FilterToggleButton } from "./FilterToggleButton";
-105
View File
@@ -1,105 +0,0 @@
import { graphql, Link } from "gatsby";
import { faEllipsisH, faPlay } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { Flex } from "components/box";
import { Icon } from "components/icon";
import { SummaryCard } from "components/card";
import { chain, themeSequenceComparator, themeTypeComparator } from "utils/comparators";
export function AnimeSummaryCard({ anime, hideThemes = false, maxThemes = 4 }) {
const { smallCover } = useImage(anime);
const animeLink = `/anime/${anime.slug}`;
let premiere = String(anime.year);
let premiereLink = `/year/${anime.year}`;
if (anime.season) {
premiere = anime.season + " " + premiere;
premiereLink += `/${anime.season.toLowerCase()}`;
}
let description = (
<SummaryCard.Description>
<span>Anime</span>
{!!anime.year && (
<Link to={premiereLink}>
<Text link>{premiere}</Text>
</Link>
)}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
return (
<SummaryCard
title={anime.name}
description={description}
image={smallCover}
to={animeLink}
>
{!hideThemes && (
<Flex display={[ "none", "flex" ]} flexWrap="wrap" gapsBoth="0.75rem">
{anime.themes
.filter((theme) => "entries" in theme && theme.entries.length && theme.entries[0].videos.length)
.sort(chain(themeTypeComparator, themeSequenceComparator))
.slice(0, maxThemes)
.map((theme) => {
const entry = theme.entries[0];
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
return (
<Link key={theme.slug} to={`/anime/${anime.slug}/${videoSlug}`}>
<Button variant="on-card">
<Button as="span" variant="primary">
<Icon icon={faPlay}/>
</Button>
{theme.slug}
</Button>
</Link>
);
})
}
{anime.themes.length > 4 && (
<Link to={animeLink}>
<Button variant="on-card" title="Show all themes">
<Icon icon={faEllipsisH}/>
</Button>
</Link>
)}
</Flex>
)}
</SummaryCard>
);
}
export const query = graphql`
fragment AnimeCard on Anime {
name
slug
year
season
themes {
type
sequence
slug
}
resources {
site
link
}
images {
facet
link
}
}
fragment AnimeCardThemes on Anime {
themes {
...VideoSlug
}
}
`;
+191
View File
@@ -0,0 +1,191 @@
import { Fragment, type MouseEvent } from "react";
import styled from "styled-components";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { uniqBy } from "lodash-es";
import { Button } from "@/components/button/Button";
import { SummaryCard } from "@/components/card/SummaryCard";
import { Icon } from "@/components/icon/Icon";
import { ThemeTable } from "@/components/table/ThemeTable";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { Collapse } from "@/components/utils/Collapse";
import type { AnimeSummaryCardAnimeExpandableFragment, AnimeSummaryCardAnimeFragment } from "@/generated/graphql";
import useMediaQuery from "@/hooks/useMediaQuery";
import useToggle from "@/hooks/useToggle";
import theme from "@/theme";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledThemeContainerInline = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
user-select: none;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledExpandButton = styled(Button)`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const StyledThemeGroupContainer = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 8px;
`;
type AnimeSummaryCardProps =
| {
anime: AnimeSummaryCardAnimeFragment;
expandable?: false;
}
| {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
expandable: true;
};
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniqBy(
anime.themes.map((theme) => theme.group),
(group) => group?.slug,
);
const animeLink = `/anime/${anime.slug}`;
let premiere = String(anime.year);
let premiereLink = `/year/${anime.year}`;
if (anime.season) {
premiere = anime.season + " " + premiere;
premiereLink += `/${anime.season.toLowerCase()}`;
}
const description = (
<SummaryCard.Description>
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
return (
<StyledWrapper>
<SummaryCard
title={anime.name}
description={description}
image={smallCover}
to={animeLink}
onClick={handleToggleExpand}
{...props}
>
{/* TODO: Remove the following line once the context menu is there. */}
{expandable && (
<StyledThemeContainerInline onClick={(event) => event.stopPropagation()}>
{/* TODO: Context Menu */}
{expandable && (
<StyledExpandButton
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledThemeContainerInline>
)}
</SummaryCard>
{expandable ? (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
<Fragment key={group?.slug}>
{!!group && <Text variant="h2">{group.name}</Text>}
<ThemeTable
themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter(
(theme) => theme.group?.slug === group?.slug,
)}
/>
</Fragment>
))}
</StyledThemeGroupContainer>
</Collapse>
) : null}
</StyledWrapper>
);
}
AnimeSummaryCard.fragments = {
anime: gql`
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
name
year
season
media_format
themes {
group {
name
slug
}
}
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment AnimeSummaryCardAnimeExpandable on Anime {
themes {
...ThemeTableTheme
group {
name
slug
}
}
}
`,
};
+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 }} />;
}
-27
View File
@@ -1,27 +0,0 @@
import useImage from "hooks/useImage";
import { SummaryCard } from "components/card";
export function ArtistSummaryCard({ artist, as }) {
const { smallCover } = useImage(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!artist.performances && (
<span>{artist.performances.length} performance{artist.performances.length === 1 ? "" : "s"}</span>
)}
{!!as && (
<span>As {as}</span>
)}
</SummaryCard.Description>
);
return (
<SummaryCard
title={artist.name}
description={description}
image={smallCover}
to={`/artist/${artist.slug}`}
/>
);
}
+38
View File
@@ -0,0 +1,38 @@
import gql from "graphql-tag";
import { SummaryCard } from "@/components/card/SummaryCard";
import type { ArtistSummaryCardArtistFragment } from "@/generated/graphql";
import extractImages from "@/utils/extractImages";
type ArtistSummaryCardProps = {
artist: ArtistSummaryCardArtistFragment;
as?: string | null;
};
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
const { smallCover } = extractImages(artist);
const description = (
<SummaryCard.Description>
<span>Artist</span>
{!!as && <span>As {as}</span>}
</SummaryCard.Description>
);
return (
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
);
}
ArtistSummaryCard.fragments = {
artist: gql`
fragment ArtistSummaryCardArtist on Artist {
slug
name
images {
link
facet
}
}
`,
};
-23
View File
@@ -1,23 +0,0 @@
import styled, { css } from "styled-components";
import { Box } from "components/box";
import theme from "theme";
export const Card = styled(Box).attrs((props) => ({
p: props.p ?? "1rem 1.5rem",
bg: props.bg || props.backgroundColor || theme.colors["solid"],
borderColor: props.borderColor || theme.colors["text-primary"]
}))`
border-left-width: 4px;
border-left-style: solid;
border-radius: 0 0.5rem 0.5rem 0;
box-shadow: ${theme.shadows.medium};
${(props) => props.hoverable && css`
cursor: pointer;
&:hover {
background-color: ${theme.colors["solid-on-card"]};
}
`}
`;
+45
View File
@@ -0,0 +1,45 @@
import styled, { css } from "styled-components";
import { Solid } from "@/components/box/Solid";
import { withHover } from "@/styles/mixins";
import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Card = styled(Solid)<{
$hoverable?: boolean;
$color?: keyof Colors;
}>`
display: block;
position: relative;
padding: 16px 24px 16px 28px;
border-radius: ${theme.scalars.borderRadiusCard};
overflow: hidden;
box-shadow: ${theme.shadows.medium};
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
${(props) =>
props.$hoverable &&
css`
cursor: pointer;
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`}
`}
&:before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: ${(props) => (props.$color ? theme.colors[props.$color] : theme.colors["text-primary"])};
}
`;
-23
View File
@@ -1,23 +0,0 @@
import { Card } from "components/card";
import { Flex } from "components/box";
import { Text } from "components/text";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
export function ErrorCard({ error }) {
return (
<Card gapsColumn="1.5rem" borderColor="text-warning">
<Flex gapsRow="1rem">
<Text color="text-warning">
<FontAwesomeIcon icon={faExclamation}/>
</Text>
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
</Flex>
<pre>
<Text variant="code" block bg="solid-on-card" overflow="auto">
{JSON.stringify(error, null, 2)}
</Text>
</pre>
</Card>
);
}
+46
View File
@@ -0,0 +1,46 @@
import styled from "styled-components";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
import { Row } from "@/components/box/Flex";
import { Card } from "@/components/card/Card";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
`;
const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
display: block;
background-color: ${theme.colors["solid-on-card"]};
overflow: auto;
`;
interface ErrorCardProps {
error: unknown;
}
export function ErrorCard({ error }: ErrorCardProps) {
return (
<StyledCard $color="text-warning">
<Row style={{ "--gap": "1rem" }}>
<Text color="text-warning">
<Icon icon={faExclamation} />
</Text>
<Text block>
An error occurred while searching! Help improving the site by sending us the following error
message:
</Text>
</Row>
<pre>
<StyledErrorMessage>
{error instanceof Error ? error.message : JSON.stringify(error, null, 2)}
</StyledErrorMessage>
</pre>
</StyledCard>
);
}
@@ -0,0 +1,91 @@
import type { PropsWithChildren } from "react";
import type { ReactNode } from "react";
import styled from "styled-components";
import gql from "graphql-tag";
import { SummaryCard } from "@/components/card/SummaryCard";
import { Text } from "@/components/text/Text";
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "@/generated/graphql";
import theme from "@/theme";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
type PlaylistSummaryCardProps =
| {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
}
| {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
export default function PlaylistSummaryCard({
playlist,
children,
menu,
showOwner,
...props
}: PropsWithChildren<PlaylistSummaryCardProps>) {
const description = (
<SummaryCard.Description>
<span>Playlist</span>
{showOwner ? <Text link>{playlist.user.name}</Text> : <span>{playlist.visibility}</span>}
<span>
{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}
</span>
</SummaryCard.Description>
);
return (
<StyledWrapper>
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
{children}
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
PlaylistSummaryCard.fragments = {
playlist: gql`
fragment PlaylistSummaryCardPlaylist on Playlist {
id
name
visibility
tracks_count
}
`,
showOwner: gql`
fragment PlaylistSummaryCardShowOwner on Playlist {
user {
name
}
}
`,
};
+52
View File
@@ -0,0 +1,52 @@
import { type SyntheticEvent, useState } from "react";
import type { Property } from "csstype";
import gql from "graphql-tag";
import { SummaryCard } from "@/components/card/SummaryCard";
import type { StudioSummaryCardStudioFragment } from "@/generated/graphql";
import extractBackgroundColor from "@/utils/extractBackgroundColor";
import extractImages from "@/utils/extractImages";
interface StudioSummaryCardProps {
studio: StudioSummaryCardStudioFragment;
}
export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
const [backgroundColor, setBackgroundColor] = useState<Property.Background>();
function handleLoad(event: SyntheticEvent<HTMLImageElement>) {
const image = event.currentTarget;
const color = extractBackgroundColor(image);
if (color) {
setBackgroundColor(color);
}
}
return (
<SummaryCard
key={studio.slug}
title={studio.name}
description="Studio"
to={`/studio/${studio.slug}`}
image={extractImages(studio).largeCover}
imageProps={{
$objectFit: "contain",
$backgroundColor: backgroundColor,
onLoad: handleLoad,
}}
/>
);
}
StudioSummaryCard.fragments = {
studio: gql`
${extractImages.fragments.resourceWithImages}
fragment StudioSummaryCardStudio on Studio {
slug
name
...extractImagesResourceWithImages
}
`,
};
-73
View File
@@ -1,73 +0,0 @@
import { Link, withPrefix } from "gatsby";
import { Text } from "components/text";
import styled, { css, keyframes } from "styled-components";
import { Card } from "components/card";
import { Flex } from "components/box";
const loadingAnimation = keyframes`
0% { background-position: 0 0; }
100% { background-position: 100% 100%; }
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})`
width: 48px;
height: 64px;
object-fit: cover;
background: radial-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.25)) no-repeat;
background-size: 500% 500%;
@media (prefers-reduced-motion: no-preference) {
animation: ${loadingAnimation} 2s infinite alternate linear;
}
${(props) => props.placeholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
`;
export function SummaryCard({ title, description, image, to, children, ...props }) {
return (
<Card display="flex" flexDirection="row" alignItems="center" p={0} pr="1rem" height="64px" {...props}>
<Link to={to}>
<StyledCover alt="Cover" src={image || withPrefix("/img/logo.svg")} placeholder={!image} loading="lazy"/>
</Link>
<Flex flex={1} flexDirection="column" justifyContent="center" gapsColumn="0.25rem" px="1rem">
<Text fontWeight="600" maxLines={1}>
{typeof title === "string" ? (
<Link to={to}>
<Text link>{title}</Text>
</Link>
) : title}
</Text>
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[ description ]}
</SummaryCard.Description>
) : description}
</Text>
</Flex>
{children}
</Card>
);
}
SummaryCard.Description = function SummaryCardDescription({ children }) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styled, { css } from "styled-components";
import Link from "next/link";
import type { Property } from "csstype";
import { Column } from "@/components/box/Flex";
import { Card } from "@/components/card/Card";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { ConditionalWrapper } from "@/components/utils/ConditionalWrapper";
import { loadingAnimation } from "@/styles/mixins";
import withBasePath from "@/utils/withBasePath";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy",
})<{
$objectFit?: Property.ObjectFit;
$backgroundColor?: Property.Background;
$isLoading?: boolean;
$isPlaceholder?: boolean;
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.$objectFit ?? "cover"};
background: ${(props) => props.$backgroundColor};
${(props) =>
props.$isPlaceholder
? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
`
: props.$isLoading
? loadingAnimation
: null}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
interface SummaryCardProps extends Omit<ComponentPropsWithoutRef<typeof StyledSummaryCard>, "title"> {
title: string | ReactNode;
description?: string | ReactNode;
image?: string;
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>;
to?: string;
children?: ReactNode;
}
export function SummaryCard({ title, description, image, imageProps, to, children, ...props }: SummaryCardProps) {
const [imageNotFound, setImageNotFound] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
return (
<StyledSummaryCard {...props}>
<ConditionalWrapper condition={!!to} wrap={(children) => <Link href={to as string}>{children}</Link>}>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
$isLoading={imageLoading}
$isPlaceholder={!image || imageNotFound}
loading="lazy"
{...imageProps}
onLoad={(event) => {
setImageLoading(false);
if (imageProps?.onLoad) {
imageProps.onLoad(event);
}
}}
onError={(event) => {
setImageNotFound(true);
setImageLoading(false);
if (imageProps?.onError) {
imageProps.onError(event);
}
}}
/>
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" && to ? <TextLink href={to}>{title}</TextLink> : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>{[description]}</SummaryCard.Description>
) : (
description
)}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
type SummaryCardDescriptionProps = {
children: Array<ReactNode>;
};
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<>
{children
.filter((child) => child)
.map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && <span> &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);
}
}}
/>
);
};
-51
View File
@@ -1,51 +0,0 @@
import {
StyledHeader,
StyledRow,
StyledSequence,
StyledThemeCard,
StyledVersion,
StyledVideoList,
StyledVideoListContainer
} from "./ThemeDetailCard.style";
import { VideoButton } from "components/button";
import { SongTitleWithArtists, ThemeEntryTags } from "components/utils";
import { Text } from "components/text";
export function ThemeDetailCard({ theme }) {
return (
<StyledThemeCard>
<StyledRow>
<StyledSequence>{theme.slug}</StyledSequence>
<StyledHeader>
<SongTitleWithArtists song={theme.song}/>
</StyledHeader>
</StyledRow>
{theme.entries.map(entry => (
<StyledVersion key={entry.version || 0}>
<StyledRow>
<StyledSequence secondary>{!!entry.version && `v${entry.version}`}</StyledSequence>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
</StyledRow>
{!!entry.videos && (
<StyledVideoListContainer>
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={theme.anime}
theme={theme}
entry={entry}
video={video}
variant="on-card"
/>
))}
</StyledVideoList>
</StyledVideoListContainer>
)}
</StyledVersion>
))}
</StyledThemeCard>
);
}
@@ -1,60 +0,0 @@
import styled, { css } from "styled-components";
import { Flex } from "components/box";
import { Text } from "components/text";
import { gapsColumn } from "styles/mixins";
import { Card } from "components/card";
export const StyledThemeCard = styled(Card)`
${gapsColumn()}
`;
export const StyledRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;
export const StyledSequence = styled(Text).attrs((props) => ({
variant: "small",
color: props.secondary ? "text-muted" : "text"
}))`
display: flex;
width: 2.5rem;
${(props) => props.secondary && css`
@media (max-width: 720px) {
align-self: flex-start;
}
`}
`;
export const StyledHeader = styled.div`
flex: 1;
`;
export const StyledVersion = styled.div`
display: flex;
flex-direction: row;
align-items: center;
@media (max-width: 720px) {
flex-direction: column;
align-items: flex-start;
${gapsColumn()}
}
`;
export const StyledVideoListContainer = styled.div`
flex: 1;
`;
export const StyledVideoList = styled(Flex).attrs({
flexWrap: "wrap",
gapsBoth: "0.75rem"
})`
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
+136
View File
@@ -0,0 +1,136 @@
import styled from "styled-components";
import gql from "graphql-tag";
import { Row } from "@/components/box/Flex";
import { VideoButton } from "@/components/button/VideoButton";
import { Card } from "@/components/card/Card";
import { ThemeMenu } from "@/components/menu/ThemeMenu";
import { ThemeEntryTags } from "@/components/tag/ThemeEntryTags";
import { VideoTags } from "@/components/tag/VideoTags";
import { Text } from "@/components/text/Text";
import { Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import type { ThemeDetailCardThemeFragment } from "@/generated/graphql";
import theme from "@/theme";
import { entryVersionComparator } from "@/utils/comparators";
const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const StyledRow = styled.div`
display: grid;
grid-template-columns: 4ch 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
interface ThemeDetailCardProps {
theme: ThemeDetailCardThemeFragment;
}
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
const { anime } = theme;
if (!anime) {
return null;
}
return (
<StyledThemeCard>
<StyledRow>
<Text variant="small" color="text">
{theme.type}
{theme.sequence || null}
</Text>
<Text>
<SongTitle song={theme.song} />
<Performances song={theme.song} expandable />
</Text>
<ThemeMenu theme={theme} />
</StyledRow>
{theme.entries.sort(entryVersionComparator).map((entry) => (
<StyledRow key={entry.version || 0}>
<Text variant="small" color="text-muted">
{!!entry.version && `v${entry.version}`}
</Text>
<Text color="text-muted">
<ThemeEntryTags entry={entry} />
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
ThemeDetailCard.fragments = {
theme: gql`
${ThemeMenu.fragments.theme}
${VideoTags.fragments.video}
fragment ThemeDetailCardTheme on Theme {
...ThemeMenuTheme
type
sequence
group {
name
slug
}
anime {
slug
name
}
song {
title
performances {
alias
as
artist {
slug
name
}
}
}
entries {
version
episodes
spoiler
nsfw
videos {
...VideoTagsVideo
filename
tags
}
}
}
`,
};
-44
View File
@@ -1,44 +0,0 @@
import { Link } from "gatsby";
import { SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist }) {
const { smallCover } = useImage(theme.anime);
if (!theme.entries.length) {
return null;
}
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.slug}</span>
<Link to={`/anime/${theme.anime.slug}`}>
<Text link>{theme.anime.name}</Text>
</Link>
</SummaryCard.Description>
);
return (
<SummaryCard
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
description={description}
image={smallCover}
to={to}
/>
);
}
+272
View File
@@ -0,0 +1,272 @@
import type { MouseEvent, PropsWithChildren } from "react";
import styled from "styled-components";
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { Button } from "@/components/button/Button";
import { SummaryCard } from "@/components/card/SummaryCard2";
import { Icon } from "@/components/icon/Icon";
import { ThemeMenu } from "@/components/menu/ThemeMenu";
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "@/components/table/Table";
import { ThemeTable } from "@/components/table/ThemeTable";
import { Text } from "@/components/text/Text";
import { TextLink } from "@/components/text/TextLink";
import { Collapse } from "@/components/utils/Collapse";
import { getDisplayedArtistName, Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
import type {
ThemeSummaryCardArtistFragment,
ThemeSummaryCardQuery,
ThemeSummaryCardThemeExpandableFragment,
ThemeSummaryCardThemeFragment,
} from "@/generated/graphql";
import useMediaQuery from "@/hooks/useMediaQuery";
import useToggle from "@/hooks/useToggle";
import { fetchDataClient } from "@/lib/client";
import theme from "@/theme";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledExpandButton = styled(Button)`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const StyledPerformedWith = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
`;
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
type ThemeSummaryCardProps =
| {
theme: ThemeSummaryCardThemeFragment;
artist?: ThemeSummaryCardArtistFragment;
expandable?: false;
onPlay?(entryIndex?: number, videoIndex?: number): void;
}
| {
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment;
artist?: ThemeSummaryCardArtistFragment;
expandable: true;
onPlay?(entryIndex?: number, videoIndex?: number): void;
};
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({
theme,
artist,
children,
expandable,
onPlay,
...props
}: PropsWithChildren<ThemeSummaryCardProps>) {
const [isExpanded, toggleExpanded] = useToggle();
const isMobile = useIsMobile();
const anime = theme.anime;
const entry = theme.entries[0];
const video = entry?.videos[0];
if (!anime || !entry || !video) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
function handleToggleExpand(event: MouseEvent, filterLinks = false) {
if (filterLinks && isLink(event.target)) {
return;
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
function isLink(element: EventTarget | null): boolean {
if (!element || !(element instanceof HTMLElement)) {
return false;
} else if (element instanceof HTMLAnchorElement && element.href) {
return true;
}
return isLink(element.parentElement);
}
return (
<StyledWrapper>
<SummaryCard {...props}>
<Link href={href} onClick={() => onPlay?.()}>
<SummaryCard.Cover src={smallCover} />
</Link>
<SummaryCard.Body onClick={(event) => handleToggleExpand(event, true)}>
<SummaryCard.Title>
<SongTitle song={theme.song} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={artist} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>
{theme.type}
{theme.sequence || null}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons>
<ThemeMenu theme={theme} />
{expandable && (
<StyledExpandButton
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledOverlayButtons>
</SummaryCard>
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable
themes={[theme]}
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
/>
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<TableHead>
<TableHeadCell>Performed {artist ? "With" : "By"}</TableHeadCell>
</TableHead>
<TableBody>
{theme.song?.performances
?.filter((performance) => performance.artist.slug !== artist?.slug)
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
.map((performance) => (
<TableRow
key={performance.artist.slug}
as={Link}
href={`/artist/${performance.artist.slug}`}
>
<TableCell>
<Text color="text-primary" weight="600">
{getDisplayedArtistName(performance)}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</StyledPerformedWith>
</Collapse>
)}
</StyledWrapper>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragments.theme}
fragment ThemeSummaryCardTheme on Theme {
...createVideoSlugTheme
...ThemeMenuTheme
type
sequence
group {
name
slug
}
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
entries {
...createVideoSlugEntry
videos {
...createVideoSlugVideo
}
}
}
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCardArtist on Artist {
...SongTitleWithArtistsArtist
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment ThemeSummaryCardThemeExpandable on Theme {
...ThemeTableTheme
}
`,
};
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
export const fetchThemeSummaryCardData = async function (id: number): Promise<FetchThemeSummaryCardData> {
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(
gql`
${ThemeSummaryCard.fragments.theme}
query ThemeSummaryCard($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCardTheme
anime {
year
season
}
}
}
`,
{ themeId: id },
).then((result) => result.data?.theme ?? null);
};
+166
View File
@@ -0,0 +1,166 @@
import { type ReactNode, type Ref } from "react";
import styled from "styled-components";
import Link from "next/link";
import { faPlay } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { SummaryCard } from "@/components/card/SummaryCard2";
import { Icon } from "@/components/icon/Icon";
import { VideoMenu } from "@/components/menu/VideoMenu";
import { TextLink } from "@/components/text/TextLink";
import { Performances } from "@/components/utils/Performances";
import { SongTitle } from "@/components/utils/SongTitle";
import { SongTitleWithArtists } from "@/components/utils/SongTitleWithArtists";
import type { VideoSummaryCardEntryFragment, VideoSummaryCardVideoFragment } from "@/generated/graphql";
import theme from "@/theme";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover &, &:has([data-state="open"]) {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledCoverLink = styled(Link)`
position: relative;
`;
const StyledCoverOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
`;
interface VideoSummaryCardProps {
ref?: Ref<HTMLDivElement>;
video: VideoSummaryCardVideoFragment;
entry: VideoSummaryCardEntryFragment;
menu?: ReactNode;
append?: ReactNode;
onPlay?(): void;
isPlaying?: boolean;
}
export function VideoSummaryCard({
ref,
video,
entry,
menu,
append,
onPlay,
isPlaying,
...props
}: VideoSummaryCardProps) {
const theme = entry.theme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>
{videoSlug}
{theme.group && ` (${theme.group.name})`}
</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu !== null ? (
<StyledOverlayButtons>{menu ?? <VideoMenu entry={entry} video={video} />}</StyledOverlayButtons>
) : null}
{append}
</SummaryCard>
</StyledWrapper>
);
}
export const VideoSummaryCardFragmentVideo = gql`
${createVideoSlug.fragments.video}
${VideoMenu.fragments.video}
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
...VideoMenuVideo
audio {
basename
}
}
`;
export const VideoSummaryCardFragmentEntry = gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${VideoMenu.fragments.entry}
fragment VideoSummaryCardEntry on Entry {
...createVideoSlugEntry
...VideoMenuEntry
id
theme {
...createVideoSlugTheme
id
type
sequence
group {
name
slug
}
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
`;
-7
View File
@@ -1,7 +0,0 @@
export { Card } from "./Card";
export { AnimeSummaryCard } from "./AnimeSummaryCard";
export { ThemeSummaryCard } from "./ThemeSummaryCard";
export { ArtistSummaryCard } from "./ArtistSummaryCard";
export { SummaryCard } from "./SummaryCard";
export { ThemeDetailCard } from "./ThemeDetailCard";
export { ErrorCard } from "./ErrorCard";
@@ -1,11 +1,8 @@
import styled from "styled-components";
import { space } from "styled-system";
export const Container = styled.div`
margin: 0 auto;
padding: 1.5rem 1rem;
padding: 24px 16px;
width: 100%;
max-width: 1100px;
${space}
`;
@@ -1,11 +0,0 @@
import styled from "styled-components";
export const SidebarContainer = styled.div`
display: grid;
grid-template-columns: 1fr 3fr;
grid-gap: 2rem 1.5rem;
@media (max-width: 720px) {
grid-template-columns: 1fr;
}
`;

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