105 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
385 changed files with 43127 additions and 19023 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_URL=https://staging.animethemes.moe
+41 -45
View File
@@ -1,47 +1,43 @@
{
"extends": [
"next/core-web-vitals",
"plugin:react/recommended"
],
"rules": {
"semi": [
"error",
"always"
],
"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": [
"error",
{
"patterns": [
"components/*/*"
]
}
],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": "error",
// For now we don't habe prop-types validation
"react/prop-types": "off"
}
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
"parserOptions": {
"project": ["./tsconfig.json"]
},
"ignorePatterns": ["src/generated"],
"rules": {
// Sort imports
"simple-import-sort/imports": [
"error",
{
"groups": [
// Packages that are used in almost all files.
["^react$", "^react\\u0000$", "^styled-components", "^next"],
// All other packages.
["^@?\\w"],
// Absolute imports and other imports such as Vue-style `@/foo`.
// Anything not matched in another group.
["^"],
// Side effect imports.
["^\\u0000"]
]
}
],
"simple-import-sort/exports": "error",
// TypeScript rules
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-empty-interface": [
"error",
{
"allowSingleExtends": true
}
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
"@typescript-eslint/consistent-type-exports": "error"
}
}
+6
View File
@@ -29,10 +29,16 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.npmrc
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
next.config.mjs
# IDE (WebStorm)
*.iml
/.idea
+4
View File
@@ -0,0 +1,4 @@
npm run graphql-codegen
git add src/generated
npm run lint
npm run type-check
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"printWidth": 120
}
+54 -20
View File
@@ -7,29 +7,63 @@
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory.
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or beta URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
```
To get to use all features of the client, you may configure some additional variables.
This is a list of all available options:
```ini
; Back-end API configuration
; These values are required.
; ===== Server-side =====
; The URL to the AnimeThemes API which will be used on the server.
ANIMETHEMES_API_URL=http://localhost
ANIMETHEMES_API_KEY=...
; (Optional) The URL to the AnimeThemes API to use on the front-end.
; For development this is "http://localhost" by default.
; For production this is "https://staging.animethemes.moe" by default.
NEXT_PUBLIC_API_URL=http://localhost
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
; (Optional) The URL from which video files should be served.
; By default this is "https://animethemes.moe".
NEXT_PUBLIC_VIDEO_URL=https://animethemes.moe
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
; (Optional) The base path the app should be hosted on.
; By default the app is hosted on the root path (/).
; ===== 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).
@@ -48,12 +82,12 @@ For more information on environment variables see the [Next.js documentation](ht
## Used technologies
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
- [Next.js](https://www.nextjs.org/)
- [styled-components](https://styled-components.com/)
- [react-query](https://react-query.tanstack.com/)
- [Font Awesome](https://fontawesome.com/)
- ...some other small packages, see the `package.json`.
### APIs
- [AnimeThemes API](https://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 -3
View File
@@ -1,5 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src"
}
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
}
}
-18
View File
@@ -1,18 +0,0 @@
const { info } = require("next/dist/build/output/log");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
if (process.env.NEXT_PUBLIC_STAGING) {
info("Running animethemes-web in staging mode!");
}
module.exports = withBundleAnalyzer({
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600
});
+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;
+14137 -7859
View File
File diff suppressed because it is too large Load Diff
+90 -43
View File
@@ -1,45 +1,92 @@
{
"name": "animethemes-web",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@graphql-tools/merge": "^8.2.1",
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@reach/listbox": "^0.16.2",
"@reach/menu-button": "^0.16.2",
"common-tags": "^1.8.0",
"framer-motion": "^6.2.1",
"graphql": "^15.6.1",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"lodash-es": "^4.17.21",
"marked": "^4.0.12",
"next": "^12.1.6",
"p-limit": "^3.1.0",
"prismjs": "^1.27.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-query": "^3.29.0",
"rehype-parse": "^8.0.4",
"rehype-react": "^7.0.4",
"sass": "^1.43.4",
"styled-components": "^5.3.3",
"unified": "^10.1.2"
},
"devDependencies": {
"eslint": "7.32.0",
"eslint-config-next": "^12.0.8"
}
"name": "animethemes-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"predev": "npm run compile-config",
"prebuild": "npm run compile-config",
"prestart": "npm run compile-config",
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
"lint": "next lint",
"type-check": "tsc",
"graphql-codegen": "graphql-codegen --config codegen.ts",
"prepare": "husky",
"postinstall": "patch-package"
},
"dependencies": {
"@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",
"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": {
"@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;
+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>
);
}
@@ -1,14 +1,23 @@
import styled from "styled-components";
const Flex = styled.div`
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);
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const Solid = styled.div`
background-color: ${theme.colors["solid"]};
-2
View File
@@ -1,2 +0,0 @@
export { Row, Column } from "./Flex";
export { Solid } from "./Solid";
+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>
);
}
-106
View File
@@ -1,106 +0,0 @@
import styled, { css } from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
import { forwardRef } from "react";
export const Button = forwardRef(ButtonWithRef);
function ButtonWithRef({
children,
variant = "solid",
isCircle = false,
disabled = false,
title,
...props
}, ref) {
let Component;
if (variant === "solid") {
Component = SolidButton;
} else if (variant === "primary") {
Component = PrimaryButton;
} else if (variant === "silent") {
Component = SilentButton;
} else {
throw new Error(`Unknown button variant "${variant}"!`);
}
return (
<Component
$isCircle={isCircle}
disabled={disabled}
title={title}
aria-label={title}
ref={ref}
{...props}
>{children}</Component>
);
}
const BaseButton = styled.button`
--gap: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
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;
}
`;
const PrimaryButton = styled(BaseButton)`
background-color: ${theme.colors["solid-primary"]};
color: ${theme.colors["text-on-primary"]};
`;
const SolidButton = styled(BaseButton)`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
${withHover(css`
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(css`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text"]};
`)}
&:not(:hover) {
box-shadow: none;
}
${Solid} & {
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
`)}
}
`;
+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,8 +0,0 @@
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { IconTextButton } from "components/button";
export function FilterToggleButton(props) {
return (
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
);
}
@@ -0,0 +1,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>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
import styled, { css } from "styled-components";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
const StyledButton = styled(Button)`
gap: 8px;
`;
const StyledText = styled.span`
${(props) => props.collapsible && css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`}
`;
export function IconTextButton({ icon, children, collapsible, ...props }) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const isCollapsed = collapsible && isMobile;
return (
<StyledButton variant="silent" isCircle={isCollapsed} {...props}>
<Icon icon={icon} color="text-disabled"/>
<StyledText collapsible={collapsible}>{children}</StyledText>
</StyledButton>
);
}
+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 { useContext } from "react";
import Link from "next/link";
import { faCompactDisc, faPlay } from "@fortawesome/free-solid-svg-icons";
import { VideoTags } from "components/utils";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
export function VideoButton({ anime, theme, entry, video, ...props }) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo && currentVideo.filename === video.filename;
return (
<Link href={`/anime/${anime.slug}/${videoSlug}`} passHref prefetch={false}>
<Button as="a" {...props}>
<Button as="span" variant="primary" isCircle>
<Icon icon={isPlaying ? faCompactDisc : faPlay} spin={isPlaying}/>
</Button>
<VideoTags video={video} hideTextOnMobile/>
</Button>
</Link>
);
}
+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
}
`,
};
-4
View File
@@ -1,4 +0,0 @@
export { Button } from "./Button";
export { VideoButton } from "./VideoButton";
export { FilterToggleButton } from "./FilterToggleButton";
export { IconTextButton } from "./IconTextButton";
@@ -1,22 +1,25 @@
import { Fragment } from "react";
import Link from "next/link";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import { Icon } from "components/icon";
import { SummaryCard } from "components/card";
import { Fragment, type MouseEvent } from "react";
import styled from "styled-components";
import useToggle from "hooks/useToggle";
import theme from "theme";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { uniq } from "lodash-es";
import { Collapse } from "components/utils";
import { ThemeTable } from "components/table";
import useMediaQuery from "hooks/useMediaQuery";
import { 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
position: relative;
`;
const StyledThemeContainerInline = styled.div`
@@ -29,7 +32,7 @@ const StyledThemeContainerInline = styled.div`
right: 16px;
opacity: 0;
transition-property: opacity;
user-select: none;
${StyledWrapper}:hover & {
@@ -37,7 +40,7 @@ const StyledThemeContainerInline = styled.div`
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
@@ -57,12 +60,25 @@ const StyledThemeGroupContainer = styled.div`
margin-top: 8px;
`;
export function AnimeSummaryCard({ anime, previewThemes = false, expandable = false, ...props }) {
type AnimeSummaryCardProps =
| {
anime: AnimeSummaryCardAnimeFragment;
expandable?: false;
}
| {
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
expandable: true;
};
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(anime);
const { smallCover } = extractImages(anime);
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const groups = uniq(anime.themes.map((theme) => theme.group));
const groups = uniqBy(
anime.themes.map((theme) => theme.group),
(group) => group?.slug,
);
const animeLink = `/anime/${anime.slug}`;
@@ -73,20 +89,16 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
premiereLink += `/${anime.season.toLowerCase()}`;
}
let description = (
const description = (
<SummaryCard.Description>
<span>Anime</span>
{!!anime.year && (
<Link href={premiereLink} passHref prefetch={false}>
<Text as="a" link>{premiere}</Text>
</Link>
)}
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
<span>{anime.themes.length} themes</span>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
@@ -109,59 +121,71 @@ export function AnimeSummaryCard({ anime, previewThemes = false, expandable = fa
{/* TODO: Context Menu */}
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
<Icon
icon={faChevronDown}
className={isExpanded ? "fa-rotate-180" : undefined}
style={{ transition: "transform 400ms" }}
/>
</StyledExpandButton>
)}
</StyledThemeContainerInline>
)}
</SummaryCard>
{expandable && (
{expandable ? (
<Collapse collapse={!isExpanded}>
<StyledThemeGroupContainer>
{groups.map((group) => (
<Fragment key={group}>
{!!group && (
<Text variant="h2">{group}</Text>
)}
<ThemeTable themes={anime.themes.filter((theme) => theme.group === 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`
${useImage.fragment}
fragment AnimeSummaryCard_anime on Anime {
...useImage_resourceWithImages
${extractImages.fragments.resourceWithImages}
fragment AnimeSummaryCardAnime on Anime {
...extractImagesResourceWithImages
slug
name
year
season
media_format
themes {
group
group {
name
slug
}
}
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment AnimeSummaryCard_anime_expandable on Anime {
fragment AnimeSummaryCardAnimeExpandable on Anime {
themes {
...ThemeTable_theme
group
...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
}
}
`,
};
-24
View File
@@ -1,24 +0,0 @@
import styled, { css } from "styled-components";
import { Solid } from "components/box";
import theme from "theme";
import { withHover } from "styles/mixins";
export const Card = styled(Solid)`
display: block;
padding: 16px 24px;
border-color: ${(props) => theme.colors[props.color] || props.color || theme.colors["text-primary"]};
border-left-width: 4px;
border-left-style: solid;
border-radius: 0 8px 8px 0;
box-shadow: ${theme.shadows.medium};
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
`;
+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"])};
}
`;
-39
View File
@@ -1,39 +0,0 @@
import { Card } from "components/card";
import { Row } from "components/box";
import { Text } from "components/text";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
import styled from "styled-components";
import theme from "theme";
const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
border-color: ${theme.colors["text-warning"]};
`;
const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
display: block;
background-color: ${theme.colors["solid-on-card"]};
overflow: auto;
`;
export function ErrorCard({ error }) {
return (
<StyledCard>
<Row style={{ "--gap": "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>
</Row>
<pre>
<StyledErrorMessage>
{JSON.stringify(error, null, 2)}
</StyledErrorMessage>
</pre>
</StyledCard>
);
}
+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
}
`,
};
-96
View File
@@ -1,96 +0,0 @@
import Link from "next/link";
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 0;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})`
width: 48px;
height: 64px;
object-fit: cover;
${loadingAnimation}
${(props) => props.isPlaceholder && css`
padding: 0.5rem;
object-fit: contain;
background: white;
`}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
export function SummaryCard({ title, description, image, to, children, ...props }) {
const [ imageNotFound, setImageNotFound ] = useState(false);
return (
<StyledSummaryCard {...props}>
<Link href={to} prefetch={false}>
<a>
<StyledCover
alt="Cover"
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
isPlaceholder={!image || imageNotFound}
loading="lazy"
onError={() => setImageNotFound(true)}
/>
</a>
</Link>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
<Link href={to} passHref prefetch={false}>
<Text as="a" link>{title}</Text>
</Link>
) : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
{typeof description === "string" ? (
<SummaryCard.Description>
{[description]}
</SummaryCard.Description>
) : description}
</Text>
)}
</StyledBody>
{children}
</StyledSummaryCard>
);
}
SummaryCard.Description = function SummaryCardDescription({ children }) {
return (
<>
{children.filter((child) => child).map((child, index, { length }) => (
<Text key={index} color="text-muted">
<span>{child}</span>
{index < length - 1 && (
<span> &bull; </span>
)}
</Text>
))}
</>
);
};
+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);
}
}}
/>
);
};
-49
View File
@@ -1,49 +0,0 @@
import {
StyledRow,
StyledSequence,
StyledThemeCard,
StyledVideoList,
StyledVideoListContainer
} from "./ThemeDetailCard.style";
import { VideoButton } from "components/button";
import { Performances, SongTitle, ThemeEntryTags } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu";
export function ThemeDetailCard({ theme }) {
return (
<StyledThemeCard>
<StyledRow>
<StyledSequence>{theme.type}{theme.sequence || null}</StyledSequence>
<Text>
<SongTitle song={theme.song}/>
<Performances song={theme.song} expandable/>
</Text>
<ThemeMenu theme={theme}/>
</StyledRow>
{theme.entries.map(entry => (
<StyledRow key={entry.version || 0}>
<StyledSequence secondary>{!!entry.version && `v${entry.version}`}</StyledSequence>
<Text color="text-muted">
<ThemeEntryTags entry={entry}/>
</Text>
<StyledVideoListContainer>
{!!entry.videos && (
<StyledVideoList>
{entry.videos.map((video, index) => (
<VideoButton
key={index}
anime={theme.anime}
theme={theme}
entry={entry}
video={video}
/>
))}
</StyledVideoList>
)}
</StyledVideoListContainer>
</StyledRow>
))}
</StyledThemeCard>
);
}
@@ -1,40 +0,0 @@
import styled from "styled-components";
import { Row } from "components/box";
import { Text } from "components/text";
import { Card } from "components/card";
import theme from "theme";
export const StyledThemeCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 1rem;
`;
export const StyledRow = styled.div`
display: grid;
grid-template-columns: 2rem 1fr auto;
align-items: baseline;
grid-gap: 1rem;
`;
export const StyledSequence = styled(Text).attrs((props) => ({
variant: "small",
color: props.secondary ? "text-muted" : "text"
}))``;
export const StyledVideoListContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-column: 1 / -1;
}
`;
export const StyledVideoList = styled(Row)`
flex-wrap: wrap;
gap: 0.75rem;
@media (min-width: 721px) {
justify-content: flex-end;
}
`;
+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
}
}
}
`,
};
-216
View File
@@ -1,216 +0,0 @@
import Link from "next/link";
import { Collapse, SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import useImage from "hooks/useImage";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
import { ThemeMenu } from "components/menu";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import { Icon } from "components/icon";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { Button } from "components/button";
import useToggle from "hooks/useToggle";
import styled from "styled-components";
import { Table, ThemeTable } from "components/table";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
const StyledWrapper = styled.div`
position: relative
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledExpandButton = styled(Button)`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`;
const StyledPerformedWith = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
`;
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function ThemeSummaryCard({ theme, artist, children, expandable = false, ...props }) {
const [isExpanded, toggleExpanded] = useToggle();
const { smallCover } = useImage(theme.anime);
const isMobile = useIsMobile();
if (!theme.entries.length) {
return null;
}
const entry = theme.entries[0];
if (!entry.videos.length) {
return null;
}
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${theme.anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<Link href={`/anime/${theme.anime.slug}`} passHref prefetch={false}>
<Text as="a" link>{theme.anime.name}</Text>
</Link>
</SummaryCard.Description>
);
function handleToggleExpand(event) {
if (event.target.href) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
return (
<StyledWrapper>
<SummaryCard
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
description={description}
image={smallCover}
to={to}
onClick={handleToggleExpand}
{...props}
>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
{expandable && (
<StyledExpandButton
forwardedAs="a"
variant="silent"
isCircle
title={isExpanded ? "Collapse" : "Expand"}
onClick={handleToggleExpand}
>
<Icon icon={faChevronDown} rotation={isExpanded ? 180 : 0} transition="transform 400ms"/>
</StyledExpandButton>
)}
</StyledOverlayButtons>
</SummaryCard>
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]}/>
{theme.song?.performances?.length > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<Table.Head>
<Table.HeadCell>Performed {artist ? "With" : "By"}</Table.HeadCell>
</Table.Head>
<Table.Body>
{theme.song?.performances
?.filter((performance) => performance.artist.slug !== artist?.slug)
.sort((a, b) => a.artist.name.localeCompare(b.artist.name))
.map((performance) => (
<Link key={performance.artist.slug} href={`/artist/${performance.artist.slug}`} passHref prefetch={false}>
<Table.Row as="a">
<Table.Cell>
<Text color="text-primary" weight="600">
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
</Text>
</Table.Cell>
</Table.Row>
</Link>
))}
</Table.Body>
</Table>
)}
</StyledPerformedWith>
</Collapse>
)}
</StyledWrapper>
);
}
ThemeSummaryCard.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${useImage.fragment}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
${ThemeMenu.fragment}
fragment ThemeSummaryCard_theme on Theme {
...createVideoSlug_theme
...ThemeMenu_theme
slug
type
sequence
group
anime {
...useImage_resourceWithImages
slug
name
}
song {
...SongTitleWithArtists_song
}
entries {
...createVideoSlug_entry
videos {
...createVideoSlug_video
}
}
}
`,
artist: gql`
${SongTitleWithArtists.fragments.artist}
fragment ThemeSummaryCard_artist on Artist {
...SongTitleWithArtists_artist
}
`,
expandable: gql`
${ThemeTable.fragments.theme}
fragment ThemeSummaryCard_theme_expandable on Theme {
...ThemeTable_theme
}
`
};
ThemeSummaryCard.fetchData = async function (id) {
return fetchDataClient(gql`
${ThemeSummaryCard.fragments.theme}
query($themeId: Int!) {
theme(id: $themeId) {
...ThemeSummaryCard_theme
anime {
year
season
}
}
}
`, { themeId: id }).then((result) => result.data?.theme);
};
+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 { SummaryCard } from "./SummaryCard";
export { AnimeSummaryCard } from "./AnimeSummaryCard";
export { ThemeSummaryCard } from "./ThemeSummaryCard";
export { ArtistSummaryCard } from "./ArtistSummaryCard";
export { ThemeDetailCard } from "./ThemeDetailCard";
export { ErrorCard } from "./ErrorCard";
@@ -2,7 +2,7 @@ import styled from "styled-components";
export const Container = styled.div`
margin: 0 auto;
padding: 1.5rem 1rem;
padding: 24px 16px;
width: 100%;
max-width: 1100px;
`;
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const SidebarContainer = styled.div`
display: grid;
@@ -9,4 +10,9 @@ export const SidebarContainer = styled.div`
@media (max-width: ${theme.breakpoints.mobileMax}) {
grid-template-columns: 1fr;
}
// This will prevent columns from overflowing
& > * {
min-width: 0;
}
`;
-2
View File
@@ -1,2 +0,0 @@
export { Container } from "./Container";
export { SidebarContainer } from "./SidebarContainer";
@@ -1,39 +0,0 @@
import styled from "styled-components";
import { Text } from "components/text";
const StyledDescriptionList = styled.dl`
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
`;
const StyledKey = styled.dt`
margin: 0 0 0.25rem 0;
`;
const StyledValue = styled.dd`
margin: 0;
&:not(:last-child) {
margin-bottom: 1.5rem;
}
`;
export function DescriptionList({ children, ...props }) {
return (
<StyledDescriptionList {...props}>
{children}
</StyledDescriptionList>
);
}
DescriptionList.Item = function DescriptionListItem({ title, children }) {
return (
<>
<StyledKey>
<Text as="span" variant="h2">{title}</Text>
</StyledKey>
<StyledValue>{children}</StyledValue>
</>
);
};
@@ -0,0 +1,48 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import styled from "styled-components";
import { Text } from "@/components/text/Text";
const StyledDescriptionList = styled.dl`
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
`;
const StyledKey = styled.dt`
margin: 0 0 0.25rem 0;
`;
const StyledValue = styled.dd`
margin: 0;
&:not(:last-child) {
margin-bottom: 1.5rem;
}
`;
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
children: ReactNode;
}
export function DescriptionList({ children, ...props }: DescriptionListProps) {
return <StyledDescriptionList {...props}>{children}</StyledDescriptionList>;
}
interface DescriptionListItemProps {
title: string;
children: ReactNode;
}
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
return (
<>
<StyledKey>
<Text as="span" variant="h2">
{title}
</Text>
</StyledKey>
<StyledValue>{children}</StyledValue>
</>
);
};
-1
View File
@@ -1 +0,0 @@
export { DescriptionList } from "./DescriptionList";
+78
View File
@@ -0,0 +1,78 @@
import type { RefObject } from "react";
import styled, { keyframes } from "styled-components";
import * as RadixDialog from "@radix-ui/react-dialog";
import { Card } from "@/components/card/Card";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const overlayAnimation = keyframes`
from {
opacity: 0;
}
`;
const contentAnimation = keyframes`
from {
transform: scale(0.9);
opacity: 0;
}
`;
const StyledOverlay = styled(RadixDialog.Overlay)`
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 32px 16px;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
animation: ${overlayAnimation} 250ms;
`;
const StyledDialogCard = styled(Card)`
width: 100%;
max-width: 450px;
animation: ${contentAnimation} 250ms;
margin: auto;
padding: 24px;
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
&:before {
display: none;
}
`;
const StyledHeader = styled(Text).attrs({ variant: "h2", as: RadixDialog.Title })`
margin: -24px -24px 24px -24px;
padding: 16px 24px;
background-color: ${theme.colors["background"]};
`;
interface DialogContentProps extends RadixDialog.DialogContentProps {
ref?: RefObject<HTMLDivElement>;
title?: string;
}
export function DialogContent({ ref, title, children, ...props }: DialogContentProps) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? <StyledHeader>{title}</StyledHeader> : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
export const Dialog = RadixDialog.Root;
export const DialogTrigger = RadixDialog.Trigger;
@@ -0,0 +1,114 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { isAxiosError } from "axios";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function ForgotPasswordDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Text variant="small" link color="text-muted">
Forgot Password?
</Text>
</DialogTrigger>
<DialogContent title="Request a Password Reset">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <ForgotPasswordForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
interface ForgotPasswordProps {
onSuccess(): void;
onCancel(): void;
}
function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
const { forgotPassword } = useAuth();
const { dispatchToast } = useToasts();
const [email, setEmail] = useState("");
const isValid = !!email;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
async function performForgotPassword(event: SyntheticEvent) {
event.preventDefault();
event.stopPropagation();
setBusy(true);
setErrors({});
try {
await forgotPassword({
email,
});
dispatchToast("forgot-password-sent", <Toast>We sent you an e-mail with a password reset link.</Toast>);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performForgotPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? <Text color="text-warning">{errors.email}</Text> : null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Submit</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { ForgotPasswordDialog } from "@/components/dialog/ForgotPasswordDialog";
import { Input } from "@/components/form/Input";
import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth, { type LoginErrors } from "@/hooks/useAuth";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function LoginDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Login</Button>
</DialogTrigger>
<DialogContent title="Login">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <LoginForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
interface LoginFormProps {
onCancel(): void;
}
function LoginForm({ onCancel }: LoginFormProps) {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isRemember, setRemember] = useState(false);
const isValid = email && password;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<LoginErrors>({});
function performLogin(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
login({
setErrors,
email,
password,
remember: isRemember,
}).finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performLogin}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
<ForgotPasswordDialog />
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch id="input-remember" isChecked={isRemember} onCheckedChange={setRemember} />
<Text as="label" htmlFor="input-remember">
Remember my login on this device.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Login</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,151 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { isAxiosError } from "axios";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import axios from "@/lib/client/axios";
import { AUTH_PATH } from "@/utils/config";
export function PasswordChangeDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Change Password</Button>
</DialogTrigger>
<DialogContent title="Change Password">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <PasswordChangeForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordChangeFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
const { dispatchToast } = useToasts();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const isValid = currentPassword && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
current_password?: string[];
password?: string[];
}>({});
async function performChangePassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await axios.put(`${AUTH_PATH}/user/password`, {
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
});
dispatchToast("password-change", <Toast>Password changed successfully.</Toast>);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performChangePassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Current Password</Text>
<Input
value={currentPassword}
onChange={setCurrentPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.current_password
? errors.current_password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Change Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { useRouter } from "next/router";
import { isAxiosError } from "axios";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
export function PasswordResetDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => setOpen(true), []);
return (
<Dialog open={open}>
<DialogContent title="Reset Password">
<PasswordResetForm onSuccess={() => router.push("/profile")} onCancel={() => router.push("/")} />
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordResetFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
const { resetPassword } = useAuth();
const { dispatchToast } = useToasts();
const router = useRouter();
const [email, setEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const token = router.query.token;
const isValid = email && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string[];
password?: string[];
}>({});
async function performResetPassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await resetPassword({
email,
password: newPassword,
password_confirmation: newPasswordConfirmation,
token: (Array.isArray(token) ? token[0] : token) ?? "",
});
dispatchToast("password-reset", <Toast>Password reset successfully.</Toast>);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performResetPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Reset Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
import styled from "styled-components";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { isAxiosError } from "axios";
import { mutate } from "swr";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import axios from "@/lib/client/axios";
interface PlaylistAddDialogProps {
trigger?: ReactNode;
}
export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} collapsible>
New
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Create a new playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistAddForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistAddFormProps {
onSuccess(): void;
onCancel(): void;
}
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState("PUBLIC");
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.post("/playlist", {
name: title,
visibility,
});
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="PUBLIC">Public</ListboxOption>
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
<ListboxOption value="PRIVATE">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning">
<strong>The playlist could not be created: </strong>
{error}
</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,144 @@
import { useState } from "react";
import type { FormEvent, ReactNode } from "react";
import styled from "styled-components";
import { faPen } from "@fortawesome/free-solid-svg-icons";
import { isAxiosError } from "axios";
import gql from "graphql-tag";
import { mutate } from "swr";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import type { PlaylistEditDialogPlaylistFragment } from "@/generated/graphql";
import axios from "@/lib/client/axios";
interface PlaylistEditDialogProps {
playlist: PlaylistEditDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPen} variant="solid">
Edit Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Edit playlist details">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistEditForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistEditDialog.fragments = {
playlist: gql`
fragment PlaylistEditDialogPlaylist on Playlist {
id
name
visibility
}
`,
};
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistEditFormProps {
playlist: PlaylistEditDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
const [title, setTitle] = useState(playlist.name);
const [visibility, setVisibility] = useState<string>(playlist.visibility);
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.put(`/playlist/${playlist.id}`, {
name: title,
visibility,
});
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input value={title} onChange={setTitle} />
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="Public">Public</ListboxOption>
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
<ListboxOption value="Private">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning">
<strong>The playlist could not be updated: </strong>
{error}
</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,114 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { mutate } from "swr";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Text } from "@/components/text/Text";
import { PlaylistRemoveToast } from "@/components/toast/PlaylistRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import type { PlaylistRemoveDialogPlaylistFragment } from "@/generated/graphql";
import axios from "@/lib/client/axios";
interface PlaylistRemoveDialogProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>
Delete playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Delete playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistRemoveForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistRemoveDialog.fragments = {
playlist: gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistRemoveToast.fragments.playlist}
fragment PlaylistRemoveDialogPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistRemoveToastPlaylist
id
name
}
`,
};
interface PlaylistRemoveFormProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removePlaylist() {
setBusy(true);
try {
await axios.delete(`/playlist/${playlist.id}`);
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} finally {
setBusy(false);
}
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>
Do you really want to delete{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<PlaylistSummaryCard playlist={playlist} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
<Busy isBusy={isBusy}>Delete playlist</Busy>
</Button>
</Row>
</Column>
);
}
@@ -0,0 +1,243 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { faArrowDown, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import useSWR, { mutate } from "swr";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { PlaylistAddDialog } from "@/components/dialog/PlaylistAddDialog";
import { Icon } from "@/components/icon/Icon";
import { Skeleton } from "@/components/skeleton/Skeleton";
import { Text } from "@/components/text/Text";
import { PlaylistTrackAddToast } from "@/components/toast/PlaylistTrackAddToast";
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import type {
PlaylistTrackAddDialogEntryFragment,
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery,
PlaylistTrackAddFormPlaylistQueryVariables,
} from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client";
import axios from "@/lib/client/axios";
interface PlaylistTrackAddDialogProps {
video: PlaylistTrackAddDialogVideoFragment;
entry: PlaylistTrackAddDialogEntryFragment;
trigger?: ReactNode;
}
export function PlaylistTrackAddDialog({ video, entry, trigger }: PlaylistTrackAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} variant="solid" collapsible>
Add to Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackAddForm video={video} entry={entry} onCancel={() => setOpen(false)} />
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackAddDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
id
}
`,
entry: gql`
${VideoSummaryCardFragmentEntry}
${PlaylistTrackRemoveToast.fragments.entry}
fragment PlaylistTrackAddDialogEntry on Entry {
...VideoSummaryCardEntry
...PlaylistTrackRemoveToastEntry
id
}
`,
};
interface PlaylistTrackAddFormProps {
video: PlaylistTrackAddDialogVideoFragment;
entry: PlaylistTrackAddDialogEntryFragment;
onCancel(): void;
}
function PlaylistTrackAddForm({ video, entry, onCancel }: PlaylistTrackAddFormProps) {
const { data: playlists } = useSWR(["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id], async () => {
const { data } = await fetchDataClient<
PlaylistTrackAddFormPlaylistQuery,
PlaylistTrackAddFormPlaylistQueryVariables
>(
gql`
${PlaylistSummaryCard.fragments.playlist}
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
me {
playlistAll {
...PlaylistSummaryCardPlaylist
id
tracks_count
}
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
id
tracks {
id
}
}
}
}
`,
{ filterVideoId: video.id },
);
const { playlistAll, playlistAllFiltered } = data.me;
return (
playlistAll?.map((playlist) => {
return {
...playlist,
...playlistAllFiltered?.find((p) => p.id === playlist.id),
};
}) ?? []
);
});
if (!playlists) {
return (
<Column style={{ "--gap": "24px" }}>
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
</Column>
);
}
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} entry={entry} menu={null} />
<Row style={{ "--justify-content": "center" }}>
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
<Column style={{ "--gap": "16px" }}>
{playlists?.length ? (
playlists.map((playlist) => (
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} entry={entry} />
))
) : (
<Text>You have not created a playlist, yet.</Text>
)}
<PlaylistAddDialog
trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
}
/>
</Column>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
</Row>
</Column>
);
}
interface PlaylistTrackAddCardProps {
playlist: NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
video: PlaylistTrackAddDialogVideoFragment;
entry: PlaylistTrackAddDialogEntryFragment;
}
function PlaylistTrackAddCard({ playlist, video, entry }: PlaylistTrackAddCardProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function addTrackToPlaylist() {
setBusy(true);
try {
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id, entry_id: entry.id });
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} entry={entry} />,
);
}
async function removeTrackFromPlaylist() {
const track = playlist.tracks?.[0];
if (!track) {
return;
}
setBusy(true);
try {
await axios.delete(`/playlist/${playlist.id}/track/${track.id}`);
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
);
}
return (
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{!playlist.tracks?.length ? (
<IconTextButton icon={faPlus} disabled={isBusy} onClick={addTrackToPlaylist}>
<Busy isBusy={isBusy}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton icon={faMinus} disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove</Busy>
</IconTextButton>
)}
</PlaylistSummaryCard>
);
}
@@ -0,0 +1,163 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import gql from "graphql-tag";
import { mutate } from "swr";
import { LoginGate } from "@/components/auth/LoginGate";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { IconTextButton } from "@/components/button/IconTextButton";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Text } from "@/components/text/Text";
import { PlaylistTrackRemoveToast } from "@/components/toast/PlaylistTrackRemoveToast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import type {
PlaylistTrackRemoveDialogEntryFragment,
PlaylistTrackRemoveDialogPlaylistFragment,
PlaylistTrackRemoveDialogVideoFragment,
} from "@/generated/graphql";
import axios from "@/lib/client/axios";
interface PlaylistTrackRemoveDialogProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
entry: PlaylistTrackRemoveDialogEntryFragment;
trigger?: ReactNode;
}
export function PlaylistTrackRemoveDialog({
playlist,
trackId,
video,
entry,
trigger,
}: PlaylistTrackRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>
Remove from Playlist
</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Remove from playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackRemoveForm
playlist={playlist}
trackId={trackId}
video={video}
entry={entry}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackRemoveDialog.fragments = {
playlist: gql`
${PlaylistTrackRemoveToast.fragments.playlist}
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
...PlaylistTrackRemoveToastPlaylist
id
name
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
}
`,
entry: gql`
${VideoSummaryCardFragmentEntry}
${PlaylistTrackRemoveToast.fragments.entry}
fragment PlaylistTrackRemoveDialogEntry on Entry {
...VideoSummaryCardEntry
...PlaylistTrackRemoveToastEntry
}
`,
};
interface PlaylistTrackRemoveFormProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
entry: PlaylistTrackRemoveDialogEntryFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistTrackRemoveForm({
playlist,
trackId,
video,
entry,
onSuccess,
onCancel,
}: PlaylistTrackRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removeTrackFromPlaylist() {
setBusy(true);
try {
await axios.delete(`/playlist/${playlist.id}/track/${trackId}`);
await mutate((key) =>
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
);
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} entry={entry} />,
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>
Do you really want to remove this video from{" "}
<Text color="text-primary" link noWrap>
{playlist.name}
</Text>
?
</Text>
<VideoSummaryCard video={video} entry={entry} menu={null} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>
Close
</Button>
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove from playlist</Busy>
</Button>
</Row>
</Column>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import Link from "next/link";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import Switch from "@/components/form/Switch";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useAuth, { type RegisterErrors } from "@/hooks/useAuth";
export function RegisterDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create new account</Button>
</DialogTrigger>
<DialogContent title="Create a new account">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <RegisterForm onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface RegisterFormProps {
onCancel(): void;
}
function RegisterForm({ onCancel }: RegisterFormProps) {
const { register } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [isTermsAccepted, setTermsAccepted] = useState(false);
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<RegisterErrors>({});
function performRegister(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
register({
setErrors,
name: username,
email,
password,
password_confirmation: passwordConfirmation,
terms: isTermsAccepted,
}).finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performRegister}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Username</Text>
<Input
value={username}
onChange={setUsername}
inputProps={{
required: true,
}}
/>
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password
? errors.password.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>Confirm Password</Text>
<Input
value={passwordConfirmation}
onChange={setPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch id="input-terms" isChecked={isTermsAccepted} onCheckedChange={setTermsAccepted} />
<Text as="label" htmlFor="input-terms">
I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>
Terms of Service
</Text>{" "}
and{" "}
<Text as={Link} href="/about/privacy-policy" link>
Privacy Policy
</Text>
.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Account</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { type FormEvent, type ReactNode, useState } from "react";
import styled from "styled-components";
import { Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { SearchFilterGroup } from "@/components/search-filter/SearchFilterGroup";
import { SearchFilterMediaFormat } from "@/components/search-filter/SearchFilterMediaFormat";
import { Switcher, SwitcherOption } from "@/components/switcher/Switcher";
import { Text } from "@/components/text/Text";
import { Busy } from "@/components/utils/Busy";
import useRandomThemes from "@/hooks/useRandomThemes";
interface ShuffleDialogProps {
trigger: ReactNode;
}
export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent title="Shuffle">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <ShuffleForm onCancel={() => setOpen(false)} onSuccess={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
display: flex;
flex-direction: column;
gap: 32px;
`;
interface ShuffleFormProps {
onSuccess(): void;
onCancel(): void;
}
function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
const [isBusy, setBusy] = useState(false);
const [filterThemeType, setFilterThemeType] = useState("");
const [filterMediaFormat, setFilterMediaFormat] = useState<string | null>(null);
const [filterAnimeYearMin, setFilterAnimeYearMin] = useState("");
const [filterAnimeYearMax, setFilterAnimeYearMax] = useState("");
const { playRandomThemes } = useRandomThemes();
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
try {
await playRandomThemes({
themeType: filterThemeType,
mediaFormat: filterMediaFormat ?? undefined,
animeYearMin: parseInt(filterAnimeYearMin),
animeYearMax: parseInt(filterAnimeYearMax),
});
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<SearchFilterGroup>
<SearchFilter>
<Text variant="h2">Theme Type</Text>
<Switcher selectedItem={filterThemeType} onChange={setFilterThemeType}>
<SwitcherOption value="">Any</SwitcherOption>
<SwitcherOption value="OP">Openings</SwitcherOption>
<SwitcherOption value="ED">Endings</SwitcherOption>
</Switcher>
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered After</Text>
<Input
value={filterAnimeYearMin}
onChange={setFilterAnimeYearMin}
inputProps={{ type: "number", placeholder: "1900" }}
/>
</SearchFilter>
<SearchFilter>
<Text variant="h2">Premiered Before</Text>
<Input
value={filterAnimeYearMax}
onChange={setFilterAnimeYearMax}
inputProps={{ type: "number", placeholder: "2100" }}
/>
</SearchFilter>
<SearchFilterMediaFormat value={filterMediaFormat} setValue={setFilterMediaFormat} />
</SearchFilterGroup>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isBusy}>
<Busy isBusy={isBusy}>Start Shuffle</Busy>
</Button>
</Row>
</StyledForm>
);
}
@@ -0,0 +1,141 @@
import { useState } from "react";
import type { SyntheticEvent } from "react";
import styled from "styled-components";
import { isAxiosError } from "axios";
import { mutate } from "swr";
import { Column, Row } from "@/components/box/Flex";
import { Button } from "@/components/button/Button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/dialog/Dialog";
import { Input } from "@/components/form/Input";
import { SearchFilter } from "@/components/search-filter/SearchFilter";
import { Text } from "@/components/text/Text";
import { Toast } from "@/components/toast/Toast";
import { Busy } from "@/components/utils/Busy";
import { useToasts } from "@/context/toastContext";
import useAuth from "@/hooks/useAuth";
import axios from "@/lib/client/axios";
import { AUTH_PATH } from "@/utils/config";
export function UserInformationDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Change User Information</Button>
</DialogTrigger>
<DialogContent title="Change User Information">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? <UserInformationForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface UserInformationFormProps {
onSuccess(): void;
onCancel(): void;
}
function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps) {
const { me } = useAuth();
const { dispatchToast } = useToasts();
const [username, setUsername] = useState(me.user?.name ?? "");
const [email, setEmail] = useState(me.user?.email ?? "");
const isValid = username && email;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
}>({});
async function performChangeUserInformation(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await axios.put(`${AUTH_PATH}/user/profile-information`, {
name: username,
email: email,
});
await mutate((key) => [key].flat().some((key) => key === "/api/me"));
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performChangeUserInformation}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Username</Text>
<Input
value={username}
onChange={setUsername}
inputProps={{
required: true,
}}
/>
{errors.name
? errors.name.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail Address</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email
? errors.email.map((error) => (
<Text key={error} color="text-warning">
{error}
</Text>
))
: null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,88 @@
import styled, { keyframes } from "styled-components";
import Link from "next/link";
import { faArrowRight, faTrophy } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
const EventButton = styled(Button)`
position: relative;
justify-content: space-between;
height: 64px;
border-radius: 8px;
gap: 8px;
overflow: hidden;
`;
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
const GradientBorder = styled.div`
position: absolute;
inset: 0;
border-radius: 8px;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
--gradient-angle: 0deg;
}
to {
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
`;
interface AnimeAwardsNowAvailableProps {
year: number;
}
export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps) {
return (
<EventButtonContainer>
<GradientBorder />
<EventButton asChild>
<Link href="/event/anime-awards">
<EventIcon icon={faTrophy} />
<Text>
<Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!
</Text>
<Icon icon={faArrowRight} color="text-primary" />
</Link>
</EventButton>
</EventButtonContainer>
);
}
@@ -1,15 +0,0 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
export function ExternalLink({ href, children, ...props }) {
return (
<Text as="a" link href={href} target="_blank" rel="noopener" {...props}>
<Text>{children}</Text>
<Text noWrap>
&nbsp;
<Icon icon={faChevronCircleRight}/>
</Text>
</Text>
);
}
@@ -0,0 +1,23 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
href?: string | null;
children: ReactNode;
}
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
return (
<Text as="a" link href={href} target="_blank" rel="noopener" {...props}>
<Text>{children}</Text>
<Text noWrap>
&nbsp;
<Icon icon={faChevronCircleRight} />
</Text>
</Text>
);
}
-1
View File
@@ -1 +0,0 @@
export { ExternalLink } from "./ExternalLink";
@@ -1,220 +0,0 @@
import { FeaturedThemePreview } from "utils/settings";
import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import useImage from "hooks/useImage";
import { useEffect, useState } from "react";
import useSetting from "hooks/useSetting";
import useCompatability from "hooks/useCompatability";
import gql from "graphql-tag";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
const videoBaseUrl = process.env.NEXT_PUBLIC_VIDEO_URL || "https://animethemes.moe";
const slowPan = keyframes`
from {
object-position: top;
}
50% {
object-position: bottom;
}
`;
const slideIn = keyframes`
from {
transform: translateY(100%);
}
to {
transform: translateY(10%);
}
`;
const StyledWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
position: relative;
@media (max-width: ${theme.breakpoints.mobileMax}) {
margin-inline-start: -16px;
margin-inline-end: -16px;
margin-bottom: 32px;
}
`;
const StyledOverflowHidden = styled.a`
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
border-radius: 0;
}
`;
const StyledCenter = styled.div`
position: absolute;
width: 400px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
width: auto;
left: 16px;
right: 16px;
bottom: 0;
transform: translateY(50%);
}
`;
const StyledVideo = styled.video`
width: 100%;
filter: blur(5px);
`;
const StyledCover = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(5px);
animation: ${slowPan} 60s ease-in-out infinite;
`;
const StyledGrillContainer = styled.div`
position: absolute;
height: 130%;
bottom: 0;
right: 32px;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
right: 0;
}
`;
const StyledGrill = styled.img`
max-width: 300px;
height: 100%;
object-fit: contain;
object-position: bottom;
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 1s;
transform: translateY(10%);
&:hover {
transform: none;
}
`;
const Box = styled.div``;
export function FeaturedTheme({ theme }) {
const [ grill, setGrill ] = useState(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
useEffect(() => {
fetchRandomGrill().then(setGrill);
}, []);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
? StyledWrapper
: Box;
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
? (
<StyledCenter>
<ThemeSummaryCard theme={theme}/>
</StyledCenter>
)
: (
<ThemeSummaryCard theme={theme}/>
);
return (
<FeaturedThemeWrapper>
<FeaturedThemeBackground theme={theme}/>
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
<StyledGrillContainer>
<StyledGrill src={grill}/>
</StyledGrillContainer>
)}
{featuredThemeSummaryCard}
</FeaturedThemeWrapper>
);
}
function FeaturedThemeBackground({ theme }) {
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability({ canPlayVideo: false });
const [ fallbackToCover, setFallbackToCover ] = useState(false);
const { smallCover: featuredCover } = 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 linkProps = {
href: `/anime/${theme.anime.slug}/${videoSlug}`,
passHref: true,
prefetch: false
};
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<Link {...linkProps}>
<StyledOverflowHidden>
<StyledVideo
src={`${videoBaseUrl}/video/${video.basename}`}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
/>
</StyledOverflowHidden>
</Link>
);
} else if (featuredThemePreview === FeaturedThemePreview.COVER || fallbackToCover) {
return (
<Link {...linkProps}>
<StyledOverflowHidden>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
</Link>
);
}
return null;
}
FeaturedTheme.fragment = gql`
${ThemeSummaryCard.fragments.theme}
${useImage.fragment}
fragment FeaturedTheme_theme on Theme {
...ThemeSummaryCard_theme
anime {
...useImage_resourceWithImages
}
entries {
videos {
basename
}
}
}
`;
@@ -0,0 +1,216 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import styled, { keyframes } from "styled-components";
import Link from "next/link";
import gql from "graphql-tag";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import type { FeaturedThemeEntryFragment, FeaturedThemeVideoFragment } from "@/generated/graphql";
import useCompatability from "@/hooks/useCompatability";
import useSetting from "@/hooks/useSetting";
import { fetchRandomGrill } from "@/lib/client/randomGrill";
import theme from "@/theme";
import { VIDEO_URL } from "@/utils/config";
import createVideoSlug from "@/utils/createVideoSlug";
import extractImages from "@/utils/extractImages";
import { FeaturedThemePreview } from "@/utils/settings";
const slowPan = keyframes`
from {
object-position: top;
}
50% {
object-position: bottom;
}
`;
const slideIn = keyframes`
from {
transform: translateY(100%);
}
to {
transform: translateY(10%);
}
`;
const StyledWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
position: relative;
@media (max-width: ${theme.breakpoints.mobileMax}) {
margin-inline-start: -16px;
margin-inline-end: -16px;
margin-bottom: 32px;
}
`;
const StyledOverflowHidden = styled(Link)`
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
border-radius: 0;
}
`;
const StyledCenter = styled.div`
position: absolute;
width: 400px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
width: auto;
left: 16px;
right: 16px;
bottom: 0;
transform: translateY(50%);
}
`;
const StyledVideo = styled.video`
width: 100%;
filter: blur(5px);
background-color: ${theme.colors["solid-on-card"]};
`;
const StyledCover = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(5px);
animation: ${slowPan} 60s ease-in-out infinite;
`;
const StyledGrillContainer = styled.div`
position: absolute;
height: 130%;
bottom: 0;
right: 32px;
overflow: hidden;
@media (max-width: ${theme.breakpoints.mobileMax}) {
right: 0;
}
`;
const StyledGrill = styled.img`
max-width: 300px;
height: 100%;
object-fit: contain;
object-position: bottom;
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
transition: transform 1s;
transform: translateY(10%);
&:hover {
transform: none;
}
`;
const Box = styled.div``;
interface FeaturedThemeProps {
entry: FeaturedThemeEntryFragment;
video: FeaturedThemeVideoFragment;
hasGrill?: boolean;
card?: ReactNode;
onPlay?(): void;
}
export function FeaturedTheme({ entry, video, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
const [grill, setGrill] = useState<string | null>(null);
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
useEffect(() => {
if (hasGrill) {
fetchRandomGrill().then(setGrill);
}
}, [hasGrill]);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED ? StyledWrapper : Box;
const featuredThemeSummaryCard =
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
<StyledCenter>{card ?? <VideoSummaryCard entry={entry} video={video} />}</StyledCenter>
) : (
(card ?? <VideoSummaryCard entry={entry} video={video} />)
);
return (
<FeaturedThemeWrapper>
<FeaturedThemeBackground entry={entry} video={video} onPlay={onPlay} />
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
<StyledGrillContainer>
<StyledGrill src={grill} />
</StyledGrillContainer>
)}
{featuredThemeSummaryCard}
</FeaturedThemeWrapper>
);
}
function FeaturedThemeBackground({ entry, video, onPlay }: FeaturedThemeProps) {
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
const { canPlayVideo } = useCompatability();
const [fallbackToCover, setFallbackToCover] = useState(false);
const { smallCover: featuredCover } = extractImages(entry.theme.anime);
const videoSlug = createVideoSlug(entry.theme, entry, video);
const href = `/anime/${entry.theme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledVideo key={video.basename} autoPlay muted loop onError={() => setFallbackToCover(true)}>
<source src={`${VIDEO_URL}/${video.basename}`} type={`video/webm; codecs="vp8, vp9, opus`} />
</StyledVideo>
</StyledOverflowHidden>
);
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<StyledOverflowHidden href={href} onClick={onPlay}>
<StyledCover src={featuredCover} />
</StyledOverflowHidden>
);
}
return null;
}
FeaturedTheme.fragments = {
entry: gql`
${VideoSummaryCardFragmentEntry}
${extractImages.fragments.resourceWithImages}
fragment FeaturedThemeEntry on Entry {
...VideoSummaryCardEntry
theme {
anime {
...extractImagesResourceWithImages
}
}
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
fragment FeaturedThemeVideo on Video {
...VideoSummaryCardVideo
}
`,
};
-1
View File
@@ -1 +0,0 @@
export { FeaturedTheme } from "./FeaturedTheme";
-60
View File
@@ -1,60 +0,0 @@
import { memo, useMemo, useState } from "react";
import { Column, Row } from "components/box";
import { ThemeDetailCard } from "components/card";
import { Listbox } from "components/listbox";
import { HorizontalScroll } from "components/utils";
import { chain, themeIndexComparator, themeTypeComparator } from "utils/comparators";
export const AnimeThemeFilter = memo(function AnimeThemeFilter({ themes }) {
const groups = useMemo(() => themes.reduce((groups, theme) => {
const groupName = theme.group || "Original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
}, []), [ themes ]);
const [ activeGroup, setActiveGroup ] = useState(groups[0].name);
const activeThemes = groups.find((group) => group.name === activeGroup).themes;
const hasMultipleTypes = activeThemes.find((theme) => theme.type === "OP") && activeThemes.find((theme) => theme.type === "ED");
const [ filterType, setFilterType ] = useState(null);
const filteredThemes = activeThemes
.filter((theme) => !filterType || theme.type === filterType)
.sort(chain(themeTypeComparator, themeIndexComparator));
return (
<Column style={{ "--gap": "16px" }}>
{(groups.length > 1 || hasMultipleTypes) && (
<HorizontalScroll fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onChange={setActiveGroup}>
{groups.map((group) => (
<Listbox.Option key={group.name} value={group.name}>{group.name}</Listbox.Option>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onChange={setFilterType} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>OP & ED</Listbox.Option>
<Listbox.Option value="OP">OP</Listbox.Option>
<Listbox.Option value="ED">ED</Listbox.Option>
</Listbox>
)}
</Row>
</HorizontalScroll>
)}
{filteredThemes.map((theme, index) => (
<ThemeDetailCard key={index} theme={theme} />
))}
</Column>
);
});
+134
View File
@@ -0,0 +1,134 @@
import { memo, useMemo, useState } from "react";
import gql from "graphql-tag";
import { Column, Row } from "@/components/box/Flex";
import { ThemeDetailCard } from "@/components/card/ThemeDetailCard";
import { Listbox, ListboxOption } from "@/components/listbox/Listbox";
import { Text } from "@/components/text/Text";
import { HorizontalScroll } from "@/components/utils/HorizontalScroll";
import type { AnimeThemeFilterThemeFragment } from "@/generated/graphql";
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>;
}
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
const [filterType, setFilterType] = useState<string | null>(null);
const filteredThemes = themes
.filter((theme) => !filterType || theme.type === filterType)
.sort(either(themeGroupComparator).or(themeTypeComparator).or(themeIndexComparator).chain());
const groups = useMemo(
() =>
filteredThemes.reduce<
{
name: string;
slug: string;
themes: typeof themes;
}[]
>((groups, theme) => {
const groupName = theme.group?.name || "Original";
const groupSlug = theme.group?.slug || "original";
const group = groups.find((group) => group.name === groupName);
if (!group) {
groups.push({
name: groupName,
slug: groupSlug,
themes: [theme],
});
} else {
group.themes.push(theme);
}
return groups;
}, []),
[filteredThemes],
);
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const activeGroupThemes = groups.find((group) => group.slug === activeGroup)?.themes;
return (
<Column style={{ "--gap": "24px" }}>
{(groups.length > 1 || hasMultipleTypes) && (
<HorizontalScroll $fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox
value={activeGroup}
onValueChange={setActiveGroup}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
All Groups
</ListboxOption>
{groups.map((group) => (
<ListboxOption key={group.slug} value={group.slug}>
{group.name}
</ListboxOption>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox
value={filterType}
onValueChange={setFilterType}
defaultValue={null}
resettable
nullable
highlightNonDefault
>
<ListboxOption value={null} hidden>
OP & ED
</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
)}
</Row>
</HorizontalScroll>
)}
<Column style={{ "--gap": "48px" }}>
{activeGroupThemes ? (
<Column style={{ "--gap": "16px" }}>
{activeGroupThemes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
) : (
groups.map((group) => (
<Column key={group.slug} style={{ "--gap": "16px" }}>
{groups.length > 1 && <Text variant="h3">{group.name}</Text>}
{group.themes.map((theme) => (
<ThemeDetailCard key={theme.id} theme={theme} />
))}
</Column>
))
)}
</Column>
</Column>
);
}
AnimeThemeFilterInternal.fragments = {
theme: gql`
${ThemeDetailCard.fragments.theme}
fragment AnimeThemeFilterTheme on Theme {
...ThemeDetailCardTheme
type
group {
name
slug
}
}
`,
};
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
-1
View File
@@ -1 +0,0 @@
export { AnimeThemeFilter } from "./AnimeThemeFilter";
@@ -1,13 +1,15 @@
import Link from "next/link";
import { Column, Row, Solid } from "components/box";
import { Container } from "components/container";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import { forwardRef } from "react";
import Link from "next/link";
import { faDiscord, faGithub, faReddit, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { Column, Row } from "@/components/box/Flex";
import { Solid } from "@/components/box/Solid";
import { Button } from "@/components/button/Button";
import { Container } from "@/components/container/Container";
import { Icon } from "@/components/icon/Icon";
import { Text } from "@/components/text/Text";
import theme from "@/theme";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -34,61 +36,64 @@ const StyledSocialList = styled(Row)`
flex-basis: 100%;
justify-content: flex-start;
}
// To avoid overlap with scroll back to top button as window width gets smaller
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width: ${theme.breakpoints.mobileMax}) {
margin-right: 64px;
}
`;
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
font-size: 1.2rem;
`;
const FooterLink = styled(Text).attrs({ as: Link })``;
const FooterTextLink = styled(FooterLink).attrs({
link: true,
block: true,
color: "text-muted",
noWrap: true,
})``;
export function Footer() {
return (
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/transparency">
Transparency
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
<FooterTextLink as="a" href="https://api-docs.animethemes.moe" target="_blank">
API Documentation
</FooterTextLink>
<Link href="/wiki/donate" passHref>
<FooterTextLink target="_self">
Donate
</FooterTextLink>
</Link>
<Link href="/wiki/faq" passHref>
<FooterTextLink target="_self">
FAQ
</FooterTextLink>
</Link>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href="https://staging.animethemes.moe/terms-of-service">
Terms of Service
</FooterTextLink>
<FooterTextLink href="https://staging.animethemes.moe/privacy-policy">
Privacy Policy
</FooterTextLink>
<FooterTextLink href="mailto:admin@animethemes.moe">
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
<FooterTextLink href="/about/privacy-policy">Privacy Policy</FooterTextLink>
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
Contact
</FooterTextLink>
</StyledLinkList>
<StyledSocialList>
<FooterLink href="https://reddit.com/r/AnimeThemes">
<StyledSocialButton title="Reddit">
<Icon icon={faReddit}/>
<Icon icon={faReddit} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://discordapp.com/invite/m9zbVyQ">
<StyledSocialButton title="Discord">
<Icon icon={faDiscord}/>
<Icon icon={faDiscord} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://twitter.com/AnimeThemesMoe">
<StyledSocialButton title="Twitter">
<Icon icon={faTwitter}/>
<Icon icon={faTwitter} />
</StyledSocialButton>
</FooterLink>
<FooterLink href="https://github.com/AnimeThemes">
<StyledSocialButton title="GitHub">
<Icon icon={faGithub}/>
<Icon icon={faGithub} />
</StyledSocialButton>
</FooterLink>
</StyledSocialList>
@@ -96,19 +101,3 @@ export function Footer() {
</StyledFooter>
);
}
const FooterLink = forwardRef(function FooterLink({ children, ...props }, ref) {
return (
<Text ref={ref} as="a" target="_blank" rel="noopener" {...props}>
{children}
</Text>
);
});
const FooterTextLink = forwardRef(function FooterTextLink({ children, ...props }, ref) {
return (
<FooterLink ref={ref} link block color="text-muted" noWrap {...props}>
{children}
</FooterLink>
);
});
-1
View File
@@ -1 +0,0 @@
export { Footer } from "./Footer";
+69
View File
@@ -0,0 +1,69 @@
import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react";
import styled from "styled-components";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@/components/button/Button";
import { Icon } from "@/components/icon/Icon";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
const StyledSearchInput = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 2rem;
gap: 8px;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
&:focus-within {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
const StyledInput = styled.input`
width: 100%;
`;
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
margin: -8px;
&:hover {
background-color: transparent;
box-shadow: none;
}
`;
interface InputProps extends Omit<ComponentPropsWithoutRef<typeof StyledSearchInput>, "onChange"> {
value: string;
onChange: (value: string) => void;
resettable?: boolean;
icon?: IconDefinition;
inputProps?: ComponentPropsWithRef<typeof StyledInput>;
}
export function Input({ value, onChange, resettable = false, icon, inputProps = {}, ...props }: InputProps) {
return (
<StyledSearchInput {...props}>
{icon && <Icon icon={icon} color="text-disabled" />}
<StyledInput
type="text"
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faXmark} onClick={() => onChange && onChange("")} />
</StyledResetButton>
)}
</StyledSearchInput>
);
}
+54
View File
@@ -0,0 +1,54 @@
import styled from "styled-components";
import * as RadixSwitch from "@radix-ui/react-switch";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
const StyledSwitch = styled(RadixSwitch.Root)`
width: 50px;
height: 25px;
background-color: ${theme.colors["solid-on-card"]};
border-radius: 9999px;
position: relative;
box-shadow: 0 2px 10px var(--blackA7);
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
&:focus {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
display: block;
width: 25px;
height: 25px;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
transition:
transform 250ms,
background-color 250ms;
will-change: transform;
&[data-state="checked"] {
transform: translateX(25px);
background-color: ${theme.colors["text-primary"]};
}
`;
interface Props extends RadixSwitch.SwitchProps {
isChecked: boolean;
onCheckedChange(isChecked: boolean): void;
}
export default function Switch({ isChecked, onCheckedChange, ...props }: Props) {
return (
<StyledSwitch checked={isChecked} onCheckedChange={onCheckedChange} {...props}>
<StyledSwitchThumb />
</StyledSwitch>
);
}
+24
View File
@@ -0,0 +1,24 @@
import styled from "styled-components";
import { withColorTheme } from "@/styles/mixins";
import theme from "@/theme";
export const TextArea = styled.textarea`
display: block;
padding: 8px 16px;
border-radius: 8px;
resize: vertical;
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-muted"]};
scrollbar-color: ${theme.colors["gray-800"]} transparent;
&:focus-within {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
+1
View File
@@ -0,0 +1 @@
export { Input } from "@/components/form/Input";
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import gql from "graphql-tag";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import type { HomePageMostPopularQuery } from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client/index";
export function MostPopularEntries() {
const { data: mostPopular } = useQuery<HomePageMostPopularQuery["entryAll"] | Array<null>>({
queryKey: ["HomePageTrending"],
queryFn: async () => {
const { data } = await fetchDataClient<HomePageMostPopularQuery>(gql`
${VideoSummaryCardFragmentEntry}
${VideoSummaryCardFragmentVideo}
query HomePageMostPopular {
entryAll(orderBy: "tracks_count", orderDesc: true, limit: 10) {
...VideoSummaryCardEntry
videos {
...VideoSummaryCardVideo
}
}
}
`);
return data.entryAll;
},
placeholderData: range(10).map(() => null),
});
return (
<Column style={{ "--gap": "16px" }}>
{mostPopular?.map((entry, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{entry ? <VideoSummaryCard video={entry.videos[0]} entry={entry} /> : null}
</Skeleton>
))}
</Column>
);
}
@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import gql from "graphql-tag";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import PlaylistSummaryCard from "@/components/card/PlaylistSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import type { HomePageRecentlyAddedPlaylistsQuery } from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client/index";
export function RecentlyAddedPlaylists() {
const { data: recentlyAddedPlaylists } = useQuery<HomePageRecentlyAddedPlaylistsQuery["playlistAll"] | null[]>({
queryKey: ["HomePageRecentlyAddedPlaylists"],
queryFn: async () => {
const { data } = await fetchDataClient<HomePageRecentlyAddedPlaylistsQuery>(gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query HomePageRecentlyAddedPlaylists {
playlistAll(orderBy: "created_at", orderDesc: true, limit: 10, onlyNonEmpty: true) {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
}
`);
return data.playlistAll;
},
placeholderData: range(10).map(() => null),
});
return (
<Column style={{ "--gap": "16px" }}>
{recentlyAddedPlaylists?.map((playlist, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{playlist ? <PlaylistSummaryCard playlist={playlist} showOwner /> : null}
</Skeleton>
))}
</Column>
);
}
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import gql from "graphql-tag";
import { range } from "lodash-es";
import { Column } from "@/components/box/Flex";
import {
VideoSummaryCard,
VideoSummaryCardFragmentEntry,
VideoSummaryCardFragmentVideo,
} from "@/components/card/VideoSummaryCard";
import { Skeleton } from "@/components/skeleton/Skeleton";
import type { HomePageRecentlyAddedQuery } from "@/generated/graphql";
import { fetchDataClient } from "@/lib/client";
export function RecentlyAddedVideos() {
const { data: recentlyAdded } = useQuery<HomePageRecentlyAddedQuery["videoAll"] | Array<null>>({
queryKey: ["HomePageRecentlyAdded"],
queryFn: async () => {
const { data } = await fetchDataClient<HomePageRecentlyAddedQuery>(gql`
${VideoSummaryCardFragmentVideo}
${VideoSummaryCardFragmentEntry}
query HomePageRecentlyAdded {
videoAll(orderBy: "id", orderDesc: true, limit: 10) {
...VideoSummaryCardVideo
entries {
...VideoSummaryCardEntry
}
}
}
`);
return data.videoAll;
},
placeholderData: range(10).map(() => null),
});
return (
<Column style={{ "--gap": "16px" }}>
{recentlyAdded?.map((video, index) => (
<Skeleton key={index} variant="summary-card" delay={index * 100}>
{video ? <VideoSummaryCard video={video} entry={video.entries[0]} /> : null}
</Skeleton>
))}
</Column>
);
}
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { Icon } from "@/components/icon/Icon";
import theme from "@/theme";
export const CornerIcon = styled(Icon)`
position: absolute;
right: 0;
top: 0;
font-size: 32px;
color: ${theme.colors["text-primary"]};
transform: translate(50%, -33%) rotate(10deg);
`;
+42
View File
@@ -0,0 +1,42 @@
import type { SVGProps } from "react";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
icon: IconDefinition;
title?: string;
};
const xmlns = "http://www.w3.org/2000/svg";
export function FontAwesomeIcon(props: FontAwesomeIconProps) {
const { icon: iconProps, children, className = "", title, ...rest } = props;
const { prefix, iconName, icon } = iconProps;
const [width, height, , , svgPathData] = icon;
const dataFa = `${prefix}-${iconName}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
xmlns={xmlns}
role={"img"}
aria-hidden="true"
data-fa={dataFa}
className={`${className} svg-inline--fa fa-fw`}
{...rest}
>
{title ? <title>{title}</title> : null}
{children}
{Array.isArray(svgPathData) ? (
<g>
<path d={svgPathData[0]} />
<path d={svgPathData[1]} />
</g>
) : (
<path fill="currentColor" d={svgPathData} />
)}
</svg>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styled, { css } from "styled-components";
import theme from "theme";
export const Icon = styled(FontAwesomeIcon).attrs({
fixedWidth: true
})`
color: ${(props) => theme.colors[props.color] || props.color};
${(props) => props.transition && css`
transition: ${(props) => props.transition};
`}
`;
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import type { Property } from "csstype";
import { FontAwesomeIcon } from "@/components/icon/FontAwesomeIcon";
import theme from "@/theme";
import type { Colors } from "@/theme/colors";
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors; transition?: Property.Transition }>`
color: ${(props) => props.color && theme.colors[props.color]};
transition: ${(props) => props.transition};
`;
-1
View File
@@ -1 +0,0 @@
export { Icon } from "./Icon";
-13
View File
@@ -1,13 +0,0 @@
import useImage from "hooks/useImage";
import { AspectRatio } from "components/utils";
import { FullWidthImage } from "components/image";
export function CoverImage({ resourceWithImages, ...props }) {
const { smallCover, largeCover } = useImage(resourceWithImages);
return (
<AspectRatio ratio={2 / 3}>
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
</AspectRatio>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ComponentPropsWithoutRef } from "react";
import gql from "graphql-tag";
import { FullWidthImage } from "@/components/image/FullWidthImage";
import { AspectRatio } from "@/components/utils/AspectRatio";
import type { CoverImageResourceWithImagesFragment } from "@/generated/graphql";
import extractImages from "@/utils/extractImages";
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
resourceWithImages: CoverImageResourceWithImagesFragment;
}
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
const { smallCover, largeCover } = extractImages(resourceWithImages);
return (
<AspectRatio $ratio={2 / 3}>
<FullWidthImage
key={largeCover}
src={largeCover}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</AspectRatio>
);
}
CoverImage.fragments = {
resourceWithImages: gql`
${extractImages.fragments.resourceWithImages}
fragment CoverImageResourceWithImages on ResourceWithImages {
...extractImagesResourceWithImages
}
`,
};
@@ -1,5 +1,6 @@
import styled from "styled-components";
import theme from "theme";
import theme from "@/theme";
export const FullWidthImage = styled.img`
width: 100%;
@@ -7,7 +8,7 @@ export const FullWidthImage = styled.img`
object-fit: cover;
border-radius: 0.5rem;
box-shadow: ${theme.shadows.medium};
background-size: cover;
background-position: center;
`;
@@ -1,8 +1,10 @@
export function Logo(props) {
import type { SVGAttributes } from "react";
export function Logo(props: SVGAttributes<SVGElement>) {
return (
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48" />
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48" />
</svg>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ComponentPropsWithoutRef } from "react";
import styled from "styled-components";
import { Logo } from "@/components/image/Logo";
import theme from "@/theme";
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
return (
<StyledPlaceholder {...props}>
<Logo />
</StyledPlaceholder>
);
}
-160
View File
@@ -1,160 +0,0 @@
import useImage from "hooks/useImage";
import { AspectRatio } from "components/utils";
import styled, { css } from "styled-components";
import theme from "theme";
import { Logo } from "components/image";
function getTranslationX(item, itemCount) {
switch (itemCount) {
case 4:
switch (item) {
case 1: return -33;
case 2: return -16.5;
case 3: return 16.5;
case 4: return 33;
}
break;
case 3:
switch (item) {
case 1: return -25;
case 2: return 0;
case 3: return 25;
}
break;
}
return 0;
}
const StyledCoverContainer = styled.div`
position: relative;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
`;
const StyledCoverItemContainer = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
${(props) => props.$itemCount > 1 && css`
&:nth-child(1) {
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
clip-path: polygon(
0 0,
calc(100% / (${props.$itemCount} - 1)) 0,
0 100%,
0 100%
);
}
&:nth-child(2) {
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1)) 0,
calc(100% / (${props.$itemCount} - 1) * 2) 0,
calc(100% / (${props.$itemCount} - 1)) 100%,
0 100%
);
}
&:nth-child(3) {
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
clip-path: polygon(
calc(100% / (${props.$itemCount} - 1) * 2) 0,
100% 0,
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
calc(100% / (${props.$itemCount} - 1)) 100%
);
}
&:nth-child(4) {
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
clip-path: polygon(
100% 0,
100% 0,
100% 100%,
calc(100% / (${props.$itemCount} - 1) * 2) 100%
);
}
&:hover {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
transition: clip-path 250ms;
z-index: 2;
}
&:not(:hover) {
transition: clip-path 500ms, z-index 1000ms;
z-index: 0;
}
`}
`;
const StyledCover = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
background-size: cover;
background-position: center;
transition: transform 500ms;
transform: translateX(var(--translate-x));
&:hover {
transition: transform 250ms;
transform: scale(1.1);
}
`;
const StyledPlaceholder = styled.div`
width: 100%;
height: 100%;
padding: 32px;
background: ${theme.colors["solid"]};
color: ${theme.colors["text-disabled"]};
& svg {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
export function MultiCoverImage({ resourcesWithImages, ...props }) {
const images = [
[ useImage(resourcesWithImages[0]), resourcesWithImages[0] ],
[ useImage(resourcesWithImages[1]), resourcesWithImages[1] ],
[ useImage(resourcesWithImages[2]), resourcesWithImages[2] ],
[ useImage(resourcesWithImages[3]), resourcesWithImages[3] ]
]
.map(([ images, resource ]) => ({
largeCover: images.largeCover,
smallCover: images.smallCover,
resource
}))
.filter(({ largeCover }) => !!largeCover);
return (
<AspectRatio ratio={2 / 3}>
<StyledCoverContainer>
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
<StyledCover
loading="lazy"
src={largeCover}
alt={`Cover image of ${resource.name}`}
title={resource.name}
style={{ backgroundImage: `url(${smallCover})` }}
{...props}
/>
</StyledCoverItemContainer>
)) : (
<StyledPlaceholder {...props}>
<Logo/>
</StyledPlaceholder>
)}
</StyledCoverContainer>
</AspectRatio>
);
}

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