parent
7a146ec027
commit
137692f654
|
|
@ -13,11 +13,14 @@
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
"@mui/icons-material": "^6.1.2",
|
"@mui/icons-material": "^6.1.2",
|
||||||
|
"@mui/lab": "^6.0.0-beta.11",
|
||||||
"@mui/material": "^6.1.2",
|
"@mui/material": "^6.1.2",
|
||||||
"@rollup/plugin-inject": "^5.0.5",
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
"@vanilla-extract/css": "^1.16.0",
|
"@vanilla-extract/css": "^1.16.0",
|
||||||
"@vanilla-extract/vite-plugin": "^4.0.16",
|
"@vanilla-extract/vite-plugin": "^4.0.16",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"framer-motion": "^11.11.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-lazy-load-image-component": "^1.6.2",
|
"react-lazy-load-image-component": "^1.6.2",
|
||||||
|
|
@ -28,6 +31,7 @@
|
||||||
"vite-plugin-top-level-await": "^1.4.4"
|
"vite-plugin-top-level-await": "^1.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-lazy-load-image-component": "^1.6.4",
|
"@types/react-lazy-load-image-component": "^1.6.4",
|
||||||
|
|
@ -2319,6 +2323,44 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||||
|
|
@ -2377,10 +2419,72 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.3.tgz",
|
||||||
"integrity": "sha512-1oE4U38/TtzLWRYWEm/m70dUbpcvBx0QvDVg6NtpOmSNQC1Mbx0X/rNvYDdZnn8DIsAiVQ+SZ3am6doSswUQ4g==",
|
"integrity": "sha512-ajMUgdfhTb++rwqj134Cq9f4SRN8oXUqMRnY72YBnXiXai3olJLLqETheRlq3MM8wCKrbq7g6j7iWL1VvP44VQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -2413,17 +2517,62 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/lab": {
|
||||||
"version": "6.1.2",
|
"version": "6.0.0-beta.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.11.tgz",
|
||||||
"integrity": "sha512-5TtHeAVX9D5d2LYfB1GAUn29BcVETVsrQ76Dwb2SpAfQGW3JVy4deJCAd0RrIkI3eEUrsl0E4xuBdreszxdTTg==",
|
"integrity": "sha512-IoYzxAepMs0gnQ2tTMokEd8Bmqt+To/8HQyzjrQCbYZmKyYR/6aK3wm3Y5NpfSLuBo1UrkeXWyKsHeRcHreGdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.25.6",
|
"@babel/runtime": "^7.25.6",
|
||||||
"@mui/core-downloads-tracker": "^6.1.2",
|
"@mui/base": "5.0.0-beta.58",
|
||||||
"@mui/system": "^6.1.2",
|
"@mui/system": "^6.1.3",
|
||||||
"@mui/types": "^7.2.17",
|
"@mui/types": "^7.2.18",
|
||||||
"@mui/utils": "^6.1.2",
|
"@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",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@types/react-transition-group": "^4.4.11",
|
"@types/react-transition-group": "^4.4.11",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -2442,7 +2591,7 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emotion/react": "^11.5.0",
|
"@emotion/react": "^11.5.0",
|
||||||
"@emotion/styled": "^11.3.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",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"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"
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
@ -2463,13 +2612,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.3.tgz",
|
||||||
"integrity": "sha512-S8WcjZdNdi++8UhrrY8Lton5h/suRiQexvdTfdcPAlbajlvgM+kx+uJstuVIEyTb3gMkxzIZep87knZ0tqcR0g==",
|
"integrity": "sha512-XK5OYCM0x7gxWb/WBEySstBmn+dE3YKX7U7jeBRLm6vHU5fGUd7GiJWRirpivHjOK9mRH6E1MPIVd+ze5vguKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.25.6",
|
"@babel/runtime": "^7.25.6",
|
||||||
"@mui/utils": "^6.1.2",
|
"@mui/utils": "^6.1.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -2490,13 +2639,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/styled-engine": {
|
"node_modules/@mui/styled-engine": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.3.tgz",
|
||||||
"integrity": "sha512-uKOfWkR23X39xj7th2nyTcCHqInTAXtUnqD3T5qRVdJcOPvu1rlgTleTwJC/FJvWZJBU6ieuTWDhbcx5SNViHQ==",
|
"integrity": "sha512-i4yh9m+eMZE3cNERpDhVr6Wn73Yz6C7MH0eE2zZvw8d7EFkIJlCQNZd1xxGZqarD2DDq2qWHcjIOucWGhxACtA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.25.6",
|
"@babel/runtime": "^7.25.6",
|
||||||
"@emotion/cache": "^11.13.1",
|
"@emotion/cache": "^11.13.1",
|
||||||
|
"@emotion/serialize": "^1.3.2",
|
||||||
"@emotion/sheet": "^1.4.0",
|
"@emotion/sheet": "^1.4.0",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
|
|
@ -2523,16 +2673,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.3.tgz",
|
||||||
"integrity": "sha512-mzW7F1ZMIYS1aLON48Nrk9c65OrVEVQ+R4lUcTWs1lCSul0VGK23eo4dmY0NX5PS7Oe4xz3P5B9tQZZ7SYgxcg==",
|
"integrity": "sha512-ILaD9UsLTBLjMcep3OumJMXh1PYr7aqnkHm/L47bH46+YmSL1zWAX6tWG8swEQROzW2GvYluEMp5FreoxOOC6w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.25.6",
|
"@babel/runtime": "^7.25.6",
|
||||||
"@mui/private-theming": "^6.1.2",
|
"@mui/private-theming": "^6.1.3",
|
||||||
"@mui/styled-engine": "^6.1.2",
|
"@mui/styled-engine": "^6.1.3",
|
||||||
"@mui/types": "^7.2.17",
|
"@mui/types": "^7.2.18",
|
||||||
"@mui/utils": "^6.1.2",
|
"@mui/utils": "^6.1.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
|
|
@ -2563,9 +2713,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/types": {
|
"node_modules/@mui/types": {
|
||||||
"version": "7.2.17",
|
"version": "7.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz",
|
||||||
"integrity": "sha512-oyumoJgB6jDV8JFzRqjBo2daUuHpzDjoO/e3IrRhhHo/FxJlaVhET6mcNrKHUq2E+R+q3ql0qAtvQ4rfWHhAeQ==",
|
"integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
@ -2577,13 +2727,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.3.tgz",
|
||||||
"integrity": "sha512-6+B1YZ8cCBWD1fc3RjqpclF9UA0MLUiuXhyCO+XowD/Z2ku5IlxeEhHHlgglyBWFGMu4kib4YU3CDsG5/zVjJQ==",
|
"integrity": "sha512-4JBpLkjprlKjN10DGb1aiy/ii9TKbQ601uSHtAmYFAS879QZgAD7vRnv/YBE4iBbc7NXzFgbQMCOFrupXWekIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.25.6",
|
"@babel/runtime": "^7.25.6",
|
||||||
"@mui/types": "^7.2.17",
|
"@mui/types": "^7.2.18",
|
||||||
"@types/prop-types": "^15.7.13",
|
"@types/prop-types": "^15.7.13",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
|
@ -3268,6 +3418,13 @@
|
||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/history": {
|
||||||
"version": "4.7.11",
|
"version": "4.7.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
"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": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||||
|
|
@ -4470,6 +4633,31 @@
|
||||||
"is-callable": "^1.1.3"
|
"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": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.2.0",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
|
||||||
|
|
@ -6904,7 +7092,6 @@
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,14 @@
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
"@mui/icons-material": "^6.1.2",
|
"@mui/icons-material": "^6.1.2",
|
||||||
|
"@mui/lab": "^6.0.0-beta.11",
|
||||||
"@mui/material": "^6.1.2",
|
"@mui/material": "^6.1.2",
|
||||||
"@rollup/plugin-inject": "^5.0.5",
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
"@vanilla-extract/css": "^1.16.0",
|
"@vanilla-extract/css": "^1.16.0",
|
||||||
"@vanilla-extract/vite-plugin": "^4.0.16",
|
"@vanilla-extract/vite-plugin": "^4.0.16",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"framer-motion": "^11.11.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-lazy-load-image-component": "^1.6.2",
|
"react-lazy-load-image-component": "^1.6.2",
|
||||||
|
|
@ -29,6 +32,7 @@
|
||||||
"vite-plugin-top-level-await": "^1.4.4"
|
"vite-plugin-top-level-await": "^1.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-lazy-load-image-component": "^1.6.4",
|
"@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 { Router, RouterProvider } from "react-router-dom";
|
||||||
import createRouter from "./Router";
|
import createRouter from "./Router";
|
||||||
import { createTheme, ThemeProvider } from "@mui/material";
|
import { createTheme, ThemeProvider } from "@mui/material";
|
||||||
|
import { Queue, QueueContextProvider } from "./hooks/useQueue";
|
||||||
|
import { Song } from "./utils/MusicServer";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const router = createRouter();
|
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 (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<RouterProvider router={router} />
|
<QueueContextProvider value={queue}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueueContextProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5,38 +5,47 @@ import {
|
||||||
Outlet,
|
Outlet,
|
||||||
Route
|
Route
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import AppHeader from "./components/AppHeader";
|
|
||||||
import { Box, useTheme } from "@mui/material";
|
|
||||||
import Landing from "./pages/Landing";
|
import Landing from "./pages/Landing";
|
||||||
import Page from "./components/Page";
|
import Page from "./components/Page";
|
||||||
import Search from "./pages/Search";
|
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() {
|
export default function createRouter() {
|
||||||
const theme = useTheme();
|
|
||||||
const router = createRoutesFromElements(
|
const router = createRoutesFromElements(
|
||||||
<Route>
|
<Route
|
||||||
|
errorElement={
|
||||||
|
<Page>
|
||||||
|
<ErrorScreen />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route
|
<Route
|
||||||
path='/'
|
path='/'
|
||||||
element={
|
element={
|
||||||
<Page>
|
<Page>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Player />
|
||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route
|
<Route
|
||||||
path='/'
|
path='/'
|
||||||
element={
|
element={<Landing />}
|
||||||
<>
|
|
||||||
<AppHeader />
|
|
||||||
<Landing />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/search'
|
path='/search'
|
||||||
element={
|
element={<Search />}
|
||||||
<Search />
|
/>
|
||||||
}
|
<Route
|
||||||
|
path='/account'
|
||||||
|
element={<Account />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/register'
|
||||||
|
element={<Register />}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</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 { AppBar, IconButton, InputBaseProps, Toolbar, Typography } from '@mui/material';
|
||||||
import { SearchContainer, SearchIcon, SearchIconWrapper, SearchInputBase } from "./Search";
|
import { SearchContainer, SearchIcon, SearchIconWrapper, SearchInputBase } from "./Search";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Close, Person } from "@mui/icons-material";
|
||||||
|
|
||||||
type AppHeaderProps = {
|
type AppHeaderProps = {
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
|
|
@ -10,21 +11,37 @@ type AppHeaderProps = {
|
||||||
export default function AppHeader({ showSearch, inputProps }: AppHeaderProps) {
|
export default function AppHeader({ showSearch, inputProps }: AppHeaderProps) {
|
||||||
const navTo = useNavigate();
|
const navTo = useNavigate();
|
||||||
return (
|
return (
|
||||||
<AppBar position='static'>
|
<AppBar position='sticky' color='info'>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
{showSearch ? (
|
{showSearch ? (
|
||||||
<SearchContainer>
|
<>
|
||||||
|
<SearchContainer sx={{flexGrow:1}}>
|
||||||
<SearchIconWrapper><SearchIcon /></SearchIconWrapper>
|
<SearchIconWrapper><SearchIcon /></SearchIconWrapper>
|
||||||
<SearchInputBase
|
<SearchInputBase
|
||||||
placeholder='Search songs...'
|
placeholder='Search songs...'
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
</SearchContainer>
|
</SearchContainer>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => navTo('/', {replace: true})}
|
||||||
|
size='large'
|
||||||
|
edge='end'
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" component='div' flexGrow={1}>
|
<Typography variant="h6" component='div' flexGrow={1}>
|
||||||
Extera Music
|
Extera Music
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => navTo('/account')}
|
||||||
|
size='large'
|
||||||
|
edge='end'
|
||||||
|
>
|
||||||
|
<Person />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => navTo('/search')}
|
onClick={() => navTo('/search')}
|
||||||
size='large'
|
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";
|
import React from "react";
|
||||||
|
|
||||||
export default function Page(props: React.PropsWithChildren) {
|
export default function Page(props: BoxProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
minHeight='100%'
|
||||||
|
maxWidth='100%'
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: '100vh',
|
|
||||||
backgroundColor: theme.palette.background.default,
|
backgroundColor: theme.palette.background.default,
|
||||||
color: theme.palette.text.primary
|
color: theme.palette.text.primary
|
||||||
}}
|
}}
|
||||||
|
bgcolor='default'
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageRoot(props: React.PropsWithChildren) {
|
export function PageRoot(props: BoxProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
display='flex'
|
||||||
|
flexDirection='column'
|
||||||
|
bgcolor='default'
|
||||||
|
p={theme.spacing(1)}
|
||||||
{...props}
|
{...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 }) => ({
|
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
'& .MuiInputBase-input': {
|
'& .MuiInputBase-input': {
|
||||||
padding: theme.spacing(1, 1, 1, 0),
|
padding: theme.spacing(1, 1, 1, 0),
|
||||||
// vertical padding + font size from searchIcon
|
// vertical padding + font size from searchIcon
|
||||||
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||||
transition: theme.transitions.create('width'),
|
transition: theme.transitions.create('width'),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
[theme.breakpoints.up('md')]: {
|
[theme.breakpoints.up('md')]: {
|
||||||
width: '20ch',
|
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 React, { MouseEventHandler } from "react";
|
||||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||||
|
import { AddToQueue, Download as DownloadIcon, PlayArrow, PlaylistAdd } from '@mui/icons-material';
|
||||||
|
|
||||||
type SongCardProps = {
|
type SongCardProps = {
|
||||||
onClick?: MouseEventHandler;
|
onClick?: MouseEventHandler;
|
||||||
|
|
@ -8,6 +9,8 @@ type SongCardProps = {
|
||||||
artist: string;
|
artist: string;
|
||||||
name: string;
|
name: string;
|
||||||
album: string;
|
album: string;
|
||||||
|
saveSong?: () => void;
|
||||||
|
playSong?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
|
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();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -54,13 +57,31 @@ export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: S
|
||||||
<Box
|
<Box
|
||||||
display='flex'
|
display='flex'
|
||||||
flexDirection='column'
|
flexDirection='column'
|
||||||
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Typography variant='subtitle1'>
|
<Typography variant='subtitle1'>
|
||||||
{name}
|
{name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='subtitle2'>
|
<Box display='flex' gap={theme.spacing(1)}>
|
||||||
{artist}
|
<Typography variant='subtitle2'>
|
||||||
</Typography>
|
{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>
|
||||||
</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 React from "react";
|
||||||
import LoadingCard from "../components/LoadingCard";
|
import LoadingCard from "../components/LoadingCard";
|
||||||
import Scroll from "../components/Scroll";
|
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() {
|
export default function Landing() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Page>
|
||||||
sx={{
|
<AppHeader />
|
||||||
p: theme.spacing(1)
|
<PageRoot>
|
||||||
}}
|
<Typography variant='button'>Playlists</Typography>
|
||||||
>
|
<Scroll display='flex' gap={theme.spacing(2)}>
|
||||||
<Typography variant='button'>Trending</Typography>
|
<AddPlaylistButton />
|
||||||
<Scroll display='flex' gap={theme.spacing(2)}>
|
</Scroll>
|
||||||
<LoadingCard />
|
</PageRoot>
|
||||||
<LoadingCard />
|
</Page>
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
<LoadingCard />
|
|
||||||
</Scroll>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 AppHeader from "../components/AppHeader";
|
||||||
import repeatArray from "../utils/repeatArray";
|
import repeatArray from "../utils/repeatArray";
|
||||||
import { LoadingSearchSongCard } from "../components/LoadingCard";
|
import { LoadingSearchSongCard } from "../components/LoadingCard";
|
||||||
import { searchSongs } from "../utils/MusicServer";
|
import { saveSong, searchSongs } from "../utils/MusicServer";
|
||||||
import { Box } from "@mui/material";
|
import { Box, IconButton, Snackbar } from "@mui/material";
|
||||||
import { SearchSongCard } from "../components/SongCard";
|
import { SearchSongCard } from "../components/SongCard";
|
||||||
|
import useQueue from "../hooks/useQueue";
|
||||||
|
import { Close } from "@mui/icons-material";
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [results, setResults] = useState<ReactNode[]>([]);
|
const [results, setResults] = useState<ReactNode[]>([]);
|
||||||
|
const queue = useQueue();
|
||||||
|
const [snackbar, setSnackbar] = useState<string | null>(null);
|
||||||
const onKeyUp = async (evt: KeyboardEvent) => {
|
const onKeyUp = async (evt: KeyboardEvent) => {
|
||||||
if (evt.key !== 'Enter') return;
|
if (evt.key !== 'Enter') return;
|
||||||
const q = inputRef.current?.value;
|
const q = inputRef.current?.value;
|
||||||
|
|
@ -26,7 +30,11 @@ export default function Search() {
|
||||||
artist={song.artist.name}
|
artist={song.artist.name}
|
||||||
name={song.name}
|
name={song.name}
|
||||||
thumbnailUrl={song.thumbnails[0].url}
|
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}
|
{results}
|
||||||
</Box>
|
</Box>
|
||||||
</PageRoot>
|
</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;
|
name: string;
|
||||||
artistId: string;
|
artistId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Thumbnail = {
|
export type Thumbnail = {
|
||||||
url: string;
|
url: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Album = {
|
export type Album = {
|
||||||
name: string;
|
name: string;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Song = {
|
export type Song = {
|
||||||
type: 'SONG';
|
type: 'SONG';
|
||||||
videoId: string;
|
videoId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -26,7 +30,7 @@ type Song = {
|
||||||
|
|
||||||
async function doPost(p: string, json: any) {
|
async function doPost(p: string, json: any) {
|
||||||
const jsonString = JSON.stringify(json);
|
const jsonString = JSON.stringify(json);
|
||||||
const response = await fetch(`http://localhost:33223${p}`, {
|
const response = await fetch(API_BASE + p, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
|
@ -38,11 +42,28 @@ async function doPost(p: string, json: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doGet(p: string) {
|
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();
|
if (!response.ok) throw await response.json();
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSongs(q: string): Promise<Song[]> {
|
export async function searchSongs(q: string): Promise<Song[]> {
|
||||||
return await doGet(`/music/search?q=${encodeURIComponent(q)}`);
|
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 {
|
body {
|
||||||
margin: 0;
|
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