diff --git a/client/index.html b/client/index.html index 2ecbadc..2dd366a 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,9 @@ + + + COSC 3380 diff --git a/client/package-lock.json b/client/package-lock.json index 0daa83c..8680c5f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,11 @@ "name": "cosc-3380-project-client", "version": "0.0.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.3.4", + "@mui/x-charts": "^8.14.0", + "@wavesurfer/react": "^1.0.11", "axios": "^1.11.0", "classnames": "^2.5.1", "dotenv": "^17.2.2", @@ -16,7 +21,10 @@ "react-helmet": "^6.1.0", "react-helmet-async": "^2.0.5", "react-icons": "^5.5.0", - "react-router-dom": "^7.8.2" + "react-router-dom": "^7.8.2", + "react-spinners": "^0.17.0", + "uuid": "^13.0.0", + "wavesurfer.js": "^7.11.0" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -38,7 +46,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", @@ -91,7 +98,6 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", @@ -123,7 +129,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -132,7 +137,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" @@ -171,7 +175,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -180,7 +183,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -211,7 +213,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, "dependencies": { "@babel/types": "^7.28.4" }, @@ -252,11 +253,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", @@ -270,7 +279,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -288,7 +296,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" @@ -297,6 +304,158 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -908,7 +1067,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -928,7 +1086,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -936,19 +1093,328 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz", + "integrity": "sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", + "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.5", + "@mui/system": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.0", + "react-transition-group": "^4.4.5" + }, + "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-pigment-css": "^7.3.5", + "@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/node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.5.tgz", + "integrity": "sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "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 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.5.tgz", + "integrity": "sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.5.tgz", + "integrity": "sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.5", + "@mui/styled-engine": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "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", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz", + "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz", + "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.8", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.0" + }, + "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/utils/node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT" + }, + "node_modules/@mui/x-charts": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.16.0.tgz", + "integrity": "sha512-Gx1kkda2BZWQUAJS5pR3hV/EaUS1cEiSWkQyu6riyeLz9trnkAuhSzOUX3X7vuN7k6JDLm+YUBpiHXFsMPrpfA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-charts-vendor": "8.15.0", + "@mui/x-internal-gestures": "0.3.4", + "@mui/x-internals": "8.16.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "flatqueue": "^3.0.0", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.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 + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.15.0.tgz", + "integrity": "sha512-0w7Who278kmRFm+5LUZY5MErSJBO75tqBH8abDkTiDNfsCe88vnrImmIT7OGzJGyRJfyrEBht8RLL6bHNEParg==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@types/d3-color": "^3.1.3", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-sankey": "^0.12.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-color": "^3.1.0", + "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/@mui/x-internal-gestures": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.3.4.tgz", + "integrity": "sha512-7qQuDE7Khks3ujWtKPvGE0PAT2D15TKS6d3PDQYg/NzLyOoXe3AR5uGO4o+dOzHpdFC/qqNvn68wmq1N7UzL6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/@mui/x-internals": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.16.0.tgz", + "integrity": "sha512-JR53WOFqmQYQzurOpB0H91K7/9uMcte1ooxHxTLGB+97PgB+rKY6siRWvUALGS56XyPV+1a2ALI33hd2E7+Rgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -984,6 +1450,16 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", @@ -1304,6 +1780,81 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.4.tgz", + "integrity": "sha512-YTicQNwioitIlvuvlfW2GfO6sKxpohzg2cSQttlXAPjFwoBuN+XpGLhUN3kLutG/dI3GCLC+DUorqiJt7Naetw==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1325,6 +1876,18 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", @@ -1352,6 +1915,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -1629,6 +2201,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@wavesurfer/react": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@wavesurfer/react/-/react-1.0.11.tgz", + "integrity": "sha512-DRpaA3MRTKy4Jby12xvoHASa+w31FZtxaqanXcJjfqNqfamkKi8VJfRnz+Uub9LkpdgoAc3g5SuZF75lEcGgzQ==", + "license": "BSD-3-Clause", + "peerDependencies": { + "react": "^18.2.0 || ^19.0.0", + "wavesurfer.js": ">=7.7.14" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1702,12 +2284,33 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1778,7 +2381,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -1824,6 +2426,15 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1873,6 +2484,22 @@ "node": ">=18" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1890,14 +2517,146 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -1924,6 +2683,16 @@ "node": ">=0.4.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "17.2.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", @@ -1954,6 +2723,15 @@ "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2049,7 +2827,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2304,6 +3081,12 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2333,6 +3116,12 @@ "node": ">=16" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -2525,6 +3314,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2538,7 +3336,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2559,6 +3356,12 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -2568,6 +3371,27 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2625,7 +3449,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -2639,6 +3462,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2685,6 +3514,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2790,8 +3625,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.11", @@ -2882,7 +3716,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -2890,6 +3723,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2908,11 +3759,25 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3125,11 +3990,62 @@ "react-dom": ">=18" } }, + "node_modules/react-spinners": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.17.0.tgz", + "integrity": "sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -3253,6 +4169,15 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3274,6 +4199,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3286,6 +4217,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3448,6 +4391,28 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -3551,6 +4516,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/wavesurfer.js": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.11.1.tgz", + "integrity": "sha512-8Q+wwItpjJAlhQ7crQLtKwgfbqqczm5/wx+76K4PptP+MBAjB0OA78+A9OuLnULz/8GpAQ+fKM6s81DonEO0Sg==", + "license": "BSD-3-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3581,6 +4552,15 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/client/package.json b/client/package.json index 6298133..70d632c 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,11 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.3.4", + "@mui/x-charts": "^8.14.0", + "@wavesurfer/react": "^1.0.11", "axios": "^1.11.0", "classnames": "^2.5.1", "dotenv": "^17.2.2", @@ -19,7 +24,10 @@ "react-helmet": "^6.1.0", "react-helmet-async": "^2.0.5", "react-icons": "^5.5.0", - "react-router-dom": "^7.8.2" + "react-router-dom": "^7.8.2", + "react-spinners": "^0.17.0", + "uuid": "^13.0.0", + "wavesurfer.js": "^7.11.0" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -36,4 +44,4 @@ "typescript-eslint": "^8.39.1", "vite": "^7.1.4" } -} +} \ No newline at end of file diff --git a/client/public/PlayerBar/BackSong.svg b/client/public/PlayerBar/BackSong.svg new file mode 100644 index 0000000..cf0d12c --- /dev/null +++ b/client/public/PlayerBar/BackSong.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Like.svg b/client/public/PlayerBar/Like.svg new file mode 100644 index 0000000..19d7b5e --- /dev/null +++ b/client/public/PlayerBar/Like.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Mask group.png b/client/public/PlayerBar/Mask group.png new file mode 100644 index 0000000..cda21bd Binary files /dev/null and b/client/public/PlayerBar/Mask group.png differ diff --git a/client/public/PlayerBar/MixSong.svg b/client/public/PlayerBar/MixSong.svg new file mode 100644 index 0000000..fbef2d3 --- /dev/null +++ b/client/public/PlayerBar/MixSong.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/NextSong.svg b/client/public/PlayerBar/NextSong.svg new file mode 100644 index 0000000..54fcad3 --- /dev/null +++ b/client/public/PlayerBar/NextSong.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Pause.svg b/client/public/PlayerBar/Pause.svg new file mode 100644 index 0000000..931bb96 --- /dev/null +++ b/client/public/PlayerBar/Pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Play.svg b/client/public/PlayerBar/Play.svg new file mode 100644 index 0000000..b668cf4 --- /dev/null +++ b/client/public/PlayerBar/Play.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Playlist.svg b/client/public/PlayerBar/Playlist.svg new file mode 100644 index 0000000..eabc19f --- /dev/null +++ b/client/public/PlayerBar/Playlist.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Replay.svg b/client/public/PlayerBar/Replay.svg new file mode 100644 index 0000000..9435723 --- /dev/null +++ b/client/public/PlayerBar/Replay.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/PlayerBar/Volume.svg b/client/public/PlayerBar/Volume.svg new file mode 100644 index 0000000..81c5357 --- /dev/null +++ b/client/public/PlayerBar/Volume.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/SideBar/Expand.svg b/client/public/SideBar/Expand.svg new file mode 100644 index 0000000..76d9eed --- /dev/null +++ b/client/public/SideBar/Expand.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/public/SideBar/Logo.svg b/client/public/SideBar/Logo.svg new file mode 100644 index 0000000..a0bb7a3 --- /dev/null +++ b/client/public/SideBar/Logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/SideBar/LogoutButton.svg b/client/public/SideBar/LogoutButton.svg new file mode 100644 index 0000000..0a8f9b6 --- /dev/null +++ b/client/public/SideBar/LogoutButton.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/public/SideBar/SongOrder.svg b/client/public/SideBar/SongOrder.svg new file mode 100644 index 0000000..761bdc5 --- /dev/null +++ b/client/public/SideBar/SongOrder.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/SideBar/Vector-2.svg b/client/public/SideBar/Vector-2.svg new file mode 100644 index 0000000..5ad6792 --- /dev/null +++ b/client/public/SideBar/Vector-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/SideBar/Vector-3.svg b/client/public/SideBar/Vector-3.svg new file mode 100644 index 0000000..e113f6b --- /dev/null +++ b/client/public/SideBar/Vector-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/SideBar/Vector-4.svg b/client/public/SideBar/Vector-4.svg new file mode 100644 index 0000000..9f05bef --- /dev/null +++ b/client/public/SideBar/Vector-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/TopBar/FeedButton.svg b/client/public/TopBar/FeedButton.svg new file mode 100644 index 0000000..c35f6c1 --- /dev/null +++ b/client/public/TopBar/FeedButton.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/public/TopBar/HomeButton.svg b/client/public/TopBar/HomeButton.svg new file mode 100644 index 0000000..6ff90a9 --- /dev/null +++ b/client/public/TopBar/HomeButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/TopBar/NotificationButton.svg b/client/public/TopBar/NotificationButton.svg new file mode 100644 index 0000000..7f8e8d3 --- /dev/null +++ b/client/public/TopBar/NotificationButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/TopBar/ProfileButton.svg b/client/public/TopBar/ProfileButton.svg new file mode 100644 index 0000000..b016a05 --- /dev/null +++ b/client/public/TopBar/ProfileButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/TopBar/SettingButton.svg b/client/public/TopBar/SettingButton.svg new file mode 100644 index 0000000..23ddf9b --- /dev/null +++ b/client/public/TopBar/SettingButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/Routes.tsx b/client/src/Routes.tsx index 1d02c9c..7b7313e 100644 --- a/client/src/Routes.tsx +++ b/client/src/Routes.tsx @@ -1,10 +1,14 @@ import { Routes, Route } from "react-router-dom"; import * as Pages from "./pages"; +import HomePage from "./pages/HomePage/HomePage"; export default function AppRoutes() { return ( - } /> + } /> + } /> + } /> + } /> ); } diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 1da052d..1c96857 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -2,8 +2,15 @@ import axios from "axios"; const API_BASE_URL = import.meta.env.VITE_API_URL; +// Ensure the baseURL always points to the API root. If a full server URL is +// provided (e.g., http://localhost:8080), append "/api". Otherwise, default +// to the Vite dev proxy on "/api" so requests are proxied during development. +const resolvedBaseUrl = API_BASE_URL + ? `${API_BASE_URL.replace(/\/$/, "")}/api` + : "/api"; + const api = axios.create({ - baseURL: API_BASE_URL || "/api", + baseURL: resolvedBaseUrl, timeout: 30000, headers: { "Content-Type": "application/json", diff --git a/client/src/api/artist.api.ts b/client/src/api/artist.api.ts new file mode 100644 index 0000000..e69de29 diff --git a/client/src/api/search.api.ts b/client/src/api/search.api.ts new file mode 100644 index 0000000..68c84de --- /dev/null +++ b/client/src/api/search.api.ts @@ -0,0 +1,69 @@ +import api from "./api"; + +export type SearchType = "all" | "songs" | "artists" | "albums" | "playlists"; + +export interface SearchSongArtistRef { + id: string | number; + name: string; +} + +export interface SearchSong { + id: string | number; + title: string; + image: string; + // Optional fields depending on API response + artist?: string; + artists?: SearchSongArtistRef[]; + plays?: number; + likes?: number; + comments?: number; + duration?: number; +} + +export interface SearchArtist { + id: string | number; + name: string; + image: string; + plays?: number; + likes?: number; + comments?: number; +} + +export interface SearchAlbum { + id: string | number; + title: string; + artist: string; + image: string; + release_date?: string; + plays?: number; + likes?: number; + comments?: number; +} + +export interface SearchPlaylist { + id: string | number; + title: string; + artist: string; + image: string; + plays?: number; + likes?: number; + comments?: number; +} + +export interface SearchResponse { + songs?: SearchSong[]; + artists?: SearchArtist[]; + albums?: SearchAlbum[]; + playlists?: SearchPlaylist[]; +} + +export async function fetchSearch( + q: string, + type: SearchType = "all", + limit = 20, + offset = 0 +): Promise { + const params = { q, type, limit, offset }; + const { data } = await api.get(`/search`, { params }); + return data; +} diff --git a/client/src/components/AppLayout/AppLayout.tsx b/client/src/components/AppLayout/AppLayout.tsx new file mode 100644 index 0000000..55d11a4 --- /dev/null +++ b/client/src/components/AppLayout/AppLayout.tsx @@ -0,0 +1,14 @@ +import { Outlet } from "react-router-dom"; +import { AudioQueueProvider } from "@contexts"; +import { DevBanner } from "@components"; + +const AppLayout: React.FC = () => { + return ( + + + + + ); +}; + +export default AppLayout; diff --git a/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.module.css b/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.module.css new file mode 100644 index 0000000..ec118b6 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.module.css @@ -0,0 +1,44 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.aboutContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md); + background: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + padding: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.aboutTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; +} + +.monthlyListenersText { + font-size: var(--font-size-md); + color: var(--color-white); + font-weight: 500; +} + +.artistBio { + font-size: var(--font-size-md); + color: var(--color-text-gray); + font-weight: 400; +} diff --git a/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.tsx b/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.tsx new file mode 100644 index 0000000..22cfcd1 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistAbout/ArtistAbout.tsx @@ -0,0 +1,60 @@ +import { memo, useMemo } from "react"; +import { PuffLoader } from "react-spinners"; +import type { UUID } from "@types"; +import { formatNumber } from "@util"; +import { useAsyncData } from "@hooks"; +import { artistApi } from "@api"; +import styles from "./ArtistAbout.module.css"; + +export interface ArtistAboutProps { + artistId: UUID; + artistName: string; + artistBio?: string; +} + +const ArtistAbout: React.FC = ({ + artistId, + artistName, + artistBio, +}) => { + const { data, loading, error } = useAsyncData( + { + monthlyListeners: () => artistApi.getMonthlyListeners(artistId), + }, + [artistId], + { + cacheKey: `about_${artistId}`, + } + ); + + const monthlyListeners = useMemo(() => { + const count = formatNumber(data?.monthlyListeners ?? 0); + return count; + }, [data]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Failed to load about section.
; + } + + return ( +
+ About + + {monthlyListeners} monthly listeners + + + {artistBio || `${artistName} has no bio yet...`} + +
+ ); +}; + +export default memo(ArtistAbout); diff --git a/client/src/components/ArtistPage/ArtistActions/ArtistActions.module.css b/client/src/components/ArtistPage/ArtistActions/ArtistActions.module.css new file mode 100644 index 0000000..a4ee388 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistActions/ArtistActions.module.css @@ -0,0 +1,97 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.artistActionsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + background: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + padding: var(--spacing-md); + min-width: 0; +} + +.actionsLayout { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + align-items: center; + row-gap: var(--spacing-md); + column-gap: var(--spacing-sm); +} + +.actionButton, +.actionButtonAlt { + font-family: "Inter", sans-serif; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + border-radius: var(--border-radius-sm); + cursor: pointer; + font-size: var(--font-size-sm); + padding: var(--spacing-sm) var(--spacing-md); + transition: background-color var(--transition-speed) ease-in-out; +} + +.actionButton { + background: var(--color-red); + border: 1px solid var(--color-red); + border: none; + color: var(--color-red-200); +} + +.actionButton:hover { + background-color: var(--color-red-700); +} + +.actionButtonAlt { + background: var(--color-gray-button); + border: 1px solid var(--color-gray-button-border); + color: var(--color-white); +} + +.actionButtonAlt:hover { + background-color: var(--color-gray-button-hover); +} + +.artistStat { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); +} + +.artistStatLarge { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 700; +} + +.artistStatLabel { + font-size: var(--font-size-sm); + color: var(--color-text-gray); + font-weight: 500; +} + +.artistStatLarge, +.artistStatLabel { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} diff --git a/client/src/components/ArtistPage/ArtistActions/ArtistActions.tsx b/client/src/components/ArtistPage/ArtistActions/ArtistActions.tsx new file mode 100644 index 0000000..bcf0ddb --- /dev/null +++ b/client/src/components/ArtistPage/ArtistActions/ArtistActions.tsx @@ -0,0 +1,174 @@ +import { useState, memo, useMemo, useCallback } from "react"; +import { PuffLoader } from "react-spinners"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@contexts"; +import { useAsyncData } from "@hooks"; +import { formatNumber } from "@util"; +import { artistApi } from "@api"; +import { ShareModal } from "@components"; +import styles from "./ArtistActions.module.css"; +import { + LuCirclePlay, + LuUserRoundPlus, + LuShare, + LuCircleAlert, + LuUsersRound, + LuMusic, + LuAudioLines, + LuUserRoundCheck, +} from "react-icons/lu"; + +const StatItem = memo( + ({ + icon: Icon, + value, + label, + }: { + icon: React.ElementType; + value: string; + label: string; + }) => ( +
+
+
+ {label} +
+ ) +); + +export interface ArtistActionsProps { + artistId: string; + userId: string; + artistName: string; + shareLink: string; +} + +const ArtistActions: React.FC = ({ + artistId, + userId, + artistName, + shareLink, +}) => { + const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); + const [isFollowed, setIsFollowed] = useState(false); + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + + const asyncConfig = useMemo( + () => ({ + followingCount: () => artistApi.getFollowingCount(userId), + followerCount: () => artistApi.getFollowerCount(userId), + numberOfSongs: () => artistApi.getNumberOfSongs(artistId), + totalStreams: () => artistApi.getTotalStreams(artistId), + }), + [artistId, userId] + ); + + const { data, loading, error } = useAsyncData(asyncConfig, [artistId], { + cacheKey: `artist_actions_${artistId}`, + hasBlobUrl: true, + }); + + const stats = useMemo( + () => ({ + followers: formatNumber(data?.followerCount ?? 0), + following: formatNumber(data?.followingCount ?? 0), + tracks: formatNumber(data?.numberOfSongs ?? 0), + streams: formatNumber(data?.totalStreams ?? 0), + }), + [data] + ); + + const handleFollowArtist = useCallback(async () => { + try { + if (isAuthenticated) { + //! send request here + setIsFollowed((prev) => !prev); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Toggling follow artist failed:", error); + } + }, [isAuthenticated, navigate]); + + const handleShare = useCallback(() => { + setIsShareModalOpen(true); + }, []); + + const handleReport = useCallback(() => { + // TODO: open report modal... + }, []); + + const handleCloseShareModal = useCallback(() => { + setIsShareModalOpen(false); + }, []); + + const handlePlayAll = useCallback(() => { + //todo: make new action - playArtist + make util function to get all songs by artist sorted by streams + }, []); + + return loading ? ( +
+ +
+ ) : ( + <> +
+
+ + + + + + {!error && ( + <> + + + + + + )} +
+
+ + + + ); +}; + +export default memo(ArtistActions); diff --git a/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.module.css b/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.module.css new file mode 100644 index 0000000..0c32e16 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.module.css @@ -0,0 +1,146 @@ +.artistBanner { + position: relative; + display: flex; + width: 100%; + height: 32rem; + background: linear-gradient(to bottom, #181818, #0f0f0f); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + border-radius: var(--border-radius-sm); + overflow: hidden; + padding: var(--spacing-lg); +} + +.bannerImage { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + z-index: 0; +} + +.artistBanner::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(65deg, #080808 0%, transparent 25%); + pointer-events: none; + z-index: 1; +} + +.artistInfo { + z-index: 2; + display: flex; + gap: var(--spacing-lg); + align-items: center; +} + +.artistImage { + width: 24rem; + height: 24rem; + border-radius: 50%; + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + transition: box-shadow var(--transition-speed) ease; +} + +.artistImageClickable { + cursor: pointer; +} + +.artistImage:hover { + box-shadow: 0 0 15px rgba(0, 0, 0, 0.6); +} + +.artistInfoRight { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.artistNameContainer { + min-width: 0; + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.artistName { + font-size: 7.2rem; + color: var(--dynamic-text-color, var(--color-white)); + font-weight: 700; +} + +.badgeWrapper { + position: relative; + display: flex; + align-items: center; +} + +.tooltip { + position: absolute; + bottom: calc(100% + var(--spacing-sm)); + left: 50%; + transform: translateX(-50%); + background: rgba(246, 246, 246, 0.8); + color: var(--color-black); + padding: 0.6rem 1.2rem; + border-radius: var(--border-radius-sm); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + opacity: 0; + animation: tooltipFadeIn 0.15s ease-out forwards; +} + +.verifiedBadge { + font-size: var(--icon-size-xl); + flex-shrink: 0; + color: var(--dynamic-text-color, var(--color-white)); + transition: color var(--transition-speed) ease-in-out; +} + +.verifiedBadge:hover { + color: var(--dynamic-text-color, var(--color-white-hover)); +} + +.tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(246, 246, 246, 0.75); +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.artistLocation { + font-size: var(--font-size-lg); + color: var(--dynamic-text-color, var(--color-white)); + font-weight: 500; +} + +.artistName, +.artistLocation { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} diff --git a/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.tsx b/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.tsx new file mode 100644 index 0000000..d9f911c --- /dev/null +++ b/client/src/components/ArtistPage/ArtistBanner/ArtistBanner.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback, memo, useMemo } from "react"; +import { CoverLightbox } from "@components"; +import { useTextContrast } from "@hooks"; +import styles from "./ArtistBanner.module.css"; +import classNames from "classnames"; +import { LuBadgeCheck } from "react-icons/lu"; +import artistPlaceholder from "@assets/artist-placeholder.png"; + +export interface ArtistBannerProps { + bannerImageUrl?: string; + artistImageUrl?: string; + artistName: string; + artistLocation?: string; + isVerified?: boolean; +} + +const ArtistBanner: React.FC = ({ + bannerImageUrl, + artistImageUrl, + artistName, + artistLocation, + isVerified, +}) => { + const { textColor, loading } = useTextContrast(bannerImageUrl); + + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + + const handleTooltipShow = useCallback(() => setIsTooltipVisible(true), []); + const handleTooltipHide = useCallback(() => setIsTooltipVisible(false), []); + const handleLightboxClose = useCallback(() => setIsLightboxOpen(false), []); + + const handleImageClick = useCallback(() => { + setIsLightboxOpen(true); + }, []); + + const bannerStyle = useMemo( + () => + ({ + "--dynamic-text-color": + textColor === "white" ? "var(--color-white)" : "var(--color-black)", + } as React.CSSProperties), + [textColor] + ); + + return ( + <> +
+ {bannerImageUrl && !loading && ( + {`${artistName} + )} +
+ {`${artistName} +
+
+

{artistName}

+ {isVerified && ( +
+ + {isTooltipVisible && ( +
Verified by CoogMusic
+ )} +
+ )} +
+ {artistLocation && ( + {artistLocation} + )} +
+
+
+ + {artistImageUrl && ( + + )} + + ); +}; + +export default memo(ArtistBanner); diff --git a/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.module.css b/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.module.css new file mode 100644 index 0000000..dbdc933 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.module.css @@ -0,0 +1,39 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.sectionTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.playlistsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.playlistsList { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; +} diff --git a/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.tsx b/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.tsx new file mode 100644 index 0000000..92acf92 --- /dev/null +++ b/client/src/components/ArtistPage/ArtistPlaylists/ArtistPlaylists.tsx @@ -0,0 +1,71 @@ +import { memo } from "react"; +import { PuffLoader } from "react-spinners"; +import type { UUID } from "@types"; +import { useAsyncData } from "@hooks"; +import { artistApi } from "@api"; +import { EntityItem } from "@components"; +import styles from "./ArtistPlaylists.module.css"; +import musicPlaceholder from "@assets/music-placeholder.png"; + +export interface ArtistPlaylistsProps { + artistId: UUID; + artistName: string; +} + +const ArtistPlaylists: React.FC = ({ + artistId, + artistName, +}) => { + const { data, loading, error } = useAsyncData( + { + playlists: () => + artistApi.getPlaylists(artistId, { includeUser: true, limit: 10 }), + }, + [artistId], + { + cacheKey: `artist_playlists_${artistId}`, + hasBlobUrl: true, + } + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Failed to load playlists.
; + } + + const playlists = data?.playlists; + + if (!playlists || playlists.length === 0) { + return null; + } + + return ( +
+ + Playlists Featuring {artistName} + +
+ {data?.playlists?.map((playlist) => ( + + ))} +
+
+ ); +}; + +export default memo(ArtistPlaylists); diff --git a/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.module.css b/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.module.css new file mode 100644 index 0000000..6d8aecf --- /dev/null +++ b/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.module.css @@ -0,0 +1,134 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.relatedArtistsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.sectionTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.relatedArtistsList { + display: flex; + gap: var(--spacing-lg); + justify-content: space-between; + min-width: 0; + transition: transform 0.3s ease; + width: 100%; + overflow-x: hidden; +} + +.relatedArtistItem { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-sm); +} + +.relatedArtistImage { + width: 14rem; + height: 14rem; + border-radius: 50%; + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + cursor: pointer; + transition: opacity var(--transition-speed) ease; +} + +.relatedArtistImage:hover { + opacity: 0.8; +} + +.relatedArtistName { + font-size: var(--font-size-md); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + cursor: pointer; + transition: color var(--transition-speed) ease-in-out; +} + +.relatedArtistName:hover { + color: var(--color-red); +} + +@media (max-width: 1700px) { + .relatedArtistsList .relatedArtistItem:last-child { + display: none; + } +} + +@media (max-width: 1550px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 2) { + display: none; + } +} + +@media (max-width: 1390px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 3) { + display: none; + } +} + +@media (max-width: 1230px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 4) { + display: none; + } +} + +@media (max-width: 1072px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 5) { + display: none; + } +} + +@media (max-width: 915px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 6) { + display: none; + } +} + +@media (max-width: 670px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 7) { + display: none; + } +} + +@media (max-width: 512px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 8) { + display: none; + } +} + +@media (max-width: 355px) { + .relatedArtistsList .relatedArtistItem:nth-last-child(-n + 9) { + display: none; + } +} diff --git a/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.tsx b/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.tsx new file mode 100644 index 0000000..15ac659 --- /dev/null +++ b/client/src/components/ArtistPage/RelatedArtists/RelatedArtists.tsx @@ -0,0 +1,72 @@ +import { memo } from "react"; +import { PuffLoader } from "react-spinners"; +import { Link } from "react-router-dom"; +import type { UUID } from "@types"; +import { artistApi } from "@api"; +import { useAsyncData } from "@hooks"; +import styles from "./RelatedArtists.module.css"; +import artistPlaceholder from "@assets/artist-placeholder.png"; + +export interface RelatedArtistsProps { + artistId: UUID; +} + +const RelatedArtists: React.FC = ({ artistId }) => { + const { data, loading, error } = useAsyncData( + { + relatedArtists: () => + artistApi.getRelatedArtists(artistId, { includeUser: true, limit: 10 }), + }, + [artistId], + { + cacheKey: `related_artists_${artistId}`, + hasBlobUrl: true, + } + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Failed to load related artists.
; + } + + const relatedArtists = data?.relatedArtists; + + if (!relatedArtists || relatedArtists.length === 0) { + return null; + } + + return ( +
+

Fans Also Like

+
+ {relatedArtists.map((related) => ( +
+ + {`${related.display_name}'s + + + {related.display_name} + +
+ ))} +
+
+ ); +}; + +export default memo(RelatedArtists); diff --git a/client/src/components/AudioQueueTest/AudioQueueTest.tsx b/client/src/components/AudioQueueTest/AudioQueueTest.tsx new file mode 100644 index 0000000..276508c --- /dev/null +++ b/client/src/components/AudioQueueTest/AudioQueueTest.tsx @@ -0,0 +1,270 @@ +import React, { useState, useEffect } from "react"; +import { useAudioQueue } from "@contexts"; +import { songApi } from "@api"; +import type { Song } from "@types"; + +const SONG_IDS = [ + "3960d25f-4c52-401a-befc-a4d86cd079e7", + "7d7c882d-21df-4645-8452-43c1015993f3", + "385c64a0-f8b6-47c4-bb5f-c98a6e3daf82", + "7d3422bb-4bb2-4fa7-a9a5-e715b432ce3f", + "8ac798e2-605e-4d3b-bc45-db87af811cab", + "6ac89875-9d7b-4a70-8324-dd0a03244736", +]; + +const AudioQueueTest: React.FC = () => { + const { state, actions } = useAudioQueue(); + const [testSongs, setTestSongs] = useState([]); + const [isLoadingSongs, setIsLoadingSongs] = useState(true); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + const fetchSongs = async () => { + try { + setIsLoadingSongs(true); + setLoadError(null); + + const songs = await Promise.all( + SONG_IDS.map(async (id) => { + try { + return await songApi.getSongById(id, { includeArtists: true }); + } catch (error) { + console.warn(`Failed to fetch song ${id}:`, error); + return null; + } + }) + ); + + const validSongs = songs.filter((song): song is Song => song !== null); + setTestSongs(validSongs); + + if (validSongs.length === 0) { + setLoadError("No songs could be loaded from the API"); + } + } catch (error) { + console.error("Failed to fetch test songs:", error); + setLoadError("Failed to load test songs"); + } finally { + setIsLoadingSongs(false); + } + }; + + fetchSongs(); + }, []); + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + if (isLoadingSongs) { + return ( +
+

AudioQueue Test

+

Loading songs from API...

+
+ ); + } + + if (loadError) { + return ( +
+

AudioQueue Test

+

Error: {loadError}

+
+ ); + } + + if (testSongs.length === 0) { + return ( +
+

AudioQueue Test Component

+

No test songs available.

+
+ ); + } + + return ( +
+

AudioQueue Test

+

+ Loaded {testSongs.length} songs from API +

+ +
+ Current Song: {state.currentSong?.title || "None"} +
+ +
+ Status: {state.isPlaying ? "Playing" : "Paused"} + {state.isLoading && " (Loading...)"} +
+ +
+ Progress: {formatTime(state.progress)} /{" "} + {formatTime(state.duration)} +
+ +
+ Queue: {state.queue.length} songs +
+ +
+ Current Index: {state.currentIndex} +
+ +
+ Navigation: + Previous: {state.hasPreviousSong ? "Available" : "Disabled"} + {state.progress > 3 && " (→ Start)"} | Next:{" "} + {state.hasNextSong ? "Available" : "Disabled"} +
+ +
+ Volume: {Math.round(state.volume * 100)}% +
+ +
+ Repeat Mode: {state.repeatMode} +
+ +
+ Shuffle: {state.isShuffled ? "On" : "Off"} +
+ + {state.error && ( +
+ Error: {state.error} +
+ )} + +
+

Playback :

+ + + + + + + + +
+ +
+

Queue :

+ + + + +
+ +
+ + + +
+ +
+

Persistence:

+ + + +
+ +
+

Volume:

+ actions.setVolume(parseInt(e.target.value) / 100)} + /> +
+ +
+

Seek:

+ { + const seekTime = (state.duration * parseInt(e.target.value)) / 100; + actions.seek(seekTime); + }} + disabled={!state.currentSong} + /> +
+ +
+

Test Songs:

+
    + {testSongs.map((song) => ( +
  • + {song.title} + +
  • + ))} +
+
+ +
+

Current Queue:

+
    + {state.queue.map((item, index) => ( +
  • + {item.song.title} + {item.isQueued && " (User Queued)"} + {item.originalIndex !== undefined && + ` [Orig: ${item.originalIndex}]`} + {index === state.currentIndex && " ← Current"} +
  • + ))} +
+
+
+ ); +}; + +export default AudioQueueTest; diff --git a/client/src/components/CoverLightbox/CoverLightbox.module.css b/client/src/components/CoverLightbox/CoverLightbox.module.css new file mode 100644 index 0000000..3875feb --- /dev/null +++ b/client/src/components/CoverLightbox/CoverLightbox.module.css @@ -0,0 +1,73 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--transition-speed) ease-out; + cursor: pointer; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.lightbox { + position: relative; + animation: slideIn var(--transition-speed) ease-out; + cursor: default; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.closeButton { + position: absolute; + top: 1.6rem; + right: 1.6rem; + background: var(--color-panel-gray); + border: 1px solid var(--color-panel-border); + border-radius: var(--border-radius-sm); + width: 3.2rem; + height: 3.2rem; + color: var(--color-white); + font-size: 1.8rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-speed) ease; + backdrop-filter: blur(4px); +} + +.closeButton:hover { + background: var(--color-panel-border); +} + +.coverImage { + width: 75rem; + height: 75rem; + object-fit: contain; + border-radius: var(--border-radius-sm); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} diff --git a/client/src/components/CoverLightbox/CoverLightbox.tsx b/client/src/components/CoverLightbox/CoverLightbox.tsx new file mode 100644 index 0000000..dd1e28d --- /dev/null +++ b/client/src/components/CoverLightbox/CoverLightbox.tsx @@ -0,0 +1,50 @@ +import { memo, useEffect } from "react"; +import { LuX } from "react-icons/lu"; +import styles from "./CoverLightbox.module.css"; + +interface CoverLightboxProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + altText: string; +} + +const CoverLightbox: React.FC = ({ + isOpen, + onClose, + imageUrl, + altText, +}) => { + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ +
e.stopPropagation()}> + {altText} +
+
+ ); +}; + +export default memo(CoverLightbox); diff --git a/client/src/components/DevBanner/DevBanner.module.css b/client/src/components/DevBanner/DevBanner.module.css new file mode 100644 index 0000000..812ff5d --- /dev/null +++ b/client/src/components/DevBanner/DevBanner.module.css @@ -0,0 +1,42 @@ +.banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-md); + background-color: var(--color-red); + color: var(--color-white); + font-size: var(--font-size-xs); + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.text { + flex: 1; + text-align: center; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-white); + cursor: pointer; + transition: color var(--transition-speed) ease-in-out; + font-size: var(--icon-size-xs); +} + +.closeButton:hover { + color: var(--color-white-hover); +} + +.closeButton:active { + color: var(--color-white-hover); +} diff --git a/client/src/components/DevBanner/DevBanner.tsx b/client/src/components/DevBanner/DevBanner.tsx new file mode 100644 index 0000000..bb76dcc --- /dev/null +++ b/client/src/components/DevBanner/DevBanner.tsx @@ -0,0 +1,44 @@ +import { useState, useEffect, memo } from "react"; +import { LuX } from "react-icons/lu"; +import styles from "./DevBanner.module.css"; + +const DevBanner: React.FC = () => { + const [isVisible, setIsVisible] = useState(false); + const [buildVersion, setBuildVersion] = useState(""); + + useEffect(() => { + const isDismissed = localStorage.getItem("dev-banner-dismissed"); + if (isDismissed) return; + if (import.meta.env.DEV) return; + + fetch("/version.json") + .then((res) => res.json()) + .then((data) => { + if (data.version) { + setBuildVersion(data.version); + setIsVisible(true); + } + }) + .catch(() => { + setIsVisible(true); + }); + }, []); + + const handleClose = () => { + setIsVisible(false); + localStorage.setItem("dev-banner-dismissed", "true"); + }; + + if (!isVisible) return null; + + return ( +
+ Development Build: {buildVersion} + +
+ ); +}; + +export default memo(DevBanner); diff --git a/client/src/components/EntityItem/EntityItem.module.css b/client/src/components/EntityItem/EntityItem.module.css new file mode 100644 index 0000000..b15968d --- /dev/null +++ b/client/src/components/EntityItem/EntityItem.module.css @@ -0,0 +1,130 @@ +.entityItem { + display: flex; + gap: var(--spacing-md); + align-items: center; + min-width: 0; + width: 100%; + position: relative; +} + +.entityImage { + width: 8rem; + height: 8rem; + border-radius: var(--border-radius-sm); + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); +} + +.entityInfo { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + min-width: 0; + width: 100%; +} + +.entityAuthor { + font-size: var(--font-size-sm); + color: var(--color-text-gray); + font-weight: 500; +} + +.entityTitle { + font-size: var(--font-size-md); + color: var(--color-white); + font-weight: 500; + transition: color var(--transition-speed) ease-in-out; +} + +.entityTitle:hover { + color: var(--color-red); +} + +.entitySubtitle { + font-size: var(--font-size-sm); + color: var(--color-text-gray); + font-weight: 500; +} + +.entityTitle, +.entityAuthor, +.entitySubtitle, +.entitySubtitle { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + max-width: 100%; +} + +.entityActionButton { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-panel-gray); + border-radius: var(--border-radius-sm); + border: 2px solid var(--color-panel-border); + color: var(--color-white); + cursor: pointer; + font-size: var(--icon-size-xs); + padding: var(--spacing-sm); + transition: color var(--transition-speed) ease-in-out; +} + +.entityActionButton:hover { + color: var(--color-red); +} + +.queueButtonContainer { + position: relative; +} + +.entityActionButtonContainer { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: var(--spacing-sm); + opacity: 0; + visibility: hidden; + transition: all var(--transition-speed) ease; + background: linear-gradient( + to left, + var(--color-black) 0%, + var(--color-black) 75%, + transparent 100% + ); + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) + var(--spacing-xl); + border-radius: var(--border-radius-sm); +} + +.entityItem:hover .entityActionButtonContainer { + opacity: 1; + visibility: visible; +} + +.entityActionButtonContainerWide { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-shrink: 0; +} + +.entityIndex { + font-size: var(--font-size-sm); + color: var(--color-white); + font-weight: 500; + width: 2rem; + min-width: 2rem; + max-width: 2rem; + flex-shrink: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-align: center; +} diff --git a/client/src/components/EntityItem/EntityItem.tsx b/client/src/components/EntityItem/EntityItem.tsx new file mode 100644 index 0000000..6f4d055 --- /dev/null +++ b/client/src/components/EntityItem/EntityItem.tsx @@ -0,0 +1,193 @@ +import { memo, useState, useCallback, useRef, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAudioQueue, useAuth } from "@contexts"; +import type { Song, Playlist, Album } from "@types"; +import { QueueMenu } from "@components"; +import styles from "./EntityItem.module.css"; +import musicPlaceholder from "@assets/music-placeholder.png"; +import artistPlaceholder from "@assets/artist-placeholder.png"; +import { LuPlay, LuListEnd } from "react-icons/lu"; + +interface EntityActionButtonsProps { + type: "song" | "list" | "artist"; + entity?: Song | Playlist | Album; + isHovered: boolean; + isSmall: boolean; +} + +const EntityActionButtons: React.FC = memo( + ({ type, entity, isHovered, isSmall }) => { + const { actions } = useAudioQueue(); + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [queueMenuOpen, setQueueMenuOpen] = useState(false); + const queueButtonRef = useRef(null); + + useEffect(() => { + if (!isHovered && queueMenuOpen) { + setQueueMenuOpen(false); + } + }, [isHovered, queueMenuOpen]); + + const handlePlay = useCallback(() => { + if (!entity) return; + actions.play(entity); + }, [actions, entity]); + + const handleAddToQueue = useCallback(async () => { + try { + if (isAuthenticated) { + setQueueMenuOpen((prev) => !prev); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding to queue failed:", error); + } + }, [isAuthenticated, navigate]); + + if (isSmall) { + return ( +
+ {type === "song" && ( +
+ + setQueueMenuOpen(false)} + song={entity as Song} + buttonRef={queueButtonRef} + justification="right" + /> +
+ )} + {(type === "song" || type === "list") && ( + + )} +
+ ); + } + + return ( +
+ {type === "song" && ( +
+ + setQueueMenuOpen(false)} + song={entity as Song} + buttonRef={queueButtonRef} + justification="right" + /> +
+ )} + {(type === "song" || type === "list") && ( + + )} +
+ ); + } +); + +type EntityItemProps = + | { + type: "artist"; + linkTo: string; + author?: string; + title: string; + subtitle?: string; + imageUrl?: string; + entity?: never; + isSmall?: boolean; + index?: number; + } + | { + type: "song" | "list"; + linkTo: string; + author?: string; + title: string; + subtitle?: string; + imageUrl?: string; + entity: Song | Playlist | Album; + isSmall?: boolean; + index?: number; + }; + +/** + * @param EntityItemProps + * @param entity The entity object to play when clicking play (Song, Playlist, or Album) + * @param imageUrl Image URL for the entity + * @param linkTo Link to navigate to when clicking the title + * @param author Author name to display (text above title) + * @param title Title of the entity + * @param subtitle Subtitle text to display (text below title) + * @param type Type of entity: "song", "list", or "artist" + * @param isSmall Whether to use small layout (default: true). + * @param index Optional index number to display (for non-small layout) + */ +const EntityItem: React.FC = ({ + entity, + imageUrl, + linkTo, + author, + title, + subtitle, + type, + isSmall = true, + index, +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {!isSmall && index !== undefined && ( + {index} + )} + {`${title} +
+ {author && {author}} + + {title} + + {subtitle && {subtitle}} +
+ +
+ ); +}; + +export default memo(EntityItem); diff --git a/client/src/components/EntityItemCard/EntityItemCard.module.css b/client/src/components/EntityItemCard/EntityItemCard.module.css new file mode 100644 index 0000000..2790870 --- /dev/null +++ b/client/src/components/EntityItemCard/EntityItemCard.module.css @@ -0,0 +1,113 @@ +.entityItemCard { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 18rem; + position: relative; +} + +.entityImageContainer { + position: relative; + width: 18rem; + height: 18rem; + flex-shrink: 0; +} + +.entityImage { + width: 100%; + height: 100%; + border-radius: var(--border-radius-sm); + object-fit: cover; + aspect-ratio: 1 / 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + transition: all var(--transition-speed) ease; +} + +.entityInfo { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + min-width: 0; + width: 100%; +} + +.entityAuthor { + font-size: var(--font-size-md); + color: var(--color-text-gray); + font-weight: 500; +} + +.entityTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + transition: color var(--transition-speed) ease-in-out; +} + +.entityTitle:hover { + color: var(--color-red); +} + +.entitySubtitle { + font-size: var(--font-size-md); + color: var(--color-text-gray); + font-weight: 500; +} + +.entityTitle, +.entityAuthor, +.entitySubtitle, +.entitySubtitle { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + max-width: 100%; +} + +.entityActionButtonContainer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + background: rgba(0, 0, 0, 0.6); + border-radius: var(--border-radius-sm); + opacity: 0; + visibility: hidden; + transition: all var(--transition-speed) ease; + z-index: 2; +} + +.entityItemCard:hover .entityActionButtonContainer { + opacity: 1; + visibility: visible; +} + +.entityActionButton { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-panel-gray); + border-radius: 50%; + border: 2px solid var(--color-panel-border); + color: var(--color-white); + cursor: pointer; + font-size: var(--icon-size-md); + padding: var(--spacing-md); + transition: all var(--transition-speed) ease; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.entityActionButton:hover { + color: var(--color-red); +} + +.queueButtonContainer { + position: relative; +} diff --git a/client/src/components/EntityItemCard/EntityItemCard.tsx b/client/src/components/EntityItemCard/EntityItemCard.tsx new file mode 100644 index 0000000..a222505 --- /dev/null +++ b/client/src/components/EntityItemCard/EntityItemCard.tsx @@ -0,0 +1,153 @@ +import { memo, useState, useCallback, useRef, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAudioQueue, useAuth } from "@contexts"; +import type { Song, Playlist, Album } from "@types"; +import { QueueMenu } from "@components"; +import styles from "./EntityItemCard.module.css"; +import musicPlaceholder from "@assets/music-placeholder.png"; +import artistPlaceholder from "@assets/artist-placeholder.png"; +import { LuPlay, LuListEnd } from "react-icons/lu"; + +interface EntityActionButtonsProps { + type: "song" | "list" | "artist"; + entity?: Song | Playlist | Album; + isHovered: boolean; +} + +const EntityActionButtons: React.FC = memo( + ({ type, entity, isHovered }) => { + const { actions } = useAudioQueue(); + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [queueMenuOpen, setQueueMenuOpen] = useState(false); + const queueButtonRef = useRef(null); + + useEffect(() => { + if (!isHovered && queueMenuOpen) { + setQueueMenuOpen(false); + } + }, [isHovered, queueMenuOpen]); + + const handlePlay = useCallback(() => { + if (!entity) return; + actions.play(entity); + }, [actions, entity]); + + const handleAddToQueue = useCallback(async () => { + try { + if (isAuthenticated) { + setQueueMenuOpen((prev) => !prev); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding to queue failed:", error); + } + }, [isAuthenticated, navigate]); + + return ( +
+ {type === "song" && ( +
+ + setQueueMenuOpen(false)} + song={entity as Song} + buttonRef={queueButtonRef} + justification="center" + /> +
+ )} + {(type === "song" || type === "list") && ( + + )} +
+ ); + } +); + +type EntityItemCardProps = + | { + type: "artist"; + linkTo: string; + author: string; + title: string; + subtitle: string; + imageUrl?: string; + entity?: never; + } + | { + type: "song" | "list"; + linkTo: string; + author: string; + title: string; + subtitle: string; + imageUrl?: string; + entity: Song | Playlist | Album; + }; + +/** + * @param EntityItemProps + * @param entity The entity object to play when clicking play (Song, Playlist, or Album) + * @param imageUrl Image URL for the entity + * @param linkTo Link to navigate to when clicking the title + * @param author Author name to display (text above title) + * @param title Title of the entity + * @param subtitle Subtitle text to display (text below title) + * @param type Type of entity: "song", "list", or "artist" + */ +const EntityItemCard: React.FC = ({ + entity, + imageUrl, + linkTo, + author, + title, + subtitle, + type, +}) => { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {`${title} + +
+
+ {author} + + {title} + + {subtitle} +
+
+ ); +}; + +export default memo(EntityItemCard); diff --git a/client/src/components/ErrorPage/ErrorPage.module.css b/client/src/components/ErrorPage/ErrorPage.module.css new file mode 100644 index 0000000..bdb6ef9 --- /dev/null +++ b/client/src/components/ErrorPage/ErrorPage.module.css @@ -0,0 +1,57 @@ +.errorPageContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 75vh; + width: 100%; + gap: var(--spacing-sm); +} + +.errorIcon { + font-size: var(--icon-size-2xl); + color: var(--color-red); +} + +.errorTitle { + font-size: var(--font-size-2xl); + font-weight: 600; +} + +.errorMessage { + font-size: var(--font-size-lg); + max-width: 60rem; + line-height: 1.5; + color: var(--color-text-gray); +} + +.statusCode { + font-size: var(--font-size-md); + color: var(--color-text-gray); +} + +.homeButton { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background-color: var(--color-red); + color: var(--color-white); + border: none; + border-radius: var(--border-radius-sm); + font-family: "Inter", sans-serif; + font-size: var(--font-size-sm); + cursor: pointer; + transition: background-color var(--transition-speed) ease-in-out; +} + +.homeButton:hover:not(:disabled) { + background-color: var(--color-red-700); +} + +.homeButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/client/src/components/ErrorPage/ErrorPage.tsx b/client/src/components/ErrorPage/ErrorPage.tsx new file mode 100644 index 0000000..d214784 --- /dev/null +++ b/client/src/components/ErrorPage/ErrorPage.tsx @@ -0,0 +1,38 @@ +import { memo } from "react"; +import { Helmet } from "react-helmet-async"; +import { useNavigate } from "react-router-dom"; +import { LuBan } from "react-icons/lu"; +import styles from "./ErrorPage.module.css"; + +type ErrorPageProps = { + title?: string; + message?: string; +}; + +const ErrorPage: React.FC = ({ title, message }) => { + const navigate = useNavigate(); + + const handleGoHome = () => { + navigate("/"); + }; + + return ( + <> + + Internal Server Error + +
+ +

{title || "An Error Occurred"}

+ + {message || "Sorry, something went wrong. Please try again later."} + + +
+ + ); +}; + +export default memo(ErrorPage); diff --git a/client/src/components/FollowProfiles/FollowProfiles.module.css b/client/src/components/FollowProfiles/FollowProfiles.module.css new file mode 100644 index 0000000..a71d5fa --- /dev/null +++ b/client/src/components/FollowProfiles/FollowProfiles.module.css @@ -0,0 +1,106 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 10vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.sectionTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.followContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.avatarStack { + display: flex; + justify-content: center; + align-items: center; + gap: 0; + position: relative; +} + +.avatarLink { + position: relative; + margin-left: calc(-1 * var(--spacing-md)); +} + +.avatarLink:first-child { + margin-left: 0; +} + +.avatarLink::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0); + transition: background-color var(--transition-speed) ease; + pointer-events: none; +} + +.avatarLink:hover::after { + background-color: rgba(0, 0, 0, 0.2); +} + +.avatar { + width: var(--icon-size-3xl); + height: var(--icon-size-3xl); + border-radius: 50%; + object-fit: cover; + border: 0.2rem solid var(--color-black); + display: block; +} + +.avatarMore { + position: relative; + width: var(--icon-size-3xl); + height: var(--icon-size-3xl); + border-radius: 50%; + background-color: #2f2f2f; + border: 0.2rem solid var(--color-black); + display: flex; + align-items: center; + justify-content: center; + color: #454545; + font-size: 2rem; + margin-left: calc(-1 * var(--spacing-md)); +} + +.avatarMore::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0); + transition: background-color var(--transition-speed) ease; + pointer-events: none; +} + +.avatarMore:hover::after { + background-color: rgba(0, 0, 0, 0.2); +} diff --git a/client/src/components/FollowProfiles/FollowProfiles.tsx b/client/src/components/FollowProfiles/FollowProfiles.tsx new file mode 100644 index 0000000..77a02b5 --- /dev/null +++ b/client/src/components/FollowProfiles/FollowProfiles.tsx @@ -0,0 +1,105 @@ +import { memo, useMemo } from "react"; +import { PuffLoader } from "react-spinners"; +import { Link } from "react-router-dom"; +import { LuPlus } from "react-icons/lu"; +import { artistApi } from "@api"; +import { useAsyncData } from "@hooks"; +import type { UUID } from "@types"; +import styles from "./FollowProfiles.module.css"; +import userPlaceholder from "@assets/user-placeholder.png"; + +export interface FollowProfilesProps { + title: string; + userId: UUID; + following?: boolean; + profileLimit: number; + profileMin?: number; +} + +const FollowProfiles: React.FC = ({ + title, + userId, + following = true, + profileLimit, + profileMin = 0, +}) => { + const { data, loading, error } = useAsyncData( + { + profiles: () => + (following ? artistApi.getFollowing : artistApi.getFollowers)(userId, { + limit: profileLimit + 1, + }), + }, + [userId, following, profileLimit], + { + cacheKey: `${following ? "following" : "followers"}_${userId}`, + hasBlobUrl: true, + } + ); + + const profiles = data?.profiles; + + const { displayProfiles, hasMore } = useMemo(() => { + if (!profiles || profiles.length === 0) { + return { displayProfiles: [], hasMore: false }; + } + const display = profiles.slice(0, profileLimit); + const more = profiles.length > profileLimit; + return { displayProfiles: display, hasMore: more }; + }, [profiles, profileLimit]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
Failed to load {title.toLowerCase()}.
+ ); + } + + if (!profiles || profiles.length === 0) { + return null; + } + + if (profiles.length < profileMin) { + return null; + } + + return ( +
+ {title} +
+ {displayProfiles.map((profile) => ( + + {`${profile.username + + ))} + {hasMore && ( + +
+
+ ); +}; + +export default memo(FollowProfiles); diff --git a/client/src/components/Forms/FormButton/FormSubmitButton.module.css b/client/src/components/Forms/FormButton/FormSubmitButton.module.css new file mode 100644 index 0000000..a6d04ed --- /dev/null +++ b/client/src/components/Forms/FormButton/FormSubmitButton.module.css @@ -0,0 +1,25 @@ +.formSubmitButton { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + background-color: var(--color-red); + color: var(--color-white); + border: none; + border-radius: var(--border-radius-md); + font-family: "Inter", sans-serif; + font-size: var(--font-size-md); + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-speed) ease-in-out; +} + +.formSubmitButton:hover:not(:disabled) { + background-color: var(--color-red-700); +} + +.formSubmitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/client/src/components/Forms/FormButton/FormSubmitButton.tsx b/client/src/components/Forms/FormButton/FormSubmitButton.tsx new file mode 100644 index 0000000..3f0f5d0 --- /dev/null +++ b/client/src/components/Forms/FormButton/FormSubmitButton.tsx @@ -0,0 +1,24 @@ +import styles from "./FormSubmitButton.module.css"; + +export interface FormSubmitButtonProps { + text: string; + disabled?: boolean; +} + +const FormSubmitButton: React.FC = ({ + text, + disabled, +}) => { + return ( + + ); +}; + +export default FormSubmitButton; diff --git a/client/src/components/Forms/InputGroup/InputGroup.module.css b/client/src/components/Forms/InputGroup/InputGroup.module.css new file mode 100644 index 0000000..5941acd --- /dev/null +++ b/client/src/components/Forms/InputGroup/InputGroup.module.css @@ -0,0 +1,61 @@ +.inputGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.formLabel { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-gray); + padding-left: var(--spacing-2xs); +} + +.input { + font-family: "Inter", sans-serif; + padding: 1.2rem; + background-color: var(--color-panel-gray-dark); + border: 2px solid var(--color-panel-border); + border-radius: var(--border-radius-sm); + color: var(--color-white); + font-size: var(--font-size-sm); + width: 100%; +} + +.input:focus { + outline: none; +} + +.input::placeholder { + color: var(--color-text-gray); +} + +.input::placeholder { + color: var(--color-text-gray); +} + +.input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.inputError { + border-color: var(--color-red); +} + +.inputErrorText { + font-size: var(--font-size-xs); + color: var(--color-red); + padding-left: var(--spacing-2xs); +} + +.inputHint { + margin-top: calc(var(--spacing-xs) * -1); +} + +.inputHintText { + font-size: var(--font-size-xs); + color: var(--color-text-gray); + font-style: italic; + padding-left: var(--spacing-2xs); +} diff --git a/client/src/components/Forms/InputGroup/InputGroup.tsx b/client/src/components/Forms/InputGroup/InputGroup.tsx new file mode 100644 index 0000000..aa87285 --- /dev/null +++ b/client/src/components/Forms/InputGroup/InputGroup.tsx @@ -0,0 +1,56 @@ +import styles from "./InputGroup.module.css"; +import classNames from "classnames"; + +export interface InputGroupProps { + label: string; + type: string; + id: string; + name?: string; // if different from id + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + error?: string; + disabled?: boolean; + hint?: string; +} + +const InputGroup: React.FC = ({ + label, + type, + id, + name, + value, + onChange, + placeholder, + error, + disabled, + hint, +}) => { + return ( +
+ + + {error && {error}} + {hint && ( +
+ {hint} +
+ )} +
+ ); +}; + +export default InputGroup; diff --git a/client/src/components/Layout/HorizontalRule/HorizontalRule.module.css b/client/src/components/Layout/HorizontalRule/HorizontalRule.module.css new file mode 100644 index 0000000..761e63b --- /dev/null +++ b/client/src/components/Layout/HorizontalRule/HorizontalRule.module.css @@ -0,0 +1,8 @@ +.horizontalRule { + width: 100%; + height: 1px; + background-color: var(--color-text-gray); + opacity: 0.25; + border-radius: 1px; + margin: 0.2rem 0; +} diff --git a/client/src/components/Layout/HorizontalRule/HorizontalRule.tsx b/client/src/components/Layout/HorizontalRule/HorizontalRule.tsx new file mode 100644 index 0000000..7ef7ead --- /dev/null +++ b/client/src/components/Layout/HorizontalRule/HorizontalRule.tsx @@ -0,0 +1,8 @@ +import { memo } from "react"; +import styles from "./HorizontalRule.module.css"; + +const HorizontalRule: React.FC = () => { + return
; +}; + +export default memo(HorizontalRule); diff --git a/client/src/components/Layout/VerticalRule/VerticalRule.module.css b/client/src/components/Layout/VerticalRule/VerticalRule.module.css new file mode 100644 index 0000000..0d5edbd --- /dev/null +++ b/client/src/components/Layout/VerticalRule/VerticalRule.module.css @@ -0,0 +1,7 @@ +.verticalRule { + width: 1.25px; + height: 100%; + background-color: var(--color-text-gray); + opacity: 0.25; + border-radius: 1px; +} diff --git a/client/src/components/Layout/VerticalRule/VerticalRule.tsx b/client/src/components/Layout/VerticalRule/VerticalRule.tsx new file mode 100644 index 0000000..2a8534f --- /dev/null +++ b/client/src/components/Layout/VerticalRule/VerticalRule.tsx @@ -0,0 +1,8 @@ +import { memo } from "react"; +import styles from "./VerticalRule.module.css"; + +const VerticalRule: React.FC = () => { + return
; +}; + +export default memo(VerticalRule); diff --git a/client/src/components/MainLayout/MainLayout.module.css b/client/src/components/MainLayout/MainLayout.module.css new file mode 100644 index 0000000..99886de --- /dev/null +++ b/client/src/components/MainLayout/MainLayout.module.css @@ -0,0 +1,44 @@ +body { + font-family: "Inter", sans-serif; + background-color: var(--color-black); + color: var(--color-white); + font-size: var(--font-size-md); +} + +/* Layout Container */ +.layoutContainer { + display: flex; + height: 100vh; + overflow: hidden; + padding: var(--spacing-sm); +} + +/* Main Content */ +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding-left: var(--spacing-sm); + position: relative; +} + +/* Content Area */ +.contentArea { + flex: 1; + overflow-y: auto; + padding: var(--spacing-xl) calc(var(--spacing-xl) - var(--spacing-sm)) + calc(var(--now-playing-height) + var(--spacing-xl)) + calc(var(--spacing-xl) - var(--spacing-sm)); +} + +/* Media Queries */ +@media (max-width: 768px) { + :root { + --sidebar-width: 0; + } + + .mainContent { + padding-left: 0; + } +} diff --git a/client/src/components/MainLayout/MainLayout.tsx b/client/src/components/MainLayout/MainLayout.tsx new file mode 100644 index 0000000..fa2c11d --- /dev/null +++ b/client/src/components/MainLayout/MainLayout.tsx @@ -0,0 +1,21 @@ +import styles from "./MainLayout.module.css"; +import { + MainLayoutHeader, + MainLayoutNowPlayingBar, + MainLayoutSidebar, +} from "@components"; + +const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( +
+ +
+ +
{children}
+
+ +
+ ); +}; + +export default MainLayout; diff --git a/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.module.css b/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.module.css new file mode 100644 index 0000000..55728cb --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.module.css @@ -0,0 +1,121 @@ +/* Header */ +.header { + height: var(--header-height); + display: flex; + align-items: center; + gap: var(--spacing-sm); + z-index: 50; +} + +.headerNav { + height: var(--header-height); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + display: flex; + align-items: center; +} + +.navbarLink, +.navbarLinkActive { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 0 var(--spacing-md); + color: var(--color-text-gray) !important; + font-size: var(--font-size-sm); + transition: color var(--transition-speed) ease-in-out; +} + +.navbarLink:hover { + color: var(--color-white) !important; +} + +.navbarLinkActive { + color: var(--color-red) !important; +} + +.navIcon { + font-size: var(--icon-size-sm); +} + +/* Header Actions */ +.headerActions { + height: var(--header-height); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + display: flex; +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + padding: 0 var(--spacing-md); + background: none; + border: none; + color: var(--color-text-gray) !important; + cursor: pointer; + border-radius: var(--border-radius-md); + transition: color var(--transition-speed) ease-in-out; + text-decoration: none; +} + +.iconButton:hover { + color: var(--color-white) !important; +} + +.actionIcon { + font-size: var(--icon-size-md); +} + +/* Header shadow */ +.headerNav, +.searchContainer, +.headerActions { + position: relative; +} + +.headerNav::after, +.searchContainer::after, +.headerActions::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 4px; + border-radius: inherit; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.75); + pointer-events: none; +} + +@media (max-width: 768px) { + .nowPlayingInfo { + max-width: 16rem; + } + + .playlistButton { + display: none; + } + + .progressContainer { + max-width: 40rem; + } + + .navbarLink span, + .navbarLinkActive span { + display: none; + } + + .nowPlayingBar { + left: var(--spacing-sm); + } +} + +@media (max-width: 480px) { + .headerActions { + display: none; + } +} diff --git a/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.tsx b/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.tsx new file mode 100644 index 0000000..3af3f5b --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutHeader/MainLayoutHeader.tsx @@ -0,0 +1,78 @@ +import React, { memo } from "react"; +import { NavLink, Link } from "react-router-dom"; +import classNames from "classnames"; + +import { useAuth } from "@contexts"; +import { MainLayoutSearchBar } from "@components"; + +import styles from "./MainLayoutHeader.module.css"; +import { + LuHouse, + LuRss, + LuBell, + LuSettings, + LuCircleUser, +} from "react-icons/lu"; + +const MainLayoutHeader: React.FC = () => { + const { user, isAuthenticated } = useAuth(); + + return ( +
+ + + + +
+ {isAuthenticated ? ( + <> + + + + + + + + + ) : ( + <> + + Sign In + + + Sign Up + + + )} +
+
+ ); +}; + +export default memo(MainLayoutHeader); diff --git a/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.module.css b/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.module.css new file mode 100644 index 0000000..4f47771 --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.module.css @@ -0,0 +1,312 @@ +.nowPlayingBar { + position: fixed; + bottom: var(--spacing-sm); + left: calc(var(--sidebar-width) + 2 * var(--spacing-sm)); + right: var(--spacing-sm); + height: var(--now-playing-height); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + display: flex; + gap: var(--spacing-sm); + align-items: center; + padding: 0 var(--spacing-lg); + z-index: 50; +} + +/* Now Playing Info */ +.nowPlayingInfo { + display: flex; + align-items: center; + gap: var(--spacing-md); + min-width: 0; + width: 30rem; +} + +.albumArt { + width: 6.4rem; + height: 6.4rem; + border-radius: var(--border-radius-sm); + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + cursor: pointer; +} + +.songInfo { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + min-width: 0; +} + +.songTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; +} + +.artistName { + font-size: var(--font-size-sm); + color: var(--color-text-gray) !important; +} + +.songTitle:hover { + color: var(--color-red); +} + +.artistName:hover { + color: var(--color-white) !important; +} + +.songTitle, +.artistName { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + max-width: 100%; + transition: color var(--transition-speed) ease-in-out; +} + +/* Player Controls */ +.playerControls { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + align-items: center; +} + +.controlButtons { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.controlButton { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-text-gray); + cursor: pointer; + font-size: var(--icon-size-md); + padding: var(--spacing-sm); + transition: color var(--transition-speed) ease-in-out; +} + +.controlButton:hover { + color: var(--color-white); +} + +.controlButtonActive { + color: var(--color-red); +} + +.controlButtonDisabled { + opacity: 0.5; + cursor: not-allowed; +} + +.controlButtonDisabled:hover { + color: var(--color-text-gray); +} + +.playButton { + font-size: var(--icon-size-xl); + color: var(--color-white); +} + +.playButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.playButton:not(:disabled):hover { + color: var(--color-red); +} + +.playButtonActive { + color: var(--color-red); +} + +.repeatIndicator { + position: absolute; + top: -2px; + right: -2px; + font-size: 10px; + font-weight: bold; + color: var(--color-white); + background-color: var(--color-red); + border-radius: 50%; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.repeatButton { + position: relative; +} + +/* Progress Bar */ +.progressContainer { + display: flex; + align-items: center; + gap: var(--spacing-md); + width: 100%; + max-width: 60rem; +} + +.timeLabel { + font-size: var(--font-size-xs); + color: var(--color-text-gray); + min-width: 4rem; + text-align: center; +} + +.progressBar { + flex: 1; + position: relative; + height: 0.4rem; + background-color: rgba(255, 255, 255, var(--opacity-high)); + border-radius: var(--border-radius-sm); +} + +.progressFill { + position: absolute; + height: 100%; + background-color: var(--color-red); + border-radius: var(--border-radius-sm); +} + +.progressSlider { + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 100%; + transform: translateY(-50%); + opacity: 0; + cursor: pointer; +} + +/* Right Controls */ +.rightControls { + display: flex; + align-items: center; + gap: var(--spacing-md); + justify-content: flex-end; +} + +.queueButtonContainer { + position: relative; +} + +.volumeControl { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.volumeIcon { + font-size: var(--icon-size-sm); + color: var(--color-text-gray); +} + +.volumeSlider { + width: 12rem; + height: 0.4rem; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, var(--opacity-high)); + border-radius: var(--border-radius-sm); + outline: none; + cursor: pointer; +} + +.volumeSlider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 1.2rem; + height: 1.2rem; + background: var(--color-white); + border-radius: 50%; + cursor: pointer; +} + +.volumeSlider::-moz-range-thumb { + width: 1.2rem; + height: 1.2rem; + background: var(--color-white); + border-radius: 50%; + cursor: pointer; + border: none; +} + +/* Media Queries */ +@media (max-width: 1400px) { + .nowPlayingInfo { + max-width: 20rem; + } + + .progressContainer { + max-width: 50rem; + } + + .nowPlayingBar { + padding: 0 var(--spacing-md); + } +} + +@media (max-width: 1200px) { + .volumeSlider { + max-width: 8rem; + } + + .shareButton { + display: none; + } +} + +@media (max-width: 968px) { + .volumeControl { + display: none; + } +} + +@media (max-width: 768px) { + .nowPlayingInfo { + max-width: 16rem; + } + + .playlistButton { + display: none; + } + + .progressContainer { + max-width: 40rem; + } + + .nowPlayingBar { + left: var(--spacing-sm); + } +} + +@media (max-width: 560px) { + .shuffleButton, + .repeatButton { + display: none; + } +} + +@media (max-width: 480px) { + .progressContainer, + .rightControls { + display: none; + } +} diff --git a/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.tsx b/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.tsx new file mode 100644 index 0000000..15da14f --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.tsx @@ -0,0 +1,373 @@ +import React, { + useState, + useEffect, + memo, + useMemo, + useCallback, + useRef, +} from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useLikeStatus } from "@hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth, useAudioQueue } from "@contexts"; +import { formatPlaybackTime, getMainArtist } from "@util"; +import { ShareModal, CoverLightbox } from "@components"; +import { QueueManager } from "@components"; +import classNames from "classnames"; +import styles from "./MainLayoutNowPlayingBar.module.css"; +import { + LuCirclePlay, + LuCirclePause, + LuRepeat, + LuSkipBack, + LuSkipForward, + LuShuffle, + LuVolume2, + LuThumbsUp, + LuListPlus, + LuShare, + LuListEnd, +} from "react-icons/lu"; +import musicPlaceholder from "@assets/music-placeholder.png"; + +const NowPlayingBar: React.FC = () => { + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + const [isQueueManagerOpen, setIsQueueManagerOpen] = useState(false); + const queueButtonRef = useRef(null); + + const { user, isAuthenticated } = useAuth(); + const { state, actions } = useAudioQueue(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const { + isPlaying, + currentSong, + progress, + duration, + volume, + repeatMode, + hasNextSong, + hasPreviousSong, + isLoading, + isShuffled, + } = state; + + const { + isLiked, + toggleLike, + isLoading: isLikeLoading, + } = useLikeStatus({ + userId: user?.id || "", + entityId: currentSong?.id || "", + entityType: "song", + isAuthenticated, + }); + + useEffect(() => { + if (user?.id && currentSong?.id) { + queryClient.invalidateQueries({ + queryKey: ["likeStatus", user.id, currentSong.id, "song"], + }); + } + }, [user?.id, currentSong?.id]); + + const handleToggleLike = useCallback(async () => { + try { + if (!isAuthenticated) return navigate("/login"); + if (!currentSong) return; + await toggleLike(); + } catch (error) { + console.error("Toggling like failed:", error); + } + }, [isAuthenticated, navigate]); + + //! add to playlist + const handleAddToPlaylist = useCallback(async () => { + try { + if (!currentSong) return; + if (isAuthenticated) { + console.log("added to playlist"); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding to playlist failed:", error); + } + }, [isAuthenticated, navigate]); + + const handleToggleShuffle = useCallback(() => { + actions.toggleShuffleQueue(); + }, [actions]); + + const handleToggleRepeat = useCallback(() => { + actions.toggleRepeatMode(); + }, [actions]); + + const handleTogglePlay = useCallback(() => { + if (isPlaying) { + actions.pause(); + } else { + actions.resume(); + } + }, [isPlaying, actions]); + + const handleNext = useCallback(() => { + if (hasNextSong) { + actions.next(); + } + }, [hasNextSong, actions]); + + const handlePrevious = useCallback(() => { + if (hasPreviousSong) { + actions.previous(); + } + }, [hasPreviousSong, actions]); + + const handleSeek = useCallback( + (e: React.ChangeEvent) => { + const newTime = Number(e.target.value); + actions.seek(newTime); + }, + [actions] + ); + + const handleVolumeChange = useCallback( + (e: React.ChangeEvent) => { + const newVolume = Number(e.target.value) / 100; + actions.setVolume(newVolume); + }, + [actions] + ); + + const handleShare = useCallback(() => { + if (!currentSong) return; + setIsShareModalOpen(true); + }, []); + + const handleManageQueue = useCallback(() => { + setIsQueueManagerOpen((prev) => !prev); + }, []); + + const mainArtist = useMemo(() => { + if (!currentSong) return { id: "", display_name: "Unknown Artist" }; + + if (!currentSong.artists || currentSong.artists.length === 0) { + return { id: "", display_name: "Unknown Artist" }; + } + + return ( + getMainArtist(currentSong.artists) ?? { + id: "", + display_name: "Unknown Artist", + } + ); + }, [currentSong]); + + return ( + <> +
+
+ {`${currentSong?.title} setIsLightboxOpen(true)} + /> +
+ {mainArtist.id ? ( + + {mainArtist.display_name || "Unknown Artist"} + + ) : ( + + {mainArtist.display_name || "Unknown Artist"} + + )} + + {currentSong?.title || "Unknown Song"} + +
+
+ +
+
+ + + + + +
+
+ + {formatPlaybackTime(progress)} + +
+
0 ? (progress / duration) * 100 : 0}%`, + }} + /> + +
+ + {formatPlaybackTime(duration)} + +
+
+ +
+ +
+ + setIsQueueManagerOpen(false)} + buttonRef={queueButtonRef} + /> +
+ +
+ + +
+ +
+
+ + setIsShareModalOpen(false)} + pageUrl={`${window.location.origin}/songs/${currentSong?.id}`} + pageTitle={currentSong?.title} + /> + + {currentSong && currentSong.image_url && ( + setIsLightboxOpen(false)} + imageUrl={currentSong.image_url} + altText={`${currentSong.title} Cover`} + /> + )} + + ); +}; + +export default memo(NowPlayingBar); diff --git a/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.module.css b/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.module.css new file mode 100644 index 0000000..f313c4c --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.module.css @@ -0,0 +1,70 @@ +.searchContainer { + height: var(--header-height); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.searchIcon { + position: absolute; + left: var(--spacing-md); + font-size: var(--icon-size-sm); + color: var(--color-text-gray); + pointer-events: none; +} + +.searchInput { + font-family: "Inter", sans-serif; + width: 100%; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) + calc(var(--spacing-md) * 3); + background-color: transparent; + border: none; + border-radius: var(--border-radius-lg); + color: var(--color-white); + font-size: var(--font-size-sm); + outline: none; +} + +.searchInput::placeholder { + color: var(--color-text-gray); +} +.searchContainer { + height: var(--header-height); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.searchIcon { + position: absolute; + left: var(--spacing-md); + font-size: var(--icon-size-sm); + color: var(--color-text-gray); + pointer-events: none; +} + +.searchInput { + font-family: "Inter", sans-serif; + width: 100%; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) + calc(var(--spacing-md) * 3); + background-color: transparent; + border: none; + border-radius: var(--border-radius-lg); + color: var(--color-white); + font-size: var(--font-size-sm); + outline: none; +} + +.searchInput::placeholder { + color: var(--color-text-gray); +} diff --git a/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.tsx b/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.tsx new file mode 100644 index 0000000..18e72ca --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.tsx @@ -0,0 +1,18 @@ +import styles from "./MainLayoutSearchBar.module.css"; +import { LuSearch } from "react-icons/lu"; + +const MainLayoutSearchBar: React.FC = () => { + return ( +
+ + +
+ ); +}; + +export default MainLayoutSearchBar; diff --git a/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.module.css b/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.module.css new file mode 100644 index 0000000..64e1f21 --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.module.css @@ -0,0 +1,72 @@ +.sidebar { + width: var(--sidebar-width); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-md); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: var(--spacing-lg) 0; + border: 2px solid var(--color-panel-border); + z-index: 100; +} + +.sidebarTop { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.sidebarLogo { + display: flex; + justify-content: center; +} + +.logoImage { + width: var(--icon-size-xl); + height: var(--icon-size-xl); +} + +.sidebarNav { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.sidebarLink { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-gray) !important; + border-radius: var(--border-radius-md); + transition: color var(--transition-speed) ease-in-out; +} + +.sidebarLink:hover { + color: var(--color-white) !important; +} + +.sidebarIcon { + font-size: var(--icon-size-md); +} + +.logoutButton { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-gray) !important; + border-radius: var(--border-radius-md); + transition: color var(--transition-speed) ease-in-out; + background: none; + border: none; + cursor: pointer; +} + +.logoutButton:hover { + color: var(--color-white) !important; +} + +@media (max-width: 768px) { + .sidebar { + display: none; + } +} diff --git a/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.tsx b/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.tsx new file mode 100644 index 0000000..36b50f6 --- /dev/null +++ b/client/src/components/MainLayout/MainLayoutSidebar/MainLayoutSidebar.tsx @@ -0,0 +1,61 @@ +import React, { useCallback } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "@contexts"; +import styles from "./MainLayoutSidebar.module.css"; +import logo from "@assets/logo.svg"; +import { + LuListMusic, + LuLibrary, + LuDisc3, + LuUserPen, + LuHistory, + LuLogOut, +} from "react-icons/lu"; + +const MainLayoutSidebar: React.FC = () => { + const { logout, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = useCallback(async () => { + try { + await logout(); + navigate("/"); + } catch (error) { + console.error("Logout failed:", error); + } + }, [logout, navigate]); + + return ( + + ); +}; + +export default MainLayoutSidebar; diff --git a/client/src/components/MainLayout/QueueManager/QueueManager.module.css b/client/src/components/MainLayout/QueueManager/QueueManager.module.css new file mode 100644 index 0000000..db9a1b1 --- /dev/null +++ b/client/src/components/MainLayout/QueueManager/QueueManager.module.css @@ -0,0 +1,222 @@ +.queueManager { + position: absolute; + bottom: calc(var(--now-playing-height) - var(--spacing-md)); + right: 0; + background: var(--color-panel-gray); + border: 2px solid var(--color-panel-border); + border-radius: var(--border-radius-md); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + z-index: 1000; + animation: menuFadeIn var(--transition-speed) ease-out; + width: 32rem; + max-height: 40rem; + display: flex; + flex-direction: column; + overflow: hidden; + overflow-x: hidden; +} + +@keyframes menuFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + border-bottom: 1px solid var(--color-panel-border); +} + +.headerButtons { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-white); + margin: 0; +} + +.closeButton { + background: none; + border: none; + color: var(--color-text-gray); + font-size: var(--font-size-lg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs); + border-radius: var(--border-radius-sm); + transition: all var(--transition-speed) ease; +} + +.closeButton:hover { + color: var(--color-white); + background: var(--color-panel-border); +} + +.clearButton { + background: none; + border: none; + color: var(--color-text-gray); + font-size: var(--font-size-lg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs); + transition: all var(--transition-speed) ease; +} + +.clearButton:hover { + color: var(--color-red); +} + +.clearButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.clearButton:disabled:hover { + color: var(--color-text-gray); + background: none; +} + +.queueContent { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.emptyQueue { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); +} + +.emptyText { + font-size: var(--font-size-sm); + color: var(--color-text-gray); + font-weight: 500; +} + +.queueList { + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + max-height: 32rem; +} + +.queueItem { + background: var(--color-panel-gray); + padding: var(--spacing-md); + display: flex; + align-items: center; + gap: var(--spacing-md); + cursor: grab; + transition: all var(--transition-speed) ease; + user-select: none; + border-bottom: 1px solid var(--color-panel-border); +} + +.queueItem:hover { + background: var(--color-panel-border); +} + +.queueItem:last-child { + border-bottom: none; +} + +.queueItemUserQueued { + background: var(--color-gray-button); +} + +.queueItemUserQueued:hover { + background: var(--color-gray-button-hover); +} + +.queueItemDragging { + opacity: 0.5; + cursor: grabbing; +} + +.queueItemDragOver { + background: var(--color-gray-button-hover); + border-top: 2px solid var(--color-red); +} + +.dragHandle { + color: var(--color-text-gray); + font-size: var(--font-size-md); + cursor: grab; + display: flex; + align-items: center; + transition: color var(--transition-speed) ease; +} + +.queueItem:hover .dragHandle { + color: var(--color-white); +} + +.queueItemDragging .dragHandle { + cursor: grabbing; +} + +.songInfo { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + min-width: 0; +} + +.artistName { + font-size: var(--font-size-xs); + color: var(--color-text-gray); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.songTitle { + font-size: var(--font-size-sm); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.removeButton { + background: none; + border: none; + color: var(--color-text-gray); + font-size: var(--font-size-md); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs); + border-radius: var(--border-radius-sm); + transition: all var(--transition-speed) ease; +} + +.removeButton:hover { + color: var(--color-red); + background: rgba(213, 49, 49, 0.1); +} diff --git a/client/src/components/MainLayout/QueueManager/QueueManager.tsx b/client/src/components/MainLayout/QueueManager/QueueManager.tsx new file mode 100644 index 0000000..327bede --- /dev/null +++ b/client/src/components/MainLayout/QueueManager/QueueManager.tsx @@ -0,0 +1,183 @@ +import { memo, useEffect, useRef, useState, useCallback } from "react"; +import { LuX, LuGripHorizontal, LuTrash } from "react-icons/lu"; +import { useAudioQueue } from "@contexts"; +import { getMainArtist } from "@util"; +import classNames from "classnames"; +import styles from "./QueueManager.module.css"; + +interface QueueManagerProps { + isOpen: boolean; + onClose: () => void; + buttonRef: React.RefObject; +} + +const QueueManager: React.FC = ({ + isOpen, + onClose, + buttonRef, +}) => { + const { state, actions } = useAudioQueue(); + const modalRef = useRef(null); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const queueItems = state.queue.slice(state.currentIndex + 1); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + modalRef.current && + !modalRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + document.addEventListener("mousedown", handleClickOutside); + document.body.style.overflow = "hidden"; + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + document.body.style.overflow = "unset"; + }; + }, [isOpen, onClose, buttonRef]); + + const handleRemoveFromQueue = useCallback( + (queueId: string) => { + actions.removeFromQueue(queueId); + }, + [actions] + ); + + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/html", ""); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + }, []); + + const handleDragLeave = useCallback(() => { + setDragOverIndex(null); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + + if (draggedIndex !== null && draggedIndex !== dropIndex) { + // add 1 for current song + const actualFromIndex = state.currentIndex + 1 + draggedIndex; + const actualToIndex = state.currentIndex + 1 + dropIndex; + + actions.moveQueueItem(actualFromIndex, actualToIndex); + } + + setDraggedIndex(null); + setDragOverIndex(null); + }, + [draggedIndex, state.currentIndex, actions] + ); + + const handleDragEnd = useCallback(() => { + setDraggedIndex(null); + setDragOverIndex(null); + }, []); + + const handleClearQueue = useCallback(() => { + actions.clearQueue(false, true); + }, [actions]); + + if (!isOpen) return null; + + return ( +
+
+

Queue

+
+ + +
+
+ +
+ {queueItems.length === 0 ? ( +
+ No songs in queue +
+ ) : ( +
+ {queueItems.map((item, index) => { + const mainArtist = getMainArtist(item.song.artists ?? []); + const isDragging = draggedIndex === index; + const isDragOver = dragOverIndex === index; + + return ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + > +
+ +
+ +
+ + {mainArtist?.display_name || "Unknown Artist"} + + {item.song.title} +
+ + +
+ ); + })} +
+ )} +
+
+ ); +}; + +export default memo(QueueManager); diff --git a/client/src/components/PageLoader/PageLoader.module.css b/client/src/components/PageLoader/PageLoader.module.css new file mode 100644 index 0000000..6a30b67 --- /dev/null +++ b/client/src/components/PageLoader/PageLoader.module.css @@ -0,0 +1,8 @@ +.loaderContainer { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} diff --git a/client/src/components/PageLoader/PageLoader.tsx b/client/src/components/PageLoader/PageLoader.tsx new file mode 100644 index 0000000..beda0d8 --- /dev/null +++ b/client/src/components/PageLoader/PageLoader.tsx @@ -0,0 +1,13 @@ +import styles from "./PageLoader.module.css"; +import { PuffLoader } from "react-spinners"; +import React from "react"; + +const PageLoader: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default PageLoader; diff --git a/client/src/components/ProtectedRoute/ProtectedRoute.tsx b/client/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..01f9243 --- /dev/null +++ b/client/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "../../contexts"; +import { PageLoader } from "../"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/client/src/components/QueueMenu/QueueMenu.module.css b/client/src/components/QueueMenu/QueueMenu.module.css new file mode 100644 index 0000000..3bef30f --- /dev/null +++ b/client/src/components/QueueMenu/QueueMenu.module.css @@ -0,0 +1,115 @@ +.queueMenu { + position: absolute; + bottom: calc(100% + 0.8rem); + background: var(--color-panel-gray); + border: 2px solid var(--color-panel-border) !important; + border-radius: var(--border-radius-md); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); + z-index: 1000; + animation: menuFadeIn var(--transition-speed) ease-out; + min-width: 15rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + overflow: hidden; +} + +.justifyCenter { + left: 50%; + transform: translateX(-50%); +} + +.justifyLeft { + left: 0; + transform: translateX(0); +} + +.justifyRight { + right: 0; + transform: translateX(0); +} + +@keyframes menuFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.justifyCenter { + animation: menuFadeInCenter var(--transition-speed) ease-out; +} + +.justifyLeft { + animation: menuFadeInLeft var(--transition-speed) ease-out; +} + +.justifyRight { + animation: menuFadeInRight var(--transition-speed) ease-out; +} + +@keyframes menuFadeInCenter { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes menuFadeInLeft { + from { + opacity: 0; + transform: translateX(0) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(0) translateY(0); + } +} + +@keyframes menuFadeInRight { + from { + opacity: 0; + transform: translateX(0) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(0) translateY(0); + } +} + +.menuButton { + font-family: "Inter", sans-serif; + width: 100%; + background: var(--color-panel-gray); + border: none; + padding: var(--spacing-md); + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-white-hover); + font-size: var(--font-size-sm); + font-weight: 400; + cursor: pointer; + transition: all var(--transition-speed) ease; +} + +.menuButton:hover { + background: var(--color-panel-border); + color: var(--color-white); +} + +.menuButton:first-child { + border-bottom: 1px solid var(--color-panel-border); +} + +.menuIcon { + font-size: var(--icon-size-sm); +} diff --git a/client/src/components/QueueMenu/QueueMenu.tsx b/client/src/components/QueueMenu/QueueMenu.tsx new file mode 100644 index 0000000..ed015ed --- /dev/null +++ b/client/src/components/QueueMenu/QueueMenu.tsx @@ -0,0 +1,95 @@ +import { memo, useEffect, useRef } from "react"; +import { LuListStart, LuListEnd } from "react-icons/lu"; +import { useAudioQueue } from "@contexts"; +import type { Song } from "@types"; +import styles from "./QueueMenu.module.css"; +import { useCallback } from "react"; +import classNames from "classnames"; + +interface QueueMenuProps { + isOpen: boolean; + onClose: () => void; + song: Song; + buttonRef: React.RefObject; + justification?: "left" | "center" | "right"; +} + +const QueueMenu: React.FC = ({ + isOpen, + onClose, + song, + buttonRef, + justification = "center", +}) => { + const { actions } = useAudioQueue(); + const menuRef = useRef(null); + + const handleEscape = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }, + [onClose] + ); + + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + onClose(); + } + }, + [onClose, buttonRef] + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener("keydown", handleEscape); + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("keydown", handleEscape); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, handleEscape, handleClickOutside]); + + const handleQueueNext = () => { + actions.queueNext(song); + onClose(); + }; + + const handleQueueLast = () => { + actions.queueLast(song); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+ + +
+ ); +}; + +export default memo(QueueMenu); diff --git a/client/src/components/ShareModal/ShareModal.module.css b/client/src/components/ShareModal/ShareModal.module.css new file mode 100644 index 0000000..080a60b --- /dev/null +++ b/client/src/components/ShareModal/ShareModal.module.css @@ -0,0 +1,192 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal { + background: var(--color-panel-gray); + border: 2px solid var(--color-panel-border); + border-radius: var(--border-radius-md); + width: 90%; + max-width: 46rem; + padding: var(--spacing-lg); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: slideIn 0.2s ease-out; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.title { + font-size: 2rem; + font-weight: 600; + color: var(--color-white); + margin: 0; +} + +.closeButton { + background: none; + border: none; + color: var(--color-text-gray); + font-size: 2rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-speed) ease; +} + +.closeButton:hover { + color: var(--color-white); +} + +.socialIcons { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-sm); +} + +.socialButton { + font-family: "Inter", sans-serif; + background: var(--color-gray-button); + border: 1px solid var(--color-gray-button-border); + border-radius: var(--border-radius-md); + padding: 2rem 1.2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.2rem; + cursor: pointer; + transition: background var(--transition-speed) ease; + color: var(--color-white); + font-size: var(--font-size-lg); +} + +.socialButton:hover { + background: var(--color-gray-button-border); +} + +.socialLabel { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-white-hover); +} + +.linkSection { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + line-height: 1.1; +} + +.linkLabel { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-white-hover); +} + +.linkInputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.linkInput { + font-family: "Inter", sans-serif; + width: 100%; + background: var(--color-gray-button); + border: 1px solid var(--color-gray-button-border); + border-radius: var(--border-radius-md); + padding: 1.04rem 4.58rem 1.04rem 1.25rem; + color: var(--color-text-gray); + font-size: var(--font-size-sm); + font-family: inherit; + transition: all var(--transition-speed) ease; +} + +.copyButton { + position: absolute; + right: 0.3125rem; + background: var(--color-gray-button-border); + border: none; + border-radius: 0.6rem; + padding: 0.8rem; + color: var(--color-white-hover); + font-size: var(--font-size-md); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-speed) ease; +} + +.copyButton:hover { + background: #5d5d5d; + color: var(--color-white); +} + +.tooltip { + position: absolute; + bottom: calc(100% + 0.8rem); + left: 50%; + transform: translateX(-50%); + background: var(--color-white); + color: var(--color-panel-gray); + padding: 0.6rem 1.2rem; + border-radius: 0.6rem; + font-size: var(--font-size-sm); + font-weight: 500; + white-space: nowrap; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + animation: tooltipFadeIn var(--transition-speed) ease-out; + pointer-events: none; +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/client/src/components/ShareModal/ShareModal.tsx b/client/src/components/ShareModal/ShareModal.tsx new file mode 100644 index 0000000..50ce23c --- /dev/null +++ b/client/src/components/ShareModal/ShareModal.tsx @@ -0,0 +1,135 @@ +import { useState, memo } from "react"; +import { FaXTwitter, FaFacebook, FaReddit, FaEnvelope } from "react-icons/fa6"; +import { LuCopy, LuX } from "react-icons/lu"; +import styles from "./ShareModal.module.css"; + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + pageUrl?: string; + pageTitle?: string; +} + +const ShareModal: React.FC = ({ + isOpen, + onClose, + pageUrl = window.location.href, + pageTitle = document.title, +}) => { + const [copied, setCopied] = useState(false); + + if (!isOpen) return null; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(pageUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + interface ShareLink { + name: string; + icon: React.ComponentType; + url: string; + isEmail?: boolean; + } + + const shareLinks: ShareLink[] = [ + { + name: "Twitter", + icon: FaXTwitter, + url: `https://twitter.com/intent/tweet?url=${encodeURIComponent( + pageUrl + )}&text=${encodeURIComponent(pageTitle)}`, + }, + { + name: "Facebook", + icon: FaFacebook, + url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent( + pageUrl + )}`, + }, + { + name: "Reddit", + icon: FaReddit, + url: `https://reddit.com/submit?url=${encodeURIComponent( + pageUrl + )}&title=${encodeURIComponent(pageTitle)}`, + }, + { + name: "Email", + icon: FaEnvelope, + url: `mailto:?subject=${encodeURIComponent( + pageTitle + )}&body=${encodeURIComponent(pageUrl)}`, + isEmail: true, + }, + ]; + + const handleShareClick = (link: ShareLink): void => { + if (link.isEmail) { + window.location.href = link.url; + } else { + window.open( + link.url, + "_blank", + "noopener,noreferrer,width=600,height=400" + ); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Share

+ +
+ +
+ {shareLinks.map((link) => { + const Icon = link.icon; + return ( + + ); + })} +
+ +
+ +
+ + + {copied &&
Copied!
} +
+
+
+
+ ); +}; + +export default memo(ShareModal); diff --git a/client/src/components/SlidingCardList/SlidingCardList.module.css b/client/src/components/SlidingCardList/SlidingCardList.module.css new file mode 100644 index 0000000..7a34501 --- /dev/null +++ b/client/src/components/SlidingCardList/SlidingCardList.module.css @@ -0,0 +1,100 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + min-height: 10rem; +} + +.error { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--color-text-gray); + font-size: var(--font-size-md); +} + +.container { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.sectionTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.withNavigation { + display: flex; + align-items: center; + gap: var(--spacing-md); + width: 100%; +} + +.scrollContainer { + flex: 1; + overflow: hidden; + position: relative; + min-width: 0; + width: 0; +} + +.list { + display: flex; + gap: var(--spacing-lg); + min-width: 0; + transition: transform 0.3s ease; + width: max-content; +} + +.navButton { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-text-gray); + cursor: pointer; + font-size: var(--icon-size-lg); + padding: var(--spacing-sm); + transition: all var(--transition-speed) ease; + flex-shrink: 0; +} + +.navButton:hover:not(:disabled) { + color: var(--color-white); + transform: scale(1.1); +} + +.navButton:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.scrollGradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to right, + rgba(8, 8, 8, 0.7) 0%, + transparent 8%, + transparent 92%, + rgba(8, 8, 8, 0.7) 100% + ); + pointer-events: none; + z-index: 2; + opacity: 1; + transition: opacity var(--transition-speed) ease; +} diff --git a/client/src/components/SlidingCardList/SlidingCardList.tsx b/client/src/components/SlidingCardList/SlidingCardList.tsx new file mode 100644 index 0000000..f65e1fe --- /dev/null +++ b/client/src/components/SlidingCardList/SlidingCardList.tsx @@ -0,0 +1,222 @@ +import { memo, useCallback, useState, useMemo, useRef, useEffect } from "react"; +import { PuffLoader } from "react-spinners"; +import { LuChevronLeft, LuChevronRight } from "react-icons/lu"; +import type { Song, Album, Playlist } from "@types"; +import { useAsyncData } from "@hooks"; +import { EntityItemCard } from "@components"; +import { formatDateString, getMainArtist } from "@util"; +import styles from "./SlidingCardList.module.css"; +import musicPlaceholder from "@assets/music-placeholder.png"; + +type CardEntityType = "song" | "album" | "playlist"; + +const CARD_WIDTH = 18; +const CARD_GAP = 2.4; +const SCROLL_TRANSITION_DURATION = 300; + +export interface SlidingCardListProps { + title: string; + artistName?: string; + fetchData: () => Promise; + type: CardEntityType; + itemsPerView?: number; + cacheKey: string; + dependencies?: any[]; +} + +const SlidingCardList: React.FC = ({ + title, + artistName, + fetchData, + type, + itemsPerView = 6, + cacheKey, + dependencies = [], +}) => { + const [scrollPosition, setScrollPosition] = useState(0); + const [isScrolling, setIsScrolling] = useState(false); + + const timeoutRef = useRef(undefined); + + const asyncConfig = useMemo( + () => ({ + items: fetchData, + }), + [fetchData] + ); + + const { data, loading, error } = useAsyncData(asyncConfig, dependencies, { + cacheKey, + hasBlobUrl: true, + }); + + const items = data?.items || []; + const scrollState = useMemo(() => { + const totalItems = items.length; + const maxScrollPosition = Math.max(0, totalItems - itemsPerView); + const needsScrolling = totalItems > itemsPerView; + const canScrollNext = scrollPosition < maxScrollPosition; + const canScrollPrevious = scrollPosition > 0; + + return { + totalItems, + maxScrollPosition, + needsScrolling, + canScrollNext, + canScrollPrevious, + }; + }, [items.length, itemsPerView, scrollPosition]); + + const transformStyle = useMemo( + () => ({ + transform: `translateX(-${scrollPosition * (CARD_WIDTH + CARD_GAP)}rem)`, + }), + [scrollPosition] + ); + + const handleScrollNext = useCallback(() => { + setIsScrolling(true); + setScrollPosition((prev) => + Math.min(prev + itemsPerView, scrollState.maxScrollPosition) + ); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout( + () => setIsScrolling(false), + SCROLL_TRANSITION_DURATION + ); + }, [scrollState.maxScrollPosition, itemsPerView]); + + const handleScrollPrevious = useCallback(() => { + setIsScrolling(true); + setScrollPosition((prev) => Math.max(prev - itemsPerView, 0)); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout( + () => setIsScrolling(false), + SCROLL_TRANSITION_DURATION + ); + }, [itemsPerView]); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + useEffect(() => { + setScrollPosition(0); + }, [cacheKey]); + + const getEntityProps = useCallback( + (item: Song | Album | Playlist) => { + switch (type) { + case "song": { + const song = item as Song; + return { + type: "song" as const, + linkTo: `/songs/${song.id}`, + author: + getMainArtist(song.artists ?? [])?.display_name || + artistName || + "", + title: song.title, + subtitle: formatDateString(song.release_date), + imageUrl: song.image_url || musicPlaceholder, + entity: song, + }; + } + case "album": { + const album = item as Album; + return { + type: "list" as const, + linkTo: `/albums/${album.id}`, + author: artistName || "", + title: album.title, + subtitle: formatDateString(album.release_date), + imageUrl: album.image_url || musicPlaceholder, + entity: album, + }; + } + case "playlist": { + const playlist = item as Playlist; + return { + type: "list" as const, + linkTo: `/playlists/${playlist.id}`, + author: playlist.user?.username || "Unknown", + title: playlist.title, + subtitle: "", + imageUrl: playlist.image_url || musicPlaceholder, + entity: playlist, + }; + } + default: + throw new Error(`Unknown entity type: ${type}`); + } + }, + [type, artistName] + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
Failed to load {title.toLowerCase()}.
+ ); + } + + if (!items || items.length === 0) { + return null; + } + + return ( +
+ {title} + {scrollState.needsScrolling ? ( +
+ +
+
+ {items.map((item) => { + const props = getEntityProps(item); + return ; + })} +
+
+
+ +
+ ) : ( +
+ {items.map((item) => { + const props = getEntityProps(item); + return ; + })} +
+ )} +
+ ); +}; + +export default memo(SlidingCardList); diff --git a/client/src/components/SongCard/SongCard.module.css b/client/src/components/SongCard/SongCard.module.css new file mode 100644 index 0000000..7357313 --- /dev/null +++ b/client/src/components/SongCard/SongCard.module.css @@ -0,0 +1,105 @@ +.songCard { + background-color: #181818; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + transition: background-color 0.2s ease, transform 0.2s ease; + cursor: pointer; +} + +.songCard:hover { + background-color: #202020; + transform: translateY(-4px); +} + +.imageContainer { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 8px; + overflow: hidden; +} + +.songImage { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; +} + +.overlay { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + display: flex; + justify-content: center; + align-items: center; + transition: opacity 0.2s ease; +} + +.imageContainer:hover .overlay { + opacity: 1; +} + +.playButton { + width: 48px; + height: 48px; + background-color: #e63946; + border: none; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.playButton:hover { + transform: scale(1.1); + background-color: #ff4d5a; +} + +.songInfo { + text-align: center; +} + +.songTitle { + font-weight: 600; + color: #ffffff; + margin-bottom: 4px; + font-size: 0.95rem; +} + +.songArtist { + font-size: 0.8rem; + color: #aaa; +} + +.songStats { + display: flex; + justify-content: space-around; + width: 100%; + margin-top: 8px; +} + +.statItem { + display: flex; + align-items: center; + gap: 6px; + color: #aaa; + font-size: 0.8rem; + transition: color 0.2s ease; +} + +.statItem:hover { + color: #fff; +} + +.statIcon { + width: 14px; + height: 14px; + fill: currentColor; +} diff --git a/client/src/components/SongCard/SongCard.tsx b/client/src/components/SongCard/SongCard.tsx new file mode 100644 index 0000000..83dcfe2 --- /dev/null +++ b/client/src/components/SongCard/SongCard.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import styles from "./SongCard.module.css"; + +export interface SongCardProps { + image: string; + title: string; + artist: string; + plays: number; + likes: number; + comments: number; +} + +const SongCard: React.FC = ({ + image, + title, + artist, + plays, + likes, + comments, +}) => { + return ( +
+
+ {title} +
+ +
+
+ +
+

{title}

+ {artist} +
+ +
+
+ {plays} +
+
+ {likes} +
+
+ {comments} +
+
+
+ ); +}; + +export default SongCard; diff --git a/client/src/components/SongPage/ArtistInfo/ArtistInfo.module.css b/client/src/components/SongPage/ArtistInfo/ArtistInfo.module.css new file mode 100644 index 0000000..49a235e --- /dev/null +++ b/client/src/components/SongPage/ArtistInfo/ArtistInfo.module.css @@ -0,0 +1,231 @@ +.artistInfoLayout { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: 0; + width: 100%; +} + +.artistInfoContainer { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + height: auto; + background: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + padding: var(--spacing-md); + min-width: 0; +} + +.artistInfoLeft { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.2rem; + width: 11rem; +} + +.artistImage { + width: 11rem; + height: 11rem; + border-radius: 50%; + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); +} + +.artistFollowButton { + font-family: "Inter", sans-serif; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + background: var(--color-red); + border-radius: var(--border-radius-sm); + border: none; + color: var(--color-red-200); + cursor: pointer; + font-size: var(--font-size-sm); + padding: var(--spacing-sm) var(--spacing-md); + transition: color var(--transition-speed) ease-in-out; +} + +.artistFollowButton:hover { + background-color: var(--color-red-700); +} + +.artistFollowButtonActive { + background-color: var(--color-red-700); +} + +.artistInfoRight { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: 0; + font-size: var(--font-size-sm); + padding: var(--spacing-sm) 0; +} + +.artistNameContainer { + min-width: 0; + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.artistInfoName { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + transition: color var(--transition-speed) ease-in-out; + cursor: pointer; +} + +.artistInfoName:hover { + color: var(--color-red); +} + +.badgeWrapper { + position: relative; + display: flex; + align-items: center; +} + +.tooltip { + position: absolute; + bottom: calc(100% + var(--spacing-sm)); + left: 50%; + transform: translateX(-50%); + background: rgba(246, 246, 246, 0.8); + color: var(--color-black); + padding: 0.6rem 1.2rem; + border-radius: var(--border-radius-sm); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); + opacity: 0; + animation: tooltipFadeIn 0.15s ease-out forwards; +} + +.tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(246, 246, 246, 0.75); +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.verifiedBadge { + font-size: var(--icon-size-sm); + flex-shrink: 0; + transition: color var(--transition-speed) ease-in-out; +} + +.verifiedBadge:hover { + color: var(--color-white-hover); +} + +.artistBio { + color: var(--color-text-gray); + font-size: var(--font-size-sm); + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + hyphens: auto; + width: 100%; + max-width: 100%; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 6; + line-clamp: 6; + min-width: 0; + line-height: 1.2; +} + +.otherArtistsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + background: var(--color-panel-gray); + border-radius: var(--border-radius-md); + border: 2px solid var(--color-panel-border); + padding: var(--spacing-md); + min-width: 0; + width: 100%; + font-size: var(--font-size-sm); +} + +.otherArtistWrapper { + display: flex; + flex-direction: column; +} + +.otherArtistItem { + display: flex; + align-items: center; + gap: var(--spacing-sm); + min-width: 0; + width: 100%; +} + +.otherArtistIcon { + color: var(--color-text-gray); +} + +.otherArtistInfo { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + min-width: 0; + gap: var(--spacing-sm); +} + +.otherArtistName { + color: var(--color-white); + transition: color var(--transition-speed) ease-in-out; + cursor: pointer; + min-width: 0; + width: fit-content; +} + +.otherArtistName:hover { + color: var(--color-red); +} + +.otherArtistRole { + color: var(--color-text-gray); + min-width: 0; +} + +.otherArtistName, +.otherArtistRole { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} diff --git a/client/src/components/SongPage/ArtistInfo/ArtistInfo.tsx b/client/src/components/SongPage/ArtistInfo/ArtistInfo.tsx new file mode 100644 index 0000000..84bcab5 --- /dev/null +++ b/client/src/components/SongPage/ArtistInfo/ArtistInfo.tsx @@ -0,0 +1,139 @@ +import { useState, Fragment, memo, useCallback, useMemo } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import type { SongArtist } from "@types"; +import { useAuth } from "@contexts"; +import styles from "./ArtistInfo.module.css"; +import { HorizontalRule } from "@components"; +import classNames from "classnames"; +import userPlaceholder from "@assets/user-placeholder.png"; +import { + LuUserRoundCheck, + LuUserRoundPlus, + LuBadgeCheck, + LuUserRoundPen, +} from "react-icons/lu"; + +export interface ArtistInfoProps { + mainArtist: SongArtist | undefined; + otherArtists: SongArtist[]; +} + +const ArtistInfo: React.FC = ({ + mainArtist, + otherArtists, +}) => { + //! user + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [isFollowed, setIsFollowed] = useState(false); + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + + const handleFollowArtist = useCallback(async () => { + try { + if (isAuthenticated) { + //! send request here + setIsFollowed((prev) => !prev); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Toggling follow artist failed:", error); + } + }, [isAuthenticated, navigate]); + + const artistDisplayName = useMemo( + () => mainArtist?.display_name || mainArtist?.user?.username, + [mainArtist] + ); + + const artistImageUrl = useMemo( + () => mainArtist?.user?.profile_picture_url || userPlaceholder, + [mainArtist] + ); + + const OtherArtistItem = memo(({ artist }: { artist: SongArtist }) => ( +
+
+ )); + + if (!mainArtist) { + return null; + } + + return ( +
+
+
+ {`${artistDisplayName} + +
+
+
+ + {artistDisplayName} + + {mainArtist?.verified && ( +
setIsTooltipVisible(true)} + onMouseLeave={() => setIsTooltipVisible(false)} + > + + {isTooltipVisible && ( +
Verified by CoogMusic
+ )} +
+ )} +
+ +
+ {mainArtist?.bio || `${artistDisplayName} has no bio yet...`} +
+
+
+ + {otherArtists.length > 0 && ( +
+ {otherArtists.map((artist: SongArtist, i: number) => ( + + + {i < otherArtists.length - 1 && } + + ))} +
+ )} +
+ ); +}; + +export default memo(ArtistInfo); diff --git a/client/src/components/SongPage/CommentItem/CommentItem.module.css b/client/src/components/SongPage/CommentItem/CommentItem.module.css new file mode 100644 index 0000000..9faab9d --- /dev/null +++ b/client/src/components/SongPage/CommentItem/CommentItem.module.css @@ -0,0 +1,129 @@ +.comment { + display: flex; + gap: var(--spacing-sm); + align-items: center; + min-width: 0; + width: 100%; +} + +.commentListUserPfp { + width: 4rem; + height: 4rem; + border-radius: 50%; + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; +} + +.commentContentWrapper { + display: flex; + align-items: flex-start; + justify-content: space-between; + min-width: 0; + width: 100%; +} + +.commentContent { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + min-width: 0; + width: 100%; + padding-right: var(--spacing-md); +} + +.commentHeader { + display: flex; + align-items: center; + min-width: 0; + width: 100%; + gap: 0; +} + +.commentSeparator { + margin: 0 var(--spacing-xs); + color: var(--color-text-gray); + font-size: var(--font-size-sm); + flex-shrink: 0; +} + +.commentUsername { + font-size: var(--font-size-sm); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + min-width: 0; + flex-shrink: 1; +} + +.commentTimestamp { + font-size: var(--font-size-sm); + color: var(--color-text-gray); + white-space: nowrap; + flex-shrink: 0; +} + +.commentText { + font-size: var(--font-size-sm); + color: var(--color-text-gray-light); + line-height: 1.2; +} + +.commentTag { + color: var(--color-red) !important; + font-weight: 600; + text-decoration: underline transparent !important; + transition: text-decoration var(--transition-speed) ease-in-out; +} + +.commentTag:hover { + text-decoration: underline var(--color-red) !important; +} + +.commentLikesContainer { + display: flex; + align-items: center; + justify-content: space-between; + background-color: none; + border-radius: var(--border-radius-md); + border: 1px solid var(--color-panel-border); + padding: var(--spacing-sm); + min-width: 6.4rem; +} + +.commentLikesContainerActive { + background-color: var(--color-panel-gray-dark); +} + +.commentLikeCount { + font-size: var(--font-size-sm); + color: var(--color-text-gray-light); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + max-width: 100%; +} + +.commentLikeButton { + display: flex; + align-items: center; + justify-content: center; + background: none; + color: var(--color-text-gray-light); + border: none; + font-size: var(--icon-size-sm); + transition: color var(--transition-speed) ease-in-out; + cursor: pointer; +} + +.commentLikeButton:hover { + color: var(--color-red); +} + +.commentLikeButtonActive { + color: var(--color-red); +} diff --git a/client/src/components/SongPage/CommentItem/CommentItem.tsx b/client/src/components/SongPage/CommentItem/CommentItem.tsx new file mode 100644 index 0000000..86f1edf --- /dev/null +++ b/client/src/components/SongPage/CommentItem/CommentItem.tsx @@ -0,0 +1,127 @@ +import { memo, useCallback, useMemo, useEffect } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { useState } from "react"; +import { useAuth } from "@contexts"; +import { formatRelativeDate } from "@util"; +import type { Comment } from "@types"; +import userPlaceholder from "@assets/user-placeholder.png"; +import styles from "./CommentItem.module.css"; +import classNames from "classnames"; +import { LuThumbsUp } from "react-icons/lu"; + +const CommentItem: React.FC<{ comment: Comment }> = ({ comment }) => { + const [isLiked, setIsLiked] = useState(comment.user_liked ?? false); + const [likeCount, setLikeCount] = useState(Number(comment.likes) || 0); + + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + //! send request here too + const toggleCommentLike = useCallback(() => { + if (!isAuthenticated) { + navigate("/login"); + return; + } + + if (isLiked) { + setLikeCount((prev) => prev - 1); + setIsLiked(false); + } else { + setLikeCount((prev) => prev + 1); + setIsLiked(true); + } + }, [isAuthenticated, navigate, isLiked]); + + useEffect(() => { + setIsLiked(comment.user_liked ?? false); + setLikeCount(Number(comment.likes) || 0); + }, [comment.id, comment.user_liked, comment.likes]); + + const { comment_text, tags } = comment; + + const segments = useMemo(() => { + const result: React.ReactNode[] = []; + let lastIndex = 0; + + if (tags && tags.length > 0) { + const sortedTags = [...tags].sort((a, b) => a.start - b.start); + + for (const tag of sortedTags) { + if (tag.start > lastIndex) { + result.push(comment_text.slice(lastIndex, tag.start)); + } + + result.push( + + {comment_text.slice(tag.start, tag.end)} + + ); + + lastIndex = tag.end; + } + } + + if (lastIndex < comment_text.length) { + result.push( + + {comment_text.slice(lastIndex)} + + ); + } + + return result; + }, [comment_text, tags]); + + const profilePicUrl = useMemo( + () => comment.profile_picture_url || userPlaceholder, + [comment.profile_picture_url] + ); + + const formattedDate = useMemo( + () => formatRelativeDate(comment.commented_at), + [comment.commented_at] + ); + + return ( +
+ {comment.username} +
+
+
+ {comment.username} + + {formattedDate} +
+ {segments} +
+
+ {likeCount} + +
+
+
+ ); +}; + +export default memo(CommentItem); diff --git a/client/src/components/SongPage/SongActions/SongActions.module.css b/client/src/components/SongPage/SongActions/SongActions.module.css new file mode 100644 index 0000000..064f2f2 --- /dev/null +++ b/client/src/components/SongPage/SongActions/SongActions.module.css @@ -0,0 +1,32 @@ +.songActionsContainer { + flex: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.actionButton { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-panel-gray); + border-radius: var(--border-radius-sm); + border: 2px solid var(--color-panel-border); + color: var(--color-white); + cursor: pointer; + font-size: var(--icon-size-md); + padding: var(--spacing-md) 2.1rem; + transition: color var(--transition-speed) ease-in-out; +} + +.actionButton:hover { + color: var(--color-red); +} + +.actionButtonActive { + color: var(--color-red); +} + +.queueButtonContainer { + position: relative; +} diff --git a/client/src/components/SongPage/SongActions/SongActions.tsx b/client/src/components/SongPage/SongActions/SongActions.tsx new file mode 100644 index 0000000..32cee1a --- /dev/null +++ b/client/src/components/SongPage/SongActions/SongActions.tsx @@ -0,0 +1,141 @@ +import React, { memo, useEffect, useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import type { Song } from "@types"; +import { useLikeStatus } from "@hooks"; +import { useAuth } from "@contexts"; +import { ShareModal, QueueMenu } from "@components"; +import { useQueryClient } from "@tanstack/react-query"; +import styles from "./SongActions.module.css"; +import classNames from "classnames"; +import { + LuThumbsUp, + LuListPlus, + LuListEnd, + LuShare, + LuCircleAlert, +} from "react-icons/lu"; + +export interface SongActionsProps { + song: Song; + songUrl?: string; +} + +const SongActions: React.FC = ({ song, songUrl }) => { + const { user, isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { + isLiked, + toggleLike, + isLoading: isLikeLoading, + } = useLikeStatus({ + userId: user?.id || "", + entityId: song.id, + entityType: "song", + isAuthenticated, + }); + + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [queueMenuOpen, setQueueMenuOpen] = useState(false); + + const queueButtonRef = useRef(null); + + useEffect(() => { + if (user?.id && song?.id) { + queryClient.invalidateQueries({ + queryKey: ["likeStatus", user.id, song.id, "song"], + }); + } + }, [user?.id, song?.id]); + + const handleToggleSongLike = async () => { + try { + if (!isAuthenticated) return navigate("/login"); + await toggleLike(); + } catch (error) { + console.error("Toggling song like failed:", error); + } + }; + + const handleAddToPlaylist = async () => { + try { + if (isAuthenticated) { + //! send request here + console.log("added to playlist: " + song.id); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding to playlist failed:", error); + } + }; + + const handleAddToQueue = async () => { + try { + if (isAuthenticated) { + setQueueMenuOpen((prev) => !prev); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding to queue failed:", error); + } + }; + + const handleShare = () => { + setIsShareModalOpen(true); + }; + + const handleReport = () => { + //! open report modal... + }; + + return ( + <> +
+ + +
+ + setQueueMenuOpen(false)} + song={song} + buttonRef={queueButtonRef} + /> +
+ + +
+ + setIsShareModalOpen(false)} + pageUrl={songUrl} + pageTitle={song.title} + /> + + ); +}; + +export default memo(SongActions); diff --git a/client/src/components/SongPage/SongComments/SongComments.module.css b/client/src/components/SongPage/SongComments/SongComments.module.css new file mode 100644 index 0000000..0bbe81a --- /dev/null +++ b/client/src/components/SongPage/SongComments/SongComments.module.css @@ -0,0 +1,103 @@ +.commentsContainer { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.noComments { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.commentsContainerTop { + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.commentUserPfp { + width: 4.8rem; + height: 4.8rem; + border-radius: 50%; + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; +} + +.commentInputContainer { + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-sm); + border: 2px solid var(--color-panel-border); + flex: 1; + position: relative; + display: flex; + align-items: center; +} + +.commentInput { + font-family: "Inter", sans-serif; + width: 96%; + padding: var(--spacing-md); + background-color: transparent; + border: none; + border-radius: var(--border-radius-lg); + color: var(--color-white); + font-size: var(--font-size-sm); + outline: none; +} + +.commentInput::placeholder { + color: var(--color-text-gray); +} + +.commentButton { + position: absolute; + right: var(--spacing-sm); + font-size: var(--icon-size-md); + color: var(--color-text-gray); + display: flex; + align-items: center; + justify-content: center; + background: var(--color-red); + border-radius: var(--border-radius-sm); + border: none; + color: var(--color-red-200); + cursor: pointer; + padding: var(--spacing-sm); + transition: color var(--transition-speed) ease-in-out; +} + +.commentButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.commentButton:not(:disabled):hover { + color: var(--color-red-950) !important; +} + +.commentLoaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.commentsList { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + min-width: 0; + width: 100%; +} diff --git a/client/src/components/SongPage/SongComments/SongComments.tsx b/client/src/components/SongPage/SongComments/SongComments.tsx new file mode 100644 index 0000000..bc14a77 --- /dev/null +++ b/client/src/components/SongPage/SongComments/SongComments.tsx @@ -0,0 +1,142 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAsyncData } from "@hooks"; +import { useAuth } from "@contexts"; +import { PuffLoader } from "react-spinners"; +import { commentApi } from "@api"; +import { CommentItem, HorizontalRule } from "@components"; +import styles from "./SongComments.module.css"; +import userPlaceholder from "@assets/user-placeholder.png"; +import { LuSend } from "react-icons/lu"; + +export interface SongCommentsProps { + songId: string; +} + +const SongComments: React.FC = ({ songId }) => { + const { user, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [commentText, setCommentText] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data, loading, error } = useAsyncData( + { + comments: () => + commentApi.getCommentsBySongId(songId, { + includeLikes: true, + currentUserId: isAuthenticated && user ? user.id : undefined, + limit: 25, + }), + }, + [songId], + { cacheKey: `comments_${songId}`, hasBlobUrl: true } + ); + + const comments = data?.comments; + + const handleCommentChange = useCallback( + (e: React.ChangeEvent) => { + setCommentText(e.target.value); + }, + [] + ); + + const handleAddComment = useCallback(() => { + if (!commentText.trim()) return; + + try { + if (isAuthenticated) { + setIsSubmitting(true); + //! send request with commentText + console.log("added comment to song: " + songId, commentText); + setCommentText(""); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Adding comment failed:", error); + } finally { + setIsSubmitting(false); + } + }, [isAuthenticated, navigate, songId, commentText, isSubmitting]); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleAddComment(); + } + }, + [handleAddComment] + ); + + const userProfilePic = useMemo( + () => user?.profile_picture_url || userPlaceholder, + [user] + ); + + if (error) { + return
Failed to load comments.
; + } + + return ( +
+
+ {user?.username + +
+ + +
+
+ {loading ? ( +
+ +
+ ) : ( + comments && + comments.length > 0 && ( + <> + +
+ {comments.map((comment: any) => ( + + ))} +
+ + ) + )} + {!loading && comments && comments.length === 0 && ( +
+ No comments yet. Be the first to comment! +
+ )} +
+ ); +}; + +export default memo(SongComments); diff --git a/client/src/components/SongPage/SongContainer/SongContainer.module.css b/client/src/components/SongPage/SongContainer/SongContainer.module.css new file mode 100644 index 0000000..4f5443a --- /dev/null +++ b/client/src/components/SongPage/SongContainer/SongContainer.module.css @@ -0,0 +1,105 @@ +.songContainer { + position: relative; + display: flex; + gap: var(--spacing-lg); + flex: 1; + padding: var(--spacing-lg); + background: linear-gradient(to bottom, #181818, #0f0f0f); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + border-radius: var(--border-radius-sm); + overflow: hidden; +} + +.songContainer::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 65deg, + var(--cover-gradient-color1) 0%, + var(--cover-gradient-color2) 15%, + transparent 35% + ); + pointer-events: none; + border-radius: var(--border-radius-sm); +} + +.coverImage { + width: 24rem; + height: 24rem; + border-radius: var(--border-radius-sm); + object-fit: cover; + aspect-ratio: 1 / 1; + flex-shrink: 0; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); + transition: box-shadow var(--transition-speed) ease; +} + +.coverImageClickable { + cursor: pointer; +} + +.coverImage:hover { + box-shadow: 0 0 15px rgba(0, 0, 0, 0.6); +} + +.songRight { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: var(--spacing-sm) 0; + gap: var(--spacing-md); + flex: 1; +} + +.songInfoContainer { + display: flex; + flex-direction: column; + min-width: 0; +} + +.artistName { + font-size: 2.4rem; + color: var(--color-text-gray-light); + font-weight: 500; +} + +.songTitle { + font-size: 4.2rem; + color: var(--color-white); + font-weight: 700; +} + +.songTitle, +.artistName { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + max-width: 100%; +} + +.interactionsContainer { + padding-top: 1rem; + display: flex; + align-items: center; + gap: var(--spacing-lg); + color: var(--color-text-gray) !important; +} + +.interactionStat { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--font-size-md); +} + +.interactionText { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} diff --git a/client/src/components/SongPage/SongContainer/SongContainer.tsx b/client/src/components/SongPage/SongContainer/SongContainer.tsx new file mode 100644 index 0000000..2eee9c7 --- /dev/null +++ b/client/src/components/SongPage/SongContainer/SongContainer.tsx @@ -0,0 +1,101 @@ +import { useMemo, useCallback, memo, useState } from "react"; +import { useStreamTracking } from "@hooks"; +import { WaveformPlayer, CoverLightbox } from "@components"; +import type { Song, CoverGradient, SongArtist } from "@types"; +import { useAudioQueue } from "@contexts"; +import { LuPlay, LuThumbsUp, LuMessageSquareText } from "react-icons/lu"; +import styles from "./SongContainer.module.css"; +import classNames from "classnames"; +import musicPlaceholder from "@assets/music-placeholder.png"; + +export interface SongContainerProps { + coverGradient: CoverGradient; + song: Song; + mainArtist: SongArtist | undefined; + numberComments?: number; +} + +const SongContainer: React.FC = ({ + coverGradient, + song, + mainArtist, + numberComments, +}) => { + const { actions } = useAudioQueue(); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + useStreamTracking(); + + const gradientStyle = useMemo( + () => + ({ + "--cover-gradient-color1": `rgba(${coverGradient.color1.r}, ${coverGradient.color1.g}, ${coverGradient.color1.b}, 0.2)`, + "--cover-gradient-color2": `rgba(${coverGradient.color2.r}, ${coverGradient.color2.g}, ${coverGradient.color2.b}, 0.2)`, + } as React.CSSProperties), + [coverGradient] + ); + + const InteractionStat = memo( + ({ icon: Icon, value }: { icon: React.ElementType; value: number }) => ( +
+ + {value} +
+ ) + ); + + const handlePlay = useCallback(() => { + actions.play(song); + }, [actions, song]); + + const handleImageClick = useCallback(() => { + setIsLightboxOpen(true); + }, []); + + return ( +
+ {`${song.title} +
+
+ {mainArtist?.display_name} + {song.title} +
+
+ + + +
+
+
+ {song.audio_url && ( + + )} +
+ + {song.image_url && ( + setIsLightboxOpen(false)} + imageUrl={song.image_url} + altText={`${song.title} Cover`} + /> + )} +
+ ); +}; + +export default SongContainer; diff --git a/client/src/components/SongPage/SongDetails/SongDetails.module.css b/client/src/components/SongPage/SongDetails/SongDetails.module.css new file mode 100644 index 0000000..ffb3032 --- /dev/null +++ b/client/src/components/SongPage/SongDetails/SongDetails.module.css @@ -0,0 +1,57 @@ +.detailsContainer { + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-sm); + border: 2px solid var(--color-panel-border); + color: var(--color-white); + padding: var(--spacing-md); + display: grid; + grid-template-columns: 1fr 2rem 1fr; + column-gap: 0.4rem; + align-items: center; + flex: 1; +} + +.detailsColumn { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + align-items: flex-start; + min-width: 0; + width: 100%; +} + +.detailLabel { + color: var(--color-text-gray); +} + +.detailWrapper { + display: flex; + gap: var(--spacing-sm); + align-items: center; + min-width: 0; + width: 100%; +} + +.detailIcon { + font-size: var(--font-size-lg); + min-width: var(--font-size-lg); + color: var(--color-text-gray); +} + +.detailName { + font-size: var(--font-size-lg); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.genreName { + transition: color var(--transition-speed) ease-in-out; + cursor: pointer; +} + +.genreName:hover { + color: var(--color-red); +} diff --git a/client/src/components/SongPage/SongDetails/SongDetails.tsx b/client/src/components/SongPage/SongDetails/SongDetails.tsx new file mode 100644 index 0000000..896cc55 --- /dev/null +++ b/client/src/components/SongPage/SongDetails/SongDetails.tsx @@ -0,0 +1,45 @@ +import { memo, useMemo } from "react"; +import { formatDateString } from "@util"; +import { VerticalRule } from "@components"; +import styles from "./SongDetails.module.css"; +import classNames from "classnames"; +import { LuMusic, LuCalendar } from "react-icons/lu"; + +export interface SongDetailsProps { + genre: string; + releaseDate: string; +} + +const SongDetails: React.FC = ({ genre, releaseDate }) => { + const formattedDate = useMemo( + () => formatDateString(releaseDate), + [releaseDate] + ); + + return ( +
+
+ Genre +
+ + {/* eventually this should link somewhere... */} + + {genre || "Unknown"} + +
+
+ +
+ Release Date +
+ + + {formattedDate || "Unknown"} + +
+
+
+ ); +}; + +export default memo(SongDetails); diff --git a/client/src/components/SongPage/SongStats/SongStats.module.css b/client/src/components/SongPage/SongStats/SongStats.module.css new file mode 100644 index 0000000..c2ba10f --- /dev/null +++ b/client/src/components/SongPage/SongStats/SongStats.module.css @@ -0,0 +1,32 @@ +.songStatsContainer { + padding: var(--spacing-sm); + background-color: var(--color-panel-gray); + border-radius: var(--border-radius-sm); + border: 2px solid var(--color-panel-border); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); +} + +.statsText { + color: var(--color-text-gray); + font-weight: 500; +} + +.noDataMessage { + color: var(--color-text-gray); + font-size: var(--font-size-sm); + display: flex; + align-items: center; + justify-content: center; + height: 8.33rem; +} + +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + height: 8.33rem; +} diff --git a/client/src/components/SongPage/SongStats/SongStats.tsx b/client/src/components/SongPage/SongStats/SongStats.tsx new file mode 100644 index 0000000..3a5f202 --- /dev/null +++ b/client/src/components/SongPage/SongStats/SongStats.tsx @@ -0,0 +1,114 @@ +import { useMemo, memo, useCallback } from "react"; +import { useAsyncData } from "@hooks"; +import { songApi } from "@api"; +import { PuffLoader } from "react-spinners"; +import { SparkLineChart } from "@mui/x-charts/SparkLineChart"; +import type { SparkLineChartProps } from "@mui/x-charts/SparkLineChart"; +import { + areaElementClasses, + lineElementClasses, +} from "@mui/x-charts/LineChart"; +import { chartsAxisHighlightClasses } from "@mui/x-charts/ChartsAxisHighlight"; +import styles from "./SongStats.module.css"; + +const CHART_HEIGHT = 80; +const CHART_WIDTH = 330; +const CHART_COLOR = "rgb(213, 49, 49)"; + +const CHART_SX = { + [`& .${areaElementClasses.root}`]: { opacity: 0.2 }, + [`& .${lineElementClasses.root}`]: { strokeWidth: 3 }, + [`& .${chartsAxisHighlightClasses.root}`]: { + stroke: CHART_COLOR, + strokeDasharray: "none", + strokeWidth: 2, + }, +}; + +const CHART_SLOT_PROPS = { + lineHighlight: { r: 4 }, +}; + +const CLIP_AREA_OFFSET = { top: 2, bottom: 2 }; + +export interface SongStatsProps { + songId: string; +} + +const SongStats: React.FC = ({ songId }) => { + const { data, loading, error } = useAsyncData( + { + weeklyPlays: () => songApi.getWeeklyPlays(songId), + }, + [songId], + { cacheKey: `weekly_plays_${songId}` } + ); + + const playsData = data?.weeklyPlays || { weeks: [], plays: [] }; + const plays = playsData.plays ?? []; + const weeks = playsData.weeks ?? []; + + const domainLimit = useCallback( + (_: any, maxValue: number) => ({ + min: -maxValue / 6, + max: maxValue, + }), + [] + ); + + const sparkLineSettings = useMemo( + () => ({ + data: plays, + baseline: "min", + xAxis: { id: "week-axis", data: weeks }, + yAxis: { + domainLimit: domainLimit, + }, + sx: CHART_SX, + slotProps: CHART_SLOT_PROPS, + clipAreaOffset: CLIP_AREA_OFFSET, + axisHighlight: { x: "line" }, + }), + [plays, weeks] + ); + + if ( + !playsData || + !plays.length || + !weeks.length || + plays.length == 1 || + weeks.length == 1 || + error + ) { + return ( +
+ Weekly Plays +
No play data available
+
+ ); + } + + return ( +
+ Weekly Plays + {loading ? ( +
+ +
+ ) : ( + + )} +
+ ); +}; + +export default memo(SongStats); diff --git a/client/src/components/SongPage/SongSuggestions/SongSuggestions.module.css b/client/src/components/SongPage/SongSuggestions/SongSuggestions.module.css new file mode 100644 index 0000000..84901bd --- /dev/null +++ b/client/src/components/SongPage/SongSuggestions/SongSuggestions.module.css @@ -0,0 +1,39 @@ +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 25vh; +} + +.suggestionsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.suggestionsWrapper { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.suggestionLabel { + font-size: var(--font-size-md); +} + +.suggestionsSection { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; +} diff --git a/client/src/components/SongPage/SongSuggestions/SongSuggestions.tsx b/client/src/components/SongPage/SongSuggestions/SongSuggestions.tsx new file mode 100644 index 0000000..aea40d5 --- /dev/null +++ b/client/src/components/SongPage/SongSuggestions/SongSuggestions.tsx @@ -0,0 +1,133 @@ +import { useMemo, memo } from "react"; +import { useAsyncData } from "@hooks"; +import { PuffLoader } from "react-spinners"; +import { useAuth } from "@contexts"; +import { songApi, artistApi } from "@api"; +import { formatDateString } from "@util"; +import { EntityItem } from "@components"; +import type { Song, Album, Artist, ArtistSong, SuggestedSong } from "@types"; +import styles from "./SongSuggestions.module.css"; + +export interface SongSuggestionsProps { + song: Song; + mainArtist?: Artist; +} + +const SongSuggestions: React.FC = ({ + song, + mainArtist, +}) => { + const songId = song.id; + const { user, isAuthenticated } = useAuth(); + + const asyncConfig = useMemo( + () => ({ + ...(mainArtist && { + moreSongsByArtist: async () => + artistApi.getSongs(mainArtist.id, { + includeArtists: true, + limit: 5, + }), + }), + suggestedSongs: () => + songApi.getSuggestedSongs(songId, { + userId: isAuthenticated && user ? user.id : undefined, + includeArtists: true, + limit: 5, + }), + }), + [songId, mainArtist, isAuthenticated, user] + ); + + const { data, loading, error } = useAsyncData( + asyncConfig, + [songId, mainArtist], + { cacheKey: `suggestions_song_${songId}`, hasBlobUrl: true } + ); + + if (error) { + return
Failed to load suggestions.
; + } + + const moreSongsByArtist = data?.moreSongsByArtist; + const filteredMoreSongs = useMemo( + () => + moreSongsByArtist?.filter( + (songItem: ArtistSong) => songItem.id !== songId + ) ?? [], + [moreSongsByArtist, songId] + ); + const suggestedSongs = data?.suggestedSongs; + + return loading ? ( +
+ +
+ ) : ( +
+ {song.albums && song.albums.length > 0 && ( +
+ On Albums +
+ {song.albums.map((album: Album) => ( + + ))} +
+
+ )} + + {filteredMoreSongs && filteredMoreSongs.length > 0 && ( +
+ + More by {mainArtist?.display_name} + +
+ {filteredMoreSongs.map((songItem: ArtistSong) => ( + + ))} +
+
+ )} + + {suggestedSongs && suggestedSongs.length > 0 && ( +
+ Related Songs +
+ {suggestedSongs.map((songItem: SuggestedSong) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default memo(SongSuggestions); diff --git a/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.module.css b/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.module.css new file mode 100644 index 0000000..5484f0f --- /dev/null +++ b/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.module.css @@ -0,0 +1,102 @@ +.playerContainer { + display: flex; + gap: var(--spacing-md); + flex: 1; + align-items: center; +} + +.playerPlayBtn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-white); + cursor: pointer; + font-size: var(--icon-size-md); + transition: color var(--transition-speed) ease-in-out; + font-size: 6.4rem; +} + +.playerPlayBtn:hover { + color: var(--color-red); +} + +.playerPlayBtn:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.playerPlayBtnActive { + color: var(--color-red); +} + +.playerPlayBtn svg { + padding: 0; +} + +.skeletonContainer { + position: relative; +} + +.waveWrapper { + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 1fr; + position: relative; + width: 100%; + min-width: 0; + height: 80px; /* WAVEFORM_HEIGHT */ + box-sizing: border-box; +} + +.playerWaveform { + grid-row: 1 / 2; + grid-column: 1 / 2; + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; +} + +.hiddenWaveform { + opacity: 0; + pointer-events: none; +} + +.skeletonWaveform { + grid-row: 1 / 2; + grid-column: 1 / 2; + display: flex; + align-items: center; + gap: 5px; /* BAR_GAP */ + padding: 0 4px; + height: 100%; + box-sizing: border-box; + pointer-events: none; + z-index: 2; + animation: pulse 1.5s ease-in-out infinite; +} + +.skeletonBar { + flex: 0 0 auto; + border-radius: 6px; + background: var(--color-white); + opacity: 0.8; + box-sizing: border-box; +} + +.playerWaveform, +.skeletonWaveform { + overflow: hidden; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.8; + } + 50% { + opacity: 0.2; + } +} diff --git a/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.tsx b/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.tsx new file mode 100644 index 0000000..2257558 --- /dev/null +++ b/client/src/components/SongPage/WaveformPlayer/WaveformPlayer.tsx @@ -0,0 +1,281 @@ +import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react"; +import Hover from "wavesurfer.js/dist/plugins/hover.esm.js"; +import WaveSurfer from "wavesurfer.js"; +import { useElementWidth } from "@hooks"; +import { useAudioQueue } from "@contexts"; +import classNames from "classnames"; +import styles from "./WaveformPlayer.module.css"; +import { LuCirclePause, LuCirclePlay } from "react-icons/lu"; + +const BAR_WIDTH = 3; +const BAR_GAP = 5; +const WAVEFORM_HEIGHT = 80; +const BAR_RADIUS = 6; +const LINE_WIDTH = 2; +const SKELETON_PADDING = 8; + +export interface WaveformPlayerProps { + audioSrc: string; + captureKeyboard?: boolean; + onPlay?: () => void; + disabled?: boolean; +} + +const WaveformPlayer: React.FC = ({ + audioSrc, + captureKeyboard, + onPlay, + disabled, +}) => { + const [isReady, setIsReady] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(true); + + const { state, actions } = useAudioQueue(); + const { isPlaying, progress, duration, currentSong, error } = state; + + const waveformRef = useRef(null); + const wavesurferRef = useRef(null); + const measureRef = useRef(null); + const skeletonBarsRef = useRef([]); + const prevCurrentSongRef = useRef(null); + + const isCurrentSong = currentSong?.audio_url === audioSrc; + + const rawWidth = useElementWidth(measureRef); + const wrapperWidth = useMemo( + () => Math.floor(rawWidth / 10) * 10, + [rawWidth] + ); + + const barSlot = BAR_WIDTH + BAR_GAP; + const skeletonCount = Math.max( + 1, + Math.floor((wrapperWidth - SKELETON_PADDING) / barSlot) + ); + + if (skeletonBarsRef.current.length !== skeletonCount) { + skeletonBarsRef.current = new Array(skeletonCount) + .fill(0) + .map(() => 20 + Math.floor(Math.random() * 80)); + } + + const skeletonBars = skeletonBarsRef.current; + + const togglePlay = useCallback(() => { + if (isCurrentSong) { + if (isPlaying) { + actions.pause(); + } else { + actions.resume(); + } + } else { + onPlay?.(); + } + }, [isCurrentSong, isPlaying, actions, onPlay]); + + const handleReady = useCallback(() => { + setIsReady(true); + setTimeout(() => setShowSkeleton(false), 100); + }, []); + + const handleWaveformClick = useCallback( + (relativeX: number) => { + const ws = wavesurferRef.current; + if (!ws) return; + + if (state.currentSong?.audio_url !== audioSrc) return; + + if (state.duration > 0) { + const seekTime = relativeX * state.duration; + actions.seek(seekTime); + } + }, + [audioSrc, actions] + ); + + const handleError = useCallback((error: any) => { + console.error("WaveSurfer error:", error); + setIsReady(false); + }, []); + + useEffect(() => { + if (!captureKeyboard || !isCurrentSong) return; + + const handleKeyPress = (e: KeyboardEvent) => { + if ( + e.code === "Space" && + e.target instanceof HTMLElement && + !["INPUT", "TEXTAREA"].includes(e.target.tagName) + ) { + e.preventDefault(); + e.stopPropagation(); + togglePlay(); + } + }; + + window.addEventListener("keydown", handleKeyPress, { capture: true }); + return () => + window.removeEventListener("keydown", handleKeyPress, { + capture: true, + }); + }, [captureKeyboard, togglePlay, isCurrentSong]); + + useEffect(() => { + setShowSkeleton(true); + setIsReady(false); + }, [audioSrc]); + + const hoverPlugin = useMemo( + () => + Hover.create({ + lineColor: "#B22323", + lineWidth: LINE_WIDTH, + labelBackground: "#B22323", + labelColor: "#F6F6F6", + labelSize: "11px", + }), + [] + ); + + useEffect(() => { + if (!waveformRef.current || !audioSrc) return; + + const loadAudioWithCORSBypass = async () => { + try { + const ws = WaveSurfer.create({ + container: waveformRef.current!, + height: WAVEFORM_HEIGHT, + waveColor: "#F6F6F6", + progressColor: "#d53131", + barWidth: BAR_WIDTH, + barRadius: BAR_RADIUS, + barGap: BAR_GAP, + normalize: true, + backend: "MediaElement" as const, + sampleRate: 44100, + autoplay: false, + plugins: [hoverPlugin], + }); + + ws.on("ready", handleReady); + ws.on("error", handleError); + wavesurferRef.current = ws; + + const urlParts = audioSrc.split("/"); + const filename = urlParts[urlParts.length - 1].split("?")[0]; + + const proxyUrl = `/api/proxy/audio/${filename}`; + const response = await fetch(proxyUrl); + if (!response.ok) { + throw new Error(`Failed to fetch audio: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + + const audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + const decodedData = await audioContext.decodeAudioData(arrayBuffer); + + const channelsNumber = decodedData.numberOfChannels; + const peaks = + channelsNumber > 1 + ? [decodedData.getChannelData(0), decodedData.getChannelData(1)] + : [decodedData.getChannelData(0)]; + const duration = decodedData.duration; + + ws.load(proxyUrl, peaks, duration); + } catch (error) { + console.error("Failed to load audio:", error); + handleError(error); + } + }; + + loadAudioWithCORSBypass(); + + return () => { + if (wavesurferRef.current) { + wavesurferRef.current.un("ready", handleReady); + wavesurferRef.current.un("error", handleError); + wavesurferRef.current.destroy(); + } + }; + }, [audioSrc, handleReady, handleError, hoverPlugin]); + + useEffect(() => { + const ws = wavesurferRef.current; + if (!ws) return; + + ws.on("click", handleWaveformClick); + return () => { + ws.un("click", handleWaveformClick); + }; + }, [handleWaveformClick]); + + useEffect(() => { + if (!wavesurferRef.current) return; + + const currentAudioUrl = currentSong?.audio_url || null; + const prevAudioUrl = prevCurrentSongRef.current; + + if (currentAudioUrl !== prevAudioUrl) { + prevCurrentSongRef.current = currentAudioUrl; + + if (currentAudioUrl === audioSrc) { + return; + } else { + wavesurferRef.current.seekTo(0); + return; + } + } + + if (isCurrentSong && duration > 0 && progress > 1) { + const progressPercent = progress / duration; + wavesurferRef.current.seekTo(progressPercent); + } + }, [currentSong?.audio_url, audioSrc, isCurrentSong, progress, duration]); + + return ( +
+ + +
+ {showSkeleton && ( + + ); +}; + +export default memo(WaveformPlayer); diff --git a/client/src/components/SongsList/SongsList.module.css b/client/src/components/SongsList/SongsList.module.css new file mode 100644 index 0000000..eb04fcf --- /dev/null +++ b/client/src/components/SongsList/SongsList.module.css @@ -0,0 +1,55 @@ +.loaderContainer { + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + min-height: 15vh; +} + +.error { + text-align: center; + color: var(--color-text-gray); + font-size: var(--font-size-sm); +} + +.songsContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; + width: 100%; +} + +.sectionHeader { + display: flex; + align-items: flex-end; + justify-content: space-between; +} + +.viewMoreLink { + font-size: var(--font-size-sm); + color: var(--color-text-gray) !important; + text-decoration: none; +} + +.viewMoreLink:hover { + color: var(--color-white) !important; +} + +.sectionTitle { + font-size: var(--font-size-lg); + color: var(--color-white); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; +} + +.songsList { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-width: 0; +} diff --git a/client/src/components/SongsList/SongsList.tsx b/client/src/components/SongsList/SongsList.tsx new file mode 100644 index 0000000..fc247b0 --- /dev/null +++ b/client/src/components/SongsList/SongsList.tsx @@ -0,0 +1,90 @@ +import { memo, useMemo } from "react"; +import { PuffLoader } from "react-spinners"; +import type { Song } from "@types"; +import { useAsyncData } from "@hooks"; +import { EntityItem } from "@components"; +import { getMainArtist } from "@util"; +import styles from "./SongsList.module.css"; +import { Link } from "react-router-dom"; + +export interface SongsListProps { + title: string; + fetchData: () => Promise; + cacheKey: string; + dependencies?: any[]; + viewMoreLink?: string; +} + +const SongsList: React.FC = ({ + title, + fetchData, + cacheKey, + dependencies = [], + viewMoreLink, +}) => { + const asyncConfig = useMemo( + () => ({ + songs: fetchData, + }), + [fetchData] + ); + + const { data, loading, error } = useAsyncData(asyncConfig, dependencies, { + cacheKey, + hasBlobUrl: true, + }); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
Failed to load {title.toLowerCase()}.
+ ); + } + + const songs = data?.songs; + + if (!songs || songs.length === 0) { + return null; + } + + return ( +
+ {viewMoreLink ? ( +
+

{title}

+ + View More + +
+ ) : ( +

{title}

+ )} + +
+ {songs.map((song, i) => ( + + ))} +
+
+ ); +}; + +export default memo(SongsList); diff --git a/client/src/components/index.ts b/client/src/components/index.ts index e69de29..f7c0482 100644 --- a/client/src/components/index.ts +++ b/client/src/components/index.ts @@ -0,0 +1,59 @@ +/* ================================= Layout ================================= */ +export { default as HorizontalRule } from "./Layout/HorizontalRule/HorizontalRule.js"; +export { default as VerticalRule } from "./Layout/VerticalRule/VerticalRule.js"; + +/* =============================== MainLayout =============================== */ +export { default as MainLayout } from "./MainLayout/MainLayout.js"; +export { default as MainLayoutHeader } from "./MainLayout/MainLayoutHeader/MainLayoutHeader.js"; +export { default as MainLayoutSidebar } from "./MainLayout/MainLayoutSidebar/MainLayoutSidebar.js"; +export { default as MainLayoutSearchBar } from "./MainLayout/MainLayoutSearchBar/MainLayoutSearchBar.js"; +export { default as MainLayoutNowPlayingBar } from "./MainLayout/MainLayoutNowPlayingBar/MainLayoutNowPlayingBar.js"; + +/* ================================== Forms ================================= */ +export { default as InputGroup } from "./Forms/InputGroup/InputGroup.js"; +export { default as FormSubmitButton } from "./Forms/FormButton/FormSubmitButton.js"; + +/* ================================= Routes ================================= */ +export { default as ProtectedRoute } from "./ProtectedRoute/ProtectedRoute.js"; +export { default as AppLayout } from "./AppLayout/AppLayout.js"; + +/* ================================ SongPage ================================ */ +export { default as WaveformPlayer } from "./SongPage/WaveformPlayer/WaveformPlayer.js"; +export { default as SongContainer } from "./SongPage/SongContainer/SongContainer.js"; +export { default as SongStats } from "./SongPage/SongStats/SongStats.js"; +export { default as SongDetails } from "./SongPage/SongDetails/SongDetails.js"; +export { default as SongActions } from "./SongPage/SongActions/SongActions.js"; +export { default as ArtistInfo } from "./SongPage/ArtistInfo/ArtistInfo.js"; +export { default as SongComments } from "./SongPage/SongComments/SongComments.js"; +export { default as CommentItem } from "./SongPage/CommentItem/CommentItem.js"; +export { default as SongSuggestions } from "./SongPage/SongSuggestions/SongSuggestions.js"; + +/* =============================== ArtistPage =============================== */ +export { default as ArtistBanner } from "./ArtistPage/ArtistBanner/ArtistBanner.js"; +export { default as RelatedArtists } from "./ArtistPage/RelatedArtists/RelatedArtists.js"; +export { default as ArtistActions } from "./ArtistPage/ArtistActions/ArtistActions.js"; +export { default as ArtistPlaylists } from "./ArtistPage/ArtistPlaylists/ArtistPlaylists.js"; +export { default as ArtistAbout } from "./ArtistPage/ArtistAbout/ArtistAbout.js"; + +/* ================================== Lists ================================= */ +export { default as SongsList } from "./SongsList/SongsList.js"; +export { default as FollowProfiles } from "./FollowProfiles/FollowProfiles.js"; + +/* ================================== Cards ================================== */ +export { default as SongCard } from "./SongCard/SongCard.js"; +export { default as EntityItem } from "./EntityItem/EntityItem.js"; +export { default as EntityItemCard } from "./EntityItemCard/EntityItemCard.js"; +export { default as SlidingCardList } from "./SlidingCardList/SlidingCardList.js"; + +/* ================================= Modals ================================= */ +export { default as ShareModal } from "./ShareModal/ShareModal.js"; +export { default as QueueMenu } from "./QueueMenu/QueueMenu.js"; +export { default as CoverLightbox } from "./CoverLightbox/CoverLightbox.js"; +export { default as QueueManager } from "./MainLayout/QueueManager/QueueManager.js"; + +/* =============================== Page States ============================== */ +export { default as PageLoader } from "./PageLoader/PageLoader.js"; +export { default as ErrorPage } from "./ErrorPage/ErrorPage.js"; + +/* ================================ Dev Tools =============================== */ +export { default as DevBanner } from "./DevBanner/DevBanner.js"; diff --git a/client/src/hooks/useAsyncData.ts b/client/src/hooks/useAsyncData.ts new file mode 100644 index 0000000..e69de29 diff --git a/client/src/pages/ArtistPage/ArtistPage.module.css b/client/src/pages/ArtistPage/ArtistPage.module.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/pages/ArtistPage/ArtistPage.tsx b/client/src/pages/ArtistPage/ArtistPage.tsx new file mode 100644 index 0000000..390e71e --- /dev/null +++ b/client/src/pages/ArtistPage/ArtistPage.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import Topbar from "../../components/TopBar/topBar"; +import Sidebar from "../../components/SideBar/sidebar"; +import PlayerBar from "../../components/PlayerBar/playerBar"; +import homeStyles from "../HomePage/HomePage.module.css"; + +const ArtistPage: React.FC = () => { + const { id } = useParams(); + + return ( + <> + + +
+
+
+
+

Artist

+
+

Artist ID: {id}

+
+
+
+ + + ); +}; + +export default ArtistPage; diff --git a/client/src/pages/HomePage/HomePage.module.css b/client/src/pages/HomePage/HomePage.module.css new file mode 100644 index 0000000..2f4efc1 --- /dev/null +++ b/client/src/pages/HomePage/HomePage.module.css @@ -0,0 +1,158 @@ +/* Reset and variables */ +:root { + --sidebar-width: 80px; + --topbar-height: 64px; + --player-height: 90px; + --content-padding: 32px; + --background-dark: #0d0d0d; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; +} + +/* Main content area */ +.contentArea { + position: fixed; + top: var(--topbar-height); + left: var(--sidebar-width); + right: 0; + bottom: var(--player-height); + overflow-y: auto; + background: var(--background-dark); + color: var(--text-primary); +} + +/* Content wrapper with padding */ +.contentWrapper { + padding: var(--content-padding); + display: flex; + flex-direction: column; + gap: 48px; + max-width: 1800px; + margin: 0 auto; +} + +/* Section styling */ +.section { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Section header row */ +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-right: 8px; +} + +.sectionTitle { + font-size: 24px; + font-weight: 600; /* semi-bold */ + font-family: 'Inter', sans-serif; + margin: 0; +} + +.viewMore { + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + transition: color 0.2s ease; + font-family: 'Inter', sans-serif; + font-weight: 600; /* semi-bold */ +} + +/* Horizontal scrollable container */ +.cardsContainer { + display: flex; + gap: 24px; + overflow-x: auto; + padding: 4px 0; + /* Hide scrollbar but keep functionality */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.cardsContainer::-webkit-scrollbar { + display: none; +} + +.topGrid { + display: grid; + grid-template-columns: 65% 35%; + gap: 40px; + margin-bottom: 48px; +} + +/* Featured section adjustments */ +.featuredSection { + height: 100%; + min-height: 400px; + padding: 40px; + background: linear-gradient(90deg, #ff3b30 0%, #ff6a4a 100%); + border-radius: 16px; + display: flex; + flex-direction: column; + justify-content: center; +} + +/* Recently played column */ +.recentlyPlayedColumn { + display: flex; + flex-direction: column; + gap: 24px; + height: 100%; +} + +/* Vertical scrollable list for recently played */ +.verticalCardsList { + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + height: calc(400px - 48px); /* Match featured height minus header */ + padding-right: 16px; +} + +/* Compact song card style for sidebar */ +.compactCard { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; + display: flex; + align-items: center; + gap: 12px; + transition: background-color 0.2s ease; +} + +.compactCard:hover { + background: rgba(255, 255, 255, 0.1); +} + +.compactCard img { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; +} + +.compactCard .songInfo { + flex: 1; + min-width: 0; +} + +.compactCard .songTitle { + font-size: 14px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: 'Inter', sans-serif; /* ensure Inter font */ +} + +.compactCard .artistName { + font-size: 12px; + color: var(--text-secondary); + margin: 4px 0 0; +} \ No newline at end of file diff --git a/client/src/pages/HomePage/HomePage.tsx b/client/src/pages/HomePage/HomePage.tsx new file mode 100644 index 0000000..6ccc985 --- /dev/null +++ b/client/src/pages/HomePage/HomePage.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import styles from "./HomePage.module.css"; +import Sidebar from "../../components/SideBar/sidebar"; +import Topbar from "../../components/TopBar/topBar"; +import PlayerBar from "../../components/PlayerBar/playerBar"; +import SongCard from "../../components/SongCard/SongCard"; + +// Local FeaturedSection component to replace missing import/file +function FeaturedSection({ + title, + description, + image, + likes, + tracks, + duration, +}: { + title: string; + description: string; + image: string; + likes: number; + tracks: number; + duration: string; +}) { + return ( +
+

{title}

+

{description}

+
+ {title} +
+

Likes: {likes.toLocaleString()}

+

Tracks: {tracks}

+

Duration: {duration}

+
+
+
+ ); +} + +const HomePage: React.FC = () => { + // Mock data + const recentSongs = Array(8).fill({ + title: "Song Title", + artist: "Artist Name", + image: "/PlayerBar/Mask group.png", + plays: 1234, + likes: 234, + comments: 12, + }); + + const newSongs = Array(5).fill({ + title: "New Release", + artist: "Artist Name", + image: "/PlayerBar/Mask group.png", + }); + + return ( + <> + + + +
+
+ {/* Top Grid Layout */} +
+ {/* Featured Column */} +
+ +
+ + {/* Recently Played Column */} +
+
+

Recently Played

+ View More +
+
+ {recentSongs.map((song, index) => ( +
+ {song.title} +
+

{song.title}

+

{song.artist}

+
+
+ ))} +
+
+
+ + {/* New Releases Section - Full Width */} +
+
+

New Releases

+ View More +
+
+ {newSongs.map((song, index) => ( + + ))} +
+
+
+
+ + + + ); +}; + +export default HomePage; \ No newline at end of file diff --git a/client/src/pages/SearchResultsPage/SearchResultsPage.module.css b/client/src/pages/SearchResultsPage/SearchResultsPage.module.css new file mode 100644 index 0000000..b6f9617 --- /dev/null +++ b/client/src/pages/SearchResultsPage/SearchResultsPage.module.css @@ -0,0 +1,47 @@ +.container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.header { + margin-bottom: 24px; +} + +.filters { + display: flex; + gap: 16px; + margin-bottom: 24px; +} + +.results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 24px; +} + +.noResults { + text-align: center; + padding: 48px 0; + color: #666; +} + +.filterButton { + padding: 8px 16px; + border: 1px solid #ddd; + border-radius: 20px; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.filterButton.active { + background: #1db954; + color: white; + border-color: #1db954; +} + +.loading { + font-family: 'Inter', sans-serif; + font-weight: 600; /* semi-bold */ +} diff --git a/client/src/pages/SearchResultsPage/SearchResultsPage.tsx b/client/src/pages/SearchResultsPage/SearchResultsPage.tsx new file mode 100644 index 0000000..547c4e1 --- /dev/null +++ b/client/src/pages/SearchResultsPage/SearchResultsPage.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, Link } from "react-router-dom"; +import homeStyles from "../HomePage/HomePage.module.css"; +import styles from "./SearchResultsPage.module.css"; +import Sidebar from "../../components/SideBar/sidebar"; +import Topbar from "../../components/TopBar/topBar"; +import PlayerBar from "../../components/PlayerBar/playerBar"; +import SongCard from "../../components/SongCard/SongCard"; +import { fetchSearch, type SearchResponse } from "../../api/search.api"; + +export default function SearchResultsPage() { + const [searchParams] = useSearchParams(); + const query = searchParams.get("q") ?? ""; + + const [results, setResults] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!query.trim()) return; + + const loadResults = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchSearch(query, "all", 20, 0); + setResults(data); + } catch (err) { + console.error("Search failed:", err); + setError("Failed to load search results. Please try again."); + } finally { + setLoading(false); + } + }; + + loadResults(); + }, [query]); + + const songs = results.songs ?? []; + const artists = results.artists ?? []; + const albums = results.albums ?? []; + const playlists = results.playlists ?? []; + + return ( + <> + + + + +
+
+ {loading &&

Loading...

} + {error &&

{error}

} + + {!loading && !error && ( + <> +
+
+

Songs

+
+
+ {songs.map((song, index) => ( +
+ {song.title} +
+

{song.title}

+

+ {song.artists && song.artists.length > 0 ? ( + song.artists.map((a, i) => ( + + {a.name} + {i < song.artists!.length - 1 ? ", " : ""} + + )) + ) : ( + song.artist || "" + )} +

+
+
+ ))} +
+
+ + {/* Artists Section */} +
+
+

Artists

+
+
+ {artists.map((artist, index) => ( + + ))} +
+
+ + {/* Albums Section */} +
+
+

Albums

+
+
+ {albums.map((album, index) => ( + + ))} +
+
+ + {/* Playlists Section */} +
+
+

Playlists

+
+
+ {playlists.map((pl, index) => ( + + ))} +
+
+ + )} +
+
+ + + + ); +} diff --git a/client/src/pages/TestPage/TestPage.tsx b/client/src/pages/TestPage/TestPage.tsx index f45f713..8d87d0e 100644 --- a/client/src/pages/TestPage/TestPage.tsx +++ b/client/src/pages/TestPage/TestPage.tsx @@ -33,12 +33,12 @@ const TestPage: React.FC = () => { Test Page - +

Test Page

- +