diff --git a/package-lock.json b/package-lock.json index e851669..701b27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,12 @@ "@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", @@ -2452,64 +2453,6 @@ "react": ">=16" } }, - "node_modules/@motionone/animation": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.15.1.tgz", - "integrity": "sha512-mZcJxLjHor+bhcPuIFErMDNyrdb2vJur8lSfMCsuCB4UyV8ILZLvK+t+pg56erv8ud9xQGK/1OGPt10agPrCyQ==", - "dependencies": { - "@motionone/easing": "^10.15.1", - "@motionone/types": "^10.15.1", - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "node_modules/@motionone/dom": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz", - "integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==", - "dependencies": { - "@motionone/animation": "^10.12.0", - "@motionone/generators": "^10.12.0", - "@motionone/types": "^10.12.0", - "@motionone/utils": "^10.12.0", - "hey-listen": "^1.0.8", - "tslib": "^2.3.1" - } - }, - "node_modules/@motionone/easing": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.15.1.tgz", - "integrity": "sha512-6hIHBSV+ZVehf9dcKZLT7p5PEKHGhDwky2k8RKkmOvUoYP3S+dXsKupyZpqx5apjd9f+php4vXk4LuS+ADsrWw==", - "dependencies": { - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "node_modules/@motionone/generators": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.15.1.tgz", - "integrity": "sha512-67HLsvHJbw6cIbLA/o+gsm7h+6D4Sn7AUrB/GPxvujse1cGZ38F5H7DzoH7PhX+sjvtDnt2IhFYF2Zp1QTMKWQ==", - "dependencies": { - "@motionone/types": "^10.15.1", - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "node_modules/@motionone/types": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.15.1.tgz", - "integrity": "sha512-iIUd/EgUsRZGrvW0jqdst8st7zKTzS9EsKkP+6c6n4MPZoQHwiHuVtTQLD6Kp0bsBLhNzKIBlHXponn/SDT4hA==" - }, - "node_modules/@motionone/utils": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.15.1.tgz", - "integrity": "sha512-p0YncgU+iklvYr/Dq4NobTRdAPv9PveRDUXabPEeOjBLSO/1FNB2phNTZxOxpi1/GZwYpAoECEa0Wam+nsmhSw==", - "dependencies": { - "@motionone/types": "^10.15.1", - "hey-listen": "^1.0.8", - "tslib": "^2.3.1" - } - }, "node_modules/@next/bundle-analyzer": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-12.3.4.tgz", @@ -3284,6 +3227,42 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.1.tgz", + "integrity": "sha512-0aswLpUKZIraPEOcXfwW25N1KPfLA6Mvm1TxogUChGsbLbys2ihd7uk9XAKsol9ZQPucxh2/mybwdRtAKrs/MQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.0", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.2", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-primitive": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + "@radix-ui/react-use-previous": "1.0.0", + "@radix-ui/react-use-size": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz", + "integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", @@ -6323,31 +6302,26 @@ } }, "node_modules/framer-motion": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", - "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", + "version": "10.12.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.12.4.tgz", + "integrity": "sha512-9gLtv8T6dui0tujHROR+VM3kdJyKiFCFiD94IQE+0OuX6LaIyXtdVpviokVdrHSb1giWhmmX4yzoucALMx6mtw==", "dependencies": { - "@motionone/dom": "10.12.0", - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "popmotion": "11.0.3", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" + "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { - "react": ">=16.8 || ^17.0.0 || ^18.0.0", - "react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/framesync": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", - "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", - "dependencies": { - "tslib": "^2.1.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, "node_modules/fs.realpath": { @@ -6890,11 +6864,6 @@ "tslib": "^2.0.3" } }, - "node_modules/hey-listen": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", - "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9680,17 +9649,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/popmotion": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", - "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", - "dependencies": { - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" - } - }, "node_modules/postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -10707,15 +10665,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-value-types": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", - "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", - "dependencies": { - "hey-listen": "^1.0.8", - "tslib": "^2.1.0" - } - }, "node_modules/styled-components": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", @@ -13775,64 +13724,6 @@ "@types/react": ">=16" } }, - "@motionone/animation": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.15.1.tgz", - "integrity": "sha512-mZcJxLjHor+bhcPuIFErMDNyrdb2vJur8lSfMCsuCB4UyV8ILZLvK+t+pg56erv8ud9xQGK/1OGPt10agPrCyQ==", - "requires": { - "@motionone/easing": "^10.15.1", - "@motionone/types": "^10.15.1", - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "@motionone/dom": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz", - "integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==", - "requires": { - "@motionone/animation": "^10.12.0", - "@motionone/generators": "^10.12.0", - "@motionone/types": "^10.12.0", - "@motionone/utils": "^10.12.0", - "hey-listen": "^1.0.8", - "tslib": "^2.3.1" - } - }, - "@motionone/easing": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.15.1.tgz", - "integrity": "sha512-6hIHBSV+ZVehf9dcKZLT7p5PEKHGhDwky2k8RKkmOvUoYP3S+dXsKupyZpqx5apjd9f+php4vXk4LuS+ADsrWw==", - "requires": { - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "@motionone/generators": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.15.1.tgz", - "integrity": "sha512-67HLsvHJbw6cIbLA/o+gsm7h+6D4Sn7AUrB/GPxvujse1cGZ38F5H7DzoH7PhX+sjvtDnt2IhFYF2Zp1QTMKWQ==", - "requires": { - "@motionone/types": "^10.15.1", - "@motionone/utils": "^10.15.1", - "tslib": "^2.3.1" - } - }, - "@motionone/types": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.15.1.tgz", - "integrity": "sha512-iIUd/EgUsRZGrvW0jqdst8st7zKTzS9EsKkP+6c6n4MPZoQHwiHuVtTQLD6Kp0bsBLhNzKIBlHXponn/SDT4hA==" - }, - "@motionone/utils": { - "version": "10.15.1", - "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.15.1.tgz", - "integrity": "sha512-p0YncgU+iklvYr/Dq4NobTRdAPv9PveRDUXabPEeOjBLSO/1FNB2phNTZxOxpi1/GZwYpAoECEa0Wam+nsmhSw==", - "requires": { - "@motionone/types": "^10.15.1", - "hey-listen": "^1.0.8", - "tslib": "^2.3.1" - } - }, "@next/bundle-analyzer": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-12.3.4.tgz", @@ -14400,6 +14291,36 @@ } } }, + "@radix-ui/react-slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.1.tgz", + "integrity": "sha512-0aswLpUKZIraPEOcXfwW25N1KPfLA6Mvm1TxogUChGsbLbys2ihd7uk9XAKsol9ZQPucxh2/mybwdRtAKrs/MQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.0", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.2", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-primitive": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + "@radix-ui/react-use-previous": "1.0.0", + "@radix-ui/react-use-size": "1.0.0" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz", + "integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + } + } + } + }, "@radix-ui/react-slot": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", @@ -16701,25 +16622,12 @@ } }, "framer-motion": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", - "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", + "version": "10.12.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.12.4.tgz", + "integrity": "sha512-9gLtv8T6dui0tujHROR+VM3kdJyKiFCFiD94IQE+0OuX6LaIyXtdVpviokVdrHSb1giWhmmX4yzoucALMx6mtw==", "requires": { "@emotion/is-prop-valid": "^0.8.2", - "@motionone/dom": "10.12.0", - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "popmotion": "11.0.3", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" - } - }, - "framesync": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", - "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", - "requires": { - "tslib": "^2.1.0" + "tslib": "^2.4.0" } }, "fs.realpath": { @@ -17106,11 +17014,6 @@ "tslib": "^2.0.3" } }, - "hey-listen": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", - "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" - }, "hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -19018,17 +18921,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, - "popmotion": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", - "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", - "requires": { - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" - } - }, "postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -19756,15 +19648,6 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, - "style-value-types": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", - "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", - "requires": { - "hey-listen": "^1.0.8", - "tslib": "^2.1.0" - } - }, "styled-components": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", diff --git a/package.json b/package.json index f8ee6fa..a4f7e81 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "@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", diff --git a/src/components/button/BackToTopButton.tsx b/src/components/button/BackToTopButton.tsx index e7690c6..2f1d49e 100644 --- a/src/components/button/BackToTopButton.tsx +++ b/src/components/button/BackToTopButton.tsx @@ -2,15 +2,16 @@ 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 } from "react"; +import React, { useState, useEffect, useContext } from "react"; import theme from "theme"; import { withHover } from "styles/mixins"; import { slideIn, slideOut } from "styles/animations"; +import PlayerContext from "context/playerContext"; -const ScrollButton = styled(Button)` +const ScrollButton = styled(Button)<{ $bottomOffset: number }>` position: fixed; right: 16px; - bottom: 16px; + bottom: ${(props) => 16 + props.$bottomOffset}px; padding: 16px; visibility: ${(props) => (props.isVisible ? "visible" : "hidden")}; @@ -27,6 +28,8 @@ export function BackToTopButton() { const [isButtonVisible, setIsButtonVisible] = useState(false); const [animation, setAnimation] = useState(slideIn()); + const { currentWatchListItem } = useContext(PlayerContext); + useEffect(() => { function handleScroll() { if (window.scrollY > 2000) { @@ -55,6 +58,7 @@ export function BackToTopButton() { isVisible={isButtonVisible} animation={animation} onMouseDown={(event:React.PointerEvent) => event.preventDefault()} + $bottomOffset={currentWatchListItem ? 76 : 0} > diff --git a/src/components/button/Button.tsx b/src/components/button/Button.tsx index d7c78a9..647048b 100644 --- a/src/components/button/Button.tsx +++ b/src/components/button/Button.tsx @@ -90,7 +90,7 @@ const PrimaryButton = styled(BaseButton)` color: ${theme.colors["text-primary"]}; `} - &:focus { + &:focus:focus-visible { background-color: ${theme.colors["text-on-primary"]}; color: ${theme.colors["text-primary"]}; } @@ -107,7 +107,7 @@ const WarningButton = styled(BaseButton)` color: ${theme.colors["text-warning"]}; `} - &:focus { + &:focus:focus-visible { background-color: ${theme.colors["text-on-warning"]}; color: ${theme.colors["text-warning"]}; } @@ -135,7 +135,7 @@ const SilentButton = styled(BaseButton)` color: ${theme.colors["text"]}; `} - &:not(:hover)&:not(:focus) { + &:not(:hover)&:not(:focus:focus-visible) { box-shadow: none; } diff --git a/src/components/button/IconTextButton.tsx b/src/components/button/IconTextButton.tsx index 2d1f987..628aff2 100644 --- a/src/components/button/IconTextButton.tsx +++ b/src/components/button/IconTextButton.tsx @@ -1,42 +1,53 @@ 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 { icon: IconDefinition | ReactNode children?: ReactNode - collapsible?: boolean + collapsible?: true | keyof typeof theme.breakpoints } export const IconTextButton = forwardRef( - function IconTextButton({ icon, children, collapsible = false, ...props }: IconTextButtonProps, ref) { - const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`); - const isCollapsed = collapsible && isMobile; + function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) { + let collapseBreakpoint = "0px"; + + if (collapsible === true) { + collapseBreakpoint = theme.breakpoints.mobileMax; + } else if (collapsible) { + collapseBreakpoint = theme.breakpoints[collapsible]; + } return ( - + {isIconDefinition(icon) ? ( ) : icon} - {children ? ( - {children} + {(children !== null && children !== undefined) ? ( + {children} ) : null} ); diff --git a/src/components/container/Container.ts b/src/components/container/Container.ts index b1d9530..74ecf7a 100644 --- a/src/components/container/Container.ts +++ b/src/components/container/Container.ts @@ -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; `; diff --git a/src/components/dialog/PlaylistEditDialog.tsx b/src/components/dialog/PlaylistEditDialog.tsx new file mode 100644 index 0000000..d436cd0 --- /dev/null +++ b/src/components/dialog/PlaylistEditDialog.tsx @@ -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 ( + + + {trigger ?? ( + Edit Playlist + )} + + + {/* Only render the form when dialog is open, so it will reset after closing. */} + {open ? ( + + setOpen(false)} + onCancel={() => setOpen(false)} + /> + + ) : null} + + + ); +} + +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(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 ( + + + + Title + + + + Visibility + + Public + Unlisted + Private + + + + Cancel + + Update Playlist + + + {error ? ( + The playlist could not be updated: {error} + ) : null} + + + ); +} diff --git a/src/components/dialog/PlaylistTrackAddDialog.tsx b/src/components/dialog/PlaylistTrackAddDialog.tsx index 4b3d269..feab5f0 100644 --- a/src/components/dialog/PlaylistTrackAddDialog.tsx +++ b/src/components/dialog/PlaylistTrackAddDialog.tsx @@ -36,7 +36,7 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo {trigger ?? ( - Add to Playlist + Add to Playlist )} diff --git a/src/components/image/MultiCoverImage.tsx b/src/components/image/MultiCoverImage.tsx index aed3157..bcaab05 100644 --- a/src/components/image/MultiCoverImage.tsx +++ b/src/components/image/MultiCoverImage.tsx @@ -115,18 +115,21 @@ interface MultiCoverImageProps extends ComponentPropsWithoutRef ({ - 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 ( diff --git a/src/components/slider/Slider.tsx b/src/components/slider/Slider.tsx new file mode 100644 index 0000000..9041410 --- /dev/null +++ b/src/components/slider/Slider.tsx @@ -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( + function Slider(props, ref) { + const value = (props.value || props.defaultValue) ?? []; + + return ( + + + + + {value.map((_, i) => ( + + ))} + + ); + } +); \ No newline at end of file diff --git a/src/components/video-player-2/ProgressBar.tsx b/src/components/video-player-2/ProgressBar.tsx index 875c714..ac8407c 100644 --- a/src/components/video-player-2/ProgressBar.tsx +++ b/src/components/video-player-2/ProgressBar.tsx @@ -159,7 +159,7 @@ function formatTime(time: number) { } const minutes = Math.floor(time / 60); - const seconds = (time % 60).toLocaleString(undefined, { minimumIntegerDigits: 2, maximumFractionDigits: 0 }); + const seconds = Math.floor(time % 60).toLocaleString(undefined, { minimumIntegerDigits: 2 }); return `${minutes}:${seconds}`; } diff --git a/src/components/video-player-2/VideoPlayer2.style.ts b/src/components/video-player-2/VideoPlayer2.style.ts index 19fe51c..961fb19 100644 --- a/src/components/video-player-2/VideoPlayer2.style.ts +++ b/src/components/video-player-2/VideoPlayer2.style.ts @@ -14,7 +14,7 @@ export const StyledPlayer = styled.div` flex: 1; } - &[data-relaxed] { + [data-fullscreen] &[data-relaxed] { cursor: none; } `; @@ -38,7 +38,7 @@ export const StyledPlayerContent = styled.div` [data-background] & { position: fixed; - inset: 70px 16px 104px 16px; + inset: 70px 16px 92px 16px; pointer-events: none; } @@ -64,6 +64,10 @@ export const StyledPlaybackArea = styled(m.div)` [data-background] & { display: none; } + + [data-fullscreen] & { + aspect-ratio: auto; + } } [data-background] & { @@ -83,6 +87,7 @@ export const StyledPlaybackArea = styled(m.div)` [data-fullscreen] & { height: 100%; margin: 0; + background: rgba(0, 0, 0); } `; diff --git a/src/components/video-player-2/VideoPlayer2.tsx b/src/components/video-player-2/VideoPlayer2.tsx index 8ce699c..b297ae3 100644 --- a/src/components/video-player-2/VideoPlayer2.tsx +++ b/src/components/video-player-2/VideoPlayer2.tsx @@ -26,6 +26,7 @@ import useMouseRelax from "hooks/useMouseRelax"; interface VideoPlayerContextValue { video: VideoSummaryCardVideoFragment; background: boolean; + videoPagePath: string; playerRef: RefObject; progressRef: RefObject; previousVideoPath: string | null; @@ -53,6 +54,8 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }: 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}`; @@ -171,7 +174,7 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }: } function onPlayerClick(event: PointerEvent) { - if (!background && event.nativeEvent.pointerType === "mouse") { + if (!background && event.nativeEvent.pointerType === "mouse" && event.nativeEvent.button === 0) { togglePlay(); } } @@ -223,6 +226,7 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }: router.push(videoPagePath)} > {audioMode === AudioMode.ENABLED ? ( - - + + { + setAspectRatio(event.currentTarget.naturalWidth / event.currentTarget.naturalHeight); + }} + /> setPlaying(false)} onEnded={() => { setPlaying(false); - playNextTrack(true); + playNextTrack(!background); }} onTimeUpdate={updateProgress} onVolumeChange={(event: SyntheticEvent) => setGlobalVolume(event.currentTarget.volume)} @@ -279,14 +290,12 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }: onPause={() => setPlaying(false)} onEnded={() => { setPlaying(false); - playNextTrack(true); + playNextTrack(!background); }} onPointerDown={onPlayerClick} onTimeUpdate={updateProgress} onVolumeChange={(event: SyntheticEvent) => setGlobalVolume(event.currentTarget.volume)} onLoadedMetadata={(event) => { - console.log(event.currentTarget.videoWidth); - console.log(event.currentTarget.videoHeight); setAspectRatio(event.currentTarget.videoWidth / event.currentTarget.videoHeight); }} /> diff --git a/src/components/video-player-2/VideoPlayerBar.tsx b/src/components/video-player-2/VideoPlayerBar.tsx index f245fff..febbee0 100644 --- a/src/components/video-player-2/VideoPlayerBar.tsx +++ b/src/components/video-player-2/VideoPlayerBar.tsx @@ -3,47 +3,53 @@ 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, faPause, faPlay, faShare, faXmark } from "@fortawesome/pro-solid-svg-icons"; +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 { Button, IconTextButton } from "components/button"; +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 createVideoSlug from "utils/createVideoSlug"; 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: 32px; + grid-gap: 16px; align-items: center; - - padding: 16px 32px; + + padding: 10px 32px; transition: opacity 100ms; - + @media (max-width: ${theme.breakpoints.tabletMax}) { grid-template-columns: 1fr auto; - grid-gap: 16px; - + padding: 8px 16px; } - + [data-fullscreen] & { position: fixed; inset: auto 0 0 0; - background-color: rgba(0, 0, 0, 0.5); - backdrop-filter: brightness(0.25); + background-color: rgba(46, 41, 58, 0.8); } [data-fullscreen] [data-relaxed] & { @@ -68,16 +74,21 @@ const StyledPlayerBarControl = styled(IconTextButton)` `; const StyledPlayerBarActions = styled.div` + align-self: stretch; + display: flex; gap: 16px; - - margin-left: auto; + 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); @@ -88,6 +99,7 @@ export function VideoPlayerBar() { const { video, background, + videoPagePath, previousVideoPath, playPreviousTrack, nextVideoPath, @@ -102,8 +114,6 @@ export function VideoPlayerBar() { const theme = entry.theme; const anime = theme.anime; - const videoPath = `/anime/${anime.slug}/${createVideoSlug(theme, entry, video)}`; - const { dispatchToast } = useToasts(); const [audioMode] = useSetting(AudioMode, { storageSync: false }); const { setCurrentWatchListItem } = useContext(PlayerContext); @@ -117,7 +127,7 @@ export function VideoPlayerBar() { - + - {theme.type}{theme.sequence || null}{theme.group && ` (${theme.group})`} from @@ -126,7 +136,7 @@ export function VideoPlayerBar() { {!!theme.song?.performances?.length && ( - + Performed @@ -176,6 +186,7 @@ export function VideoPlayerBar() { )} + Add to Playlist + } /> - - - Share - + Share saveToClipboard(location.href)}>Copy URL to this Page diff --git a/src/components/video-player-2/VideoPlayerOverlay.tsx b/src/components/video-player-2/VideoPlayerOverlay.tsx index 2201765..125846e 100644 --- a/src/components/video-player-2/VideoPlayerOverlay.tsx +++ b/src/components/video-player-2/VideoPlayerOverlay.tsx @@ -27,7 +27,9 @@ const StyledOverlay = styled.div` opacity: 0; transition: opacity 500ms; - ${StyledPlayer}:not([data-relaxed]) ${StyledPlaybackArea}:hover &, &:hover { + &:hover, + html:not([data-fullscreen]) ${StyledPlaybackArea}:hover &, + ${StyledPlayer}:not([data-relaxed]) ${StyledPlaybackArea}:hover & { opacity: 1; transition: opacity 100ms; } diff --git a/src/components/video-player-2/VolumeControl.tsx b/src/components/video-player-2/VolumeControl.tsx new file mode 100644 index 0000000..1082a18 --- /dev/null +++ b/src/components/video-player-2/VolumeControl.tsx @@ -0,0 +1,53 @@ +import { IconTextButton } from "components/button"; +import { faVolume, faVolumeHigh, faVolumeLow, faVolumeSlash } from "@fortawesome/pro-solid-svg-icons"; +import useSetting from "hooks/useSetting"; +import { GlobalVolume } from "utils/settings"; +import styled from "styled-components"; +import { Row } from "components/box"; +import { Slider } from "components/slider/Slider"; +import type { ComponentPropsWithoutRef } from "react"; + +const StyledRow = styled(Row)` + align-self: stretch; + align-items: center; +`; + +const StyledSlider = styled(Slider)` + opacity: 0; + pointer-events: none; + + transition: opacity 250ms; + + ${StyledRow}:hover & { + opacity: 1; + pointer-events: revert; + } +`; + +export function VolumeControl(props: ComponentPropsWithoutRef) { + const [volume, setVolume] = useSetting(GlobalVolume); + + let icon; + if (volume > 0.66) { + icon = faVolumeHigh; + } else if (volume > 0.33) { + icon = faVolume; + } else if (volume > 0) { + icon = faVolumeLow; + } else { + icon = faVolumeSlash; + } + + return ( + + setVolume(volume === 0 ? 1 : 0)} /> + setVolume(volume)} + min={0} + max={1} + step={0.01} + /> + + ); +} diff --git a/src/generated/graphql.ts b/src/generated/graphql.ts index 028bd9f..5837cbd 100644 --- a/src/generated/graphql.ts +++ b/src/generated/graphql.ts @@ -592,6 +592,8 @@ export type ThemeSummaryCardQuery = { theme: { slug: string, type: string, seque export type VideoSummaryCardVideoFragment = { id: number, basename: string, tags: string, entries: Array<{ version: number | null, theme: { id: number, slug: string, type: string, sequence: number | null, group: string | null, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }; +export type PlaylistEditDialogPlaylistFragment = { id: string, name: string, visibility: PlaylistVisibility }; + export type PlaylistRemoveDialogPlaylistFragment = { id: string, name: string, visibility: PlaylistVisibility, tracks_count: number }; export type PlaylistTrackAddDialogVideoFragment = { id: number, basename: string, tags: string, entries: Array<{ version: number | null, theme: { id: number, slug: string, type: string, sequence: number | null, group: string | null, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } }>, audio: { basename: string } }; @@ -860,6 +862,11 @@ export type HomePageQueryVariables = Exact<{ [key: string]: never; }>; export type HomePageQuery = { featuredTheme: { theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, entries: Array<{ version: number | null, videos: Array<{ basename: string, id: number, tags: string, audio: { basename: string } }> }>, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } } | null, announcementAll: Array<{ content: string }> }; +export type LandingPageQueryVariables = Exact<{ [key: string]: never; }>; + + +export type LandingPageQuery = { featuredTheme: { theme: { slug: string, type: string, sequence: number | null, group: string | null, id: number, anime: { slug: string, name: string, images: Array<{ link: string, facet: string | null }> }, entries: Array<{ version: number | null, videos: Array<{ basename: string, id: number, tags: string, audio: { basename: string } }> }>, song: { title: string | null, performances: Array<{ as: string | null, artist: { slug: string, name: string } }> } | null } } | null, announcementAll: Array<{ content: string }> }; + export type PlaylistDetailPagePlaylistQueryVariables = Exact<{ playlistId: Scalars['String']; }>; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4c93caa..987d75e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -54,6 +54,12 @@ const StyledContainer = styled(Container)` gap: 24px; `; +const StyledFullWidthContainer = styled(Container)` + margin: 0; + padding: 0; + max-width: none; +`; + // TODO: Add proper type checking, also extract layout modes out of _app.tsx // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function MyApp({ Component, pageProps }: AppProps) { @@ -61,7 +67,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { const [colorTheme, setColorTheme] = useColorTheme(); const [developerMode] = useSetting(DeveloperMode); - const { lastBuildAt, apiRequests, isVideoPage = false } = pageProps; + const { lastBuildAt, apiRequests, isVideoPage = false, isFullWidthPage = false } = pageProps; const [watchList, setWatchList] = useState(() => { if (isVideoPage) { @@ -164,6 +170,8 @@ export default function MyApp({ Component, pageProps }: AppProps) { return null; } + const Container = isFullWidthPage ? StyledFullWidthContainer : StyledContainer; + return ( - + {!isFullWidthPage ? ( + + ) : null} {!isVideoPage ? ( <> - + {!!pageProps.year && ( @@ -248,7 +258,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { {developerMode === DeveloperMode.ENABLED && lastBuildAt && ( )} - + > ) : null} diff --git a/src/pages/anime/[animeSlug]/[videoSlug]/index.tsx b/src/pages/anime/[animeSlug]/[videoSlug]/index.tsx index 753694e..c3f309c 100644 --- a/src/pages/anime/[animeSlug]/[videoSlug]/index.tsx +++ b/src/pages/anime/[animeSlug]/[videoSlug]/index.tsx @@ -56,7 +56,7 @@ export default function VideoPage({ anime, themeIndex, entryIndex, videoIndex, l const relatedThemes = anime.themes .filter((relatedTheme) => relatedTheme.slug !== theme.slug) - .slice(0, showMoreRelatedThemes ? undefined : 4); + .slice(0, showMoreRelatedThemes ? undefined : 3); const usedAlsoAs = video.entries .map((entry) => entry.theme) diff --git a/src/pages/landing.tsx b/src/pages/landing.tsx new file mode 100644 index 0000000..093c780 --- /dev/null +++ b/src/pages/landing.tsx @@ -0,0 +1,78 @@ +import styled from "styled-components"; +import type { GetStaticProps } from "next"; +import getSharedPageProps from "utils/getSharedPageProps"; +import { Row } from "components/box"; +import { FeaturedTheme } from "components/featured-theme"; +import { fetchData } from "lib/server"; +import type { LandingPageQuery } from "generated/graphql"; +import gql from "graphql-tag"; +import serializeMarkdown from "utils/serializeMarkdown"; +import type { MDXRemoteSerializeResult } from "next-mdx-remote"; + +const StyledSection = styled.section` + min-height: 100dvh; + + display: flex; + flex-direction: column; + justify-content: center; + + padding: 64px; +`; + +const StyledHeadline = styled.h1` + font-size: 4rem; + line-height: 1.25; +`; + +const StyledHighlight = styled.span` + font-size: 6rem; + font-weight: 850; + + background-image: linear-gradient(135deg, rgba(166,125,252,1) 0%, rgba(131,185,255,1) 50%, rgba(127,251,231,1) 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +`; + +interface LandingPageProps { + featuredTheme: NonNullable["theme"] | null; + announcementSources: MDXRemoteSerializeResult[] +} + +export default function LandingPage() { + return ( + + + Welcome to, AnimeThemes + + + ); +} + +export const getStaticProps: GetStaticProps = async () => { + const { data, apiRequests } = await fetchData(gql` + ${FeaturedTheme.fragments.theme} + + query LandingPage { + featuredTheme { + theme { + ...FeaturedThemeTheme + } + } + announcementAll { + content + } + } + `); + + return { + props: { + ...getSharedPageProps(apiRequests), + featuredTheme: data?.featuredTheme?.theme ?? null, + announcementSources: await Promise.all( + data?.announcementAll.map(async (announcement) => (await serializeMarkdown(announcement.content)).source) + ), + isFullWidthPage: true, + }, + }; +}; diff --git a/src/pages/playlist/[playlistId]/index.tsx b/src/pages/playlist/[playlistId]/index.tsx index 1c4206f..1228a81 100644 --- a/src/pages/playlist/[playlistId]/index.tsx +++ b/src/pages/playlist/[playlistId]/index.tsx @@ -45,6 +45,9 @@ import useSWR from "swr"; import { fetchDataClient } from "lib/client"; import PlayerContext, { createWatchListItem } from "context/playerContext"; import { Menu, MenuContent, MenuItem, MenuTrigger } from "components/menu/Menu"; +import { PlaylistEditDialog } from "components/dialog/PlaylistEditDialog"; +import { Reorder } from "framer-motion"; +import axios from "lib/client/axios"; const StyledDesktopOnly = styled.div` gap: 24px; @@ -58,6 +61,14 @@ const StyledHeader = styled.div` justify-content: space-between; align-items: center; `; +const StyledReorderContainer = styled.div` + // Hack to style the framer motion reorder component + & > div { + display: flex; + flex-direction: column; + gap: 16px; + } +`; interface PlaylistDetailPageProps extends SharedPageProps, RequiredNonNullable {} @@ -68,7 +79,7 @@ interface PlaylistDetailPageParams extends ParsedUrlQuery { export default function PlaylistDetailPage({ playlist: initialPlaylist, me: initialMe }: PlaylistDetailPageProps) { const { setWatchList, setWatchListFactory, setCurrentWatchListItem, addWatchListItem, addWatchListItemNext } = useContext(PlayerContext); - const { data: playlist } = useSWR( + const { data: playlist, mutate } = useSWR( ["PlaylistDetailPagePlaylist", `/api/playlist/${initialPlaylist.id}`], async () => { const { data } = await fetchDataClient(gql` @@ -113,7 +124,10 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init const [ showFilter, toggleShowFilter ] = useToggle(); const [ sortBy, setSortBy ] = useState(UNSORTED); - const tracks = [...playlist.forward].sort( + const isOwner = me.user?.name === playlist.user.name; + + const tracks = [...playlist.forward]; + const tracksSorted = [...tracks].sort( sortTransformed( getComparator(sortBy), (track) => { @@ -134,12 +148,84 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init ); function playAll(initiatingVideoIndex: number) { - const watchList = tracks.map((track) => createWatchListItem(track.video)); + const watchList = tracksSorted.map((track) => createWatchListItem(track.video)); setWatchList(watchList); setWatchListFactory(null); setCurrentWatchListItem(watchList[initiatingVideoIndex]); } + async function updateTrackOrder(newTracks: typeof tracks) { + await mutate({ + ...playlist, + forward: newTracks, + }, { revalidate: false }); + } + + async function updateTrackOrderRemote(trackId: string) { + const trackIndex = tracks.findIndex((track) => track.id === trackId); + + const nextId = tracks[trackIndex + 1]?.id; + const previousId = tracks[trackIndex - 1]?.id; + + if (nextId || previousId) { + await axios.put(`/api/playlist/${playlist.id}/track/${trackId}`, { + next: nextId, + previous: nextId ? undefined : previousId, + }); + + await mutate(); + } + } + + const trackElements = tracksSorted.map((track, index) => [track, ( + playAll(index)} + menu={ + + + + + + + + event.preventDefault()}> + + Add to Playlist + + } + /> + {isOwner ? ( + event.preventDefault()}> + + Remove from Playlist + + } + /> + ) : null} + addWatchListItem(track.video)}> + + Add to Watch List + + addWatchListItemNext(track.video)}> + + Play Next + + + + } + /> + )] as const); + return ( <> @@ -156,6 +242,9 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init })} /> + {isOwner ? ( + + ) : null} {playlist.user.name} @@ -173,7 +262,7 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init - Recently Added + Custom A ➜ Z (Song) Z ➜ A (Song) A ➜ Z (Anime) @@ -183,56 +272,21 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init - - {tracks.map((track, index) => ( - playAll(index)} - menu={ - - - - - - - - event.preventDefault()}> - - Add to Playlist - - } - /> - {me.user?.name === playlist.user.name ? ( - event.preventDefault()}> - - Remove from Playlist - - } - /> - ) : null} - addWatchListItem(track.video)}> - - Add to Watch List - - addWatchListItemNext(track.video)}> - - Play Next - - - - } - /> - ))} - + + {(sortBy === UNSORTED && isOwner) ? ( + + {trackElements.map(([track, node]) => ( + updateTrackOrderRemote(track.id)}> + {node} + + ))} + + ) : ( + + {trackElements.map(([, node]) => node)} + + )} + > @@ -242,8 +296,12 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init PlaylistDetailPage.fragments = { playlist: gql` ${VideoSummaryCard.fragments.video} + ${PlaylistEditDialog.fragments.playlist} + ${PlaylistTrackRemoveDialog.fragments.playlist} fragment PlaylistDetailPagePlaylist on Playlist { + ...PlaylistEditDialogPlaylist + ...PlaylistTrackRemoveDialogPlaylist id name visibility diff --git a/src/pages/search/[entity]/index.tsx b/src/pages/search/[entity]/index.tsx index 11ce6fc..f180f59 100644 --- a/src/pages/search/[entity]/index.tsx +++ b/src/pages/search/[entity]/index.tsx @@ -23,10 +23,8 @@ export default function SearchEntityPage({ entity }: SearchEntityPageProps) { return ( <> - - {router.isReady ? ( - - ) : null} + + > ); } diff --git a/src/styles/global.ts b/src/styles/global.ts index e396138..328674f 100644 --- a/src/styles/global.ts +++ b/src/styles/global.ts @@ -5,7 +5,7 @@ import theme, { createCssDefinition } from "theme"; import { defineColorTheme } from "styles/mixins"; import { Nunito } from "next/font/google"; -const nunito = Nunito({ subsets: ["latin"] }); +const nunito = Nunito({ subsets: ["latin"], weight: "variable" }); export default createGlobalStyle` * { @@ -28,6 +28,11 @@ export default createGlobalStyle` // is not scrollable to prevent layout shift. overflow-y: scroll; + [data-fullscreen] & { + // Except in fullscreen mode. + overflow-y: hidden; + } + ${defineColorTheme("light")` ${createCssDefinition(colors)} ${createCssDefinition(shadows)} diff --git a/src/theme/index.ts b/src/theme/index.ts index ee421ac..2d49172 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -25,7 +25,7 @@ type CssDefinition = { [Key in `--${keyof T & string}`]?: T[keyof T]; }; -const theme: Theme = { +const theme = { breakpoints: { mobileMax: "720px", tabletMin: "721px", @@ -50,7 +50,7 @@ const theme: Theme = { scalars: { borderRadiusCard: "4px" } -}; +} satisfies Theme; export default theme;