parent
7a146ec027
commit
137692f654
|
|
@ -13,11 +13,14 @@
|
|||
"@emotion/styled": "^11.13.0",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@mui/icons-material": "^6.1.2",
|
||||
"@mui/lab": "^6.0.0-beta.11",
|
||||
"@mui/material": "^6.1.2",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@vanilla-extract/css": "^1.16.0",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.16",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^11.11.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-lazy-load-image-component": "^1.6.2",
|
||||
|
|
@ -28,6 +31,7 @@
|
|||
"vite-plugin-top-level-await": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-lazy-load-image-component": "^1.6.4",
|
||||
|
|
@ -2319,6 +2323,44 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz",
|
||||
"integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz",
|
||||
"integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/utils": "^0.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
|
||||
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||
|
|
@ -2377,10 +2419,72 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/base": {
|
||||
"version": "5.0.0-beta.58",
|
||||
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.58.tgz",
|
||||
"integrity": "sha512-P0E7ZrxOuyYqBvVv9w8k7wm+Xzx/KRu+BGgFcR2htTsGCpJNQJCSUXNUZ50MUmSU9hzqhwbQWNXhV1MBTl6F7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@floating-ui/react-dom": "^2.1.1",
|
||||
"@mui/types": "^7.2.15",
|
||||
"@mui/utils": "6.0.0-rc.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/base/node_modules/@mui/utils": {
|
||||
"version": "6.0.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.0.0-rc.0.tgz",
|
||||
"integrity": "sha512-tBp0ILEXDL0bbDDT8PnZOjCqSm5Dfk2N0Z45uzRw+wVl6fVvloC9zw8avl+OdX1Bg3ubs/ttKn8nRNv17bpM5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@mui/types": "^7.2.15",
|
||||
"@types/prop-types": "^15.7.12",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-is": "^18.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.2.tgz",
|
||||
"integrity": "sha512-1oE4U38/TtzLWRYWEm/m70dUbpcvBx0QvDVg6NtpOmSNQC1Mbx0X/rNvYDdZnn8DIsAiVQ+SZ3am6doSswUQ4g==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.3.tgz",
|
||||
"integrity": "sha512-ajMUgdfhTb++rwqj134Cq9f4SRN8oXUqMRnY72YBnXiXai3olJLLqETheRlq3MM8wCKrbq7g6j7iWL1VvP44VQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
|
@ -2413,17 +2517,62 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.2.tgz",
|
||||
"integrity": "sha512-5TtHeAVX9D5d2LYfB1GAUn29BcVETVsrQ76Dwb2SpAfQGW3JVy4deJCAd0RrIkI3eEUrsl0E4xuBdreszxdTTg==",
|
||||
"node_modules/@mui/lab": {
|
||||
"version": "6.0.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.11.tgz",
|
||||
"integrity": "sha512-IoYzxAepMs0gnQ2tTMokEd8Bmqt+To/8HQyzjrQCbYZmKyYR/6aK3wm3Y5NpfSLuBo1UrkeXWyKsHeRcHreGdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@mui/core-downloads-tracker": "^6.1.2",
|
||||
"@mui/system": "^6.1.2",
|
||||
"@mui/types": "^7.2.17",
|
||||
"@mui/utils": "^6.1.2",
|
||||
"@mui/base": "5.0.0-beta.58",
|
||||
"@mui/system": "^6.1.3",
|
||||
"@mui/types": "^7.2.18",
|
||||
"@mui/utils": "^6.1.3",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@mui/material": "^6.1.3",
|
||||
"@mui/material-pigment-css": "^6.1.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"@mui/material-pigment-css": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.3.tgz",
|
||||
"integrity": "sha512-loV5MBoMKLrK80JeWINmQ1A4eWoLv51O2dBPLJ260IAhupkB3Wol8lEQTEvvR2vO3o6xRHuXe1WaQEP6N3riqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@mui/core-downloads-tracker": "^6.1.3",
|
||||
"@mui/system": "^6.1.3",
|
||||
"@mui/types": "^7.2.18",
|
||||
"@mui/utils": "^6.1.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -2442,7 +2591,7 @@
|
|||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@mui/material-pigment-css": "^6.1.2",
|
||||
"@mui/material-pigment-css": "^6.1.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -2463,13 +2612,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mui/private-theming": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.2.tgz",
|
||||
"integrity": "sha512-S8WcjZdNdi++8UhrrY8Lton5h/suRiQexvdTfdcPAlbajlvgM+kx+uJstuVIEyTb3gMkxzIZep87knZ0tqcR0g==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.3.tgz",
|
||||
"integrity": "sha512-XK5OYCM0x7gxWb/WBEySstBmn+dE3YKX7U7jeBRLm6vHU5fGUd7GiJWRirpivHjOK9mRH6E1MPIVd+ze5vguKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@mui/utils": "^6.1.2",
|
||||
"@mui/utils": "^6.1.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -2490,13 +2639,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mui/styled-engine": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.2.tgz",
|
||||
"integrity": "sha512-uKOfWkR23X39xj7th2nyTcCHqInTAXtUnqD3T5qRVdJcOPvu1rlgTleTwJC/FJvWZJBU6ieuTWDhbcx5SNViHQ==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.3.tgz",
|
||||
"integrity": "sha512-i4yh9m+eMZE3cNERpDhVr6Wn73Yz6C7MH0eE2zZvw8d7EFkIJlCQNZd1xxGZqarD2DDq2qWHcjIOucWGhxACtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@emotion/cache": "^11.13.1",
|
||||
"@emotion/serialize": "^1.3.2",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
"csstype": "^3.1.3",
|
||||
"prop-types": "^15.8.1"
|
||||
|
|
@ -2523,16 +2673,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mui/system": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.2.tgz",
|
||||
"integrity": "sha512-mzW7F1ZMIYS1aLON48Nrk9c65OrVEVQ+R4lUcTWs1lCSul0VGK23eo4dmY0NX5PS7Oe4xz3P5B9tQZZ7SYgxcg==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.3.tgz",
|
||||
"integrity": "sha512-ILaD9UsLTBLjMcep3OumJMXh1PYr7aqnkHm/L47bH46+YmSL1zWAX6tWG8swEQROzW2GvYluEMp5FreoxOOC6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@mui/private-theming": "^6.1.2",
|
||||
"@mui/styled-engine": "^6.1.2",
|
||||
"@mui/types": "^7.2.17",
|
||||
"@mui/utils": "^6.1.2",
|
||||
"@mui/private-theming": "^6.1.3",
|
||||
"@mui/styled-engine": "^6.1.3",
|
||||
"@mui/types": "^7.2.18",
|
||||
"@mui/utils": "^6.1.3",
|
||||
"clsx": "^2.1.1",
|
||||
"csstype": "^3.1.3",
|
||||
"prop-types": "^15.8.1"
|
||||
|
|
@ -2563,9 +2713,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mui/types": {
|
||||
"version": "7.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.17.tgz",
|
||||
"integrity": "sha512-oyumoJgB6jDV8JFzRqjBo2daUuHpzDjoO/e3IrRhhHo/FxJlaVhET6mcNrKHUq2E+R+q3ql0qAtvQ4rfWHhAeQ==",
|
||||
"version": "7.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz",
|
||||
"integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -2577,13 +2727,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mui/utils": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.2.tgz",
|
||||
"integrity": "sha512-6+B1YZ8cCBWD1fc3RjqpclF9UA0MLUiuXhyCO+XowD/Z2ku5IlxeEhHHlgglyBWFGMu4kib4YU3CDsG5/zVjJQ==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.3.tgz",
|
||||
"integrity": "sha512-4JBpLkjprlKjN10DGb1aiy/ii9TKbQ601uSHtAmYFAS879QZgAD7vRnv/YBE4iBbc7NXzFgbQMCOFrupXWekIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@mui/types": "^7.2.17",
|
||||
"@mui/types": "^7.2.18",
|
||||
"@types/prop-types": "^15.7.13",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
|
|
@ -3268,6 +3418,13 @@
|
|||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
|
|
@ -4397,6 +4554,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||
|
|
@ -4470,6 +4633,31 @@
|
|||
"is-callable": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "11.11.8",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.8.tgz",
|
||||
"integrity": "sha512-mnGQNEoz99GtFXBBPw+Ag5K4FcfP5XrXxrxHz+iE4Lmg7W3sf2gKmGuvfkZCW/yIfcdv5vJd6KiSPETH1Pw68Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
|
||||
|
|
@ -6904,7 +7092,6 @@
|
|||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
|
|
|
|||
|
|
@ -14,11 +14,14 @@
|
|||
"@emotion/styled": "^11.13.0",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||
"@mui/icons-material": "^6.1.2",
|
||||
"@mui/lab": "^6.0.0-beta.11",
|
||||
"@mui/material": "^6.1.2",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@vanilla-extract/css": "^1.16.0",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.16",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^11.11.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-lazy-load-image-component": "^1.6.2",
|
||||
|
|
@ -29,6 +32,7 @@
|
|||
"vite-plugin-top-level-await": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-lazy-load-image-component": "^1.6.4",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Router, RouterProvider } from "react-router-dom";
|
||||
import createRouter from "./Router";
|
||||
import { createTheme, ThemeProvider } from "@mui/material";
|
||||
import { Queue, QueueContextProvider } from "./hooks/useQueue";
|
||||
import { Song } from "./utils/MusicServer";
|
||||
|
||||
export default function App() {
|
||||
const router = createRouter();
|
||||
|
|
@ -11,9 +13,20 @@ export default function App() {
|
|||
}
|
||||
});
|
||||
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [i, setI] = useState<number>(0);
|
||||
const [repeat, setRepeat] = useState(false);
|
||||
|
||||
const queue = useMemo(() => new Queue(songs, setSongs, i, setI, repeat, setRepeat), [songs, i, repeat]);
|
||||
|
||||
//@ts-ignore
|
||||
globalThis.queue = queue;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<RouterProvider router={router} />
|
||||
<QueueContextProvider value={queue}>
|
||||
<RouterProvider router={router} />
|
||||
</QueueContextProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,38 +5,47 @@ import {
|
|||
Outlet,
|
||||
Route
|
||||
} from 'react-router-dom';
|
||||
import AppHeader from "./components/AppHeader";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import Landing from "./pages/Landing";
|
||||
import Page from "./components/Page";
|
||||
import Search from "./pages/Search";
|
||||
import ErrorScreen from "./pages/ErrorScreen";
|
||||
import Account from "./pages/Account";
|
||||
import Player from "./components/Player";
|
||||
import Register from "./pages/Register";
|
||||
|
||||
export default function createRouter() {
|
||||
const theme = useTheme();
|
||||
const router = createRoutesFromElements(
|
||||
<Route>
|
||||
<Route
|
||||
errorElement={
|
||||
<Page>
|
||||
<ErrorScreen />
|
||||
</Page>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<Page>
|
||||
<Outlet />
|
||||
<Player />
|
||||
</Page>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path='/'
|
||||
element={
|
||||
<>
|
||||
<AppHeader />
|
||||
<Landing />
|
||||
</>
|
||||
}
|
||||
element={<Landing />}
|
||||
/>
|
||||
<Route
|
||||
path='/search'
|
||||
element={
|
||||
<Search />
|
||||
}
|
||||
element={<Search />}
|
||||
/>
|
||||
<Route
|
||||
path='/account'
|
||||
element={<Account />}
|
||||
/>
|
||||
<Route
|
||||
path='/register'
|
||||
element={<Register />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { Add } from "@mui/icons-material";
|
||||
import { Box, BoxProps, Theme, Typography, useTheme } from "@mui/material";
|
||||
import React from "react";
|
||||
import { MotionBox } from "./MotionComponents";
|
||||
import { Variants } from "framer-motion";
|
||||
|
||||
export default function AddPlaylistButton(props: BoxProps) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
justifyContent='center'
|
||||
{...props}
|
||||
>
|
||||
<MotionBox
|
||||
width={120}
|
||||
height={120}
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignContent='center'
|
||||
alignItems='center'
|
||||
bgcolor='success.main'
|
||||
color='success.contrastText'
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
whileHover={{ borderRadius: '30%', scale: 0.8 }}
|
||||
whileTap={{borderRadius: '40%', scale: 0.7, backgroundColor: theme.palette.info.main}}
|
||||
>
|
||||
<Add sx={{scale:2}} />
|
||||
</MotionBox>
|
||||
<Typography textAlign='center'>
|
||||
New playlist
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { AppBar, IconButton, InputBaseProps, Toolbar, Typography } from '@mui/material';
|
||||
import { SearchContainer, SearchIcon, SearchIconWrapper, SearchInputBase } from "./Search";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Close, Person } from "@mui/icons-material";
|
||||
|
||||
type AppHeaderProps = {
|
||||
showSearch?: boolean;
|
||||
|
|
@ -10,21 +11,37 @@ type AppHeaderProps = {
|
|||
export default function AppHeader({ showSearch, inputProps }: AppHeaderProps) {
|
||||
const navTo = useNavigate();
|
||||
return (
|
||||
<AppBar position='static'>
|
||||
<AppBar position='sticky' color='info'>
|
||||
<Toolbar>
|
||||
{showSearch ? (
|
||||
<SearchContainer>
|
||||
<>
|
||||
<SearchContainer sx={{flexGrow:1}}>
|
||||
<SearchIconWrapper><SearchIcon /></SearchIconWrapper>
|
||||
<SearchInputBase
|
||||
placeholder='Search songs...'
|
||||
{...inputProps}
|
||||
/>
|
||||
</SearchContainer>
|
||||
<IconButton
|
||||
onClick={() => navTo('/', {replace: true})}
|
||||
size='large'
|
||||
edge='end'
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="h6" component='div' flexGrow={1}>
|
||||
Extera Music
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() => navTo('/account')}
|
||||
size='large'
|
||||
edge='end'
|
||||
>
|
||||
<Person />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => navTo('/search')}
|
||||
size='large'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import React, { forwardRef } from "react";
|
||||
import {motion} from 'framer-motion';
|
||||
import { Box, BoxProps } from "@mui/material";
|
||||
|
||||
const BoxComponent = forwardRef(
|
||||
(props: BoxProps, ref) => <Box {...props} ref={ref} />
|
||||
);
|
||||
|
||||
export const MotionBox = motion.create(BoxComponent);
|
||||
|
|
@ -1,24 +1,32 @@
|
|||
import { Box, useTheme } from "@mui/material";
|
||||
import { Box, BoxProps, useTheme } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export default function Page(props: React.PropsWithChildren) {
|
||||
export default function Page(props: BoxProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
minHeight='100%'
|
||||
maxWidth='100%'
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
bgcolor='default'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageRoot(props: React.PropsWithChildren) {
|
||||
export function PageRoot(props: BoxProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
bgcolor='default'
|
||||
p={theme.spacing(1)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
import { BottomNavigation, BottomNavigationAction, Box, Button, IconButton, Theme, Typography, useTheme } from "@mui/material";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useQueue from "../hooks/useQueue";
|
||||
import { getSongURL, Song } from "../utils/MusicServer";
|
||||
import { Pause, PlayArrow, Repeat, RepeatOn, RepeatOne, RepeatOneOn, SkipNext, SkipPrevious } from "@mui/icons-material";
|
||||
import durationToStr from "../utils/duration";
|
||||
import { MotionBox } from "./MotionComponents";
|
||||
import { Variants } from "framer-motion";
|
||||
|
||||
const variants = (theme: Theme): Variants => (
|
||||
{
|
||||
initial: {
|
||||
bottom: theme.spacing(-12)
|
||||
},
|
||||
final: {
|
||||
bottom: theme.spacing(0)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function Player() {
|
||||
const theme = useTheme();
|
||||
const queue = useQueue();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [song, setSong] = useState<Song | undefined>();
|
||||
const [paused, setPaused] = useState(audioRef.current?.paused || false);
|
||||
const [canPlayNext, setCanPlayNext] = useState(false);
|
||||
const [canPlayPrev, setCanPlayPrev] = useState(false);
|
||||
const [repeatOne, setRepeatOne] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const songURL = useMemo(
|
||||
() => getSongURL(song),
|
||||
[song, queue]
|
||||
);
|
||||
|
||||
const togglePlaying = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) audio.play();
|
||||
else audio.pause();
|
||||
|
||||
setPaused(audio.paused);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
queue?.prev();
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (repeatOne) {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.currentTime = 0;
|
||||
audio.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
queue?.next();
|
||||
};
|
||||
|
||||
const handleRepeat = () => {
|
||||
if (!queue) return console.error('No queue bruh');
|
||||
console.log(`handleRepeat`, queue.repeat, repeatOne);
|
||||
if (!queue.repeat && !repeatOne) {
|
||||
queue.setRepeat(true);
|
||||
} else if (queue.repeat && !repeatOne) {
|
||||
queue.setRepeat(false);
|
||||
setRepeatOne(true);
|
||||
} else if (repeatOne) {
|
||||
setRepeatOne(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCurrentPlaying = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
setCurrentTime(audio.currentTime);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
updateCurrentPlaying();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [audioRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setSong(queue?.getCurrent());
|
||||
console.log(`Song updated. ${songURL}`, song);
|
||||
console.log(`Queue updated.`, queue);
|
||||
}, [queue!.i]);
|
||||
|
||||
useEffect(() => {
|
||||
setSong(queue?.getCurrent());
|
||||
console.log(`Song updated. ${songURL}`, song);
|
||||
}, [queue!.songs]);
|
||||
|
||||
useEffect(() => {
|
||||
setCanPlayNext(queue?.canPlayNext() || false);
|
||||
setCanPlayPrev(queue?.canPlayPrev() || false);
|
||||
}, [queue!.songs, queue!.i, queue!.repeat]);
|
||||
|
||||
return song && (
|
||||
<MotionBox
|
||||
display='flex'
|
||||
position='absolute'
|
||||
height={theme.spacing(12)}
|
||||
width='100%'
|
||||
maxWidth='100%'
|
||||
bgcolor='background.paper'
|
||||
borderColor='divider'
|
||||
borderTop={0.2}
|
||||
variants={variants(theme)}
|
||||
initial='initial'
|
||||
animate='final'
|
||||
>
|
||||
<Box width='100%' height={theme.spacing(6)} p={2}>
|
||||
{song && songURL && (
|
||||
<audio onEnded={handleNext} autoPlay src={songURL} ref={audioRef} controls={false} />
|
||||
)}
|
||||
<Box display='flex' flexGrow={1} gap={2}>
|
||||
<img src={song?.thumbnails[0].url} width={64} />
|
||||
<Box display='flex' flexDirection='column' flexGrow={1}>
|
||||
<Typography variant='h6' component='div'>
|
||||
{song?.name}
|
||||
</Typography>
|
||||
<Box display='flex' gap={1}>
|
||||
<Typography color='textSecondary'>
|
||||
{song?.artist.name}
|
||||
</Typography>
|
||||
{song?.album.name !== song?.name && (
|
||||
<Typography color='textDisabled'>
|
||||
{song?.album.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography
|
||||
color='textDisabled'
|
||||
component='div'
|
||||
variant='button'
|
||||
alignSelf='center'
|
||||
>
|
||||
{currentTime && durationToStr(currentTime)}
|
||||
/
|
||||
{currentTime && song && durationToStr(song.duration)}
|
||||
</Typography>
|
||||
<Box display='flex' height='min-content' alignSelf='center' gap={2} flexShrink={0}>
|
||||
<IconButton
|
||||
color={(queue!.repeat || repeatOne) ? 'success' : 'inherit'}
|
||||
onClick={handleRepeat}
|
||||
>
|
||||
{queue!.repeat && (
|
||||
<Repeat />
|
||||
)}
|
||||
{repeatOne && (
|
||||
<RepeatOne />
|
||||
)}
|
||||
{!queue!.repeat && !repeatOne && (
|
||||
<Repeat />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton disabled={!canPlayPrev} onClick={handlePrev}>
|
||||
<SkipPrevious />
|
||||
</IconButton>
|
||||
<IconButton onClick={togglePlaying}>
|
||||
{paused ? <PlayArrow /> : <Pause />}
|
||||
</IconButton>
|
||||
<IconButton disabled={!canPlayNext} onClick={handleNext}>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,12 +29,14 @@ const SearchIconWrapper = styled('div')(({ theme }) => ({
|
|||
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
color: 'inherit',
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
'& .MuiInputBase-input': {
|
||||
padding: theme.spacing(1, 1, 1, 0),
|
||||
// vertical padding + font size from searchIcon
|
||||
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: '20ch',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Box, Skeleton, Typography, useTheme } from "@mui/material";
|
||||
import { Box, IconButton, Skeleton, Typography, useTheme } from "@mui/material";
|
||||
import React, { MouseEventHandler } from "react";
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material';
|
||||
|
||||
type SongCardProps = {
|
||||
onClick?: MouseEventHandler;
|
||||
|
|
@ -8,6 +9,8 @@ type SongCardProps = {
|
|||
artist: string;
|
||||
name: string;
|
||||
album: string;
|
||||
saveSong?: () => void;
|
||||
playSong?: () => void;
|
||||
};
|
||||
|
||||
export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
|
||||
|
|
@ -30,7 +33,7 @@ export default function SongCard({ onClick, thumbnailUrl, artist, name, album }:
|
|||
);
|
||||
}
|
||||
|
||||
export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
|
||||
export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album, saveSong, playSong }: SongCardProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
|
|
@ -54,13 +57,31 @@ export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: S
|
|||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
flexGrow={1}
|
||||
>
|
||||
<Typography variant='subtitle1'>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant='subtitle2'>
|
||||
{artist}
|
||||
</Typography>
|
||||
<Box display='flex' gap={theme.spacing(1)}>
|
||||
<Typography variant='subtitle2'>
|
||||
{artist}
|
||||
</Typography>
|
||||
<Typography variant='subtitle2' color='textDisabled'>
|
||||
{album}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box alignSelf='center' paddingRight={theme.spacing(1)}>
|
||||
{saveSong && (
|
||||
<IconButton onClick={saveSong}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{playSong && (
|
||||
<IconButton onClick={playSong}>
|
||||
<PlayArrow />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import React, { useMemo } from "react";
|
||||
|
||||
type AccountData = {
|
||||
username?: string;
|
||||
token?: string;
|
||||
};
|
||||
export default function useAccount(): AccountData {
|
||||
const username = useMemo(
|
||||
() => localStorage.username,
|
||||
[localStorage]
|
||||
);
|
||||
const token = useMemo(
|
||||
() => localStorage.token,
|
||||
[localStorage]
|
||||
);
|
||||
|
||||
return {
|
||||
username,
|
||||
token
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAlive = (): (() => boolean) => {
|
||||
const aliveRef = useRef<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const alive = useCallback(() => aliveRef.current, []);
|
||||
return alive;
|
||||
};
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useAlive } from './useAlive';
|
||||
|
||||
export enum AsyncStatus {
|
||||
Idle = 'idle',
|
||||
Loading = 'loading',
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type AsyncIdle = {
|
||||
status: AsyncStatus.Idle;
|
||||
};
|
||||
|
||||
export type AsyncLoading = {
|
||||
status: AsyncStatus.Loading;
|
||||
};
|
||||
|
||||
export type AsyncSuccess<D> = {
|
||||
status: AsyncStatus.Success;
|
||||
data: D;
|
||||
};
|
||||
|
||||
export type AsyncError<E = unknown> = {
|
||||
status: AsyncStatus.Error;
|
||||
error: E;
|
||||
};
|
||||
|
||||
export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess<D> | AsyncError<E>;
|
||||
|
||||
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
|
||||
|
||||
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
|
||||
asyncCallback: AsyncCallback<TArgs, TData>
|
||||
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
|
||||
const [state, setState] = useState<AsyncState<TData, TError>>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
const alive = useAlive();
|
||||
|
||||
// Tracks the request number.
|
||||
// If two or more requests are made subsequently
|
||||
// we will throw all old request's response after they resolved.
|
||||
const reqNumberRef = useRef(0);
|
||||
|
||||
const callback: AsyncCallback<TArgs, TData> = useCallback(
|
||||
async (...args) => {
|
||||
queueMicrotask(() => {
|
||||
// Warning: flushSync was called from inside a lifecycle method.
|
||||
// React cannot flush when React is already rendering.
|
||||
// Consider moving this call to a scheduler task or micro task.
|
||||
flushSync(() => {
|
||||
// flushSync because
|
||||
// https://github.com/facebook/react/issues/26713#issuecomment-1872085134
|
||||
setState({
|
||||
status: AsyncStatus.Loading,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
reqNumberRef.current += 1;
|
||||
|
||||
const currentReqNumber = reqNumberRef.current;
|
||||
try {
|
||||
const data = await asyncCallback(...args);
|
||||
if (currentReqNumber !== reqNumberRef.current) {
|
||||
throw new Error('AsyncCallbackHook: Request replaced!');
|
||||
}
|
||||
if (alive()) {
|
||||
queueMicrotask(() => {
|
||||
setState({
|
||||
status: AsyncStatus.Success,
|
||||
data,
|
||||
});
|
||||
});
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (currentReqNumber !== reqNumberRef.current) {
|
||||
throw new Error('AsyncCallbackHook: Request replaced!');
|
||||
}
|
||||
|
||||
if (alive()) {
|
||||
queueMicrotask(() => {
|
||||
setState({
|
||||
status: AsyncStatus.Error,
|
||||
error: e as TError,
|
||||
});
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[asyncCallback, alive]
|
||||
);
|
||||
|
||||
return [state, callback];
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { useReducer } from "react";
|
||||
|
||||
export default function useForceUpdate() {
|
||||
const [reducer, dispatch] = useReducer(x => x + 1, 0);
|
||||
return [reducer, dispatch];
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React, { createContext, useContext, useEffect } from "react";
|
||||
import { Song } from "../utils/MusicServer";
|
||||
import useForceUpdate from "./useForceUpdate";
|
||||
|
||||
export class Queue {
|
||||
|
||||
constructor(
|
||||
songs: Song[],
|
||||
setSongs: React.Dispatch<React.SetStateAction<Song[]>>,
|
||||
i: number,
|
||||
setI: React.Dispatch<React.SetStateAction<number>>,
|
||||
repeat: boolean,
|
||||
setRepeat: React.Dispatch<React.SetStateAction<boolean>>
|
||||
) {
|
||||
this.songs = songs;
|
||||
this.setSongs = setSongs;
|
||||
this.i = i;
|
||||
this.setI = setI;
|
||||
this.repeat = repeat;
|
||||
this.setRepeat = setRepeat;
|
||||
}
|
||||
|
||||
songs: Song[];
|
||||
setSongs: React.Dispatch<React.SetStateAction<Song[]>>;
|
||||
i: number;
|
||||
private setI: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
repeat: boolean;
|
||||
setRepeat: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
shuffle() {
|
||||
this.songs = this.songs.sort(() => Math.random() - 0.5);
|
||||
}
|
||||
|
||||
getCurrent(): Song | undefined {
|
||||
const current = this.songs[this.i];
|
||||
return current;
|
||||
}
|
||||
|
||||
getSongs(): Song[] {
|
||||
return this.songs;
|
||||
}
|
||||
|
||||
addSong(song: Song) {
|
||||
this.setSongs((prevSongs) => {
|
||||
console.log(`Song added`, song);
|
||||
return [...prevSongs, song]; // создаем новый массив
|
||||
});
|
||||
}
|
||||
|
||||
removeSong(index: number) {
|
||||
this.setSongs((prevSongs) => {
|
||||
return prevSongs.filter((_, i) => i !== index); // создаем новый массив без удаленного элемента
|
||||
});
|
||||
}
|
||||
|
||||
prev(): Song | undefined {
|
||||
if (this.i === 0) {
|
||||
if (!this.repeat) return undefined;
|
||||
this.setI(this.songs.length - 1);
|
||||
return this.songs[this.i];
|
||||
}
|
||||
this.setI((i) => i - 1);
|
||||
return this.songs[this.i];
|
||||
}
|
||||
|
||||
next(): Song | undefined {
|
||||
if (this.i === this.songs.length - 1) {
|
||||
if (!this.repeat) return undefined;
|
||||
this.setI(0);
|
||||
return this.songs[this.i];
|
||||
}
|
||||
this.setI((i) => i + 1);
|
||||
return this.songs[this.i];
|
||||
}
|
||||
|
||||
canPlayNext() {
|
||||
return this.i !== this.songs.length - 1 || this.repeat || false;
|
||||
}
|
||||
|
||||
canPlayPrev() {
|
||||
return this.i !== 0 || this.repeat || false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const QueueContext = createContext<Queue | null>(null);
|
||||
export const QueueContextProvider = QueueContext.Provider;
|
||||
|
||||
export default function useQueue(): Queue | null {
|
||||
const queue = useContext(QueueContext);
|
||||
return queue;
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import React, { FormEventHandler, useCallback, useMemo } from "react";
|
||||
import useAccount from "../hooks/useAccount";
|
||||
import { Alert, AppBar, Box, Button, Card, DialogActions, Divider, Link, TextField, Typography, useTheme } from "@mui/material";
|
||||
import Page, { PageRoot } from "../components/Page";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AsyncStatus, useAsyncCallback } from "../hooks/useAsyncCallback";
|
||||
import { login } from "../utils/MusicServer";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import AppHeader from "../components/AppHeader";
|
||||
|
||||
function Login() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loginState, loginAccount] = useAsyncCallback(
|
||||
useCallback(
|
||||
(uname: string, passwd: string) => login(uname, passwd),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const usernameInput = evt.currentTarget.elements.item(0) as HTMLInputElement;
|
||||
const passwordInput = evt.currentTarget.elements.item(2) as HTMLInputElement;
|
||||
const [username, password] = [usernameInput.value, passwordInput.value];
|
||||
|
||||
const token = await loginAccount(username, password);
|
||||
localStorage.token = token;
|
||||
localStorage.username = username;
|
||||
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageRoot
|
||||
alignSelf='center'
|
||||
>
|
||||
<Box width='90%' height='100%' display='flex' alignSelf='center'>
|
||||
<Card sx={{ width: '100%', p: 3, gap: 2, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography width='100%' variant="h5" textAlign='center'>
|
||||
Use existing account
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit} style={{ flexGrow: 1, display: 'flex', flexDirection:'column', gap: theme.spacing(2) }}>
|
||||
<TextField
|
||||
label='Username'
|
||||
required
|
||||
fullWidth
|
||||
size='small'
|
||||
name='usernameInput'
|
||||
/>
|
||||
<TextField
|
||||
label='Password'
|
||||
required
|
||||
fullWidth
|
||||
size='small'
|
||||
name='passwordInput'
|
||||
type='password'
|
||||
/>
|
||||
<Link component='button' onClick={() => navigate('/register')} underline="hover">
|
||||
Don't have an account yet?
|
||||
</Link>
|
||||
{loginState.status === AsyncStatus.Error && (
|
||||
<Alert severity='error'>
|
||||
An error has occured, logging in failed.
|
||||
</Alert>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={() => navigate('/')}>
|
||||
Back
|
||||
</Button>
|
||||
<LoadingButton loading={loginState.status === AsyncStatus.Loading} variant='contained' type='submit'>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Card>
|
||||
</Box>
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const { token, username } = useAccount();
|
||||
|
||||
return (
|
||||
<Page
|
||||
alignContent='center'
|
||||
>
|
||||
{token && <AppHeader />}
|
||||
{token ? (
|
||||
<PageRoot>
|
||||
<Typography>
|
||||
You are logged in as {username}
|
||||
</Typography>
|
||||
</PageRoot>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export default function ErrorScreen() {
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogTitle>Hey check this out</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
A critical error has occured. See F12 Console for more details.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {location.href = '/'}}>Refresh</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,30 +2,22 @@ import { Box, Typography, useTheme } from "@mui/material";
|
|||
import React from "react";
|
||||
import LoadingCard from "../components/LoadingCard";
|
||||
import Scroll from "../components/Scroll";
|
||||
import Page, { PageRoot } from "../components/Page";
|
||||
import AppHeader from "../components/AppHeader";
|
||||
import AddPlaylistButton from "../components/AddPlaylistButton";
|
||||
|
||||
export default function Landing() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: theme.spacing(1)
|
||||
}}
|
||||
>
|
||||
<Typography variant='button'>Trending</Typography>
|
||||
<Scroll display='flex' gap={theme.spacing(2)}>
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
<LoadingCard />
|
||||
</Scroll>
|
||||
</Box>
|
||||
<Page>
|
||||
<AppHeader />
|
||||
<PageRoot>
|
||||
<Typography variant='button'>Playlists</Typography>
|
||||
<Scroll display='flex' gap={theme.spacing(2)}>
|
||||
<AddPlaylistButton />
|
||||
</Scroll>
|
||||
</PageRoot>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React, { FormEventHandler, useCallback } from "react";
|
||||
import { PageRoot } from "../components/Page";
|
||||
import { Alert, Box, Button, Card, DialogActions, Link, TextField, Typography, useTheme } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { register } from "../utils/MusicServer";
|
||||
import { AsyncStatus, useAsyncCallback } from "../hooks/useAsyncCallback";
|
||||
import { LoadingButton } from '@mui/lab';
|
||||
|
||||
export default function Register() {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [registerState, registerAccount] = useAsyncCallback(
|
||||
useCallback(
|
||||
(uname: string, passwd: string) => register(uname, passwd),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const usernameInput = evt.currentTarget.elements.item(0) as HTMLInputElement;
|
||||
const passwordInput = evt.currentTarget.elements.item(2) as HTMLInputElement;
|
||||
const [username, password] = [usernameInput.value, passwordInput.value];
|
||||
|
||||
const token = await registerAccount(username, password);
|
||||
localStorage.token = token;
|
||||
localStorage.username = username;
|
||||
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageRoot
|
||||
alignSelf='center'
|
||||
>
|
||||
<Box width='90%' height='100%' display='flex' alignSelf='center'>
|
||||
<Card sx={{ width: '100%', p: 3, gap: 2, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography width='100%' variant="h5" textAlign='center'>
|
||||
Create a new account
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit} style={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: theme.spacing(2) }}>
|
||||
<TextField
|
||||
label='Username'
|
||||
required
|
||||
fullWidth
|
||||
size='small'
|
||||
name='usernameInput'
|
||||
/>
|
||||
<TextField
|
||||
label='Password'
|
||||
required
|
||||
fullWidth
|
||||
size='small'
|
||||
name='passwordInput'
|
||||
type='password'
|
||||
/>
|
||||
<Link component='button' onClick={() => navigate('/account')} underline="hover">
|
||||
Already have an account?
|
||||
</Link>
|
||||
{registerState.status === AsyncStatus.Error && (
|
||||
<Alert severity='error'>
|
||||
An error has occured, registration failed.
|
||||
</Alert>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={() => navigate('/')}>
|
||||
Back
|
||||
</Button>
|
||||
<LoadingButton loading={registerState.status === AsyncStatus.Loading} variant='contained' type='submit'>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Card>
|
||||
</Box>
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,13 +3,17 @@ import { PageRoot } from "../components/Page";
|
|||
import AppHeader from "../components/AppHeader";
|
||||
import repeatArray from "../utils/repeatArray";
|
||||
import { LoadingSearchSongCard } from "../components/LoadingCard";
|
||||
import { searchSongs } from "../utils/MusicServer";
|
||||
import { Box } from "@mui/material";
|
||||
import { saveSong, searchSongs } from "../utils/MusicServer";
|
||||
import { Box, IconButton, Snackbar } from "@mui/material";
|
||||
import { SearchSongCard } from "../components/SongCard";
|
||||
import useQueue from "../hooks/useQueue";
|
||||
import { Close } from "@mui/icons-material";
|
||||
|
||||
export default function Search() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [results, setResults] = useState<ReactNode[]>([]);
|
||||
const queue = useQueue();
|
||||
const [snackbar, setSnackbar] = useState<string | null>(null);
|
||||
const onKeyUp = async (evt: KeyboardEvent) => {
|
||||
if (evt.key !== 'Enter') return;
|
||||
const q = inputRef.current?.value;
|
||||
|
|
@ -26,7 +30,11 @@ export default function Search() {
|
|||
artist={song.artist.name}
|
||||
name={song.name}
|
||||
thumbnailUrl={song.thumbnails[0].url}
|
||||
onClick={() => alert(song.name)}
|
||||
saveSong={() => saveSong(song)}
|
||||
playSong={() => {
|
||||
queue?.addSong(song);
|
||||
setSnackbar(`Added "${song.name}" to queue.`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,6 +60,21 @@ export default function Search() {
|
|||
{results}
|
||||
</Box>
|
||||
</PageRoot>
|
||||
<Snackbar
|
||||
open={snackbar ? true : false}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar(null)}
|
||||
message={snackbar}
|
||||
action={
|
||||
<IconButton
|
||||
size='small'
|
||||
color='inherit'
|
||||
onClick={() => setSnackbar(null)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
type Artist = {
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
const API_BASE = `http://localhost:33223`;
|
||||
|
||||
export type Artist = {
|
||||
name: string;
|
||||
artistId: string;
|
||||
};
|
||||
|
||||
type Thumbnail = {
|
||||
export type Thumbnail = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Album = {
|
||||
export type Album = {
|
||||
name: string;
|
||||
albumId: string;
|
||||
};
|
||||
|
||||
type Song = {
|
||||
export type Song = {
|
||||
type: 'SONG';
|
||||
videoId: string;
|
||||
name: string;
|
||||
|
|
@ -26,7 +30,7 @@ type Song = {
|
|||
|
||||
async function doPost(p: string, json: any) {
|
||||
const jsonString = JSON.stringify(json);
|
||||
const response = await fetch(`http://localhost:33223${p}`, {
|
||||
const response = await fetch(API_BASE + p, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
|
|
@ -38,7 +42,7 @@ async function doPost(p: string, json: any) {
|
|||
}
|
||||
|
||||
async function doGet(p: string) {
|
||||
const response = await fetch(`http://localhost:33223${p}`);
|
||||
const response = await fetch(API_BASE + p);
|
||||
if (!response.ok) throw await response.json();
|
||||
return await response.json();
|
||||
}
|
||||
|
|
@ -46,3 +50,20 @@ async function doGet(p: string) {
|
|||
export async function searchSongs(q: string): Promise<Song[]> {
|
||||
return await doGet(`/music/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
export function getSongURL(song?: Song) {
|
||||
if (!song) return null;
|
||||
return `${API_BASE}/music/file?id=${song.videoId}`;
|
||||
}
|
||||
|
||||
export async function register(username?: string, password?: string): Promise<string> {
|
||||
return (await doPost(`/auth/register`, {username, password})).token as string;
|
||||
}
|
||||
|
||||
export async function login(username?: string, password?: string): Promise<string> {
|
||||
return (await doPost(`/auth/login`, {username, password})).token as string;
|
||||
}
|
||||
|
||||
export function saveSong(song: Song) {
|
||||
saveAs(getSongURL(song)!, `${song.artist.name} - ${song.name}.mp3`);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export default function durationToStr(t: number) {
|
||||
const min = Math.floor(t / 60);
|
||||
const sec = Math.floor(t % 60);
|
||||
return `${min}:${`0${sec}`.slice(-2)}`;
|
||||
}
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body, #root {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
Loading…
Reference in New Issue