51 Commits

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

* feat: utilize Framer Motion to animate back to top button
2024-03-21 22:36:34 +01:00
Mani 23998c4c64 feat: Added forgot password function (#181)
* Added ranking mode to playlists.
2024-03-21 22:36:34 +01:00
Mani abd3ef6c17 feat: Added reorder function to playlist page (#180)
* Added volume control to video player.
* Added playlist edit dialog.
* Added slider component.
* Optimized video player for mobile devices.
* Double-clicking the mini player now maximizes the video player.
* Fixed back-to-top button overlapping with video player bar.
* Fixed focus styles appearing on click.
* Fixed multi cover image showing the same image multiple times.
* Fixed progress bar showing 0:60 instead of 1:00.
* Fixed right click on video toggling pause/play.
* Fixed scroll bar being visible while in full screen mode.
2024-03-21 22:36:34 +01:00
Mani 9d2d981632 feat: Added fullscreen mode (#179)
* Added theme entry menu to video player.
* Added overlay to video player.
* Improved video player styles for mobile.
* Fixed button focus ring showing on click.
* Fixed z-index issue with multi cover image.
* Fixed global search.
* Fixed various watch list issues.
2024-03-21 22:36:34 +01:00
Mani 6392ff5967 fix: Added support for new include path (#178)
* Fixed ESLint errors.
2024-03-21 22:36:34 +01:00
Mani 5a69a427c7 feat: Added mini video player back (#177)
* Added shuffle mode.
* Added play next/previous actions to media session.
* Fixed play next/previous actions causing the background player to maximize.
* Added button to close the background player.
* Added revalidation info to video page.
* Fixed z-index issues with list box and dialog.
* Changed default Gravatar image.
* Extract video player sidebar into page component.
* Added support for new pivot handling of the API.
* Added new font optimization from Next.js.
2024-03-21 22:36:34 +01:00
Shehab Mohsen 8e99040807 feat: Add back to top button to index pages (#175) 2024-03-21 22:36:34 +01:00
Mani 62eaff6dc1 feat: Next.js 13 and React 18 (#176)
* Replaced all Reach UI components with Radix UI.
* Improved UX of playlist "Play All".
* Highlight current playing watch list item.
* Added support for hash ids.
* Added menu options for "Play Next" and "Add to Watch List".
* Made "Play Previous" button functional.
* Player buttons now have hover/focus feedback.
* Removed default controls from video element.
* Clicking the video element now toggles the playing state.
* Made the video element bigger, especially on high-res displays.
* Added "Other Entries" to new video player.
* Added support for browser media metadata to new video player.
* Switching between audio and video mode now retains the playback position.
* Removed GraphQL endpoint.
* Disabled custom scroll bar styling for touch devices.
2024-03-21 22:36:34 +01:00
Mani 2995543309 fix: Fixed missing include path for roles (#174) 2024-03-21 22:36:34 +01:00
Mani de0a8e49c4 feat: Started work on the new video player (#173)
* Redesigned the navigation.
  * Removed the mobile navigation toggle.
  * Made the background transparent when page is not scrolled.
  * Made buttons collapse to icons on mobile.
  * Made the logo smaller.
* Added track count to playlist summary card.
* Fixed playlist page only displaying 15 tracks.
* Added option to remember login.
* Added "Most Viewed" section to home page.
2024-03-21 22:36:34 +01:00
Mani 46e390eaed feat: Add support for role color (#172)
* Added role badges on profile page.
* Added special welcome message for new users.
2024-03-21 22:36:34 +01:00
Mani 39efac2233 feat: Add support for searching playlists (#171)
* Added password change dialog.
* Added links to terms of service and privacy policy to register dialog.
* Added notification on profile page when user has not verified their email yet.
* Added action to resend the verification email.
* Adjusted layout of profile page.
* Playlist summary cards can now include the owner.
2024-03-21 22:36:34 +01:00
Mani 7b34229866 feat: Add support for Gravatar (#170)
* Revised layout of profile page.
* Fixed issue where SSR builds used the client user as the authenticated user.
2024-03-21 22:36:34 +01:00
Mani a0c7351249 feat: Various improvements to playlist support (#169)
* Added action to delete playlists.
* Added playlist add action to track add dialog.
* Fixed global search.
* Fixed busy state of register dialog.
* Fixed message that shows when no playlist are available.
* Improved error handling when adding playlists.
2024-03-21 22:36:34 +01:00
Mani 7f5f8ef4fa fix: Fixed two more page build errors (#168) 2024-03-21 22:36:34 +01:00
Mani 02e42fa3c1 fix: Fixed page build errors (#167) 2024-03-21 22:36:34 +01:00
Mani e9b4133490 feat: Added support for playlists (#166)
* Added playlist page.
* Added playlist summary card.
* Added video summary card.
* Added playlist and login/register sections to profile page.
* Changed profile page to SSR mode.
* Added playlist actions to theme menu.
* Removed local playlist actions from theme menu and video page.
* Added dialog component.
* Added dialogs for playlist actions (add playlist, add track to playlist, remove track from playlist).
* Added login dialog.
* Added register dialog.
* Added toasts for playlist actions.
* Added busy component to show in-progress state.
* Added login gate component to protect section from unauthorized access.
* Added focus style to button and listbox.
* Improved hover style for primary button.
* Added warning button variant.
* Added switch (checkbox) component.
* Split transparency page into multiple pages (to allow for links to lead to specific months).
* Fixed table spacing on Markdown pages.
* Removed suffix from embed titles.
* Experimental: Added new implementation of listbox based on Radix.
2024-03-21 22:36:34 +01:00
Manuel S 418a6a3dde feat: Added first auth support (#147)
* APP_URL is now AUTH_URL.
2024-03-21 22:36:34 +01:00
Gaporigo f879241752 feat: Added r/anime award results for 2023 (#192) 2024-03-02 21:27:17 +01:00
Gaporigo 3ba96d7236 feat: Added r/anime award nominations for 2023 (#190) 2024-01-15 15:32:45 +01:00
Mani e6fce90ca7 feat: Add bracket page and mappings for Best Opening X (#184) 2023-08-29 00:51:21 +02:00
Mani f294b1bad2 feat: Added r/anime award results for 2022 (#165) 2023-02-26 03:19:28 +01:00
Mani 753c8d01d0 feat: Replaced Markdown implementation with MDX (#164)
* Replaced announcement toast with MDX component on the home page.
* Added special anime award announcement button.
* Fixed size of corner icons.
* Removed design page.
2023-02-25 04:37:13 +01:00
Mani de45d719bc feat: Add support for Twitter cards (#163) 2023-02-23 23:00:42 +01:00
Mani 2ccd6ee6df feat: Added r/anime award nominations for 2022 (#162) 2023-01-16 23:57:42 +01:00
Mani ef5c9d0325 fix: Fixed download dump button on mobile (#161)
* Improved encoding script download.
2023-01-16 23:54:22 +01:00
Shehab Mohsen 787e14727a chore: Decouple verbose logging from staging (#160)
* Decoupled verbose logs from staging mode.
* Added verbose logs option to README.
2023-01-09 10:24:26 +01:00
Shehab Mohsen 0ef42a79d0 feat: Added audio files url environment variable (#158) 2023-01-03 08:48:59 +01:00
Mani 0edf1daa17 feat: Added allowed include path for videoscripts (#157) 2023-01-01 18:24:00 +01:00
Manuel S 70aab886c1 fix: Reverted concurrent API requests not getting limited, which resulted in timeouts (#156) 2022-12-26 01:06:22 +01:00
Manuel S 350defb4b3 fix: Fixed missing base path from GraphQL endpoint URL (#155) 2022-12-08 06:50:04 +01:00
Manuel S 2168ac6c55 fix: Fixed performance issues with watch history (#154) 2022-12-07 06:57:39 +01:00
Manuel S bfe3edddbf feat: Added support for video scripts (#153)
* Added GraphQL endpoint.
* Experimental: Recently added themes are now fetched via the GraphQL endpoint.
* Optimized build time of bracket pages.
* Fixed circular dependency.
* Fixed some type warnings.
2022-12-07 06:54:23 +01:00
Manuel S c628185bea fix: Fixed GraphQL codegen (#152) 2022-12-06 08:08:33 +01:00
Manuel S 2ebf4437ed feat: Added transparency page (#151)
* Added dump page.
* Added terms of service page.
* Added privacy policy page.
* Removed dev page.
* Added secure headers.
* Updated footer links.
* Fixed some global styles.
* Bumped Next.js to 12.3.
2022-12-06 02:54:06 +01:00
Manuel S 14a25b725a fix: Optimize initial build performance by caching data between pages (#150) 2022-12-06 02:02:00 +01:00
Manuel S 48b8f1628a fix: Fixed share audio (#148) 2022-10-10 20:33:03 +02:00
Manuel S 09b92ef6f6 fix: Fixed too many API calls on the client side (#145)
* Added option to share embeddable audio.
* Fixed text wrapping in multiple places.
* Fixed volume synchronization.
* Fixed switching player mode affecting other tabs.
2022-09-02 01:14:53 +02:00
172 changed files with 17435 additions and 7691 deletions
+5
View File
@@ -29,10 +29,15 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.npmrc
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE (WebStorm)
*.iml
/.idea
+16 -6
View File
@@ -7,14 +7,14 @@
## Configuration
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
To get started you need to define some environment variables. This can be done by creating a `.env.local` file in the
root directory. A minimal setup only requires one variable to be set:
```ini
; Set this to the URL on which your local API is served.
;
; (If you do not have a local instance of animethemes-server set up
; you can also use the production or staging URLs of the AnimeThemes API.
; you can also use the production or beta URLs of the AnimeThemes API.
; Keep in mind though that this puts addtional load on our servers.
; That's why it's recommended to set up your own API instance locally.)
ANIMETHEMES_API_URL=http://localhost
@@ -37,6 +37,10 @@ ANIMETHEMES_API_KEY=...
; The token to use for manual revalidation.
REVALIDATE_TOKEN=secret
; Set to any truthy value to activate minimal build mode.
; In minimal build mode, only a small subset of pages get prerendered at build time.
MINIMAL_BUILD=true
; Set to any truthy value to activate the bundle analyzer.
ANALYZE=true
@@ -51,12 +55,18 @@ NEXT_PUBLIC_API_URL=https://api.animethemes.moe
; The URL from which video files should be served.
NEXT_PUBLIC_VIDEO_URL=https://v.animethemes.moe
; The URL to use for app links.
NEXT_PUBLIC_APP_URL=https://app.animethemes.moe
; The URL from which audio files should be served.
NEXT_PUBLIC_AUDIO_URL=https://a.animethemes.moe
; The URL to use for authentication API requests.
NEXT_PUBLIC_AUTH_URL=https://app.animethemes.moe
; Set to any truthy value to activate staging mode.
; Staging mode only pre-renders a small subset of pages to reduce build time.
STAGING=true
; In staging mode a warning banner is displayed at the top of the page.
NEXT_PUBLIC_STAGING=true
; To enable verbose logging.
NEXT_PUBLIC_VERBOSE_LOGS=true
```
For more information on environment variables see the [Next.js documentation](https://nextjs.org/docs/basic-features/environment-variables).
+34
View File
@@ -0,0 +1,34 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: [
"src/lib/common/animethemes/type-defs.ts",
"src/lib/server/animebracket/type-defs.ts",
"src/lib/client/search.ts",
],
documents: [
"src/**/*.js",
"src/**/*.ts",
"src/**/*.tsx",
],
generates: {
"src/generated/graphql.ts": {
plugins: [
"typescript",
"typescript-operations",
],
config: {
avoidOptionals: {
object: true,
field: true,
inputValue: false,
},
enumsAsTypes: true,
skipTypename: true,
},
},
},
};
export default config;
-23
View File
@@ -1,23 +0,0 @@
overwrite: true
schema:
- "src/lib/common/animethemes/type-defs.ts"
- "src/lib/server/animebracket/type-defs.ts"
- "src/lib/client/search.ts"
documents:
- "src/**/*.js"
- "src/**/*.ts"
- "src/**/*.tsx"
generates:
src/generated/graphql.ts:
plugins:
- "typescript"
- "typescript-operations"
config:
avoidOptionals:
object: true
field: true
inputValue: false
enumsAsTypes: true
skipTypename: true
require:
- ts-node/register
-27
View File
@@ -1,27 +0,0 @@
const { info, error } = require("next/dist/build/output/log");
const { ANALYZE, STAGING, BASE_PATH, validateConfig } = require("./src/utils/config");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: ANALYZE,
});
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
module.exports = withBundleAnalyzer({
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600,
experimental: {
newNextLinkBehavior: true
}
});
+89
View File
@@ -0,0 +1,89 @@
// @ts-check
import { ANALYZE, BASE_PATH, STAGING, validateConfig } from "./src/utils/config.mjs";
import { error, info } from "./src/utils/log.mjs";
import NextBundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = NextBundleAnalyzer({
enabled: ANALYZE,
});
if (!validateConfig()) {
error("Shutting down because of invalid configuration...");
process.exit(1);
}
if (STAGING) {
info("Running animethemes-web in staging mode!");
}
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
basePath: BASE_PATH,
reactStrictMode: true,
compiler: {
styledComponents: true
},
staticPageGenerationTimeout: 3600,
experimental: {
// We don't want to multi-thread page building
// to make use of caching between page builds.
workerThreads: false,
cpus: 1,
},
transpilePackages: [
"ahooks"
],
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload"
},
{
key: "X-XSS-Protection",
value: "1; mode=block"
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN"
},
{
key: "X-Content-Type-Options",
value: "nosniff"
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin"
}
]
}
];
},
async redirects() {
return [
{
source: "/wiki/blog/:slug*",
destination: "/blog/:slug*",
permanent: true,
},
{
source: "/wiki/status/:slug*",
destination: "/blog/status_:slug*",
permanent: true,
},
];
}
};
export default withBundleAnalyzer(nextConfig);
+8782 -4232
View File
File diff suppressed because it is too large Load Diff
+28 -17
View File
@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"graphql-codegen": "graphql-codegen --config codegen.yml"
"graphql-codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
@@ -17,28 +17,40 @@
"@graphql-tools/schema": "^8.2.0",
"@graphql-tools/utils": "^8.6.5",
"@next/bundle-analyzer": "^12.0.3",
"@reach/listbox": "^0.17.0",
"@reach/menu-button": "^0.17.0",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-switch": "^1.0.1",
"ahooks": "^3.7.6",
"axios": "^1.2.2",
"common-tags": "^1.8.0",
"framer-motion": "^6.5.1",
"framer-motion": "^10.12.4",
"graphql": "^15.8.0",
"graphql-parse-resolve-info": "^4.12.0",
"graphql-tag": "^2.12.6",
"hast-util-has-property": "^2.0.1",
"hast-util-heading-rank": "^2.1.1",
"hast-util-to-string": "^2.0.0",
"lodash-es": "^4.17.21",
"marked": "^4.0.12",
"next": "^12.2.5",
"md5": "^2.3.0",
"next": "^13.3.0",
"next-mdx-remote": "^4.3.0",
"p-limit": "^3.1.0",
"prismjs": "^1.27.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"picocolors": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-query": "^3.29.0",
"rehype-parse": "^8.0.4",
"rehype-react": "^7.0.4",
"rehype-pretty-code": "^0.9.4",
"rehype-slug": "^5.1.0",
"remark-gfm": "^3.0.1",
"sass": "^1.43.4",
"shiki": "^0.14.1",
"styled-components": "^5.3.3",
"unified": "^10.1.2",
"use-local-storage-state": "^17.2.0",
"use-session-storage-state": "^17.2.0"
"swr": "^2.2.4",
"unist-util-visit": "^4.1.2",
"use-local-storage-state": "^18.3.0",
"use-session-storage-state": "^18.1.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.9.1",
@@ -46,15 +58,14 @@
"@graphql-codegen/typescript-operations": "2.4.1",
"@types/common-tags": "^1.8.1",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.3",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.41",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.0.12",
"@types/styled-components": "^5.1.25",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"eslint": "7.32.0",
"eslint-config-next": "^12.0.8",
"eslint-config-next": "^13.3.0",
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
}
+33
View File
@@ -0,0 +1,33 @@
import useAuth from "hooks/useAuth";
import type { ReactNode } from "react";
import { Text } from "components/text";
import { Column, Row } from "components/box";
import { LoginDialog } from "components/dialog/LoginDialog";
import { RegisterDialog } from "components/dialog/RegisterDialog";
interface LoginGateProps {
children: ReactNode;
}
export function LoginGate({ children }: LoginGateProps) {
const { me } = useAuth();
if (me.user) {
// User is already logged in, so we can safely show the content.
return (
<>
{children}
</>
);
}
return (
<Column style={{ "--gap": "16px" }}>
<Text>You need to log in to continue:</Text>
<Row style={{ "--gap": "16px" }}>
<LoginDialog />
<RegisterDialog />
</Row>
</Column>
);
}
+64
View File
@@ -0,0 +1,64 @@
import styled from "styled-components";
import { Button } from "./Button";
import { Icon } from "components/icon";
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import React, { useState, useEffect, useContext } from "react";
import theme from "theme";
import { withHover } from "styles/mixins";
import PlayerContext from "context/playerContext";
import { AnimatePresence, m } from "framer-motion";
const ScrollButton = styled(m(Button))<{ $bottomOffset: number }>`
position: fixed;
right: 16px;
bottom: ${(props) => 16 + props.$bottomOffset}px;
padding: 16px;
${withHover`
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text-primary"]};
`}
`;
export function BackToTopButton() {
const [isButtonVisible, setIsButtonVisible] = useState(false);
const { currentWatchListItem } = useContext(PlayerContext);
function scrollUp() {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
useEffect(() => {
function handleScroll() {
if (window.scrollY > 2000) {
setIsButtonVisible(true);
} else {
setIsButtonVisible(false);
}
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<AnimatePresence>
{isButtonVisible ? (
<ScrollButton
key={"scrollButton"}
initial={{ y: "150%" }}
animate={{ y: "0" }}
exit={{ y: "450%" }}
variant="primary"
isCircle={true}
onClick={scrollUp}
onMouseDown={(event: React.PointerEvent) => event.preventDefault()}
$bottomOffset={currentWatchListItem ? 76 : 0}
>
<Icon icon={faChevronUp} />
</ScrollButton>
) : null}
</AnimatePresence>
);
}
+51 -9
View File
@@ -1,4 +1,4 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
@@ -9,7 +9,7 @@ export const Button = forwardRef(ButtonWithRef);
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
children?: ReactNode
variant?: "solid" | "primary" | "silent"
variant?: "solid" | "primary" | "warning" | "silent"
isCircle?: boolean
disabled?: boolean
}
@@ -26,6 +26,8 @@ function ButtonWithRef({
Component = SolidButton;
} else if (variant === "primary") {
Component = PrimaryButton;
} else if (variant === "warning") {
Component = WarningButton;
} else if (variant === "silent") {
Component = SilentButton;
} else {
@@ -46,11 +48,13 @@ function ButtonWithRef({
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;
@@ -71,20 +75,51 @@ const BaseButton = styled.button<{ $isCircle: boolean }>`
box-shadow: none;
margin: -8px 8px -8px -16px;
}
&:focus:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-color);
}
`;
const PrimaryButton = styled(BaseButton)`
background-color: ${theme.colors["solid-primary"]};
color: ${theme.colors["text-on-primary"]};
${withHover`
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-primary"]};
color: ${theme.colors["text-primary"]};
}
`;
const WarningButton = styled(BaseButton)`
--focus-ring-color: ${theme.colors["text-warning"]};
background-color: ${theme.colors["solid-warning"]};
color: ${theme.colors["text-on-warning"]};
${withHover`
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
`}
&:focus:focus-visible {
background-color: ${theme.colors["text-on-warning"]};
color: ${theme.colors["text-warning"]};
}
`;
const SolidButton = styled(BaseButton)`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
color: ${theme.colors["text"]};
`)}
`}
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
@@ -95,19 +130,26 @@ const SilentButton = styled(BaseButton)`
background-color: transparent;
color: ${theme.colors["text-muted"]};
${withHover(css`
${withHover`
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text"]};
`)}
`}
&:not(:hover) {
&:not(:hover)&:not(:focus:focus-visible) {
box-shadow: none;
}
${Solid} & {
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
}
${Solid} ${Solid} & {
${withHover`
background-color: transparent;
box-shadow: none;
`}
}
`;
+43 -21
View File
@@ -1,37 +1,59 @@
import { Icon } from "components/icon";
import { Button } from "components/button";
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import useMediaQuery from "hooks/useMediaQuery";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { forwardRef } from "react";
const StyledButton = styled(Button)`
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
aspect-ratio: 1 / 1;
padding: 8px;
}
`;
const StyledText = styled.span<{ collapsible: boolean }>`
${(props) => props.collapsible && css`
@media (max-width: ${theme.breakpoints.mobileMax}) {
display: none;
}
`}
const StyledText = styled.span<{ $collapseBreakpoint: string }>`
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
display: none;
}
`;
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
icon: IconDefinition
children: ReactNode
collapsible?: boolean
icon: IconDefinition | ReactNode
children?: ReactNode
collapsible?: true | keyof typeof theme.breakpoints
}
export function IconTextButton({ icon, children, collapsible = false, ...props }: IconTextButtonProps) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
const isCollapsed = collapsible && isMobile;
export const IconTextButton = forwardRef(
function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) {
let collapseBreakpoint = "0px";
return (
<StyledButton variant="silent" isCircle={isCollapsed} {...props}>
<Icon icon={icon} color="text-disabled"/>
<StyledText collapsible={collapsible}>{children}</StyledText>
</StyledButton>
);
if (collapsible === true) {
collapseBreakpoint = theme.breakpoints.mobileMax;
} else if (collapsible) {
collapseBreakpoint = theme.breakpoints[collapsible];
}
return (
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
{isIconDefinition(icon) ? (
<Icon icon={icon} color="text-disabled"/>
) : icon}
{(children !== null && children !== undefined) ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
}
);
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
return !!icon && typeof icon === "object" && "icon" in icon;
}
+3 -3
View File
@@ -6,7 +6,7 @@ import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { Icon } from "components/icon";
import { Button } from "components/button";
import { VideoTags } from "components/tag";
import { VideoTags } from "components/tag/VideoTags";
import gql from "graphql-tag";
import type {
VideoButtonAnimeFragment,
@@ -25,7 +25,7 @@ interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
const { currentVideo } = useContext(PlayerContext);
const videoSlug = createVideoSlug(theme, entry, video);
const isPlaying = currentVideo ? currentVideo.filename === video.filename : false;
const isPlaying = currentVideo ? currentVideo.id === video.id : false;
return (
<Link
@@ -63,7 +63,7 @@ VideoButton.fragments = {
fragment VideoButtonVideo on Video {
...VideoTagsVideo
filename
id
tags
}
`,
+1
View File
@@ -2,3 +2,4 @@ export { Button } from "./Button";
export { VideoButton } from "./VideoButton";
export { FilterToggleButton } from "./FilterToggleButton";
export { IconTextButton } from "./IconTextButton";
export { BackToTopButton } from "./BackToTopButton";
+2 -1
View File
@@ -84,7 +84,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
const description = (
<SummaryCard.Description>
<span>Anime</span>
<span>{anime.media_format ?? "Anime"}</span>
{!!anime.year && (
<TextLink href={premiereLink}>
{premiere}
@@ -161,6 +161,7 @@ AnimeSummaryCard.fragments = {
name
year
season
media_format
themes {
group
}
+13
View File
@@ -0,0 +1,13 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { Markdown } from "components/markdown/Markdown";
import { AnimeAwardsNowAvailable } from "components/event/AnimeAwardsNowAvailable";
interface AnnouncementCardProps {
announcementSource: MDXRemoteSerializeResult;
}
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
return (
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
);
}
+2 -2
View File
@@ -24,9 +24,9 @@ export const Card = styled(Solid)<{
${(props) => props.hoverable && css`
cursor: pointer;
${withHover(css`
${withHover`
background-color: ${theme.colors["solid-on-card"]};
`)}
`}
`}
&:before {
+1 -1
View File
@@ -19,7 +19,7 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
`;
interface ErrorCardProps {
error: any
error: unknown
}
export function ErrorCard({ error }: ErrorCardProps) {
@@ -0,0 +1,84 @@
import { SummaryCard } from "components/card/SummaryCard";
import type { PropsWithChildren } from "react";
import gql from "graphql-tag";
import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerFragment } from "generated/graphql";
import type { ReactNode } from "react";
import styled from "styled-components";
import theme from "theme";
import { Text } from "components/text";
const StyledWrapper = styled.div`
position: relative
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
type PlaylistSummaryCardProps = {
playlist: PlaylistSummaryCardPlaylistFragment;
menu?: ReactNode;
showOwner?: false;
} | {
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
menu?: ReactNode;
showOwner: true;
};
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
const description = (
<SummaryCard.Description>
<span>Playlist</span>
<span>{playlist.visibility}</span>
{showOwner ? (
<Text link>{playlist.user.name}</Text>
) : null}
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
</SummaryCard.Description>
);
return (
<StyledWrapper>
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
{children}
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
PlaylistSummaryCard.fragments = {
playlist: gql`
fragment PlaylistSummaryCardPlaylist on Playlist {
id
name
visibility
tracks_count
}
`,
showOwner: gql`
fragment PlaylistSummaryCardShowOwner on Playlist {
user {
name
}
}
`,
};
+2 -3
View File
@@ -100,9 +100,8 @@ export function SummaryCard({
</ConditionalWrapper>
<StyledBody>
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
{typeof title === "string" ? (
to ? <TextLink href={to}>{title}</TextLink> : title
) : title}
{typeof title === "string" && to ?
<TextLink href={to}>{title}</TextLink> : title}
</Text>
{!!description && (
<Text variant="small" maxLines={1}>
+125
View File
@@ -0,0 +1,125 @@
import { Text } from "components/text";
import styled, { css } from "styled-components";
import { Card } from "components/card";
import { Column } from "components/box";
import withBasePath from "utils/withBasePath";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { useState } from "react";
import { loadingAnimation } from "styles/mixins";
import type { Property } from "csstype";
const StyledSummaryCard = styled(Card)`
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 1rem 0 4px;
`;
const StyledCover = styled.img.attrs({
loading: "lazy"
})<{
objectFit?: Property.ObjectFit
backgroundColor?: Property.Background
isLoading?: boolean
isPlaceholder?: boolean
}>`
width: 48px;
height: 64px;
object-fit: ${(props) => props.objectFit ?? "cover"};
background: ${(props) => props.backgroundColor};
${(props) => props.isPlaceholder ? css`
padding: 0.5rem;
object-fit: contain;
background-color: white;
` : (props.isLoading ? loadingAnimation : null)}
`;
const StyledBody = styled(Column)`
flex: 1;
justify-content: center;
gap: 0.25rem;
word-break: break-all;
`;
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
children?: ReactNode
};
export function SummaryCard({ children, ...props }: SummaryCardProps) {
return (
<StyledSummaryCard {...props}>
{children}
</StyledSummaryCard>
);
}
SummaryCard.Body = StyledBody;
interface SummaryCardTitleProps extends ComponentPropsWithoutRef<typeof Text> {
children: ReactNode;
}
SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCardTitleProps) {
return (
<Text maxLines={1} {...props}>
{children}
</Text>
);
};
interface SummaryCardDescriptionProps {
children: string | Array<ReactNode>
}
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
return (
<Text variant="small" maxLines={1} color="text-muted">
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
<>
{child}
{index < length - 1 && (
<span> &bull; </span>
)}
</>
))}
</Text>
);
};
interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCover> {
src?: string;
}
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
const [ imageNotFound, setImageNotFound ] = useState(false);
const [ imageLoading, setImageLoading ] = useState(true);
return (
<StyledCover
alt="Cover"
src={(!imageNotFound && src) || withBasePath("/img/logo.svg")}
isLoading={imageLoading}
isPlaceholder={!src || imageNotFound}
loading="lazy"
{...props}
onLoad={(event) => {
setImageLoading(false);
if (props?.onLoad) {
props.onLoad(event);
}
}}
onError={() => {
setImageNotFound(true);
setImageLoading(false);
if (props?.onError) {
props.onError();
}
}}
/>
);
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { VideoButton } from "components/button";
import { Performances, SongTitle } from "components/utils";
import { Text } from "components/text";
import { ThemeMenu } from "components/menu";
import { ThemeEntryTags } from "components/tag";
import { ThemeMenu } from "components/menu/ThemeMenu";
import { ThemeEntryTags } from "components/tag/ThemeEntryTags";
import { VideoTags } from "components/tag/VideoTags";
import gql from "graphql-tag";
import type { ThemeDetailCardThemeFragment } from "generated/graphql";
+32 -23
View File
@@ -1,10 +1,10 @@
import Link from "next/link";
import { Collapse, SongTitleWithArtists } from "components/utils";
import { Collapse, Performances, SongTitle, SongTitleWithArtists } from "components/utils";
import { Text } from "components/text";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card";
import { ThemeMenu } from "components/menu";
import { SummaryCard } from "components/card/SummaryCard2";
import { ThemeMenu } from "components/menu/ThemeMenu";
import gql from "graphql-tag";
import { fetchDataClient } from "lib/client";
import { Icon } from "components/icon";
@@ -66,14 +66,16 @@ 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, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
const [isExpanded, toggleExpanded] = useToggle();
const isMobile = useIsMobile();
@@ -87,34 +89,41 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const to = `/anime/${anime.slug}/${videoSlug}`;
const description = (
<SummaryCard.Description>
<span>Theme</span>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
);
const href = `/anime/${anime.slug}/${videoSlug}`;
function handleToggleExpand(event: MouseEvent) {
if (event.target instanceof HTMLAnchorElement && event.target.href) {
if (isLink(event.target)) {
event.stopPropagation();
} else if (expandable && !isMobile) {
toggleExpanded();
}
}
function isLink(element: EventTarget | null): boolean {
if (!element || !(element instanceof HTMLElement)) {
return false;
} else if (element instanceof HTMLAnchorElement && element.href) {
return true;
}
return isLink(element.parentElement);
}
return (
<StyledWrapper>
<SummaryCard
title={<SongTitleWithArtists song={theme.song} songTitleLinkTo={to} artist={artist}/>}
description={description}
image={smallCover}
to={to}
onClick={handleToggleExpand}
{...props}
>
<SummaryCard onClick={handleToggleExpand} {...props}>
<Link href={href} onClick={() => onPlay?.()}>
<SummaryCard.Cover src={smallCover} />
</Link>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={() => onPlay?.()} />
<Performances song={theme.song} artist={artist} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{children}
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
<ThemeMenu theme={theme}/>
@@ -137,7 +146,7 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, ...props
{expandable && (
<Collapse collapse={!isExpanded}>
<StyledPerformedWith>
<ThemeTable themes={[theme]}/>
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
<Table style={{ "--columns": "1fr" }}>
<TableHead>
+139
View File
@@ -0,0 +1,139 @@
import { Performances, SongTitle, SongTitleWithArtists } from "components/utils";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { SummaryCard } from "components/card/SummaryCard2";
import gql from "graphql-tag";
import styled from "styled-components";
import theme from "theme";
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
import { type ForwardedRef, forwardRef, type ReactNode } from "react";
import { TextLink } from "components/text/TextLink";
import Link from "next/link";
import { Icon } from "components/icon";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
const StyledWrapper = styled.div`
position: relative;
`;
const StyledOverlayButtons = styled.div`
position: absolute;
right: 16px;
opacity: 0;
transition-property: opacity;
${StyledWrapper}:hover & {
position: static;
opacity: 1;
transition-duration: 250ms;
}
@media (max-width: ${theme.breakpoints.mobileMax}) {
position: static;
opacity: 1;
}
`;
const StyledCoverLink = styled(Link)`
position: relative;
`;
const StyledCoverOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
`;
interface VideoSummaryCardProps {
video: VideoSummaryCardVideoFragment;
menu?: ReactNode;
onPlay?(): void;
isPlaying?: boolean;
}
export const VideoSummaryCard = forwardRef(
function VideoSummaryCard({ video, menu, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme?.anime;
if (!entry || !theme || !anime) {
return null;
}
const { smallCover } = extractImages(anime);
const videoSlug = createVideoSlug(theme, entry, video);
const href = `/anime/${anime.slug}/${videoSlug}`;
return (
<StyledWrapper ref={ref}>
<SummaryCard {...props}>
<StyledCoverLink href={href} onClick={onPlay}>
<SummaryCard.Cover src={smallCover} />
{isPlaying ? (
<StyledCoverOverlay>
<Icon icon={faPlay} />
</StyledCoverOverlay>
) : null}
</StyledCoverLink>
<SummaryCard.Body>
<SummaryCard.Title>
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
<Performances song={theme.song} />
</SummaryCard.Title>
<SummaryCard.Description>
<span>{videoSlug}{theme.group && ` (${theme.group})`}</span>
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
</SummaryCard.Description>
</SummaryCard.Body>
{menu ? (
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
{menu}
</StyledOverlayButtons>
) : null}
</SummaryCard>
</StyledWrapper>
);
}
);
export const VideoSummaryCardFragmentVideo = gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment VideoSummaryCardVideo on Video {
id
basename
...createVideoSlugVideo
entries {
...createVideoSlugEntry
theme {
...createVideoSlugTheme
id
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
song {
...SongTitleWithArtistsSong
}
}
}
audio {
basename
}
}
`;
+1 -1
View File
@@ -2,7 +2,7 @@ import styled from "styled-components";
export const Container = styled.div`
margin: 0 auto;
padding: 1.5rem 1rem;
padding: 24px 16px;
width: 100%;
max-width: 1100px;
`;
+81
View File
@@ -0,0 +1,81 @@
import { forwardRef } from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import styled, { keyframes } from "styled-components";
import { Card } from "components/card";
import theme from "theme";
import { Text } from "components/text";
const overlayAnimation = keyframes`
from {
opacity: 0;
}
`;
const contentAnimation = keyframes`
from {
transform: scale(0.9);
opacity: 0;
}
`;
const StyledOverlay = styled(RadixDialog.Overlay)`
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 32px 16px;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
animation: ${overlayAnimation} 250ms;
`;
const StyledDialogCard = styled(Card)`
width: 100%;
max-width: 450px;
animation: ${contentAnimation} 250ms;
margin: auto;
padding: 24px;
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
&:before {
display: none;
}
`;
const StyledHeader = styled.div`
margin: -24px -24px 24px -24px;
padding: 16px 24px;
background-color: ${theme.colors["background"]};
`;
interface DialogContentProps extends RadixDialog.DialogContentProps {
title?: string;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
function DialogContent({ title, children, ...props }, ref) {
return (
<RadixDialog.Portal>
<StyledOverlay>
<RadixDialog.Content asChild {...props} ref={ref}>
<StyledDialogCard>
{title ? (
<StyledHeader>
<Text variant="h2">{title}</Text>
</StyledHeader>
) : null}
{children}
</StyledDialogCard>
</RadixDialog.Content>
</StyledOverlay>
</RadixDialog.Portal>
);
}
);
export const Dialog = RadixDialog.Root;
export const DialogTrigger = RadixDialog.Trigger;
@@ -0,0 +1,118 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { isAxiosError } from "axios";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function ForgotPasswordDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Text variant="small" link color="text-muted">Forgot Password?</Text>
</DialogTrigger>
<DialogContent title="Request a Password Reset">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<ForgotPasswordForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface ForgotPasswordProps {
onSuccess(): void;
onCancel(): void;
}
function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
const { forgotPassword } = useAuth();
const { dispatchToast } = useToasts();
const [email, setEmail] = useState("");
const isValid = !!email;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
async function performForgotPassword(event: SyntheticEvent) {
event.preventDefault();
event.stopPropagation();
setBusy(true);
setErrors({});
try {
await forgotPassword({
email,
});
dispatchToast(
"forgot-password-sent",
<Toast>We sent you an e-mail with a password reset link.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performForgotPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Submit</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Button } from "components/button";
import { Input } from "components/form";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import styled from "styled-components";
import useAuth from "hooks/useAuth";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Column, Row } from "components/box";
import { Busy } from "components/utils/Busy";
import Switch from "components/form/Switch";
import { ForgotPasswordDialog } from "components/dialog/ForgotPasswordDialog";
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
export function LoginDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Login</Button>
</DialogTrigger>
<DialogContent title="Login">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginForm onCancel={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
interface LoginFormProps {
onCancel(): void;
}
function LoginForm({ onCancel }: LoginFormProps) {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isRemember, setRemember] = useState(false);
const isValid = email && password;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string;
}>({});
function performLogin(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
login({
setErrors,
email,
password,
remember: isRemember,
})
.finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performLogin}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? (
<Text color="text-warning">{errors.email}</Text>
) : null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
<ForgotPasswordDialog />
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-remember"
isChecked={isRemember}
onCheckedChange={setRemember}
/>
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Login</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,149 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import axios from "lib/client/axios";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
export function PasswordChangeDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Change Password</Button>
</DialogTrigger>
<DialogContent title="Change Password">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<PasswordChangeForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordChangeFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
const { dispatchToast } = useToasts();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const isValid = currentPassword && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
current_password?: string[];
password?: string[];
}>({});
async function performChangePassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await axios.put(
"/user/password",
{
current_password: currentPassword,
password: newPassword,
password_confirmation: newPasswordConfirmation,
}
);
dispatchToast(
"password-change",
<Toast>Password changed successfully.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performChangePassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Current Password</Text>
<Input
value={currentPassword}
onChange={setCurrentPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.current_password ? errors.current_password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Change Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,149 @@
import type { SyntheticEvent } from "react";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import styled from "styled-components";
import { Column, Row } from "components/box";
import { Dialog, DialogContent } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import { useToasts } from "context/toastContext";
import { Toast } from "components/toast";
import { useRouter } from "next/router";
import useAuth from "hooks/useAuth";
export function PasswordResetDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => setOpen(true), []);
return (
<Dialog open={open}>
<DialogContent title="Reset Password">
<PasswordResetForm
onSuccess={() => router.push("/profile")}
onCancel={() => router.push("/")}
/>
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface PasswordResetFormProps {
onSuccess(): void;
onCancel(): void;
}
function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
const { resetPassword } = useAuth();
const { dispatchToast } = useToasts();
const router = useRouter();
const [email, setEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const token = router.query.token;
const isValid = email && newPassword && newPasswordConfirmation;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
email?: string[];
password?: string[];
}>({});
async function performResetPassword(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
setErrors({});
try {
await resetPassword({
email,
password: newPassword,
password_confirmation: newPasswordConfirmation,
token,
});
dispatchToast(
"password-reset",
<Toast>Password reset successfully.</Toast>
);
onSuccess();
} catch (error) {
if (!isAxiosError(error) || !error.response || error.response.status !== 422) {
throw error;
}
setErrors(error.response.data.errors);
} finally {
setBusy(false);
}
}
return (
<>
<StyledForm onSubmit={performResetPassword}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>New Password</Text>
<Input
value={newPassword}
onChange={setNewPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm New Password</Text>
<Input
value={newPasswordConfirmation}
onChange={setNewPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Reset Password</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
interface PlaylistAddDialogProps {
trigger?: ReactNode;
}
export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Create a new playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistAddForm
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistAddFormProps {
onSuccess(): void;
onCancel(): void;
}
function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
const [title, setTitle] = useState("");
const [visibility, setVisibility] = useState("PUBLIC");
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.post("/api/playlist", {
name: title,
visibility,
});
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="PUBLIC">Public</ListboxOption>
<ListboxOption value="UNLISTED">Unlisted</ListboxOption>
<ListboxOption value="PRIVATE">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,140 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faPen } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
import axios from "lib/client/axios";
import styled from "styled-components";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import { isAxiosError } from "axios";
import gql from "graphql-tag";
import type { PlaylistEditDialogPlaylistFragment } from "generated/graphql";
interface PlaylistEditDialogProps {
playlist: PlaylistEditDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Edit playlist details">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistEditForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistEditDialog.fragments = {
playlist: gql`
fragment PlaylistEditDialogPlaylist on Playlist {
id
name
visibility
}
`,
};
const StyledForm = styled.form`
position: relative;
`;
interface PlaylistEditFormProps {
playlist: PlaylistEditDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormProps) {
const [title, setTitle] = useState(playlist.name);
const [visibility, setVisibility] = useState<string>(playlist.visibility);
const isValid = title !== "";
const [isBusy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await axios.put(`/api/playlist/${playlist.id}`, {
name: title,
visibility,
});
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} catch (error: unknown) {
if (isAxiosError(error) && error.response) {
setError(error.response.data.message ?? "An unknown error occured!");
}
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<StyledForm onSubmit={submit}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Title</Text>
<Input
value={title}
onChange={setTitle}
/>
</SearchFilter>
<SearchFilter>
<Text>Visibility</Text>
<Listbox value={visibility} onValueChange={setVisibility}>
<ListboxOption value="Public">Public</ListboxOption>
<ListboxOption value="Unlisted">Unlisted</ListboxOption>
<ListboxOption value="Private">Private</ListboxOption>
</Listbox>
</SearchFilter>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Update Playlist</Busy>
</Button>
</Row>
{error ? (
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
) : null}
</Column>
</StyledForm>
);
}
@@ -0,0 +1,104 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import { Text } from "components/text";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import { LoginGate } from "components/auth/LoginGate";
import { mutate } from "swr";
import { Busy } from "components/utils/Busy";
import gql from "graphql-tag";
import { useToasts } from "context/toastContext";
import { PlaylistRemoveToast } from "components/toast/PlaylistRemoveToast";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import type { PlaylistRemoveDialogPlaylistFragment } from "generated/graphql";
interface PlaylistRemoveDialogProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
trigger?: ReactNode;
}
export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Delete playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistRemoveForm
playlist={playlist}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistRemoveDialog.fragments = {
playlist: gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistRemoveToast.fragments.playlist}
fragment PlaylistRemoveDialogPlaylist on Playlist {
...PlaylistSummaryCardPlaylist
...PlaylistRemoveToastPlaylist
id
name
}
`,
};
interface PlaylistRemoveFormProps {
playlist: PlaylistRemoveDialogPlaylistFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removePlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}`);
await mutate((key) => [key].flat().includes("/api/me/playlist"));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-${playlist.id}`,
<PlaylistRemoveToast playlist={playlist} />
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<PlaylistSummaryCard playlist={playlist} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
<Busy isBusy={isBusy}>Delete playlist</Busy>
</Button>
</Row>
</Column>
);
}
@@ -0,0 +1,232 @@
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faArrowDown, faListMusic, faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { Column, Row } from "components/box";
import type { ReactNode } from "react";
import { useState } from "react";
import axios from "lib/client/axios";
import useSWR, { mutate } from "swr";
import { Icon } from "components/icon";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
import { useToasts } from "context/toastContext";
import type {
PlaylistTrackAddDialogVideoFragment,
PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables
} from "generated/graphql";
import { Text } from "components/text";
import { PlaylistTrackAddToast } from "components/toast/PlaylistTrackAddToast";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { LoginGate } from "components/auth/LoginGate";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import gql from "graphql-tag";
import { Skeleton } from "components/skeleton/Skeleton";
import { Busy } from "components/utils/Busy";
import { fetchDataClient } from "lib/client";
import { PlaylistAddDialog } from "components/dialog/PlaylistAddDialog";
interface PlaylistTrackAddDialogProps {
video: PlaylistTrackAddDialogVideoFragment;
trigger?: ReactNode;
}
export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackAddForm
video={video}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackAddDialog.fragments = {
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackAddDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
id
}
`,
};
interface PlaylistTrackAddFormProps {
video: PlaylistTrackAddDialogVideoFragment;
onCancel(): void;
}
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
const { data: playlists } = useSWR(
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
async () => {
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
${PlaylistSummaryCard.fragments.playlist}
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
me {
playlistAll {
...PlaylistSummaryCardPlaylist
id
tracks_count
}
playlistAllFiltered: playlistAll(filterVideoId: $filterVideoId) {
id
tracks {
id
}
}
}
}
`, { filterVideoId: video.id });
const { playlistAll, playlistAllFiltered } = data.me;
return playlistAll?.map((playlist) => {
return {
...playlist,
...playlistAllFiltered?.find((p) => p.id === playlist.id)
};
}) ?? [];
},
);
if (!playlists) {
return (
<Column style={{ "--gap": "24px" }}>
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
<Skeleton variant="summary-card" />
</Column>
);
}
return (
<Column style={{ "--gap": "24px" }}>
<VideoSummaryCard video={video} />
<Row style={{ "--justify-content": "center" }}>
<Icon icon={faArrowDown} color="text-disabled" />
</Row>
<Column style={{ "--gap": "16px" }}>
{playlists?.length ? playlists.map((playlist) => (
<PlaylistTrackAddCard
key={playlist.id}
playlist={playlist}
video={video}
/>
)) : (
<Text>You have not created a playlist, yet.</Text>
)}
<PlaylistAddDialog trigger={
<Button style={{ "--gap": "8px" }}>
<Icon icon={faPlus} />
<Text>Create new Playlist</Text>
</Button>
} />
</Column>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
</Row>
</Column>
);
}
interface PlaylistTrackAddCardProps {
playlist:
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
video: PlaylistTrackAddDialogVideoFragment;
}
function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function addTrackToPlaylist() {
setBusy(true);
try {
await axios.post(`/api/playlist/${playlist.id}/track`, { video_id: video.id, });
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-add-track-${playlist.id}-${video.id}`,
<PlaylistTrackAddToast playlist={playlist} video={video} />
);
}
async function removeTrackFromPlaylist() {
const track = playlist.tracks?.[0];
if (!track) {
return;
}
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${track.id}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${track.id}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
}
return (
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
{!playlist.tracks?.length ? (
<IconTextButton
icon={faPlus}
disabled={isBusy}
onClick={addTrackToPlaylist}
>
<Busy isBusy={isBusy}>Add</Busy>
</IconTextButton>
) : (
<IconTextButton
icon={faMinus}
disabled={isBusy}
onClick={removeTrackFromPlaylist}
>
<Busy isBusy={isBusy}>Remove</Busy>
</IconTextButton>
)}
</PlaylistSummaryCard>
);
}
@@ -0,0 +1,125 @@
import type {
PlaylistTrackRemoveDialogPlaylistFragment,
PlaylistTrackRemoveDialogVideoFragment
} from "generated/graphql";
import type { ReactNode } from "react";
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Button, IconTextButton } from "components/button";
import { faMinus } from "@fortawesome/pro-solid-svg-icons";
import { LoginGate } from "components/auth/LoginGate";
import axios from "lib/client/axios";
import { Column, Row } from "components/box";
import { VideoSummaryCard, VideoSummaryCardFragmentVideo } from "components/card/VideoSummaryCard";
import { Text } from "components/text";
import { PlaylistTrackRemoveToast } from "components/toast/PlaylistTrackRemoveToast";
import { useToasts } from "context/toastContext";
import { mutate } from "swr";
import gql from "graphql-tag";
import { Busy } from "components/utils/Busy";
interface PlaylistTrackRemoveDialogProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
trigger?: ReactNode;
}
export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }: PlaylistTrackRemoveDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Remove from playlist">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<LoginGate>
<PlaylistTrackRemoveForm
playlist={playlist}
trackId={trackId}
video={video}
onSuccess={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</LoginGate>
) : null}
</DialogContent>
</Dialog>
);
}
PlaylistTrackRemoveDialog.fragments = {
playlist: gql`
${PlaylistTrackRemoveToast.fragments.playlist}
fragment PlaylistTrackRemoveDialogPlaylist on Playlist {
...PlaylistTrackRemoveToastPlaylist
id
name
}
`,
video: gql`
${VideoSummaryCardFragmentVideo}
${PlaylistTrackRemoveToast.fragments.video}
fragment PlaylistTrackRemoveDialogVideo on Video {
...VideoSummaryCardVideo
...PlaylistTrackRemoveToastVideo
}
`,
};
interface PlaylistTrackRemoveFormProps {
playlist: PlaylistTrackRemoveDialogPlaylistFragment;
trackId: string;
video: PlaylistTrackRemoveDialogVideoFragment;
onSuccess(): void;
onCancel(): void;
}
function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel }: PlaylistTrackRemoveFormProps) {
const { dispatchToast } = useToasts();
const [isBusy, setBusy] = useState(false);
async function removeTrackFromPlaylist() {
setBusy(true);
try {
await axios.delete(`/api/playlist/${playlist.id}/track/${trackId}`);
await mutate((key) => (
[key].flat().some((key) =>
key === `/api/playlist/${playlist.id}` ||
key === "/api/me/playlist"
)
));
} finally {
setBusy(false);
}
dispatchToast(
`playlist-remove-track-${playlist.id}-${trackId}`,
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
);
onSuccess();
}
return (
<Column style={{ "--gap": "24px" }}>
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
<VideoSummaryCard video={video} />
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button variant="silent" onClick={onCancel}>Close</Button>
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
<Busy isBusy={isBusy}>Remove from playlist</Busy>
</Button>
</Row>
</Column>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { SyntheticEvent } from "react";
import { useState } from "react";
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Input } from "components/form";
import { Button } from "components/button";
import useAuth from "hooks/useAuth";
import styled from "styled-components";
import Switch from "components/form/Switch";
import { Column, Row } from "components/box";
import { Dialog, DialogContent, DialogTrigger } from "components/dialog/Dialog";
import { Busy } from "components/utils/Busy";
import Link from "next/link";
export function RegisterDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create new account</Button>
</DialogTrigger>
<DialogContent title="Create a new account">
{/* Only render the form when dialog is open, so it will reset after closing. */}
{open ? (
<RegisterForm onCancel={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface RegisterFormProps {
onCancel(): void;
}
function RegisterForm({ onCancel }: RegisterFormProps) {
const { register } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [isTermsAccepted, setTermsAccepted] = useState(false);
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
const [isBusy, setBusy] = useState(false);
const [errors, setErrors] = useState<{
name?: string[];
email?: string[];
password?: string[]
}>({});
function performRegister(event: SyntheticEvent) {
event.preventDefault();
setBusy(true);
register({
setErrors,
name: username,
email,
password,
password_confirmation: passwordConfirmation,
terms: isTermsAccepted,
})
.finally(() => setBusy(false));
}
return (
<>
<StyledForm onSubmit={performRegister}>
<Column style={{ "--gap": "24px" }}>
<SearchFilter>
<Text>Username</Text>
<Input
value={username}
onChange={setUsername}
inputProps={{
required: true,
}}
/>
{errors.name ? errors.name.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>E-Mail</Text>
<Input
value={email}
onChange={setEmail}
inputProps={{
type: "email",
required: true,
}}
/>
{errors.email ? errors.email.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Password</Text>
<Input
value={password}
onChange={setPassword}
inputProps={{
type: "password",
required: true,
}}
/>
{errors.password ? errors.password.map((error) => (
<Text key={error} color="text-warning">{error}</Text>
)) : null}
</SearchFilter>
<SearchFilter>
<Text>Confirm Password</Text>
<Input
value={passwordConfirmation}
onChange={setPasswordConfirmation}
inputProps={{
type: "password",
required: true,
}}
/>
</SearchFilter>
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
<Switch
id="input-terms"
isChecked={isTermsAccepted}
onCheckedChange={setTermsAccepted}
/>
<Text as="label" htmlFor="input-terms">I accept the{" "}
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
</Text>
</Row>
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
<Busy isBusy={isBusy}>Create Account</Busy>
</Button>
</Row>
</Column>
</StyledForm>
</>
);
}
@@ -0,0 +1,84 @@
import styled, { keyframes } from "styled-components";
import theme from "theme";
import Link from "next/link";
import { faArrowRight, faTrophy } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Icon } from "components/icon";
import { Button } from "components/button";
const EventButtonContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
const EventButton = styled(Button)`
position: relative;
justify-content: space-between;
height: 64px;
border-radius: 8px;
gap: 8px;
overflow: hidden;
`;
const EventIcon = styled(Icon)`
margin: 0 0 -1rem -2rem;
font-size: 56px;
color: ${theme.colors["text-disabled"]};
`;
const GradientBorder = styled.div`
position: absolute;
inset: 0;
border-radius: 8px;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-2px, -2px);
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
--gradient-angle: 360deg;
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
animation: ${keyframes`
from {
--gradient-angle: 0deg;
}
to {
--gradient-angle: 360deg;
}
`} 5s linear infinite;
transition: opacity 250ms;
opacity: 0;
${EventButtonContainer}:hover & {
opacity: 1;
}
`;
interface AnimeAwardsNowAvailableProps {
year: number;
}
export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps) {
return (
<EventButtonContainer>
<GradientBorder />
<Link href="/event/anime-awards" passHref legacyBehavior>
<EventButton forwardedAs="a">
<EventIcon icon={faTrophy} />
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
<Icon icon={faArrowRight} color="text-primary" />
</EventButton>
</Link>
</EventButtonContainer>
);
}
+31 -32
View File
@@ -3,13 +3,14 @@ import styled, { keyframes } from "styled-components";
import theme from "theme";
import { ThemeSummaryCard } from "components/card";
import extractImages from "utils/extractImages";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import useSetting from "hooks/useSetting";
import useCompatability from "hooks/useCompatability";
import { fetchRandomGrill } from "lib/client/randomGrill";
import createVideoSlug from "utils/createVideoSlug";
import Link from "next/link";
import { VIDEO_URL } from "utils/config";
import { VIDEO_URL } from "utils/config.mjs";
import gql from "graphql-tag";
import type { FeaturedThemeThemeFragment } from "generated/graphql";
@@ -47,7 +48,7 @@ const StyledWrapper = styled.div`
}
`;
const StyledOverflowHidden = styled.a`
const StyledOverflowHidden = styled(Link)`
width: 100%;
height: 100%;
border-radius: 0.5rem;
@@ -117,16 +118,20 @@ const StyledGrill = styled.img`
const Box = styled.div``;
interface FeaturedThemeProps {
theme: FeaturedThemeThemeFragment
theme: FeaturedThemeThemeFragment;
hasGrill?: boolean;
card?: ReactNode;
}
export function FeaturedTheme({ theme }: FeaturedThemeProps) {
export function FeaturedTheme({ theme, hasGrill = true, card }: FeaturedThemeProps) {
const [ grill, setGrill ] = useState<string | null>(null);
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
useEffect(() => {
fetchRandomGrill().then(setGrill);
}, []);
if (hasGrill) {
fetchRandomGrill().then(setGrill);
}
}, [hasGrill]);
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
? StyledWrapper
@@ -135,11 +140,11 @@ export function FeaturedTheme({ theme }: FeaturedThemeProps) {
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
? (
<StyledCenter>
<ThemeSummaryCard theme={theme}/>
{card ?? <ThemeSummaryCard theme={theme}/>}
</StyledCenter>
)
: (
<ThemeSummaryCard theme={theme}/>
card ?? <ThemeSummaryCard theme={theme}/>
);
return (
@@ -174,36 +179,30 @@ function FeaturedThemeBackground({ theme }: FeaturedThemeProps) {
const video = entry.videos[0];
const videoSlug = createVideoSlug(theme, entry, video);
const linkProps = {
href: `/anime/${theme.anime.slug}/${videoSlug}`,
passHref: true,
};
const href = `/anime/${theme.anime.slug}/${videoSlug}`;
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
return (
<Link {...linkProps} legacyBehavior>
<StyledOverflowHidden>
<StyledVideo
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
</StyledVideo>
</StyledOverflowHidden>
</Link>
<StyledOverflowHidden href={href}>
<StyledVideo
key={video.basename}
autoPlay
muted
loop
onError={() => setFallbackToCover(true)}
>
<source
src={`${VIDEO_URL}/${video.basename}`}
type={`video/webm; codecs="vp8, vp9, opus`}
/>
</StyledVideo>
</StyledOverflowHidden>
);
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
return (
<Link {...linkProps} legacyBehavior>
<StyledOverflowHidden>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
</Link>
<StyledOverflowHidden href={href}>
<StyledCover src={featuredCover}/>
</StyledOverflowHidden>
);
}
+8 -8
View File
@@ -1,11 +1,11 @@
import { memo, useMemo, useState } from "react";
import { Column, Row } from "components/box";
import { ThemeDetailCard } from "components/card";
import { Listbox } from "components/listbox";
import { HorizontalScroll } from "components/utils";
import { either, themeIndexComparator, themeTypeComparator } from "utils/comparators";
import gql from "graphql-tag";
import type { AnimeThemeFilterThemeFragment } from "generated/graphql";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface AnimeThemeFilterProps {
themes: Array<AnimeThemeFilterThemeFragment>
@@ -39,7 +39,7 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
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 [ filterType, setFilterType ] = useState<string | null>(null);
const filteredThemes = activeThemes
.filter((theme) => !filterType || theme.type === filterType)
@@ -51,17 +51,17 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
<HorizontalScroll fixShadows>
<Row style={{ "--gap": "16px" }}>
{groups.length > 1 && (
<Listbox value={activeGroup} onChange={setActiveGroup}>
<Listbox value={activeGroup} onValueChange={setActiveGroup}>
{groups.map((group) => (
<Listbox.Option key={group.name} value={group.name}>{group.name}</Listbox.Option>
<ListboxOption key={group.name} value={group.name}>{group.name}</ListboxOption>
))}
</Listbox>
)}
{hasMultipleTypes && (
<Listbox value={filterType} onChange={setFilterType} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>OP & ED</Listbox.Option>
<Listbox.Option value="OP">OP</Listbox.Option>
<Listbox.Option value="ED">ED</Listbox.Option>
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
)}
</Row>
+14 -9
View File
@@ -8,7 +8,6 @@ import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import type { ComponentPropsWithoutRef } from "react";
import { APP_URL } from "utils/config";
const StyledFooter = styled(Solid)`
margin-top: auto;
@@ -30,11 +29,17 @@ const StyledSocialList = styled(Row)`
justify-content: flex-end;
align-items: flex-end;
gap: 4px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-basis: 100%;
justify-content: flex-start;
}
// To avoid overlap with scroll back to top button as window width gets smaller
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
margin-right: 64px;
}
`;
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
@@ -46,24 +51,24 @@ export function Footer() {
<StyledFooter>
<StyledContainer>
<StyledLinkList>
<FooterTextLink href={`${APP_URL}/transparency`}>
<FooterTextLink href="/about/transparency">
Transparency
</FooterTextLink>
<FooterTextLink forwardedAs={Link} href="/wiki/donate" target="_self">
<FooterTextLink href="/about/donate">
Donate
</FooterTextLink>
<FooterTextLink forwardedAs={Link} href="/wiki/faq" target="_self">
<FooterTextLink href="/about/faq">
FAQ
</FooterTextLink>
</StyledLinkList>
<StyledLinkList>
<FooterTextLink href={`${APP_URL}/terms-of-service`}>
<FooterTextLink href="/about/terms-of-service">
Terms of Service
</FooterTextLink>
<FooterTextLink href={`${APP_URL}/privacy-policy`}>
<FooterTextLink href="/about/privacy-policy">
Privacy Policy
</FooterTextLink>
<FooterTextLink href="mailto:admin@animethemes.moe">
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
Contact
</FooterTextLink>
</StyledLinkList>
@@ -96,7 +101,7 @@ export function Footer() {
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
return (
<Text as="a" target="_blank" rel="noopener" {...props}/>
<Text as={Link} {...props}/>
);
}
@@ -1,10 +1,10 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { Icon } from "components/icon";
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { ComponentPropsWithoutRef, FormEvent } from "react";
import type { ComponentPropsWithoutRef } from "react";
import { withColorTheme } from "styles/mixins";
const StyledSearchInput = styled.div`
@@ -22,14 +22,11 @@ const StyledSearchInput = styled.div`
&:focus-within {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark", css`
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`)}
`}
}
`;
const StyledForm = styled.form`
flex: 1;
`;
const StyledInput = styled.input`
width: 100%;
`;
@@ -45,7 +42,6 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
value: string
onChange: (value: string) => void
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
resettable?: boolean
icon?: IconDefinition
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
@@ -54,7 +50,6 @@ interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput>
export function Input({
value,
onChange,
onSubmit,
resettable = false,
icon,
inputProps = {},
@@ -65,19 +60,12 @@ export function Input({
{icon && (
<Icon icon={icon} color="text-disabled"/>
)}
<StyledForm onSubmit={(event) => {
event.preventDefault();
if (onSubmit) {
onSubmit(event);
}
}}>
<StyledInput
type="text"
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
</StyledForm>
<StyledInput
type="text"
value={value}
onChange={(event) => onChange && onChange(event.target.value)}
{...inputProps}
/>
{resettable && !!value && (
<StyledResetButton>
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
+50
View File
@@ -0,0 +1,50 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import styled from "styled-components";
import theme from "theme";
import { withColorTheme } from "styles/mixins";
const StyledSwitch = styled(RadixSwitch.Root)`
width: 50px;
height: 25px;
background-color: ${theme.colors["solid-on-card"]};
border-radius: 9999px;
position: relative;
box-shadow: 0 2px 10px var(--blackA7);
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
&:focus {
box-shadow: ${theme.shadows.low};
${withColorTheme("dark")`
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
`}
}
`;
const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
display: block;
width: 25px;
height: 25px;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
transition: transform 250ms, background-color 250ms;
will-change: transform;
&[data-state='checked'] {
transform: translateX(25px);
background-color: ${theme.colors["text-primary"]};
}
`;
interface Props extends RadixSwitch.SwitchProps {
isChecked: boolean;
onCheckedChange(isChecked: boolean): void;
}
export default function Switch({ isChecked, onCheckedChange, ...props }: Props) {
return (
<StyledSwitch checked={isChecked} onCheckedChange={onCheckedChange} {...props}>
<StyledSwitchThumb />
</StyledSwitch>
);
}
+1
View File
@@ -0,0 +1 @@
export { Input } from "components/form/Input";
+2 -3
View File
@@ -2,12 +2,11 @@ import styled from "styled-components";
import { Icon } from "components/icon/Icon";
import theme from "theme";
export const CornerIcon = styled(Icon).attrs({
size: "2x"
})`
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);
`;
+16 -12
View File
@@ -32,6 +32,7 @@ const StyledCoverContainer = styled.div`
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
isolation: isolate;
`;
const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
position: absolute;
@@ -114,18 +115,21 @@ interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCov
}
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
const images = [
[ extractImages(resourcesWithImages[0]), resourcesWithImages[0] ] as const,
[ extractImages(resourcesWithImages[1]), resourcesWithImages[1] ] as const,
[ extractImages(resourcesWithImages[2]), resourcesWithImages[2] ] as const,
[ extractImages(resourcesWithImages[3]), resourcesWithImages[3] ] as const
]
.map(([ images, resource ]) => ({
largeCover: images.largeCover,
smallCover: images.smallCover,
resource
}))
.filter(({ largeCover }) => !!largeCover);
const images = resourcesWithImages
.filter((resource, index, list) =>
list.findIndex((r) => r.name === resource.name) === index
)
.map((resource) => {
const { largeCover, smallCover } = extractImages(resource);
return {
largeCover,
smallCover,
resource,
};
})
.filter(({ largeCover }) => !!largeCover)
.slice(0, 4);
return (
<AspectRatio ratio={2 / 3}>
+31
View File
@@ -0,0 +1,31 @@
import gql from "graphql-tag";
import type { ProfileImageUserFragment } from "generated/graphql";
import md5 from "md5";
interface ProfileImageProps {
user: ProfileImageUserFragment;
size?: number;
}
export function ProfileImage({ user, size = 80, ...props }: ProfileImageProps) {
const hash = md5(user.email.trim().toLowerCase());
return (
<img
src={`https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`}
alt={user.name}
width={size}
height={size}
{...props}
/>
);
}
ProfileImage.fragments = {
user: gql`
fragment ProfileImageUser on UserAuth {
name
email
}
`,
};
@@ -49,7 +49,6 @@ export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, chil
key={firstLetter}
href={`#${firstLetter}`}
passHref
prefetch={false}
legacyBehavior>
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
</Link>
-1
View File
@@ -1 +0,0 @@
export { Input } from "components/input/Input";
+153 -69
View File
@@ -1,84 +1,168 @@
import { ListboxCustom, ListboxNative } from "components/listbox";
import styled from "styled-components";
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import theme from "theme";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import useMediaQuery from "hooks/useMediaQuery";
import * as RadixSelect from "@radix-ui/react-select";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
import { forwardRef } from "react";
const ListboxContext = createContext({
isMobile: false
});
// Radix' listbox can't handle null values, so we are giving it a fake null value.
const NULL_VALUE = "__NULL__";
export interface ListboxProps<T extends string | null> {
children: ReactNode
value: T
onChange: (newValue: T) => void
resettable?: boolean
defaultValue?: T | null
highlightNonDefault?: boolean
disabled?: boolean
const StyledListboxButton = styled(Button)`
display: inline-flex;
align-items: center;
justify-content: space-between;
white-space: nowrap;
gap: 8px;
&:focus {
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledListboxPopover = styled(RadixSelect.Portal)`
min-width: max-content;
padding: 0;
border-radius: 8px;
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
// Revert CSS rules set by Radix UI which hide the native scrollbar.
[data-radix-select-viewport] {
scrollbar-width: revert;
-ms-overflow-style: revert;
}
[data-radix-select-viewport]::-webkit-scrollbar {
display: revert;
}
`;
const StyledListboxList = styled(RadixSelect.Content)`
width: var(--radix-select-trigger-width);
max-height: calc(var(--radix-select-content-available-height) - 32px);
padding: 8px 0;
`;
const StyledListboxOption = styled(RadixSelect.Item)`
display: ${(props) => props.hidden ? "none" : "flex"};
align-items: center;
justify-content: space-between;
padding: 8px 16px;
gap: 8px;
color: ${theme.colors["text-muted"]};
cursor: pointer;
&[data-highlighted] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
outline: none;
}
&[data-state="checked"] {
color: ${theme.colors["text-primary"]};
}
`;
type ListboxProps =
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
& (PropsNullable | PropsNotNullable)
& {
resettable?: boolean;
highlightNonDefault?: boolean;
};
interface PropsNullable {
nullable: true;
value: string | null;
onValueChange(newValue: string | null): void;
defaultValue?: string | null;
}
export function Listbox<T extends string | null>({
value,
onChange,
interface PropsNotNullable {
nullable?: false;
value: string;
onValueChange(newValue: string): void;
defaultValue?: string;
}
// Adds a reset button to the list box once a non-default value has been selected.
resettable = false,
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(
function Listbox({
value,
onValueChange,
defaultValue,
nullable,
resettable,
highlightNonDefault,
children,
...props
}, ref) {
const radixValue = value === null ? NULL_VALUE : value;
const radixOnValueChange = (newValue: string) => {
if (nullable) {
onValueChange(newValue === NULL_VALUE ? null : newValue);
} else {
onValueChange(newValue);
}
};
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
// The value to return to after a reset.
defaultValue = null,
// Highlights the listbox if an option other than defaultValue is selected.
highlightNonDefault = false,
disabled = false,
...props
}: ListboxProps<T>) {
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
if (isMobile) {
return (
<ListboxContext.Provider value={{ isMobile }}>
<ListboxNative
value={value}
onChange={onChange}
resettable={resettable}
defaultValue={defaultValue}
highlightNonDefault={highlightNonDefault}
disabled={disabled}
{...props}
/>
</ListboxContext.Provider>
<RadixSelect.Root value={radixValue} onValueChange={radixOnValueChange} defaultValue={radixDefaultValue} {...props}>
<RadixSelect.Trigger asChild ref={ref}>
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
<RadixSelect.Value />
<RadixSelect.Icon>
{(resettable && radixDefaultValue && value !== defaultValue) ? (
<StyledListboxReset
onClick={() => radixOnValueChange(radixDefaultValue)}
onPointerDown={(event) => event.stopPropagation()}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort} />
)}
</RadixSelect.Icon>
</StyledListboxButton>
</RadixSelect.Trigger>
<StyledListboxPopover>
<StyledListboxList position="popper" sideOffset={8}>
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
</StyledListboxList>
</StyledListboxPopover>
</RadixSelect.Root>
);
}
);
return (
<ListboxContext.Provider value={{ isMobile }}>
<ListboxCustom
value={value}
onChange={onChange}
resettable={resettable}
defaultValue={defaultValue}
highlightNonDefault={highlightNonDefault}
disabled={disabled}
{...props}
/>
</ListboxContext.Provider>
);
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
value: string | null;
}
export interface ListboxOptionProps {
value?: string | null
children: string
hidden?: boolean
}
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
function ListboxOption({ value, children, ...props }, ref) {
const radixValue = value === null ? NULL_VALUE : value;
Listbox.Option = function ListboxOption(props: ListboxOptionProps) {
const { isMobile } = useContext(ListboxContext);
if (isMobile) {
return <ListboxNative.Option {...props}/>;
return (
<StyledListboxOption value={radixValue} {...props} ref={ref}>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
<RadixSelect.ItemIndicator>
<Icon icon={faCheck} />
</RadixSelect.ItemIndicator>
</StyledListboxOption>
);
}
return <ListboxCustom.Option {...props}/>;
};
);
-167
View File
@@ -1,167 +0,0 @@
import styled from "styled-components";
import { faCheck, faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
import {
ListboxButton,
ListboxInput,
ListboxList,
ListboxOption,
ListboxPopover,
useListboxContext,
} from "@reach/listbox";
import { flipDown } from "styles/animations";
import { Icon } from "components/icon";
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
import type { SyntheticEvent } from "react";
// ReachUI's listbox can't handle null values, so we are giving it a fake null value.
// The caveat is, that we can't use this value normally, thus it's obscure name.
const NULL_VALUE = "__NULL__";
const StyledListbox = styled(ListboxInput)`
display: inline-block;
`;
const StyledListboxButton = styled(Button)`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
white-space: nowrap;
gap: 8px;
&[aria-expanded="true"] {
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledListboxPopover = styled(ListboxPopover)`
z-index: ${theme.zIndices.menuPopover};
min-width: max-content;
margin-top: 0.5rem;
padding: 0;
border-radius: 0.5rem;
overflow: hidden;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
`;
const StyledListboxList = styled(ListboxList)`
max-height: 33vh;
margin: 0;
padding: 0.5rem 0;
list-style: none;
overflow: auto;
`;
const StyledListboxOption = styled(ListboxOption)`
display: ${(props) => props.hidden ? "none" : "flex"};
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
gap: 8px;
color: ${theme.colors["text-muted"]};
cursor: pointer;
&[data-current-nav] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
}
&[data-current-selected] {
color: ${theme.colors["text-primary"]};
}
`;
export function ListboxCustom<T extends string | null>({
children,
value,
onChange,
resettable,
defaultValue = null,
highlightNonDefault,
disabled,
...props
}: ListboxProps<T>) {
function handleChange(newValue: unknown) {
if (newValue === NULL_VALUE) {
onChange(null as T);
} else{
onChange(newValue as T);
}
}
function handleResetClick() {
if (!disabled) {
onChange(defaultValue as T);
}
}
function stopPropagation(event: SyntheticEvent) {
event.stopPropagation();
}
return (
<StyledListbox
value={value ?? NULL_VALUE}
onChange={handleChange}
{...props}
>
{({ valueLabel }) => (
<>
<StyledListboxButton
forwardedAs={ListboxButton}
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
disabled={disabled}
>
<Text>{valueLabel}</Text>
{(resettable && value !== defaultValue) ? (
<StyledListboxReset
onClick={handleResetClick}
onMouseDown={stopPropagation}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort}/>
)}
</StyledListboxButton>
<StyledListboxPopover>
<StyledListboxList>
{children}
</StyledListboxList>
</StyledListboxPopover>
</>
)}
</StyledListbox>
);
}
ListboxCustom.Option = function ListboxCustomOption({ value, children, hidden = false }: ListboxOptionProps) {
const context = useListboxContext();
if (!context) {
return null;
}
return (
<StyledListboxOption value={value ?? NULL_VALUE} hidden={hidden}>
<Text>{children}</Text>
{value === context.value && (
<Icon icon={faCheck}/>
)}
</StyledListboxOption>
);
};
-144
View File
@@ -1,144 +0,0 @@
import styled from "styled-components";
import { faSort, faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "components/button";
import { Text } from "components/text";
import theme from "theme";
import type { SyntheticEvent } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { Icon } from "components/icon";
import type { ListboxOptionProps, ListboxProps } from "components/listbox/Listbox";
const NULL_VALUE = "__NULL__";
interface IListboxContext {
setLabel: (value: string | null, label: string) => void
removeLabel: (value: string | null) => void
}
const ListboxContext = createContext<IListboxContext>({
setLabel: () => { /* Do nothing. */ },
removeLabel: () => { /* Do nothing. */ },
});
const StyledListbox = styled.div`
display: inline-block;
position: relative;
`;
const StyledListboxButton = styled(Button)`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
white-space: nowrap;
gap: 8px;
`;
const StyledListboxReset = styled.span`
display: inline-block;
isolation: isolate;
margin: -8px;
padding: 8px;
`;
const StyledSelect = styled.select`
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
background-color: ${theme.colors["solid"]};
color: ${theme.colors["text-muted"]};
`;
export function ListboxNative<T extends string | null>({
children,
value,
onChange,
resettable,
defaultValue = null,
highlightNonDefault,
disabled,
...props
}: ListboxProps<T>) {
const [valueLabels, setValueLabels] = useState(() => new Map());
const contextValue: IListboxContext = {
setLabel: useCallback((value, label) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
newMap.set(value, label);
return newMap;
});
}, []),
removeLabel: useCallback((value) => {
setValueLabels((oldMap) => {
const newMap = new Map(oldMap);
newMap.delete(value);
return newMap;
});
}, [])
};
function handleChange(newValue: T) {
if (newValue === NULL_VALUE) {
onChange(null as T);
} else{
onChange(newValue);
}
}
function handleResetClick() {
if (!disabled) {
onChange(defaultValue as T);
}
}
function stopPropagation(event: SyntheticEvent) {
event.stopPropagation();
}
return (
<StyledListbox {...props}>
<StyledSelect
value={value ?? NULL_VALUE}
onChange={(event) => handleChange(event.target.value as T)}
>
<ListboxContext.Provider value={contextValue}>
{children}
</ListboxContext.Provider>
</StyledSelect>
<StyledListboxButton
variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}
disabled={disabled}
>
<Text>{valueLabels.get(value)}</Text>
{(resettable && value !== defaultValue) ? (
<StyledListboxReset
onClick={handleResetClick}
onMouseDown={stopPropagation}
>
<Icon icon={faTimes}/>
</StyledListboxReset>
) : (
<Icon icon={faSort}/>
)}
</StyledListboxButton>
</StyledListbox>
);
}
ListboxNative.Option = function ListboxNativeOption({ value = null, children }: ListboxOptionProps) {
const { setLabel, removeLabel } = useContext(ListboxContext);
useEffect(() => {
setLabel(value, children);
return () => removeLabel(value);
}, [children, removeLabel, setLabel, value]);
return (
<option value={value ?? NULL_VALUE}>
{children}
</option>
);
};
-3
View File
@@ -1,3 +0,0 @@
export { Listbox } from "./Listbox";
export { ListboxCustom } from "./ListboxCustom";
export { ListboxNative } from "./ListboxNative";
+140
View File
@@ -0,0 +1,140 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { MDXRemote } from "next-mdx-remote";
import type { ComponentPropsWithoutRef } from "react";
import { TextLink } from "components/text/TextLink";
import { Text } from "components/text";
import { Card } from "components/card";
import styled from "styled-components";
import theme from "theme";
import type { MDXComponents } from "mdx/types";
const StyledMarkdown = styled.div`
line-height: 1.75;
word-break: break-word;
& h1 {
margin-bottom: 32px;
}
& h2 {
margin-bottom: 24px;
font-size: 1.2rem;
}
& h3 {
margin-bottom: 16px;
}
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
margin-top: 48px;
}
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
margin-top: 32px;
}
& p {
margin-top: 0;
margin-bottom: 16px;
}
& table {
width: 100%;
table-layout: auto;
text-align: left;
border-collapse: collapse;
}
& thead {
border-bottom: 1px solid ${theme.colors["text-muted"]};
& th {
font-weight: 600;
vertical-align: bottom;
padding-left: 8px;
padding-right: 8px;
padding-bottom: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
& tbody tr {
border-bottom: 1px solid ${theme.colors["text-disabled"]};
&:last-child {
border-bottom-width: 0;
}
}
& tbody td {
vertical-align: baseline;
padding: 8px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
& pre {
margin-bottom: 16px;
overflow-x: auto;
}
& pre > code {
display: block;
min-width: 100%;
width: max-content;
padding: 16px;
}
& ${Card} {
margin-bottom: 16px;
}
& img {
border-radius: ${theme.scalars.borderRadiusCard};
}
`;
interface MarkdownProps {
source: MDXRemoteSerializeResult;
components?: MDXComponents;
}
export function Markdown({ source, components = {} }: MarkdownProps) {
return (
<StyledMarkdown>
<MDXRemote
{...source}
components={{
a: (props: ComponentPropsWithoutRef<"a">) => {
const { href } = props;
if (href?.startsWith("/")) {
return <TextLink href={href} {...props}/>;
}
return <Text as="a" link href={href} {...props}/>;
},
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
Card,
...components,
}}
/>
</StyledMarkdown>
);
}
@@ -0,0 +1,84 @@
import type { Heading } from "utils/rehypeExtractHeadings";
import { useEffect, useState } from "react";
import { Text } from "components/text";
import styled from "styled-components";
import { m } from "framer-motion";
import theme from "theme";
const StyledTableOfContents = styled.ul`
position: sticky;
// TODO: Magic value neccessary?
top: 92px;
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 16px;
max-height: calc(100vh - 92px);
padding-left: 16px;
padding-bottom: 16px;
list-style: none;
overflow-y: auto;
& > li {
position: relative;
display: flex;
align-items: center;
}
`;
const StyledTableOfContentsHeading = styled.li<{ $depth: number }>`
padding-left: ${(props) => props.$depth === 3 && "16px"};
font-size: ${(props) => props.$depth === 3 && "0.9rem"};
`;
const StyledDot = styled(m.div)`
position: absolute;
left: -16px;
width: 4px;
height: 100%;
border-radius: 4px;
background-color: ${theme.colors["text-primary"]};
`;
export function TableOfContents({ headings }: { headings: Array<Heading> }) {
const [currentSlug, setCurrentSlug] = useState<string | undefined>();
useEffect(() => {
function onScroll() {
const headings = [...document.querySelectorAll<HTMLHeadingElement>("h2, h3")];
let currentHeading = null;
for (const heading of headings) {
if (heading.offsetTop > window.scrollY + window.innerHeight) {
break;
}
currentHeading = heading;
if (heading.offsetTop > window.scrollY) {
break;
}
}
setCurrentSlug(currentHeading?.id);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<StyledTableOfContents>
{headings.map(({ text, slug, depth }) => (
<StyledTableOfContentsHeading key={slug} $depth={depth}>
{slug === currentSlug && (
<StyledDot layoutId="dot"/>
)}
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
</StyledTableOfContentsHeading>
))}
</StyledTableOfContents>
);
}
+51 -70
View File
@@ -1,93 +1,74 @@
import styled, { css } from "styled-components";
import { Menu as ReachMenu, MenuButton, MenuItem, MenuItems, MenuPopover, } from "@reach/menu-button";
import { Button } from "components/button";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import { Icon } from "components/icon";
import { faEllipsisV } from "@fortawesome/pro-solid-svg-icons";
import { fadeIn, flipDown, slideIn } from "styles/animations";
import type { ComponentPropsWithoutRef, PropsWithChildren, ReactNode } from "react";
const StyledMenuItems = styled(MenuItems)`
z-index: ${theme.zIndices.menuPopover};
import { flipDown } from "styles/animations";
import { forwardRef } from "react";
import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu";
import * as RadixMenu from "@radix-ui/react-dropdown-menu";
const StyledMenuContent = styled(RadixMenu.Content)`
width: max-content;
margin-top: 0.5rem;
padding: 0.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
max-height: var(--radix-dropdown-menu-content-available-height);
padding: 8px 0;
border-radius: 8px;
overflow: auto;
background-color: ${theme.colors["solid"]};
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
transform-origin: top;
animation: ${flipDown} 200ms ease-out;
@media (max-width: 720px) {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
border-radius: 1rem 1rem 0 0;
z-index: ${theme.zIndices.toast};
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.high};
animation: ${slideIn()} 200ms both;
}
`;
const StyledMenuItem = styled(MenuItem)`
export const Menu = RadixMenu.Root;
export const MenuTrigger = RadixMenu.Trigger;
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
function MenuContent({ children, ...props }, forwardedRef) {
return (
<RadixMenu.Portal>
<StyledMenuContent align="start" sideOffset={8} collisionPadding={8} {...props} ref={forwardedRef}>
{children}
</StyledMenuContent>
</RadixMenu.Portal>
);
}
);
export const MenuItem = styled(RadixMenu.Item)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0.5rem 1rem;
padding: 8px 16px;
cursor: pointer;
color: ${theme.colors["text-muted"]};
${withHover(css`
&[data-highlighted] {
background-color: ${theme.colors["solid-on-card"]};
color: ${theme.colors["text"]};
`)}
`;
const StyledMenuOverlay = styled(MenuPopover)`
@media (max-width: 720px) {
position: fixed !important;
inset: 0 !important;
z-index: ${theme.zIndices.menuOverlay};
background-color: rgba(0, 0, 0, 0.5);
animation: ${fadeIn} 200ms both;
outline: none;
}
&[data-disabled] {
opacity: 0.5;
cursor: revert;
}
`;
interface MenuProps extends PropsWithChildren {
button?: (button: typeof MenuButton) => ReactNode
}
export const MenuLabel = styled(RadixMenu.Label)`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
color: ${theme.colors["text-muted"]};
font-size: 0.8rem;
`;
export function Menu({ button, children }: MenuProps) {
return (
<ReachMenu>
{button ? button(MenuButton) : (
<Button as={MenuButton} variant="silent" isCircle>
<Icon icon={faEllipsisV}/>
</Button>
)}
<StyledMenuOverlay>
<StyledMenuItems>
{children}
</StyledMenuItems>
</StyledMenuOverlay>
</ReachMenu>
);
}
interface MenuOptionProps extends PropsWithChildren, ComponentPropsWithoutRef<typeof StyledMenuItem> {}
Menu.Option = function MenuOption({ children, ...props }: MenuOptionProps) {
return (
<StyledMenuItem onSelect={() => { /* do nothing */ }} {...props}>
{children}
</StyledMenuItem>
);
};
export const MenuSeparator = styled(RadixMenu.Separator)`
height: 2px;
background-color: ${theme.colors["text-primary"]};
margin: 8px 0;
`;
+66 -26
View File
@@ -1,51 +1,91 @@
import { Icon } from "components/icon";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
import { faEllipsisV, faListMusic } from "@fortawesome/pro-solid-svg-icons";
import { Text } from "components/text";
import { Menu } from "components/menu";
import useLocalPlaylist from "hooks/useLocalPlaylist";
import gql from "graphql-tag";
import type { ThemeMenuThemeFragment } from "generated/graphql";
import { PlaylistTrackAddDialog } from "components/dialog/PlaylistTrackAddDialog";
import gql from "graphql-tag";
import { SongTitleWithArtists } from "components/utils";
import extractImages from "utils/extractImages";
import createVideoSlug from "utils/createVideoSlug";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu";
import { Button } from "components/button";
interface ThemeMenuProps {
theme: ThemeMenuThemeFragment
theme: ThemeMenuThemeFragment;
}
export function ThemeMenu({ theme }: ThemeMenuProps) {
const { addToPlaylist, removeFromPlaylist, isInPlaylist } = useLocalPlaylist();
const entry = theme.entries[0];
const video = entry?.videos[0];
const options = [
!!theme.id && (isInPlaylist(theme) ? (
<Menu.Option key="add" onSelect={() => removeFromPlaylist(theme)}>
<Icon icon={faMinus}/>
<Text>Remove from Playlist</Text>
</Menu.Option>
) : (
<Menu.Option key="remove" onSelect={() => addToPlaylist(theme)}>
<Icon icon={faPlus}/>
<Text>Add to Playlist</Text>
</Menu.Option>
))
].filter((option) => option);
if (!options.length) {
if (!entry || !video) {
return null;
}
return (
<Menu>
{options}
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisV} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={{
// Flip the structure on it's head,
// because we need video as the root object here.
...video,
entries: [{
...entry,
theme,
}],
}}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic} />
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
</MenuContent>
</Menu>
);
}
ThemeMenu.fragments = {
theme: gql`
${SongTitleWithArtists.fragments.song}
${extractImages.fragments.resourceWithImages}
${createVideoSlug.fragments.theme}
${createVideoSlug.fragments.entry}
${createVideoSlug.fragments.video}
fragment ThemeMenuTheme on Theme {
...createVideoSlugTheme
id
# Hidden inside local playlist context
slug
type
sequence
group
anime {
...extractImagesResourceWithImages
slug
name
}
song {
title
...SongTitleWithArtistsSong
}
entries {
...createVideoSlugEntry
videos {
...createVideoSlugVideo
id
basename
audio {
basename
}
}
}
}
`
`,
};
-2
View File
@@ -1,2 +0,0 @@
export { Menu } from "components/menu/Menu";
export { ThemeMenu } from "./ThemeMenu";
+34 -69
View File
@@ -1,88 +1,45 @@
import styled, { css } from "styled-components";
import { Container } from "components/container";
import { Button } from "components/button";
import theme from "theme";
import { blurOut, zoomIn } from "styles/animations";
import { Solid } from "components/box";
import { Logo } from "components/image";
import { withColorTheme } from "styles/mixins";
import { ProfileImage } from "components/image/ProfileImage";
export const StyledNavigation = styled(Solid).attrs({ as: "nav" })<{ show: boolean }>`
export const StyledNavigation = styled.nav<{ $floating: boolean }>`
position: sticky;
top: 0;
z-index: ${theme.zIndices.navigation};
background-color: ${theme.colors["background"]};
box-shadow: ${theme.shadows.medium};
@media (max-width: 720px) {
display: none;
align-items: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
transition: 100ms ease;
transition-property: background-color, box-shadow;
${(props) => props.$floating && css`
transition: 500ms ease;
background-color: transparent;
${(props) => props.show && css`
display: flex;
animation: ${blurOut} 250ms ease forwards;
`}
box-shadow: none;
`}
[data-fullscreen] & {
display: none;
}
`;
export const StyledNavigationContainer = styled(Container)`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
gap: 16px;
@media (min-width: 721px) {
flex-direction: row;
align-items: stretch;
padding: 0.5rem 1rem;
}
@media (max-width: 720px) {
flex-direction: column;
align-items: center;
padding: 1rem;
border-radius: 1rem;
width: auto;
background-color: ${theme.colors["solid"]};
animation: ${zoomIn} 250ms ease;
will-change: transform;
${withColorTheme("dark", css`
border: 2px solid ${theme.colors["text-disabled"]};
`)}
}
padding: 8px 16px;
`;
export const StyledNavigationLinks = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
@media (max-width: ${theme.breakpoints.mobileMax}) {
flex-direction: column;
align-items: flex-start;
}
`;
export const StyledCollapsibleLink = styled(Button).attrs({ variant: "silent" })`
gap: 8px;
@media (min-width: ${theme.breakpoints.tabletMin}) and (max-width: ${theme.breakpoints.tabletMax}) {
& > :last-child {
display: none;
}
}
gap: 16px;
`;
export const StyledLogoContainer = styled.a`
@@ -96,19 +53,27 @@ export const StyledLogoContainer = styled.a`
export const StyledLogo = styled(Logo)`
width: auto;
height: 2rem;
height: 1.5rem;
color: ${theme.colors["text"]};
`;
export const StyledMobileToggle = styled(Button)`
position: fixed;
bottom: ${(props) => props.offsetToggleButton ? "5rem" : "1rem"};
right: 1rem;
export const StyledProfileImageIcon = styled.div`
position: relative;
width: 1.25em;
height: 1em;
`;
padding: 1rem;
z-index: ${theme.zIndices.navigation};
export const StyledProfileImage = styled(ProfileImage)`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2em;
height: 2em;
max-width: none;
border-radius: 9999px;
@media (min-width: 721px) {
display: none;
transform: translate(calc(-50% - 4px), -50%);
}
`;
+46 -41
View File
@@ -1,43 +1,51 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { faBars, faRandom, faSearch, faTimes, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
import { faRandom, faSearch, faTv, faUser } from "@fortawesome/pro-solid-svg-icons";
import {
StyledCollapsibleLink,
StyledLogo,
StyledLogoContainer,
StyledMobileToggle,
StyledNavigation,
StyledNavigationContainer,
StyledNavigationLinks
StyledNavigationLinks,
StyledProfileImage,
StyledProfileImageIcon
} from "./Navigation.style";
import { Button } from "components/button";
import { Icon } from "components/icon";
import { Text } from "components/text";
import { IconTextButton } from "components/button";
import useCurrentSeason from "hooks/useCurrentSeason";
import navigateToRandomTheme from "utils/navigateToRandomTheme";
import useRandomThemes from "hooks/useRandomThemes";
import { useRouter } from "next/router";
import useAuth from "hooks/useAuth";
interface NavigationProps {
offsetToggleButton?: boolean
}
export function Navigation() {
const { me } = useAuth();
export function Navigation({ offsetToggleButton = false }: NavigationProps) {
const [ show, setShow ] = useState(false);
const { currentYear, currentSeason } = useCurrentSeason();
const { playRandomThemes } = useRandomThemes();
const router = useRouter();
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
const [ isFloating, setFloating ] = useState(true);
useEffect(() => {
function onScroll() {
setFloating(window.scrollY === 0);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
// If the user clicks on a link which sends them to another page,
// we want to close the modal navigation.
if (router.pathname !== prevPathname) {
setShow(false);
setPrevPathname(router.pathname);
return null;
}
return <>
<StyledNavigation show={show} onClick={() => setShow(false)}>
<StyledNavigation $floating={isFloating}>
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
<Link href="/" passHref legacyBehavior>
<StyledLogoContainer>
@@ -46,41 +54,38 @@ export function Navigation({ offsetToggleButton = false }: NavigationProps) {
</Link>
<StyledNavigationLinks>
<Link href="/search" passHref legacyBehavior>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faSearch}/>
<Text>Search</Text>
</Button>
<IconTextButton forwardedAs="a" icon={faSearch} variant="silent" collapsible style={{ "--gap": "8px" }}>
Search
</IconTextButton>
</Link>
<Button variant="silent" style={{ "--gap": "8px" }} onClick={navigateToRandomTheme}>
<Icon icon={faRandom}/>
<Text>Play Random</Text>
</Button>
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }} onClick={playRandomThemes}>
Shuffle
</IconTextButton>
<Link
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
passHref
legacyBehavior>
<Button as="a" variant="silent" style={{ "--gap": "8px" }}>
<Icon icon={faTv}/>
<Text>Current Season</Text>
</Button>
<IconTextButton forwardedAs="a" variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
Current Season
</IconTextButton>
</Link>
<Link href="/profile" passHref legacyBehavior>
<StyledCollapsibleLink forwardedAs="a" title="My Profile">
<Icon icon={faUser}/>
<span>My Profile</span>
</StyledCollapsibleLink>
<IconTextButton
forwardedAs="a"
variant="silent"
icon={me.user ? (
<StyledProfileImageIcon>
<StyledProfileImage user={me.user} />
</StyledProfileImageIcon>
) : faUser}
title="My Profile"
collapsible
>
My Profile
</IconTextButton>
</Link>
</StyledNavigationLinks>
</StyledNavigationContainer>
</StyledNavigation>
<StyledMobileToggle
variant={show ? "solid" : "primary"}
isCircle
offsetToggleButton={offsetToggleButton}
onClick={() => setShow(!show)}
>
<Icon icon={show ? faTimes : faBars}/>
</StyledMobileToggle>
</>;
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Text } from "components/text";
import { Input } from "components/input";
import { Input } from "components/form";
import { HorizontalScroll } from "components/utils";
import { Switcher } from "components/switcher";
import styled from "styled-components";
@@ -112,6 +112,7 @@ export function SearchNavigation() {
<SwitcherOption as={Link} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
<SwitcherOption as={Link} href={{ pathname: "/search/playlist", query }} value="playlist">Playlist</SwitcherOption>
</Switcher>
</HorizontalScroll>
</StyledSearchOptions>
@@ -1,6 +1,6 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
const letters = createLetters();
@@ -13,10 +13,10 @@ export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLe
return (
<SearchFilter>
<Text variant="h2">First Letter</Text>
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>Any</Listbox.Option>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
{letters.map((letter) => (
<Listbox.Option key={letter} value={letter}>{letter}</Listbox.Option>
<ListboxOption key={letter} value={letter}>{letter}</ListboxOption>
))}
</Listbox>
</SearchFilter>
@@ -0,0 +1,22 @@
import { Text } from "components/text";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface SearchFilterMediaFormatProps {
value: string | null
setValue: (newValue: string | null) => void
}
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
return (
<SearchFilter>
<Text variant="h2">Format</Text>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value="TV">TV</ListboxOption>
<ListboxOption value="TV Short">TV Short</ListboxOption>
<ListboxOption value="Movie">Movie</ListboxOption>
</Listbox>
</SearchFilter>
);
}
@@ -1,6 +1,6 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface SearchFilterSeasonProps {
value: string | null
@@ -11,12 +11,12 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
return (
<SearchFilter>
<Text variant="h2">Season</Text>
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>Any</Listbox.Option>
<Listbox.Option value="Winter">Winter</Listbox.Option>
<Listbox.Option value="Spring">Spring</Listbox.Option>
<Listbox.Option value="Summer">Summer</Listbox.Option>
<Listbox.Option value="Fall">Fall</Listbox.Option>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value="Winter">Winter</ListboxOption>
<ListboxOption value="Spring">Spring</ListboxOption>
<ListboxOption value="Summer">Summer</ListboxOption>
<ListboxOption value="Fall">Fall</ListboxOption>
</Listbox>
</SearchFilter>
);
@@ -1,7 +1,8 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import type { ReactNode } from "react";
import type { ListboxOptionProps } from "components/listbox/Listbox";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface SearchFilterSortByProps<T extends string | null> {
children: ReactNode
@@ -13,13 +14,13 @@ export function SearchFilterSortBy<T extends string | null>({ children, value, s
return (
<SearchFilter>
<Text variant="h2">Sort by</Text>
<Listbox value={value} onChange={setValue}>
<Listbox value={value} onValueChange={setValue} defaultValue={null} nullable>
{children}
</Listbox>
</SearchFilter>
);
}
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ComponentPropsWithoutRef<typeof Listbox.Option>) {
return <Listbox.Option {...props}/>;
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ListboxOptionProps) {
return <ListboxOption {...props}/>;
};
@@ -1,6 +1,6 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface SearchFilterThemeTypeProps {
value: string | null
@@ -11,10 +11,10 @@ export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeType
return (
<SearchFilter>
<Text variant="h2">Type</Text>
<Listbox value={value} onChange={setValue} resettable highlightNonDefault>
<Listbox.Option value={null} hidden>Any</Listbox.Option>
<Listbox.Option value="OP">OP</Listbox.Option>
<Listbox.Option value="ED">ED</Listbox.Option>
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
<ListboxOption value="OP">OP</ListboxOption>
<ListboxOption value="ED">ED</ListboxOption>
</Listbox>
</SearchFilter>
);
@@ -1,7 +1,7 @@
import { Text } from "components/text";
import { Listbox } from "components/listbox";
import useYearList from "hooks/useYearList";
import { SearchFilter } from "components/search-filter";
import { Listbox, ListboxOption } from "components/listbox/Listbox";
interface SearchFilterYearProps {
value: string | null
@@ -14,10 +14,10 @@ export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
return (
<SearchFilter>
<Text variant="h2">Year</Text>
<Listbox value={value ? String(value) : null} onChange={setValue} resettable highlightNonDefault>
<Listbox.Option value={null}>Any</Listbox.Option>
<Listbox value={value ? String(value) : null} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
<ListboxOption value={null} hidden>Any</ListboxOption>
{yearList.map((year) => (
<Listbox.Option key={year} value={year ? String(year) : null}>{String(year)}</Listbox.Option>
<ListboxOption key={year} value={year ? String(year) : null}>{String(year)}</ListboxOption>
))}
</Listbox>
</SearchFilter>
+6 -2
View File
@@ -11,11 +11,13 @@ import gql from "graphql-tag";
import type { SearchAnimeQuery, SearchAnimeQueryVariables } from "generated/graphql";
import { fetchDataClient } from "lib/client";
import useFilterStorage from "hooks/useFilterStorage";
import { SearchFilterMediaFormat } from "components/search-filter/SearchFilterMediaFormat";
const initialFilter = {
firstLetter: null,
season: null,
year: null,
mediaFormat: null,
sortBy: "name",
};
@@ -53,7 +55,8 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
filters: {
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
season: filter.season,
year: filter.year
year: filter.year,
media_format: filter.mediaFormat,
},
sortBy: filter.sortBy,
}}
@@ -83,9 +86,10 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")}/>
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")}/>
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
+1 -1
View File
@@ -72,7 +72,7 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
+17 -2
View File
@@ -11,6 +11,7 @@ import { fetchDataClient } from "lib/client";
import gql from "graphql-tag";
import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "generated/graphql";
import type { ReactNode } from "react";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
interface SearchGlobalProps {
searchQuery?: string
@@ -23,6 +24,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
${ThemeSummaryCard.fragments.theme}
${ThemeSummaryCard.fragments.expandable}
${ArtistSummaryCard.fragments.artist}
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query SearchGlobal($args: SearchArgs!) {
search(args: $args) {
@@ -45,6 +48,10 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
slug
name
}
playlists {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
}
}
`, { args: { query: searchQuery ?? null } });
@@ -79,7 +86,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
themes: themeResults = [],
artists: artistResults = [],
series: seriesResults = [],
studios: studioResults = []
studios: studioResults = [],
playlists: playlistResults = [],
} = data.data.search;
const totalResults =
@@ -87,7 +95,8 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
themeResults.length +
artistResults.length +
seriesResults.length +
studioResults.length;
studioResults.length +
playlistResults.length;
if (!totalResults) {
return (
@@ -133,6 +142,12 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
to={`/studio/${studio.slug}`}/>
)}
/>
<GlobalSearchSection
entity="playlist"
title="Playlists"
results={playlistResults}
renderSummaryCard={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
/>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { SearchFilterSortBy } from "components/search-filter";
import { SearchEntity } from "components/search";
import { useState } from "react";
import gql from "graphql-tag";
import type { SearchPlaylistQuery, SearchPlaylistQueryVariables } from "generated/graphql";
import { fetchDataClient } from "lib/client";
import useFilterStorage from "hooks/useFilterStorage";
import PlaylistSummaryCard from "components/card/PlaylistSummaryCard";
const initialFilter = {
sortBy: "name",
};
interface SearchPlaylistProps {
searchQuery?: string
}
export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
const { filter, updateFilter, bindUpdateFilter } = useFilterStorage("filter-playlist", {
...initialFilter,
sortBy: searchQuery ? null : initialFilter.sortBy,
});
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
if (!searchQuery && filter.sortBy === null) {
updateFilter("sortBy", initialFilter.sortBy);
return null;
}
if (searchQuery !== prevSearchQuery) {
// Check if user is switching from non-searching to searching
if (searchQuery && !prevSearchQuery) {
updateFilter("sortBy", null);
}
setPrevSearchQuery(searchQuery);
return null;
}
return (
<SearchEntity
<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
entity="playlist"
searchArgs={{
query: searchQuery,
sortBy: filter.sortBy,
}}
fetchResults={async (searchArgs) => {
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(gql`
${PlaylistSummaryCard.fragments.playlist}
${PlaylistSummaryCard.fragments.showOwner}
query SearchPlaylist($args: SearchArgs!) {
searchPlaylist(args: $args) {
data {
...PlaylistSummaryCardPlaylist
...PlaylistSummaryCardShowOwner
}
nextPage
}
}
`, { args: searchArgs });
return data.searchPlaylist;
}}
renderResult={(playlist) => (
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
)}
filters={
<>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
</SearchFilterSortBy>
</>
}
/>
);
}
+1 -1
View File
@@ -71,7 +71,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
+1 -1
View File
@@ -79,7 +79,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="name">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-name">Z A</SearchFilterSortBy.Option>
+1 -1
View File
@@ -78,7 +78,7 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")}/>
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
{searchQuery ? (
<SearchFilterSortBy.Option>Relevance</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
) : null}
<SearchFilterSortBy.Option value="song.title">A Z</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value="-song.title">Z A</SearchFilterSortBy.Option>
+1 -1
View File
@@ -20,7 +20,7 @@ export function SEO({
<Head>
<title key="title">{titleWithSuffix}</title>
<meta key="description" name="description" content={description}/>
<meta key="og:title" name="og:title" content={titleWithSuffix}/>
<meta key="og:title" name="og:title" content={title}/>
<meta key="og:description" name="og:description" content={description}/>
<meta key="og:image" name="og:image" content={image}/>
<meta key="og:site_name" name="og:site_name" content="AnimeThemes"/>
+1 -1
View File
@@ -19,7 +19,7 @@ const StyledContent = styled.div<{ style: { "--delay": Property.AnimationDelay }
`;
interface SkeletonProps {
children: ReactNode
children?: ReactNode
variant?: "summary-card"
delay?: number
}
+62
View File
@@ -0,0 +1,62 @@
import { forwardRef } from "react";
import * as RadixSlider from "@radix-ui/react-slider";
import styled from "styled-components";
import theme from "theme";
const StyledSlider = styled(RadixSlider.Root)`
position: relative;
display: flex;
align-items: center;
user-select: none;
touch-action: none;
width: 100px;
height: 20px;
`;
const StyledTrack = styled(RadixSlider.Track)`
background-color: ${theme.colors["solid-on-card"]};
position: relative;
flex-grow: 1;
border-radius: 9999px;
height: 4px;
`;
const StyledRange = styled(RadixSlider.Range)`
position: absolute;
background-color: ${theme.colors["text-disabled"]};
border-radius: 9999px;
height: 100%;
`;
const StyledThumb = styled(RadixSlider.Thumb)`
display: block;
width: 20px;
height: 20px;
background-color: ${theme.colors["text-disabled"]};
box-shadow: ${theme.shadows.low};
border-radius: 10px;
cursor: pointer;
&:hover {
background-color: ${theme.colors["text-muted"]};
}
&:focus:focus-within {
outline: none;
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
}
`;
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(
function Slider(props, ref) {
const value = (props.value || props.defaultValue) ?? [];
return (
<StyledSlider {...props} ref={ref}>
<StyledTrack>
<StyledRange />
</StyledTrack>
{value.map((_, i) => (
<StyledThumb key={i} />
))}
</StyledSlider>
);
}
);
+11 -13
View File
@@ -1,19 +1,13 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import { LayoutGroup, m } from "framer-motion";
import theme from "theme";
import { uniqueId as createUniqueId } from "lodash-es";
import type {
ComponentPropsWithoutRef,
ComponentPropsWithRef,
ReactNode } from "react";
import {
createContext,
useContext,
useMemo
} from "react";
import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from "react";
import { createContext, useContext, useMemo } from "react";
import { faTimes } from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { withHover } from "styles/mixins";
import { Solid } from "components/box";
interface SwitcherContextValue {
selectedItem: string | null
@@ -35,6 +29,10 @@ const StyledSwitcher = styled.div`
background-color: ${theme.colors["solid"]};
box-shadow: ${theme.shadows.low};
${Solid} & {
background-color: ${theme.colors["solid-on-card"]};
}
`;
interface StyledButtonProps {
@@ -61,10 +59,10 @@ const StyledButton = styled.button<StyledButtonProps>`
transition: color 500ms;
${withHover(css<StyledButtonProps>`
color: ${(props: StyledButtonProps) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
${withHover`
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
transition-duration: 250ms;
`)}
`}
`;
const StyledButtonBackground = styled(m.div)`
+6 -4
View File
@@ -1,9 +1,11 @@
import styled, { css } from "styled-components";
import styled from "styled-components";
import theme from "theme";
import { withHover } from "styles/mixins";
import type { Property } from "csstype";
export const Table = styled.div<{ style: { "--columns": Property.GridTemplateColumns } }>``;
export const Table = styled.div<{ style: { "--columns": Property.GridTemplateColumns } }>`
width: 100%;
`;
export const TableBody = styled.div`
border: 2px solid ${theme.colors["solid-on-card"]};
@@ -22,9 +24,9 @@ export const TableRow = styled.div`
border-bottom: 2px solid ${theme.colors["solid-on-card"]};
}
${withHover(css`
${withHover`
background-color: ${theme.colors["solid"]};
`)}
`}
`;
export const TableCell = styled.div<{ style?: { "--span"?: number } }>`
+9 -5
View File
@@ -4,7 +4,9 @@ import Link from "next/link";
import { Table } from "components/table";
import { Text } from "components/text";
import { SongTitle } from "components/utils";
import { ContentWarningTags, EpisodeTag, VideoTags } from "components/tag";
import { ContentWarningTags } from "components/tag/ContentWarningTags";
import { EpisodeTag } from "components/tag/EpisodeTag";
import { VideoTags } from "components/tag/VideoTags";
import { Row } from "components/box";
import { TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "components/table/Table";
import gql from "graphql-tag";
@@ -12,23 +14,24 @@ import type { ThemeTableThemeFragment } from "generated/graphql";
export interface ThemeTableProps {
themes: Array<ThemeTableThemeFragment>
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void
}
export function ThemeTable({ themes }: ThemeTableProps) {
export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
const rows = themes
.filter((theme) => theme.anime && theme.entries.length && theme.entries[0]?.videos.length)
.sort(either(themeTypeComparator).or(themeIndexComparator).chain())
.map((theme) => theme.entries.map((entry, entryIndex) => entry.videos.map((video, videoIndex) => {
const anime = theme.anime as NonNullable<typeof theme["anime"]>;
const videoSlug = createVideoSlug(theme, entry, video);
return (
<Link
key={anime.slug + videoSlug}
href={`/anime/${anime.slug}/${videoSlug}`}
passHref
legacyBehavior>
<TableRow as="a">
legacyBehavior
>
<TableRow as="a" onClick={()=> onPlay?.(theme.id, entryIndex, videoIndex)}>
<TableCell style={{ "--span": (entryIndex || videoIndex) ? 2 : undefined }}>
{!videoIndex && (
(entry.version ?? 1) > 1 ? (
@@ -90,6 +93,7 @@ ThemeTable.fragments = {
fragment ThemeTableTheme on Theme {
...createVideoSlugTheme
id
type
sequence
anime {
+1 -1
View File
@@ -1,5 +1,5 @@
import { faExclamationCircle } from "@fortawesome/pro-solid-svg-icons";
import { Tag } from "components/tag";
import { Tag } from "components/tag/Tag";
import { Icon } from "components/icon";
import gql from "graphql-tag";
import type { ContentWarningTagsEntryFragment } from "generated/graphql";
+2 -2
View File
@@ -1,5 +1,5 @@
import { faFilm } from "@fortawesome/pro-solid-svg-icons";
import { Tag } from "components/tag";
import { Tag } from "components/tag/Tag";
import gql from "graphql-tag";
import type { EpisodeTagEntryFragment } from "generated/graphql";
@@ -9,7 +9,7 @@ interface EpisodeTagProps {
export function EpisodeTag({ entry }: EpisodeTagProps) {
return (
<Tag icon={faFilm}>
<Tag icon={faFilm} title={entry.episodes ? `Used in episode ${entry.episodes}` : "Used in all episodes"}>
{entry.episodes || "—"}
</Tag>
);
+1 -1
View File
@@ -13,7 +13,7 @@ const StyledTag = styled.span`
gap: 4px;
& ${Icon} {
transform: translateY(0.2rem);
transform: translateY(0.2em);
}
`;
+2 -1
View File
@@ -1,4 +1,5 @@
import { ContentWarningTags, EpisodeTag } from "components/tag";
import { ContentWarningTags } from "components/tag/ContentWarningTags";
import { EpisodeTag } from "components/tag/EpisodeTag";
import { Row } from "components/box";
import gql from "graphql-tag";
import type { ThemeEntryTagsEntryFragment } from "generated/graphql";
+1 -1
View File
@@ -6,7 +6,7 @@ import {
faCompactDisc,
faEyes
} from "@fortawesome/pro-solid-svg-icons";
import { Tag } from "components/tag";
import { Tag } from "components/tag/Tag";
import { Row } from "components/box";
import styled from "styled-components";
import gql from "graphql-tag";
-5
View File
@@ -1,5 +0,0 @@
export { Tag } from "./Tag";
export { EpisodeTag } from "./EpisodeTag";
export { ContentWarningTags } from "./ContentWarningTags";
export { ThemeEntryTags } from "./ThemeEntryTags";
export { VideoTags } from "./VideoTags";
+3 -3
View File
@@ -14,15 +14,14 @@ interface TextProps {
weight?: Property.FontWeight
color?: keyof Colors
as?: string | ReactElement
wrapAnywhere?: boolean
}
export const Text = styled.span<TextProps>.attrs(getAttributes)`
export const Text = styled.span.attrs(getAttributes)<TextProps>`
// Reset margin for elements like <p>
margin: 0;
scroll-margin-top: 4rem;
overflow-wrap: anywhere;
${(props) => props.variant === "h1" && css`
font-size: 2rem;
font-weight: 700;
@@ -92,6 +91,7 @@ export const Text = styled.span<TextProps>.attrs(getAttributes)`
font-style: ${(props) => props.italics && "italic"};
font-weight: ${(props) => props.weight};
color: ${(props) => props.color && theme.colors[props.color]};
overflow-wrap: ${(props) => props.wrapAnywhere && "anywhere"};
`;
function getAttributes(props: TextProps) {
@@ -0,0 +1,25 @@
import { Toast } from "components/toast";
import { Text } from "components/text";
import type { PlaylistTrackRemoveToastPlaylistFragment } from "generated/graphql";
import gql from "graphql-tag";
interface PlaylistRemoveToastProps {
playlist: PlaylistTrackRemoveToastPlaylistFragment;
}
export function PlaylistRemoveToast({ playlist }: PlaylistRemoveToastProps) {
return (
<Toast>
<Text color="text-primary">{playlist.name}</Text> was deleted!
</Toast>
);
}
PlaylistRemoveToast.fragments = {
playlist: gql`
fragment PlaylistRemoveToastPlaylist on Playlist {
id
name
}
`,
};
@@ -0,0 +1,49 @@
import Link from "next/link";
import { Toast } from "components/toast";
import { Text } from "components/text";
import { Row } from "components/box";
import { SongTitle } from "components/utils";
import type { PlaylistTrackAddToastPlaylistFragment, PlaylistTrackAddToastVideoFragment } from "generated/graphql";
import gql from "graphql-tag";
interface PlaylistTrackAddToastProps {
playlist: PlaylistTrackAddToastPlaylistFragment;
video: PlaylistTrackAddToastVideoFragment;
}
export function PlaylistTrackAddToast({ playlist, video }: PlaylistTrackAddToastProps) {
return (
<Link href={`/playlist/${playlist.id}`} passHref legacyBehavior>
<Toast as="a" hoverable>
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
<span>
<SongTitle song={video.entries[0]?.theme?.song ?? null} /> was added to <Text color="text-primary">{playlist.name}</Text>!
</span>
<Text color="text-disabled">(Click to view playlist.)</Text>
</Row>
</Toast>
</Link>
);
}
PlaylistTrackAddToast.fragments = {
playlist: gql`
fragment PlaylistTrackAddToastPlaylist on Playlist {
id
name
}
`,
video: gql`
${SongTitle.fragments.song}
fragment PlaylistTrackAddToastVideo on Video {
entries {
theme {
song {
...SongTitleSong
}
}
}
}
`,
};
@@ -0,0 +1,52 @@
import Link from "next/link";
import { Toast } from "components/toast";
import { Text } from "components/text";
import { Row } from "components/box";
import { SongTitle } from "components/utils";
import type {
PlaylistTrackRemoveToastPlaylistFragment,
PlaylistTrackRemoveToastVideoFragment
} from "generated/graphql";
import gql from "graphql-tag";
interface PlaylistTrackRemoveToastProps {
playlist: PlaylistTrackRemoveToastPlaylistFragment;
video: PlaylistTrackRemoveToastVideoFragment;
}
export function PlaylistTrackRemoveToast({ playlist, video }: PlaylistTrackRemoveToastProps) {
return (
<Link href={`/playlist/${playlist.id}`} passHref legacyBehavior>
<Toast as="a" hoverable>
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
<span>
<SongTitle song={video.entries[0]?.theme?.song ?? null} /> was removed from <Text color="text-primary">{playlist.name}</Text>!
</span>
<Text color="text-disabled">(Click to view playlist.)</Text>
</Row>
</Toast>
</Link>
);
}
PlaylistTrackRemoveToast.fragments = {
playlist: gql`
fragment PlaylistTrackRemoveToastPlaylist on Playlist {
id
name
}
`,
video: gql`
${SongTitle.fragments.song}
fragment PlaylistTrackRemoveToastVideo on Video {
entries {
theme {
song {
...SongTitleSong
}
}
}
}
`,
};
+32
View File
@@ -0,0 +1,32 @@
import styled from "styled-components";
import { Icon } from "components/icon";
import type { ReactNode } from "react";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
const StyledWrapper = styled.div`
position: relative;
display: inline-flex;
justify-content: center;
align-items: center;
`;
const StyledIcon = styled(Icon)`
position: absolute;
font-size: 1.25rem;
`;
interface BusyProps {
children?: ReactNode;
isBusy: boolean;
}
export function Busy({ isBusy, children, ...props }: BusyProps) {
return (
<StyledWrapper {...props}>
<span style={{ visibility: isBusy ? "hidden" : undefined }}>{children}</span>
{isBusy ? (
<StyledIcon icon={faSpinnerThird} className="fa-spin"/>
) : null}
</StyledWrapper>
);
}
-32
View File
@@ -1,32 +0,0 @@
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import { Text } from "components/text";
Prism.manual = true;
interface HighlightProps {
children: string
block?: boolean
}
export function Highlight({ children, block = false }: HighlightProps) {
const codeHighlighted = Prism.highlight(children, Prism.languages.jsx, "jsx");
const codeBlock = (
<Text
variant="code"
block={block}
className="language-jsx"
dangerouslySetInnerHTML={{ __html: codeHighlighted }}
/>
);
if (block) {
return (
<pre>
{codeBlock}
</pre>
);
}
return codeBlock;
}
+8 -8
View File
@@ -6,22 +6,22 @@ export const HorizontalScroll = styled.div<{
display: flex;
overflow: auto;
max-width: 100vw;
margin-left: -1rem;
margin-right: -1rem;
padding-left: 1rem;
padding-right: 1rem;
margin-left: -16px;
margin-right: -16px;
padding-left: 16px;
padding-right: 16px;
& > * {
min-width: max-content;
}
${(props) => !!props.fixShadows && css`
margin-top: -1rem;
margin-bottom: -1rem;
margin-top: -16px;
margin-bottom: -16px;
& > * {
margin-top: 1rem;
margin-bottom: 1rem;
margin-top: 16px;
margin-bottom: 16px;
}
`}
`;
+67
View File
@@ -0,0 +1,67 @@
import { useRouter } from "next/router";
import useSetting from "hooks/useSetting";
import { RevalidationToken } from "utils/settings";
import { useState } from "react";
import { Text } from "components/text";
interface PageRevalidationProps {
lastBuildAt: number
apiRequests: number
}
export function PageRevalidation({ lastBuildAt, apiRequests, ...props }: PageRevalidationProps) {
const router = useRouter();
const [secret] = useSetting(RevalidationToken);
const [isRevalidating, setRevalidating] = useState(false);
function revalidate() {
if (isRevalidating || !secret) {
return;
}
setRevalidating(true);
revalidateAsync()
.then(() => location.reload())
.catch((err) => alert(`Error while revalidating: ${err.message}`))
.finally(() => setRevalidating(false));
}
async function revalidateAsync() {
const res = await fetch(`${router.basePath}/api/revalidate?secret=${secret}&id=${router.asPath}`);
if (!res.ok) {
throw new Error((await res.json()).message);
}
const json = await res.json();
if (!json.revalidated) {
throw new Error();
}
return json;
}
const minutesSinceLastBuild = Math.round((Date.now() - lastBuildAt) / 60000);
const lastBuildDescription = minutesSinceLastBuild
? `${minutesSinceLastBuild} minute${minutesSinceLastBuild === 1 ? "" : "s"}`
: "a few seconds";
const apiRequestsDescription = apiRequests
? ` using ${apiRequests} API request${apiRequests === 1 ? "" : "s"}`
: "";
const canRebuild = !isRevalidating && !!secret;
const rebuildDescription = isRevalidating
? "Rebuild in progress... The page will automatically reload after it's finished."
: (secret
? "Click to start a rebuild."
: "Setup the revalidation token on your profile to manually start a rebuild."
);
return (
<Text variant="small" color="text-disabled" link={canRebuild} onClick={revalidate} {...props}>
Page was last built {lastBuildDescription} ago{apiRequestsDescription}. {rebuildDescription}
</Text>
);
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function Performances({ song, artist, maxPerformances = 3, expandable = f
maxPerformances = song.performances.length;
}
const performances = song.performances.sort((a, b) => a.artist.name.localeCompare(b.artist.name));
const performances = [...song.performances].sort((a, b) => a.artist.name.localeCompare(b.artist.name));
const performancesShown = performances.slice(0, maxPerformances);
const performancesHidden = performances.slice(maxPerformances);
+8 -7
View File
@@ -2,25 +2,26 @@ import Link from "next/link";
import { Text } from "components/text";
import gql from "graphql-tag";
import type { SongTitleSongFragment } from "generated/graphql";
import type { ComponentPropsWithoutRef } from "react";
export interface SongTitleProps {
export interface SongTitleProps extends ComponentPropsWithoutRef<typeof Text> {
song: SongTitleSongFragment | null
songTitleLinkTo?: string
href?: ComponentPropsWithoutRef<typeof Link>["href"]
}
export function SongTitle({ song, songTitleLinkTo }: SongTitleProps) {
export function SongTitle({ song, href, ...props }: SongTitleProps) {
const songTitle = song?.title || "T.B.A.";
if (songTitleLinkTo) {
if (href) {
return (
<Link href={songTitleLinkTo} passHref legacyBehavior>
<Text as="a" link title={songTitle} italics={!song?.title}>{songTitle}</Text>
<Link href={href} passHref legacyBehavior>
<Text as="a" link title={songTitle} italics={!song?.title} wrapAnywhere {...props}>{songTitle}</Text>
</Link>
);
}
return (
<Text color="text-primary" weight="600" italics={!song?.title}>{songTitle}</Text>
<Text color="text-primary" weight="600" italics={!song?.title} wrapAnywhere {...props}>{songTitle}</Text>
);
}
@@ -7,13 +7,14 @@ interface SongTitleWithArtistsProps {
song: SongTitleWithArtistsSongFragment | null
songTitleLinkTo?: string
artist?: SongTitleWithArtistsArtistFragment
onPlay?: ()=> void
}
// Specify an artist if you want to display this in an artist context (e.g. artist page)
export function SongTitleWithArtists({ song, songTitleLinkTo, artist }: SongTitleWithArtistsProps) {
export function SongTitleWithArtists({ song, songTitleLinkTo, artist, onPlay }: SongTitleWithArtistsProps) {
return (
<Text>
<SongTitle song={song} songTitleLinkTo={songTitleLinkTo}/>
<Text onClick={onPlay}>
<SongTitle song={song} href={songTitleLinkTo}/>
<Performances song={song} artist={artist}/>
</Text>
);
@@ -0,0 +1,165 @@
import styled from "styled-components";
import theme from "theme";
import { useContext, useEffect, useRef } from "react";
import { VideoPlayerContext } from "components/video-player-2/VideoPlayer2";
const StyledPlayerProgress = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: 16px 0;
cursor: pointer;
touch-action: none;
transform: translateY(-50%);
`;
const StyledPlayerProgressBackground = styled.div`
width: 100%;
height: 2px;
background-color: ${theme.colors["solid-on-card"]};
${StyledPlayerProgress}:hover & {
height: 4px;
}
`;
const StyledPlayerProgressBar = styled.div`
position: relative;
height: 100%;
background-color: ${theme.colors["text-primary"]};
&:after {
content: " ";
position: absolute;
top: 50%;
right: 0;
display: block;
width: 16px;
height: 16px;
border-radius: 8px;
background-color: ${theme.colors["text-primary"]};
transform: translateY(-50%);
transition: opacity 200ms;
opacity: 0;
}
${StyledPlayerProgress}:hover &:after {
opacity: 1;
transform: translateY(-50%);
transition: opacity 100ms;
}
`;
const StyledPlayerProgressBarHover = styled.div`
position: absolute;
top: -24px;
padding: 4px 8px;
border-radius: 4px;
background-color: ${theme.colors["solid"]};
transform: translateX(-50%);
display: none;
${StyledPlayerProgress}:hover & {
display: block;
}
`;
export function ProgressBar() {
const context = useContext(VideoPlayerContext);
if (!context) {
throw new Error("ProgressBar needs to be inside VideoPlayer!");
}
const {
playerRef,
progressRef,
} = context;
const progressHoverRef = useRef<HTMLDivElement>(null);
const nextProgressRef = useRef(0);
const isDraggingRef = useRef(false);
useEffect(() => {
function onMouseUp() {
if (isDraggingRef.current) {
isDraggingRef.current = false;
document.body.style.removeProperty("user-select");
if (playerRef.current) {
playerRef.current.currentTime = nextProgressRef.current * playerRef.current.duration;
}
}
}
function onMouseMove(event: MouseEvent) {
const nextProgress = Math.min(1, event.clientX / document.body.getBoundingClientRect().width);
if (isDraggingRef.current) {
nextProgressRef.current = nextProgress;
if (progressRef.current) {
progressRef.current.style.width = `${nextProgress * 100}%`;
}
}
}
window.addEventListener("pointerup", onMouseUp);
window.addEventListener("pointermove", onMouseMove);
return () => {
window.removeEventListener("pointerup", onMouseUp);
window.removeEventListener("pointermove", onMouseMove);
};
}, [playerRef, progressRef]);
return (
<StyledPlayerProgress
onPointerDown={(event) => {
const nextProgress = event.clientX / event.currentTarget.getBoundingClientRect().width;
isDraggingRef.current = true;
nextProgressRef.current = nextProgress;
document.body.style.userSelect = "none";
if (progressRef.current) {
progressRef.current.style.width = `${nextProgress * 100}%`;
}
}}
onPointerMove={(event) => {
const nextProgress = event.clientX / event.currentTarget.getBoundingClientRect().width;
if (progressHoverRef.current) {
progressHoverRef.current.innerText = playerRef.current ? formatTime(nextProgress * playerRef.current.duration) : "";
const widthCard = progressHoverRef.current.getBoundingClientRect().width;
const widthBody = document.body.getBoundingClientRect().width;
let left = nextProgress * widthBody;
if (left < 8 + widthCard / 2) {
left = 8 + widthCard / 2;
} else if (left > widthBody -8 - widthCard / 2) {
left = widthBody - 8 - widthCard / 2;
}
progressHoverRef.current.style.left = `${left}px`;
}
}}
>
<StyledPlayerProgressBackground>
<StyledPlayerProgressBar ref={progressRef} />
</StyledPlayerProgressBackground>
<StyledPlayerProgressBarHover ref={progressHoverRef} />
</StyledPlayerProgress>
);
}
function formatTime(time: number) {
if (!isFinite(time)) {
return "0:00";
}
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60).toLocaleString(undefined, { minimumIntegerDigits: 2 });
return `${minutes}:${seconds}`;
}
@@ -0,0 +1,164 @@
import styled from "styled-components";
import theme from "theme";
import { Switcher } from "components/switcher";
import { m } from "framer-motion";
export const StyledPlayer = styled.div`
position: sticky;
bottom: 0;
display: flex;
flex-direction: column;
&:not([data-background]) {
flex: 1;
}
[data-fullscreen] &[data-relaxed] {
cursor: none;
}
`;
export const StyledPlayerContent = styled.div`
flex: 1;
display: grid;
grid-template-columns: 3fr 1fr;
grid-gap: 5%;
padding: 0 5%;
@media (max-width: ${theme.breakpoints.tabletMax}) {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
grid-gap: 16px;
padding: 0 16px;
}
[data-background] & {
position: fixed;
inset: 70px 16px 92px 16px;
pointer-events: none;
}
[data-fullscreen] & {
grid-template-columns: 1fr;
grid-template-rows: auto;
padding: 0;
}
`;
export const StyledPlaybackArea = styled(m.div)`
align-self: stretch;
justify-self: stretch;
position: relative;
margin: 32px 0;
@media (max-width: ${theme.breakpoints.tabletMax}) {
aspect-ratio: 16 / 9;
margin: 0 -16px;
[data-background] & {
display: none;
}
[data-fullscreen] & {
aspect-ratio: auto;
}
}
[data-background] & {
position: absolute;
bottom: 0;
left: 0;
width: 300px;
aspect-ratio: 16 / 9;
margin: 0;
padding: 0;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 8px;
overflow: hidden;
pointer-events: all;
}
[data-fullscreen] & {
height: 100%;
margin: 0;
background: rgba(0, 0, 0);
}
`;
export const StyledVideoBackground = styled.div`
position: absolute;
inset: 0;
margin: auto;
max-width: 100%;
max-height: 100%;
aspect-ratio: 16 / 9;
`;
export const StyledVideo = styled.video`
width: 100%;
height: 100%;
outline: none;
`;
export const StyledAudioBackground = styled.div`
position: absolute;
inset: 0;
margin: auto;
max-width: 100%;
max-height: 100%;
`;
export const StyledAudio = styled.audio`
display: none;
`;
export const StyledAudioCover = styled.img`
width: 100%;
height: 100%;
outline: none;
object-fit: contain;
[data-background] & {
pointer-events: none;
}
`;
export const StyledAside = styled.aside`
display: flex;
flex-direction: column;
gap: 32px;
padding-top: 64px;
@media (max-width: ${theme.breakpoints.tabletMax}) {
padding-top: 0;
gap: 16px;
}
[data-background] &, [data-fullscreen] & {
display: none;
}
`;
export const StyledSwitcher = styled(Switcher)`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: 100%;
`;
export const StyledScrollArea = styled.div`
flex: 1 0 0;
padding-bottom: 16px;
overflow: auto;
`;
@@ -0,0 +1,320 @@
import type { PointerEvent, ReactNode, RefObject, SyntheticEvent } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import {
StyledAside,
StyledAudio,
StyledAudioBackground,
StyledAudioCover,
StyledPlaybackArea,
StyledPlayer,
StyledPlayerContent,
StyledVideo,
StyledVideoBackground
} from "./VideoPlayer2.style";
import PlayerContext from "context/playerContext";
import createVideoSlug from "utils/createVideoSlug";
import { useRouter } from "next/router";
import useSetting from "hooks/useSetting";
import { AudioMode, GlobalVolume } from "utils/settings";
import { AUDIO_URL, VIDEO_URL } from "utils/config.mjs";
import extractImages from "utils/extractImages";
import useWatchHistory from "hooks/useWatchHistory";
import type { VideoSummaryCardVideoFragment } from "generated/graphql";
import { VideoPlayerBar } from "components/video-player-2/VideoPlayerBar";
import useMouseRelax from "hooks/useMouseRelax";
interface VideoPlayerContextValue {
video: VideoSummaryCardVideoFragment;
background: boolean;
videoPagePath: string;
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
progressRef: RefObject<HTMLDivElement>;
previousVideoPath: string | null;
playPreviousTrack(navigate: boolean): void;
nextVideoPath: string | null;
playNextTrack(navigate: boolean): void;
isPlaying: boolean;
togglePlay(): void;
videoUrl: string;
audioUrl: string;
updateAudioMode(audioMode: string): void;
}
export const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
type VideoPlayerProps = {
video: VideoSummaryCardVideoFragment;
background: boolean;
children?: ReactNode;
overlay?: ReactNode;
}
export function VideoPlayer2({ video, background, children, overlay, ...props }: VideoPlayerProps) {
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme.anime;
const videoPagePath = `/anime/${anime.slug}/${createVideoSlug(theme, entry, video)}`;
const videoUrl = `${VIDEO_URL}/${video.basename}`;
const audioUrl = `${AUDIO_URL}/${video.audio.basename}`;
const [isPlaying, setPlaying] = useState(false);
const [aspectRatio, setAspectRatio] = useState(16 / 9);
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const progressRef = useRef<HTMLDivElement>(null);
const currentTimeBeforeModeSwitch = useRef<number | null>(null);
const {
watchList,
currentWatchListItem,
setCurrentWatchListItem,
isAutoPlay,
isForceAutoPlay,
} = useContext(PlayerContext);
const router = useRouter();
const [globalVolume, setGlobalVolume] = useSetting(GlobalVolume);
const { smallCover, largeCover } = extractImages(anime);
const [audioMode, setAudioMode] = useSetting(AudioMode, { storageSync: false });
const { addToHistory } = useWatchHistory();
const isRelaxed = useMouseRelax();
const previousVideo = getWatchListVideo(-1);
const previousEntry = previousVideo?.entries[0];
const previousTheme = previousEntry?.theme;
const previousAnime = previousTheme?.anime;
const previousVideoPath = (previousAnime && previousTheme && previousVideo)
? `/anime/${previousAnime.slug}/${createVideoSlug(previousTheme, previousEntry, previousVideo)}`
: null;
const playPreviousTrack = useCallback((navigate = false) => {
if (previousVideoPath) {
setCurrentWatchListItem(previousVideo);
if (navigate) {
router.push(previousVideoPath);
}
}
}, [previousVideo, previousVideoPath, router, setCurrentWatchListItem]);
const nextVideo = getWatchListVideo(1);
const nextEntry = nextVideo?.entries[0];
const nextTheme = nextEntry?.theme;
const nextAnime = nextTheme?.anime;
const nextVideoPath = (nextAnime && nextTheme && nextVideo)
? `/anime/${nextAnime.slug}/${createVideoSlug(nextTheme, nextEntry, nextVideo)}`
: null;
const playNextTrack = useCallback((navigate = false) => {
if ((isAutoPlay || isForceAutoPlay) && nextVideoPath) {
setCurrentWatchListItem(nextVideo);
if (navigate) {
router.push(nextVideoPath);
}
}
}, [isAutoPlay, isForceAutoPlay, nextVideo, nextVideoPath, router, setCurrentWatchListItem]);
useEffect(() => {
if (playerRef.current) {
playerRef.current.volume = globalVolume;
}
}, [globalVolume]);
useEffect(() => {
addToHistory({
...theme,
entries: [
{
...entry,
videos: [
video,
],
},
],
});
// Reset the progress bar (otherwise we'd have to wait for the player to load).
if (progressRef.current) {
progressRef.current.style.width = "0%";
}
// We don't want to re-add the theme when the history changes, because it can cause
// various issues when multiple tabs are open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
useEffect(() => {
if (theme && smallCover && navigator.mediaSession) {
navigator.mediaSession.metadata = new MediaMetadata({
title: `${theme.slug}${theme.song?.title || "T.B.A."}`,
artist: theme.song?.performances
? theme.song.performances.map((performance) => performance.as || performance.artist.name).join(", ")
: undefined,
album: anime.name,
artwork: [
{ src: smallCover, sizes: "512x512", type: "image/jpeg" }
]
});
navigator.mediaSession.setActionHandler("previoustrack", () => { playPreviousTrack(true); });
navigator.mediaSession.setActionHandler("nexttrack", () => { playNextTrack(true); });
}
}, [anime, theme, smallCover, playNextTrack, playPreviousTrack]);
function onPlayerMount(player: HTMLVideoElement) {
playerRef.current = player;
if (playerRef.current) {
setPlaying(!playerRef.current.paused);
if ("videoWidth" in playerRef.current) {
setAspectRatio(playerRef.current.videoWidth / playerRef.current.videoHeight);
}
playerRef.current.volume = globalVolume;
if (currentTimeBeforeModeSwitch.current) {
playerRef.current.currentTime = currentTimeBeforeModeSwitch.current;
currentTimeBeforeModeSwitch.current = null;
}
}
}
function onPlayerClick(event: PointerEvent) {
if (!background && event.nativeEvent.pointerType === "mouse" && event.nativeEvent.button === 0) {
togglePlay();
}
}
function togglePlay() {
if (isPlaying) {
playerRef.current?.pause();
} else {
playerRef.current?.play();
}
}
function updateProgress(event: SyntheticEvent<HTMLVideoElement | HTMLAudioElement>) {
if (progressRef.current) {
// Update the progress bar using a ref to prevent re-rendering.
const progress = event.currentTarget.currentTime / event.currentTarget.duration * 100;
progressRef.current.style.width = `${progress}%`;
}
}
function updateAudioMode(audioMode: string) {
currentTimeBeforeModeSwitch.current = playerRef.current?.currentTime ?? null;
setAudioMode(audioMode);
}
function getWatchListVideo(offset: 1 | -1) {
if (!currentWatchListItem) {
return null;
}
const currentTrackIndex = watchList.findIndex((item) => item.watchListId === currentWatchListItem.watchListId);
if (currentTrackIndex < 0) {
return null;
}
const nextTrackIndex = currentTrackIndex + offset;
if (!watchList[nextTrackIndex]) {
return null;
}
return watchList[nextTrackIndex];
}
const constraintRef = useRef<HTMLDivElement>(null);
return (
<VideoPlayerContext.Provider value={{
video,
background,
videoPagePath,
playerRef,
progressRef,
previousVideoPath,
playPreviousTrack,
nextVideoPath,
playNextTrack,
isPlaying,
togglePlay,
videoUrl,
audioUrl,
updateAudioMode,
}}>
<StyledPlayer
ref={containerRef}
data-background={background || undefined}
data-relaxed={isRelaxed || undefined}
{...props}
>
<StyledPlayerContent ref={constraintRef}>
<StyledPlaybackArea
layout
drag={background}
dragConstraints={constraintRef}
animate={background ? undefined : {
x: 0,
y: 0,
}}
onDoubleClick={() => router.push(videoPagePath)}
>
{audioMode === AudioMode.ENABLED ? (
<StyledAudioBackground style={{ aspectRatio }}>
<StyledAudioCover
src={largeCover}
onPointerDown={onPlayerClick}
onLoad={(event) => {
setAspectRatio(event.currentTarget.naturalWidth / event.currentTarget.naturalHeight);
}}
/>
<StyledAudio
ref={onPlayerMount}
src={audioUrl}
autoPlay
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
playNextTrack(!background);
}}
onTimeUpdate={updateProgress}
onVolumeChange={(event: SyntheticEvent<HTMLAudioElement>) => setGlobalVolume(event.currentTarget.volume)}
/>
{overlay}
</StyledAudioBackground>
) : (
<StyledVideoBackground style={{ aspectRatio }}>
<StyledVideo
ref={onPlayerMount}
src={videoUrl}
autoPlay
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
playNextTrack(!background);
}}
onPointerDown={onPlayerClick}
onTimeUpdate={updateProgress}
onVolumeChange={(event: SyntheticEvent<HTMLVideoElement>) => setGlobalVolume(event.currentTarget.volume)}
onLoadedMetadata={(event) => {
setAspectRatio(event.currentTarget.videoWidth / event.currentTarget.videoHeight);
}}
/>
{overlay}
</StyledVideoBackground>
)}
</StyledPlaybackArea>
<StyledAside>
{children}
</StyledAside>
</StyledPlayerContent>
<VideoPlayerBar />
</StyledPlayer>
</VideoPlayerContext.Provider>
);
}
@@ -0,0 +1,227 @@
import { Column, Solid } from "components/box";
import { Text } from "components/text";
import { Performances, SongTitle } from "components/utils";
import Link from "next/link";
import { ConditionalWrapper } from "components/utils/ConditionalWrapper";
import {
faBackwardStep,
faForwardStep,
faListMusic,
faPause,
faPlay,
faShare,
faXmark
} from "@fortawesome/pro-solid-svg-icons";
import { Icon } from "components/icon";
import { PlaylistTrackAddDialog } from "components/dialog/PlaylistTrackAddDialog";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu";
import { IconTextButton } from "components/button";
import { AudioMode } from "utils/settings";
import { ProgressBar } from "components/video-player-2/ProgressBar";
import styled from "styled-components";
import theme from "theme";
import { Toast } from "components/toast";
import { useToasts } from "context/toastContext";
import { useContext } from "react";
import { VideoPlayerContext } from "components/video-player-2/VideoPlayer2";
import useSetting from "hooks/useSetting";
import PlayerContext from "context/playerContext";
import { VolumeControl } from "components/video-player-2/VolumeControl";
const StyledPlayerBar = styled(Solid)`
position: relative;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-gap: 16px;
align-items: center;
padding: 10px 32px;
transition: opacity 100ms;
@media (max-width: ${theme.breakpoints.tabletMax}) {
grid-template-columns: 1fr auto;
padding: 8px 16px;
}
[data-fullscreen] & {
position: fixed;
inset: auto 0 0 0;
background-color: rgba(46, 41, 58, 0.8);
}
[data-fullscreen] [data-relaxed] & {
opacity: 0;
transition: opacity 500ms;
}
`;
const StyledPlayerBarControls = styled.div`
display: flex;
gap: 16px;
@media (max-width: ${theme.breakpoints.tabletMax}) {
& > :first-child, & > :last-child {
display: none;
}
}
`;
const StyledPlayerBarControl = styled(IconTextButton)`
font-size: 2rem;
`;
const StyledPlayerBarActions = styled.div`
align-self: stretch;
display: flex;
gap: 16px;
align-items: center;
@media (max-width: ${theme.breakpoints.tabletMax}) {
display: none;
}
`;
const StyledVolumeControl = styled(VolumeControl)`
margin-right: auto;
`;
export function VideoPlayerBar() {
const context = useContext(VideoPlayerContext);
if (!context) {
throw new Error("VideoPlayerBar needs to be inside VideoPlayer!");
}
const {
video,
background,
videoPagePath,
previousVideoPath,
playPreviousTrack,
nextVideoPath,
playNextTrack,
isPlaying,
togglePlay,
videoUrl,
audioUrl,
} = context;
const entry = video.entries[0];
const theme = entry.theme;
const anime = theme.anime;
const { dispatchToast } = useToasts();
const [audioMode] = useSetting(AudioMode, { storageSync: false });
const { setCurrentWatchListItem } = useContext(PlayerContext);
function saveToClipboard(url: string) {
navigator.clipboard.writeText(url)
.then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
}
return (
<StyledPlayerBar>
<Column style={{ "--gap": "8px" }}>
<Text color="text-muted" maxLines={1}>
<SongTitle song={theme.song} href={videoPagePath} />
<Text variant="small"> - </Text>
<Text weight={600}>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`}</Text>
<Text variant="small"> from </Text>
<Link href={`/anime/${anime.slug}`} passHref legacyBehavior>
<Text as="a" link>{anime.name}</Text>
</Link>
</Text>
{!!theme.song?.performances?.length && (
<Text variant="small" color="text-muted" maxLines={1}>
<Text>Performed</Text>
<Performances song={theme.song} maxPerformances={3} />
</Text>
)}
</Column>
<StyledPlayerBarControls>
{previousVideoPath ? (
<ConditionalWrapper
condition={!background}
wrap={(children) => <Link href={previousVideoPath}>{children}</Link>}
>
<StyledPlayerBarControl
icon={faBackwardStep}
isCircle
onClick={() => playPreviousTrack(!background)}
/>
</ConditionalWrapper>
) : (
<StyledPlayerBarControl
icon={faBackwardStep}
isCircle
disabled
/>
)}
<StyledPlayerBarControl
icon={isPlaying ? faPause : <Icon icon={faPlay} color="text-disabled" style={{ transform: "translateX(2px)" }} />}
isCircle
onClick={togglePlay}
/>
{nextVideoPath ? (
<ConditionalWrapper
condition={!background}
wrap={(children) => <Link href={nextVideoPath}>{children}</Link>}
>
<StyledPlayerBarControl
icon={faForwardStep}
isCircle
onClick={() => playNextTrack(!background)}
/>
</ConditionalWrapper>
) : (
<StyledPlayerBarControl
icon={faForwardStep}
isCircle
disabled
/>
)}
</StyledPlayerBarControls>
<StyledPlayerBarActions>
<StyledVolumeControl />
<PlaylistTrackAddDialog
video={{
// Flip the structure on it's head,
// because we need video as the root object here.
...video,
entries: [{
...entry,
theme,
}],
}}
trigger={
<IconTextButton icon={faListMusic} variant="solid" collapsible="socialListMax">Add to Playlist</IconTextButton>
}
/>
<Menu modal={false}>
<MenuTrigger asChild>
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">Share</IconTextButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => saveToClipboard(location.href)}>Copy URL to this Page</MenuItem>
{audioMode === AudioMode.ENABLED ? (
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
) : (
<MenuItem onSelect={() => saveToClipboard(videoUrl)}>Copy URL to Embeddable Video</MenuItem>
)}
</MenuContent>
</Menu>
<IconTextButton
icon={faXmark}
isCircle
disabled={!background}
onClick={() => setCurrentWatchListItem(null)}
/>
</StyledPlayerBarActions>
<ProgressBar />
</StyledPlayerBar>
);
}

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