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.
This commit is contained in:
Mani
2023-05-04 00:06:54 +02:00
parent 9d2d981632
commit abd3ef6c17
24 changed files with 680 additions and 340 deletions
+86 -203
View File
@@ -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",
+2 -1
View File
@@ -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",
+7 -3
View File
@@ -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}
>
<Icon icon={faChevronUp} />
</ScrollButton>
+3 -3
View File
@@ -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;
}
+27 -16
View File
@@ -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<typeof StyledButton> {
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 (
<StyledButton ref={ref} variant="silent" isCircle={isCollapsed} {...props}>
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
{isIconDefinition(icon) ? (
<Icon icon={icon} color="text-disabled"/>
) : icon}
{children ? (
<StyledText collapsible={collapsible}>{children}</StyledText>
{(children !== null && children !== undefined) ? (
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
) : null}
</StyledButton>
);
+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;
`;
@@ -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>
);
}
@@ -36,7 +36,7 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
<IconTextButton icon={faListMusic} variant="solid" collapsible>Add to Playlist</IconTextButton>
)}
</DialogTrigger>
<DialogContent title="Add to playlist">
+15 -12
View File
@@ -115,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}>
+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>
);
}
);
@@ -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}`;
}
@@ -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);
}
`;
+16 -7
View File
@@ -26,6 +26,7 @@ import useMouseRelax from "hooks/useMouseRelax";
interface VideoPlayerContextValue {
video: VideoSummaryCardVideoFragment;
background: boolean;
videoPagePath: string;
playerRef: RefObject<HTMLVideoElement | HTMLAudioElement | null>;
progressRef: RefObject<HTMLDivElement>;
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 }:
<VideoPlayerContext.Provider value={{
video,
background,
videoPagePath,
playerRef,
progressRef,
previousVideoPath,
@@ -250,10 +254,17 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }:
x: 0,
y: 0,
}}
onDoubleClick={() => router.push(videoPagePath)}
>
{audioMode === AudioMode.ENABLED ? (
<StyledAudioBackground>
<StyledAudioCover src={largeCover} onPointerDown={onPlayerClick}/>
<StyledAudioBackground style={{ aspectRatio }}>
<StyledAudioCover
src={largeCover}
onPointerDown={onPlayerClick}
onLoad={(event) => {
setAspectRatio(event.currentTarget.naturalWidth / event.currentTarget.naturalHeight);
}}
/>
<StyledAudio
ref={onPlayerMount}
src={audioUrl}
@@ -262,7 +273,7 @@ export function VideoPlayer2({ video, background, children, overlay, ...props }:
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
playNextTrack(true);
playNextTrack(!background);
}}
onTimeUpdate={updateProgress}
onVolumeChange={(event: SyntheticEvent<HTMLAudioElement>) => 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<HTMLVideoElement>) => setGlobalVolume(event.currentTarget.volume)}
onLoadedMetadata={(event) => {
console.log(event.currentTarget.videoWidth);
console.log(event.currentTarget.videoHeight);
setAspectRatio(event.currentTarget.videoWidth / event.currentTarget.videoHeight);
}}
/>
@@ -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() {
<StyledPlayerBar>
<Column style={{ "--gap": "8px" }}>
<Text color="text-muted" maxLines={1}>
<SongTitle song={theme.song} href={videoPath} />
<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>
@@ -126,7 +136,7 @@ export function VideoPlayerBar() {
</Link>
</Text>
{!!theme.song?.performances?.length && (
<Text variant="small" color="text-muted">
<Text variant="small" color="text-muted" maxLines={1}>
<Text>Performed</Text>
<Performances song={theme.song} maxPerformances={3} />
</Text>
@@ -176,6 +186,7 @@ export function VideoPlayerBar() {
)}
</StyledPlayerBarControls>
<StyledPlayerBarActions>
<StyledVolumeControl />
<PlaylistTrackAddDialog
video={{
// Flip the structure on it's head,
@@ -186,13 +197,13 @@ export function VideoPlayerBar() {
theme,
}],
}}
trigger={
<IconTextButton icon={faListMusic} variant="solid" collapsible="socialListMax">Add to Playlist</IconTextButton>
}
/>
<Menu modal={false}>
<MenuTrigger asChild>
<Button style={{ "--gap": "8px" }}>
<Icon icon={faShare}/>
<Text>Share</Text>
</Button>
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">Share</IconTextButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => saveToClipboard(location.href)}>Copy URL to this Page</MenuItem>
@@ -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;
}
@@ -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<typeof StyledRow>) {
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 (
<StyledRow style={{ "--gap": "8px" }} {...props}>
<IconTextButton icon={icon} isCircle onClick={() => setVolume(volume === 0 ? 1 : 0)} />
<StyledSlider
value={[volume]}
onValueChange={([volume]) => setVolume(volume)}
min={0}
max={1}
step={0.01}
/>
</StyledRow>
);
}
+7
View File
@@ -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'];
}>;
+14 -4
View File
@@ -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<WatchListItem[]>(() => {
if (isVideoPage) {
@@ -164,6 +170,8 @@ export default function MyApp({ Component, pageProps }: AppProps) {
return null;
}
const Container = isFullWidthPage ? StyledFullWidthContainer : StyledContainer;
return (
<MultiContextProvider providers={[
stackContext(ThemeProvider, { theme }),
@@ -220,10 +228,12 @@ export default function MyApp({ Component, pageProps }: AppProps) {
<meta name="theme-color" content="#1c1823"/>
</Head>
<StyledWrapper>
<Navigation />
{!isFullWidthPage ? (
<Navigation />
) : null}
{!isVideoPage ? (
<>
<StyledContainer>
<Container>
{!!pageProps.year && (
<Column style={{ "--gap": "16px" }}>
<YearNavigation {...pageProps}/>
@@ -248,7 +258,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
{developerMode === DeveloperMode.ENABLED && lastBuildAt && (
<PageRevalidation lastBuildAt={lastBuildAt} apiRequests={apiRequests}/>
)}
</StyledContainer>
</Container>
<Footer />
</>
) : null}
@@ -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)
+78
View File
@@ -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<LandingPageQuery["featuredTheme"]>["theme"] | null;
announcementSources: MDXRemoteSerializeResult[]
}
export default function LandingPage() {
return (
<StyledSection>
<Row style={{ "--justify-content": "space-between" }}>
<StyledHeadline>Welcome to, <br /><StyledHighlight>AnimeThemes</StyledHighlight></StyledHeadline>
</Row>
</StyledSection>
);
}
export const getStaticProps: GetStaticProps<LandingPageProps> = async () => {
const { data, apiRequests } = await fetchData<LandingPageQuery>(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,
},
};
};
+112 -54
View File
@@ -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<PlaylistDetailPageQuery> {}
@@ -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<PlaylistDetailPagePlaylistQuery, PlaylistDetailPagePlaylistQueryVariables>(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, (
<VideoSummaryCard
key={track.id}
video={track.video}
onPlay={() => playAll(index)}
menu={
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisV} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic}/>
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{isOwner ? (
<PlaylistTrackRemoveDialog
playlist={playlist}
trackId={track.id}
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faMinus}/>
<Text>Remove from Playlist</Text>
</MenuItem>
}
/>
) : null}
<MenuItem onSelect={() => addWatchListItem(track.video)}>
<Icon icon={faPlus}/>
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(track.video)}>
<Icon icon={faPlus}/>
<Text>Play Next</Text>
</MenuItem>
</MenuContent>
</Menu>
}
/>
)] as const);
return (
<>
<SEO title={playlist.name} />
@@ -156,6 +242,9 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
})}
/>
</StyledDesktopOnly>
{isOwner ? (
<PlaylistEditDialog playlist={playlist} />
) : null}
<DescriptionList>
<DescriptionList.Item title="Playlist by">
<Text link>{playlist.user.name}</Text>
@@ -173,7 +262,7 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
<Collapse collapse={!showFilter}>
<SearchFilterGroup>
<SearchFilterSortBy value={sortBy} setValue={setSortBy}>
<SearchFilterSortBy.Option value={UNSORTED}>Recently Added</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={UNSORTED}>Custom</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_A_Z}>A Z (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={SONG_Z_A}>Z A (Song)</SearchFilterSortBy.Option>
<SearchFilterSortBy.Option value={ANIME_A_Z}>A Z (Anime)</SearchFilterSortBy.Option>
@@ -183,56 +272,21 @@ export default function PlaylistDetailPage({ playlist: initialPlaylist, me: init
</SearchFilterSortBy>
</SearchFilterGroup>
</Collapse>
<Column style={{ "--gap": "16px" }}>
{tracks.map((track, index) => (
<VideoSummaryCard
key={track.id}
video={track.video}
onPlay={() => playAll(index)}
menu={
<Menu modal={false}>
<MenuTrigger asChild>
<Button variant="silent" isCircle>
<Icon icon={faEllipsisV} />
</Button>
</MenuTrigger>
<MenuContent>
<PlaylistTrackAddDialog
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faListMusic}/>
<Text>Add to Playlist</Text>
</MenuItem>
}
/>
{me.user?.name === playlist.user.name ? (
<PlaylistTrackRemoveDialog
playlist={playlist}
trackId={track.id}
video={track.video}
trigger={
<MenuItem onSelect={(event) => event.preventDefault()}>
<Icon icon={faMinus}/>
<Text>Remove from Playlist</Text>
</MenuItem>
}
/>
) : null}
<MenuItem onSelect={() => addWatchListItem(track.video)}>
<Icon icon={faPlus}/>
<Text>Add to Watch List</Text>
</MenuItem>
<MenuItem onSelect={() => addWatchListItemNext(track.video)}>
<Icon icon={faPlus}/>
<Text>Play Next</Text>
</MenuItem>
</MenuContent>
</Menu>
}
/>
))}
</Column>
<StyledReorderContainer>
{(sortBy === UNSORTED && isOwner) ? (
<Reorder.Group as="div" axis="y" values={tracksSorted} onReorder={updateTrackOrder}>
{trackElements.map(([track, node]) => (
<Reorder.Item key={track.id} as="div" value={track} onDragEnd={() => updateTrackOrderRemote(track.id)}>
{node}
</Reorder.Item>
))}
</Reorder.Group>
) : (
<div>
{trackElements.map(([, node]) => node)}
</div>
)}
</StyledReorderContainer>
</Column>
</SidebarContainer>
</>
@@ -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
+2 -4
View File
@@ -23,10 +23,8 @@ export default function SearchEntityPage({ entity }: SearchEntityPageProps) {
return (
<>
<SEO title={`${searchQuery ? `${searchQuery} - ` : ""}${capitalize(entity)} Index`}/>
{router.isReady ? (
<Index searchEntity={entity} searchQuery={searchQuery}/>
) : null}
<SEO title={`${searchQuery ? `${searchQuery} - ` : ""}${capitalize(entity)} Index`} />
<Index searchEntity={entity} searchQuery={searchQuery} />
</>
);
}
+6 -1
View File
@@ -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)}
+2 -2
View File
@@ -25,7 +25,7 @@ type CssDefinition<T extends VariableDefinition> = {
[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;