animations

a lot of new things
This commit is contained in:
OfficialDakari 2024-10-11 22:46:49 +05:00
parent 7a146ec027
commit 137692f654
24 changed files with 1064 additions and 89 deletions

255
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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'

View File

@ -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);

View File

@ -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}
/> />
); );

View File

@ -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>
);
}

View File

@ -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',
}, },

View File

@ -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>
); );

View File

@ -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
};
}

15
src/app/hooks/useAlive.ts Normal file
View File

@ -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;
};

View File

@ -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];
};

View File

@ -0,0 +1,6 @@
import { useReducer } from "react";
export default function useForceUpdate() {
const [reducer, dispatch] = useReducer(x => x + 1, 0);
return [reducer, dispatch];
}

93
src/app/hooks/useQueue.ts Normal file
View File

@ -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;
}

102
src/app/pages/Account.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
}
/>
</> </>
); );
} }

View File

@ -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`);
} }

View File

@ -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)}`;
}

View File

@ -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%;
} }