diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..31f9ec9 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + tsconfigRootDir: __dirname, + project: ["tsconfig.json"], + sourceType: "module", + }, + ignorePatterns: ["/lib/**/*"], + plugins: ["@typescript-eslint", "import"], + rules: { + quotes: ["error", "double"], + "import/no-unresolved": "off", + "no-case-declarations": "off" + }, +}; diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index b005742..88ff350 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -16,6 +16,8 @@ env: REACT_APP_MESSAGING_SENDER_ID: ${{secrets.REACT_APP_MESSAGING_SENDER_ID}} REACT_APP_APP_ID: ${{secrets.REACT_APP_APP_ID}} REACT_APP_DB_DATA_PATH: ${{secrets.REACT_APP_DB_DATA_PATH}} + REACT_APP_STREAM_COLLECTION_NAME: ${{secrets.REACT_APP_STREAM_COLLECTION_NAME}} + REACT_APP_STREAMER_COLLECTION_NAME: ${{secrets.REACT_APP_STREAMER_COLLECTION_NAME}} CI: false jobs: diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 961496b..d607c13 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -13,6 +13,8 @@ env: REACT_APP_MESSAGING_SENDER_ID: ${{secrets.REACT_APP_MESSAGING_SENDER_ID}} REACT_APP_APP_ID: ${{secrets.REACT_APP_APP_ID}} REACT_APP_DB_DATA_PATH: ${{secrets.REACT_APP_DB_DATA_PATH}} + REACT_APP_STREAM_COLLECTION_NAME: ${{secrets.REACT_APP_STREAM_COLLECTION_NAME}} + REACT_APP_STREAMER_COLLECTION_NAME: ${{secrets.REACT_APP_STREAMER_COLLECTION_NAME}} CI: false jobs: diff --git a/.gitignore b/.gitignore index a8328a2..4d91862 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,4 @@ npm-debug.log* yarn-debug.log* -yarn-error.log* -*.log \ No newline at end of file +yarn-error.log* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d99432f..bf223ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,11 @@ }, "files.eol": "\n", "cSpell.words": [ + "easings", "Firestore", + "HHMM", + "hoverable", + "Vspo", "vssdb" ] } diff --git a/@types/index.d.ts b/@types/index.d.ts deleted file mode 100644 index f4b70a8..0000000 --- a/@types/index.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module "*.png"; - -declare module "process" { - global { - namespace NodeJS { - interface ProcessEnv { - REACT_APP_API_KEY: string; - REACT_APP_AUTH_DOMAIN: string; - REACT_APP_DATABASE_URL: string; - REACT_APP_PROJECT_ID: string; - REACT_APP_STORAGE_BUCKET: string; - REACT_APP_MESSAGING_SENDER_ID: string; - REACT_APP_APP_ID: string; - REACT_APP_DB_DATA_PATH: string; - } - } - } -} diff --git a/firebase.json b/firebase.json index 5d98b3c..57facbc 100644 --- a/firebase.json +++ b/firebase.json @@ -21,7 +21,8 @@ "ignore": [ "firebase.json", "**/.*", - "**/node_modules/**" + "**/node_modules/**", + "**/functions/**" ] }, "emulators": { diff --git a/package-lock.json b/package-lock.json index 917cb39..5fb1ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,37 @@ { "name": "vspo-stream-schedule", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vspo-stream-schedule", - "version": "1.0.0", + "version": "2.0.0", "dependencies": { "@react-spring/web": "^9.7.2", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", "firebase": "^9.22.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.9.0", - "react-merge-refs": "^2.0.2", "react-scripts": "5.0.1", + "react-transition-group": "^4.4.5", + "styled-components": "^5.3.10", "web-vitals": "^2.1.4" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/react-transition-group": "^4.4.11", "@types/styled-components": "^5.1.26", - "styled-components": "^5.3.10", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.3.3", "user-agent-data-types": "^0.3.1" } }, @@ -38,7 +46,8 @@ "node_modules/@adobe/css-tools": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==" + "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==", + "dev": true }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -2262,7 +2271,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", - "dev": true, "dependencies": { "@emotion/memoize": "^0.8.1" } @@ -2270,20 +2278,17 @@ "node_modules/@emotion/memoize": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", - "dev": true + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/stylis": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", - "dev": true + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" }, "node_modules/@emotion/unitless": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "dev": true + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", @@ -3297,6 +3302,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.5.0.tgz", "integrity": "sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==", + "dev": true, "dependencies": { "jest-get-type": "^29.4.3" }, @@ -3308,6 +3314,7 @@ "version": "29.4.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -3822,6 +3829,18 @@ "node": ">= 8" } }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -4314,6 +4333,7 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz", "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==", + "dev": true, "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -4333,6 +4353,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "peer": true, "dependencies": { "color-convert": "^2.0.1" @@ -4348,6 +4369,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "peer": true, "dependencies": { "ansi-styles": "^4.1.0", @@ -4364,6 +4386,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "peer": true, "dependencies": { "color-name": "~1.1.4" @@ -4376,12 +4399,14 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "peer": true }, "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "peer": true, "engines": { "node": ">=8" @@ -4391,6 +4416,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "peer": true, "dependencies": { "has-flag": "^4.0.0" @@ -4403,6 +4429,7 @@ "version": "5.16.5", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, "dependencies": { "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", @@ -4424,6 +4451,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4438,6 +4466,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4450,6 +4479,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4460,12 +4490,14 @@ "node_modules/@testing-library/jest-dom/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4474,6 +4506,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4485,6 +4518,7 @@ "version": "13.4.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", + "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.5.0", @@ -4502,6 +4536,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", "integrity": "sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4520,6 +4555,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4534,6 +4570,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4549,6 +4586,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4559,12 +4597,14 @@ "node_modules/@testing-library/react/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@testing-library/react/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4573,6 +4613,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4584,6 +4625,7 @@ "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "dev": true, "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -4614,7 +4656,8 @@ "node_modules/@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", - "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==" + "integrity": "sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==", + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.1", @@ -4788,6 +4831,7 @@ "version": "29.5.2", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.2.tgz", "integrity": "sha512-mSoZVJF5YzGVCk+FsDxzDuH7s+SCkzrgKZzf0Z0T2WudhBUPoF6ktoTPC4R0ZoCPCV5xUvuU6ias5NvxcBcMMg==", + "dev": true, "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -4797,6 +4841,7 @@ "version": "29.4.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz", "integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==", + "dev": true, "dependencies": { "@sinclair/typebox": "^0.25.16" }, @@ -4808,6 +4853,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.5.0.tgz", "integrity": "sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==", + "dev": true, "dependencies": { "@jest/schemas": "^29.4.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -4823,12 +4869,14 @@ "node_modules/@types/jest/node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", - "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==" + "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", + "dev": true }, "node_modules/@types/jest/node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4837,6 +4885,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4851,6 +4900,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4866,6 +4916,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4876,12 +4927,14 @@ "node_modules/@types/jest/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@types/jest/node_modules/diff-sequences": { "version": "29.4.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -4890,6 +4943,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.5.0.tgz", "integrity": "sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==", + "dev": true, "dependencies": { "@jest/expect-utils": "^29.5.0", "jest-get-type": "^29.4.3", @@ -4905,6 +4959,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4913,6 +4968,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz", "integrity": "sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.4.3", @@ -4927,6 +4983,7 @@ "version": "29.4.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -4935,6 +4992,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz", "integrity": "sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.5.0", @@ -4949,6 +5007,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.5.0.tgz", "integrity": "sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.5.0", @@ -4968,6 +5027,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz", "integrity": "sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==", + "dev": true, "dependencies": { "@jest/types": "^29.5.0", "@types/node": "*", @@ -4984,6 +5044,7 @@ "version": "29.5.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, "dependencies": { "@jest/schemas": "^29.4.3", "ansi-styles": "^5.0.0", @@ -4997,6 +5058,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -5007,12 +5069,14 @@ "node_modules/@types/jest/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true }, "node_modules/@types/jest/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -5058,7 +5122,8 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "dev": true }, "node_modules/@types/q": { "version": "1.5.5", @@ -5079,6 +5144,7 @@ "version": "18.2.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.8.tgz", "integrity": "sha512-lTyWUNrd8ntVkqycEEplasWy2OxNlShj3zqS0LuB1ENUGis5HodmhM7DtCoUGbxj3VW/WsGA0DUhpG6XrM7gPA==", + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5089,6 +5155,16 @@ "version": "18.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "dev": true, "dependencies": { "@types/react": "*" } @@ -5109,7 +5185,8 @@ "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "dev": true }, "node_modules/@types/semver": { "version": "7.5.0", @@ -5170,6 +5247,7 @@ "version": "5.14.6", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.6.tgz", "integrity": "sha512-FkHXCb+ikSoUP4Y4rOslzTdX5sqYwMxfefKh1GmZ8ce1GOkEHntSp6b5cGadmNfp5e4BMEWOMx+WSKd5/MqlDA==", + "dev": true, "dependencies": { "@types/jest": "*" } @@ -6208,7 +6286,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.3.tgz", "integrity": "sha512-jBioLwBVHpOMU4NsueH/ADcHrjS0Y/WTpt2eGVmmuSFNEv2DF3XhcMncuZlbbjxQ4vzxg+yEr6E6TNjrIQbsJQ==", - "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-module-imports": "^7.21.4", @@ -6223,8 +6300,7 @@ "node_modules/babel-plugin-syntax-jsx": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", - "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==", - "dev": true + "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" }, "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", @@ -6547,7 +6623,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6991,7 +7066,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "dev": true, "engines": { "node": ">=4" } @@ -7181,7 +7255,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "dev": true, "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", @@ -7222,7 +7295,8 @@ "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "node_modules/cssdb": { "version": "7.6.0", @@ -7630,7 +7704,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -7640,6 +7715,15 @@ "utila": "~0.4" } }, + "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==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -8095,6 +8179,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-config-react-app": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", @@ -8296,6 +8392,36 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.32.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", @@ -8834,6 +8960,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -9690,7 +9822,6 @@ "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==", - "dev": true, "dependencies": { "react-is": "^16.7.0" } @@ -9698,8 +9829,7 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hoopy": { "version": "0.1.4", @@ -10035,6 +10165,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "engines": { "node": ">=8" } @@ -12858,6 +12989,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "bin": { "lz-string": "bin/bin.js" } @@ -13004,6 +13136,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "engines": { "node": ">=4" } @@ -15012,6 +15145,33 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -15432,15 +15592,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "node_modules/react-merge-refs": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-2.0.2.tgz", - "integrity": "sha512-V5BGTwGa2r+/t0A/BZMS6L7VPXY0CU8xtAhkT3XUoI1WJJhhtvulvoiZkJ5Jt9YAW23m4xFWmhQ+C5HwjtTFhQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -15521,6 +15672,21 @@ } } }, + "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==", + "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/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -15568,6 +15734,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -16247,8 +16414,7 @@ "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "dev": true + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -16619,6 +16785,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -16656,7 +16823,6 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.10.tgz", "integrity": "sha512-3kSzSBN0TiCnGJM04UwO1HklIQQSXW7rCARUk+VyMR7clz8XVlA3jijtf5ypqoDIdNMKx3la4VvaPFR855SFcg==", - "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.0.0", "@babel/traverse": "^7.4.5", @@ -16888,6 +17054,22 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/synckit": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/tailwindcss": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", @@ -17192,9 +17374,9 @@ } }, "node_modules/tslib": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", - "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index ea82f01..550456a 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,16 @@ { "name": "vspo-stream-schedule", - "version": "1.0.0", + "version": "2.0.0", "private": true, "dependencies": { "@react-spring/web": "^9.7.2", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", "firebase": "^9.22.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.9.0", - "react-merge-refs": "^2.0.2", "react-scripts": "5.0.1", + "react-transition-group": "^4.4.5", + "styled-components": "^5.3.10", "web-vitals": "^2.1.4" }, "scripts": { @@ -41,8 +39,18 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/react-transition-group": "^4.4.11", "@types/styled-components": "^5.1.26", - "styled-components": "^5.3.10", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.3.3", "user-agent-data-types": "^0.3.1" } } diff --git a/src/App.tsx b/src/App.tsx index ab54022..f190635 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,22 @@ import React from "react"; +import { MainContainer } from "./components"; import { - VspoStreamingProvider, - WindowSizeProvider, + DisplaySizeProvider, + SettingProvider, ThemeProvider, - MainContainer, - ConfigProvider, - Background, -} from "./components"; + VspoStreamProvider, +} from "./providers"; export const App: React.FC = () => { return ( - - - - - - - - - - - + + + + + + + + + ); }; diff --git a/src/Firebase.ts b/src/Firebase.ts index a291e65..b05a722 100644 --- a/src/Firebase.ts +++ b/src/Firebase.ts @@ -1,5 +1,5 @@ import { initializeApp } from "firebase/app"; -import { getDatabase } from "firebase/database"; +import { getFirestore } from "firebase/firestore"; const firebaseConfig = { apiKey: process.env.REACT_APP_API_KEY, @@ -12,4 +12,4 @@ const firebaseConfig = { }; const app = initializeApp(firebaseConfig); -export const database = getDatabase(app); +export const firestore = getFirestore(app); diff --git a/src/colors/common.ts b/src/colors/common.ts deleted file mode 100644 index a59e16d..0000000 --- a/src/colors/common.ts +++ /dev/null @@ -1,7 +0,0 @@ -// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/colors/common.js -const common = { - black: "#000", - white: "#fff", -}; - -export default common; diff --git a/src/colors/index.ts b/src/colors/index.ts deleted file mode 100644 index 3416007..0000000 --- a/src/colors/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as common } from "./common"; -export { default as grey } from "./grey"; -export { default as pink } from "./pink"; -export { default as blue } from "./blue"; -export { default as icon } from "./icon"; diff --git a/src/components/Background.tsx b/src/components/Background.tsx deleted file mode 100644 index 61af0b0..0000000 --- a/src/components/Background.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { ChildrenNode } from "../types"; - -const Container = styled.div` - height: 100svh; - width: 100svw; - background-color: ${(p) => p.theme.bg.primary}; - transition: background-color 0.3s ease; -`; - -export const Background = React.memo(({ children, ...props }) => { - return {children}; -}); diff --git a/src/components/DateBorder.tsx b/src/components/DateBorder.tsx deleted file mode 100644 index 60dbfd2..0000000 --- a/src/components/DateBorder.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useEffect } from "react"; -import styled from "styled-components"; -import { animated, useSpring, useSpringRef } from "@react-spring/web"; -import { DateBorderProps, ColorLevel } from "../types"; -import { getFormattedDate, parseToJST } from "../utils"; -import { springConfig } from "../configs"; - -const Container = styled.div` - display: flex; - height: 50px; -`; - -const Icon = styled.div` - display: flex; - gap: 5px; - margin-top: auto; - width: 30px; - aspect-ratio: 1; -`; - -const Bar = styled(animated.div)<{ type: keyof ColorLevel }>` - margin-top: auto; - width: 5px; - background-color: ${(p) => p.theme.vspo[p.type]}; - transition: background-color 0.3s ease; -`; - -const DateLabel = styled.div` - font-size: 48px; - font-family: "Itim", cursive; - letter-spacing: -0.03em; - margin-top: 5px; -`; - -export const DateBorder = React.memo( - ({ dateString, ...props }) => { - const parseToViewDate = (dateString: string) => { - const today = parseToJST(Date.now()); - if (getFormattedDate(today) === dateString) { - return "Today"; - } - - const tomorrow = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() + 1 - ); - if (getFormattedDate(tomorrow) === dateString) { - return "Tomorrow"; - } - - const yesterday = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() - 1 - ); - if (getFormattedDate(yesterday) === dateString) { - return "Yesterday"; - } - - return dateString; - }; - - const calcHeight = (max: number) => max - 7 * Math.random(); - - const animApi = useSpringRef(); - const { lh, mh, rh } = useSpring({ - ref: animApi, - from: { - lh: 0, - mh: 0, - rh: 0, - }, - to: { - lh: calcHeight(30), - mh: calcHeight(20), - rh: calcHeight(16), - }, - delay: 200, - config: springConfig, - }); - - useEffect(() => { - animApi.start(); - }, []); - - return ( - - - - - - - {parseToViewDate(dateString)} - - ); - } -); diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 90a65f2..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { SettingMenu } from "./settingMenu"; -import { breakpoints } from "../configs"; -import logo from "../logo.png"; -import { useWindowSize } from "../hooks"; - -const Container = styled.div` - width: 100%; - height: 80px; - // position: sticky; - // top: 0; - // left: 0; - display: flex; - align-items: center; - - ${breakpoints.mediaQueries.md` - height: 100px; - `} -`; - -const Title = styled.div<{ isPhone: boolean }>` - width: 100%; - display: flex; - justify-content: center; - margin-left: ${(p) => (p.isPhone ? "40px" : "0px")}; - - ${breakpoints.mediaQueries.md` - justify-content: start; - `} -`; - -const Icon = styled.img` - width: 60px; - height: 60px; - - ${breakpoints.mediaQueries.md` - width: 50px; - height: 50px; - `} -`; - -const TitleText = styled.div` - margin-left: 10px; - margin-top: 8px; - font-size: 28px; - font-family: "Itim", cursive; - letter-spacing: -0.05em; -`; - -const Wrapper = styled.div` - width: 40px; - display: flex; - justify-content: flex-end; -`; - -export const Header: React.FC = () => { - const { isPhoneSize } = useWindowSize(); - return ( - - - <Icon src={logo} alt="logo" /> - {!isPhoneSize && <TitleText>Vspo stream schedule</TitleText>} - - - - - - ); -}; diff --git a/src/components/MainContainer.tsx b/src/components/MainContainer.tsx deleted file mode 100644 index afce430..0000000 --- a/src/components/MainContainer.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { breakpoints } from "../configs"; -import { StreamingTable } from "./StreamingTable"; -import { DateBorder } from "./DateBorder"; -import { useConfig, useVspoStreams } from "../hooks"; -import { StreamInfo, StreamList } from "../types"; -import { Header } from "./Header"; - -const Container = styled.div` - margin: 0 auto; - background: rgba(240, 240, 240, 0.03); - box-shadow: 0px 0px 4px 4px rgba(0, 0, 0, 0.2); - color: ${(p) => p.theme.text.primary}; - transition: color 0.3s ease; - - height: 100%; - overflow: scroll; - -ms-overflow-style: none; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - ${breakpoints.mediaQueries.sm` - width: ${breakpoints.values.sm}px; - `} - - ${breakpoints.mediaQueries.md` - width: ${breakpoints.values.md}px; - `} - - ${breakpoints.mediaQueries.lg` - width: ${breakpoints.values.lg}px; - `} - - ${breakpoints.mediaQueries.xl` - width: ${breakpoints.values.xl}px; - `} - - ${breakpoints.mediaQueries.xxl` - width: ${breakpoints.values.xxl}px; - `} -`; - -const InnerContainer = styled.div` - margin: 0 auto; - width: 90%; - - ${breakpoints.mediaQueries.sm` - width: 88%; - `} - - ${breakpoints.mediaQueries.lg` - width: 73%; - `} - - ${breakpoints.mediaQueries.xl` - width: 88%; - `} -`; - -const Spacer = styled.div` - height: 20px; -`; - -const TableContainer = styled.div` - width: 100%; - margin: 0 auto; - padding-bottom: 40px; -`; - -export const MainContainer = React.memo(() => { - const streams = useVspoStreams(); - const { config } = useConfig(); - - const parseToStreamList = (streams: StreamInfo[]): StreamList[] => { - const dateSet = new Set(streams.map((s) => s.scheduledDate)); - const streamList = [...dateSet].map((date) => ({ - date, - streams: streams.filter((s) => s.scheduledDate === date), - })); - - return streamList.sort((a, b) => (a.date > b.date ? 1 : -1)); - }; - - return ( - - -
- {parseToStreamList(streams).map((s) => ( - - - - - - ))} - - - ); -}); diff --git a/src/components/StreamingTable.tsx b/src/components/StreamingTable.tsx deleted file mode 100644 index 223b749..0000000 --- a/src/components/StreamingTable.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useMemo, useLayoutEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { StreamingTableProps } from "../types"; -import { StreamingCard } from "./card"; -import { useWindowSize } from "../hooks"; -import { breakpoints } from "../configs"; - -const Container = styled.div<{ height: number }>` - min-height: ${(p) => p.height}px; - display: flex; - flex-direction: row; - justify-content: space-between; -`; - -const FlexBox = styled.div` - display: flex; - flex-direction: column; - gap: 20px; - - ${breakpoints.mediaQueries.md` - gap: 40px; - `} -`; - -export const StreamingTable: React.FC = React.memo( - ({ streams }) => { - const container = useRef(null!); - const { width, isPhoneSize } = useWindowSize(); - const [rowNum, setRowNum] = useState(0); - - const cardSize = useMemo( - () => ({ - width: isPhoneSize ? 160 : 320, - height: isPhoneSize ? 90 : 180, - headerHeight: isPhoneSize ? 30 : 60, - }), - [isPhoneSize] - ); - - useLayoutEffect(() => { - setRowNum( - Math.floor((container.current.offsetWidth ?? 0) / (5 + cardSize.width)) - ); - }, [width]); - - const sortedStreams = streams.sort((a, b) => - a.startAt + a.name > b.startAt + b.name ? 1 : -1 - ); - - const columnNum = Math.ceil(streams.length / rowNum); - const height = - columnNum * cardSize.height + - (columnNum - 1) * 40 + - cardSize.headerHeight; - - const streamsMatrix = [...Array(rowNum)].map((_, i) => - sortedStreams.filter((_, j) => j % rowNum === i) - ); - - return ( - - {streamsMatrix.map((st, i) => ( - - {st.length ? ( - st.map((s) => ( - - )) - ) : ( -
- )} - - ))} - - ); - }, - (prev, next) => - prev.streams.map((s) => s.id).toString() === - next.streams.map((s) => s.id).toString() -); diff --git a/src/components/buttons/BaseButton.tsx b/src/components/buttons/BaseButton.tsx deleted file mode 100644 index c2ad8b9..0000000 --- a/src/components/buttons/BaseButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { animated, useSpring } from "@react-spring/web"; -import { IconContext } from "react-icons"; -import { useHover, useWindowSize } from "../../hooks"; -import { BaseButtonProps } from "../../types"; - -const Container = styled.div` - width: 27px; - height: 27px; -`; - -export const BaseButton: React.FC = ({ - onClickHandler, - children, - ...props -}) => { - const { hovered, hoverSpread } = useHover(); - const { isMobile } = useWindowSize(); - - const { transform } = useSpring({ - transform: hovered ? "scale(1.1)" : "scale(1)", - config: { - duration: 100, - }, - }); - - return ( - - - - {children} - - - - ); -}; diff --git a/src/components/buttons/GithubLinkButton.tsx b/src/components/buttons/GithubLinkButton.tsx deleted file mode 100644 index 81b912a..0000000 --- a/src/components/buttons/GithubLinkButton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react"; -import { FaGithub } from "react-icons/fa"; -import { BaseButton } from "./BaseButton"; - -export const GithubLinkButton: React.FC< - React.HTMLAttributes -> = ({ ...props }) => { - const url = "https://github.com/mnsinri/vspo-stream-schedule"; - - return ( - window.open(url)} {...props}> - - - ); -}; diff --git a/src/components/buttons/ThemeButton.tsx b/src/components/buttons/ThemeButton.tsx deleted file mode 100644 index a0039ed..0000000 --- a/src/components/buttons/ThemeButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { animated, useTransition } from "@react-spring/web"; -import { IoMdSunny, IoMdMoon } from "react-icons/io"; -import { useTheme } from "../../hooks"; -import { BaseButton } from "./BaseButton"; -import { springConfig } from "../../configs"; - -const Wrapper = styled(animated.div)` - position: absolute; -`; - -export const ThemeButton: React.FC> = ({ - ...props -}) => { - const { themeType, setThemeDark } = useTheme(); - - const transitions = useTransition(themeType, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - config: springConfig, - }); - - return ( - setThemeDark(true)} {...props}> - {transitions((style, themeType) => ( - - {themeType === "dark" ? : } - - ))} - - ); -}; diff --git a/src/components/buttons/index.ts b/src/components/buttons/index.ts deleted file mode 100644 index b79b4ef..0000000 --- a/src/components/buttons/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./ThemeButton"; -export * from "./BaseButton"; -export * from "./GithubLinkButton"; diff --git a/src/components/card/ServiceIcon.tsx b/src/components/card/ServiceIcon.tsx deleted file mode 100644 index 84d7111..0000000 --- a/src/components/card/ServiceIcon.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { ServiceIconProps } from "../../types"; -import styled from "styled-components"; -import { animated, easings, useSpring } from "@react-spring/web"; -import { IconContext } from "react-icons"; -import { FaYoutube, FaTwitch } from "react-icons/fa"; -import { TbBroadcast } from "react-icons/tb"; -import { useTheme, useWindowSize } from "../../hooks"; -import { breakpoints } from "../../configs"; -import { icon } from "../../colors"; -import { parseToJST } from "../../utils"; - -const Panel = styled(animated.div)` - display: flex; - background-color: ${(p) => p.theme.bg.secondary}; - transition: background-color 0.3s ease; - height: 18px; - border-radius: 8px; - box-shadow: inset 0px 2px 2px rgba(0, 0, 0, 0.25); - - ${breakpoints.mediaQueries.md` - height: 28px; - border-radius: 15px; - box-shadow: inset 0px 3px 3px rgba(0, 0, 0, 0.25); - `} -`; - -const InnerContainer = styled(animated.div)` - display: flex; - align-items: center; - height: 14px; - margin: auto; - gap: 2px; - - ${breakpoints.mediaQueries.md` - height: 24px; - gap: 5px; - `} -`; - -const Icon = styled(animated.div)` - height: 100%; - width: 12px; - display: flex; - - ${breakpoints.mediaQueries.md` - width: 20px; - `} -`; - -const StateText = styled(animated.div)` - font-weight: bold; - font-size: 10px; - - ${breakpoints.mediaQueries.md` - font-size: 16px; - `} -`; - -const getStartTime = (timeString: string) => { - const date = parseToJST(Date.parse(timeString)); - return date.getHours() + ":" + date.getMinutes().toString().padStart(2, "0"); -}; - -export const ServiceIcon: React.FC = ({ - service, - startAt, - isExpand, - ...props -}) => { - const { isPhoneSize } = useWindowSize(); - const serviceColor = useMemo(() => { - switch (service) { - case "youtube": - return icon.youtube; - case "twitch": - return icon.twitch; - case "twitCasting": - return icon.twitCasting; - } - }, [service]); - const startDate = useMemo(() => new Date(startAt), [startAt]); - const [isLive, setLive] = useState(false); - const { theme } = useTheme(); - - const checkLive = () => startDate.getTime() < Date.now(); - - const ServiceIcon = useCallback(() => { - switch (service) { - case "youtube": - return ; - case "twitch": - return ; - case "twitCasting": - return ; - } - }, [service]); - - useEffect(() => { - const timerId = setInterval(() => { - if (checkLive()) { - setLive(true); - clearInterval(timerId); - } - }, 30000); - - if (checkLive()) { - setLive(true); - clearInterval(timerId); - } - - return () => clearInterval(timerId); - }, []); - - const baseSpringConfig = { - display: isExpand ? "block" : "none", - width: isExpand ? "85px" : "30px", - color: isLive ? serviceColor : theme.text.primary, - config: { - duration: 150, - easing: easings.easeInOutSine, - }, - }; - - const mobileSpringConfig = { - width: isExpand ? "48px" : "18px", - }; - - const { width, display, color } = useSpring({ - ...baseSpringConfig, - ...(isPhoneSize ? mobileSpringConfig : {}), - }); - const { opacity } = useSpring({ - opacity: isExpand ? 1 : 0, - config: { - duration: 250, - easing: easings.easeInQuart, - }, - }); - - return ( -
- - - - - - - - - {isLive ? "LIVE" : getStartTime(startAt)} - - - -
- ); -}; diff --git a/src/components/card/StreamingCard.tsx b/src/components/card/StreamingCard.tsx deleted file mode 100644 index 26f5a62..0000000 --- a/src/components/card/StreamingCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from "react"; -import { StreamingCardProps } from "../../types"; -import { ServiceIcon } from "./ServiceIcon"; -import { ThumbnailBlock } from "./ThumbnailBlock"; -import styled from "styled-components"; -import { useConfig, useHover, useWindowSize } from "../../hooks"; -import { animated } from "@react-spring/web"; -import { breakpoints } from "../../configs"; - -const Container = styled(animated.div)` - width: 160px; - - ${breakpoints.mediaQueries.md` - width: 320px; - `} -`; - -const Card = styled(animated.div)` - position: relative; - - ${breakpoints.mediaQueries.md` - min-height: 180px; - `} -`; - -export const StreamingCard = React.memo( - ({ title, thumbnail, name, icon, service, url, startAt }) => { - const { hovered, hoverSpread } = useHover(); - const { isMobile, isDesktopSize } = useWindowSize(); - const { config } = useConfig(); - - const expand = isMobile || !isDesktopSize; - - return ( - - window.open(url)} - aria-label={title} - {...hoverSpread} - > - - - - - ); - } -); diff --git a/src/components/card/ThumbnailBlock.tsx b/src/components/card/ThumbnailBlock.tsx deleted file mode 100644 index 69b399e..0000000 --- a/src/components/card/ThumbnailBlock.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useMemo } from "react"; -import styled from "styled-components"; -import { animated, easings, useSpring } from "@react-spring/web"; -import { breakpoints } from "../../configs"; -import { useConfig, useWindowSize } from "../../hooks"; -import { Marquee } from "../marquee"; -import { ThumbnailBlockProps } from "../../types"; - -const Panel = styled(animated.div)` - width: 160px; - height: 90px; - background-color: white; - border-radius: 5px; - filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); - position: relative; - background-color: ${(p) => p.theme.bg.secondary}; - transition: background-color 0.3s ease; - - ${breakpoints.mediaQueries.md` - width: 320px; - height: 180px; - border-radius: 10px; - `} -`; - -const Thumbnail = styled(animated.img)` - width: 160px; - height: 90px; - - ${breakpoints.mediaQueries.md` - width: 320px; - height: 180px; - `} -`; - -const Header = styled.div` - width: 100%; - display: flex; - position: absolute; - left: 0; - bottom: 0; - height: 30px; - - ${breakpoints.mediaQueries.md` - height: 60px; - `} -`; - -const Icon = styled(animated.img)` - height: 25px; - aspect-ratio: 1; - margin: auto 0; - margin-left: 3px; - border-radius: 50%; - object-fit: cover; - - ${breakpoints.mediaQueries.md` - height: 50px; - margin-left: 6px; - `} -`; - -const Contents = styled(animated.div)` - margin-left: 3px; - width: 125px; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - - ${breakpoints.mediaQueries.md` - margin-left: 6px; - width: 250px; - `} -`; - -const MarqueeTitle = styled(Marquee)` - font-family: "Zen Kaku Gothic New", sans-serif; - font-size: 10px; - width: 100%; - margin-top: 2px; - - ${breakpoints.mediaQueries.md` - font-size: 20px; - margin-top: 0; - `} -`; - -const Name = styled.div` - font-family: "Zen Kaku Gothic New", sans-serif; - font-size: 10px; - transform: scale(0.8); - transform-origin: 0 0; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding: 0 3%; - - ${breakpoints.mediaQueries.md` - font-size: 15px; - transform: scale(1); - `} -`; - -export const ThumbnailBlock: React.FC = ({ - title, - thumbnail, - name, - icon, - isExpand, - hovered, - ...props -}) => { - const { isPhoneSize, isMobile, isDesktopSize } = useWindowSize(); - const { config } = useConfig(); - - const baseSpringConfig = { - height: isExpand ? "240px" : "180px", - borderRadius: isExpand ? "10px 10px 0px 0px" : "10px 10px 10px 10px", - shadow: isExpand - ? "drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.25))" - : "drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25))", - config: { - duration: 150, - easing: easings.easeInOutSine, - }, - }; - - const mobileSpringConfig = { - height: isExpand ? "120px" : "90px", - borderRadius: isExpand ? "5px 5px 0px 0px" : "5px 5px 5px 5px", - shadow: isExpand - ? "drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.25))" - : "drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.25))", - }; - - const { height, borderRadius, shadow } = useSpring({ - ...baseSpringConfig, - ...(isPhoneSize ? mobileSpringConfig : {}), - }); - - const { x } = useSpring({ - x: isExpand ? 1 : 0, - config: { - duration: 500, - easing: easings.easeOutExpo, - }, - }); - - const speed = useMemo(() => { - const speed = isPhoneSize ? 0.45 : 0.9; - if (!isMobile && (!isDesktopSize || config.isExpandAlways) && hovered) - return speed / 2; - return speed; - }, [ - !isMobile && (!isDesktopSize || config.isExpandAlways) && hovered, - isPhoneSize, - ]); - - return ( - - -
- - - - {title} - - {name} - -
-
- ); -}; diff --git a/src/components/card/index.ts b/src/components/card/index.ts deleted file mode 100644 index b383279..0000000 --- a/src/components/card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./StreamingCard"; diff --git a/src/components/dropdownMenu/dropdownItem/index.tsx b/src/components/dropdownMenu/dropdownItem/index.tsx new file mode 100644 index 0000000..e49109d --- /dev/null +++ b/src/components/dropdownMenu/dropdownItem/index.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from "react"; +import { IconContainer, Item, ItemText } from "./styles"; +import { CSSObject } from "styled-components"; + +type Props = { + children?: ReactNode; + contents?: { + icon?: ReactNode; + text?: string; + }; + style?: CSSObject; + onClick?: () => void; +}; + +export const DropdownItem: React.FC = ({ + children, + contents = {}, + style, + onClick, +}) => { + const { icon, text } = contents; + + return ( + + {icon && {icon}} + {text && {text}} + {children} + + ); +}; diff --git a/src/components/dropdownMenu/dropdownItem/styles.tsx b/src/components/dropdownMenu/dropdownItem/styles.tsx new file mode 100644 index 0000000..e4e0240 --- /dev/null +++ b/src/components/dropdownMenu/dropdownItem/styles.tsx @@ -0,0 +1,32 @@ +import styled, { css } from "styled-components"; + +export const Item = styled.li<{ hoverable: boolean }>` + list-style: none; + display: flex; + gap: 5px; + align-items: center; + transition: background-color 0.3s ease; + background-color: ${({ theme }) => theme.dropdown.item.default.bg.normal}; + padding: 7px 14px; + border-radius: 7px; + cursor: pointer; + + ${(p) => + p.hoverable && + css` + &:hover { + background-color: ${({ theme }) => + theme.dropdown.item.default.bg.hover}; + } + `} +`; + +export const IconContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +export const ItemText = styled.span` + line-height: 1.2; +`; diff --git a/src/components/dropdownMenu/index.tsx b/src/components/dropdownMenu/index.tsx new file mode 100644 index 0000000..d2d435d --- /dev/null +++ b/src/components/dropdownMenu/index.tsx @@ -0,0 +1,108 @@ +import React, { + FC, + memo, + ReactElement, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useTransition } from "@react-spring/web"; +import { DropdownContainer, DropdownContainerProps } from "./styles"; + +type Direction = "top" | "right" | "bottom" | "left"; + +type Position = { + x: number; + y: number; +}; + +type EntryConfig = { + from: Direction; + dist: number; +}; + +type Props = { + trigger: ReactElement; + position?: Partial; + entry?: Partial; + children?: ReactNode; +} & DropdownContainerProps; + +const calcPosition = ({ + x = 0, + y = 10, + from = "top", + dist = 10, +}: Position & EntryConfig): Position => { + switch (from) { + case "top": + return { x, y: y - dist }; + case "bottom": + return { x, y: y + dist }; + case "right": + return { x: x + dist, y }; + case "left": + return { x: x - dist, y }; + } +}; + +export const Dropdown: FC = memo( + ({ trigger, width, position = {}, entry = {}, children }) => { + const [isOpen, setOpen] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const refDropdown = useRef(null!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const refBtn = useRef(null!); + + const { x = 0, y = 30 } = position; + const { from = "top", dist = 10 } = entry; + const initPosition = calcPosition({ x, y, from, dist }); + + const checkClicksOutside = useCallback( + (e: MouseEvent) => { + if (!(e.target instanceof Node)) return; + + const isOutSideMenu = !refDropdown.current?.contains(e.target); + const isOutSideBtn = !refBtn.current?.contains(e.target); + + if (isOutSideMenu && isOutSideBtn) setOpen(false); + }, + [setOpen], + ); + + useEffect(() => { + if (isOpen) document.addEventListener("mousedown", checkClicksOutside); + return () => + document.removeEventListener("mousedown", checkClicksOutside); + }, [isOpen]); + + const config = { duration: 250 }; + const transitions = useTransition(isOpen, { + from: { opacity: 0, ...initPosition, config }, + enter: { opacity: 1, x, y, config }, + leave: { opacity: 0, ...initPosition, config }, + }); + + return ( + <> +
setOpen((o) => !o)}> + {trigger} +
+ {transitions( + (style, isOpen) => + isOpen && ( + + {children} + + ), + )} + + ); + }, +); + +export * from "./dropdownItem"; +export * from "./toggleButtonItem"; +export { Border } from "./styles"; diff --git a/src/components/dropdownMenu/styles.tsx b/src/components/dropdownMenu/styles.tsx new file mode 100644 index 0000000..0137565 --- /dev/null +++ b/src/components/dropdownMenu/styles.tsx @@ -0,0 +1,38 @@ +import styled from "styled-components"; +import { animated } from "@react-spring/web"; + +export const MenuButton = styled.div` + border: 0; + border-radius: 5px; + background-color: ${({ theme }) => theme.dropdown.input.bg.normal}; + transition: 0.3s ease; + color: ${({ theme }) => theme.dropdown.input.icon}; + + &:hover { + background-color: ${({ theme }) => theme.dropdown.input.bg.hover}; + } +`; + +export type DropdownContainerProps = { + width?: number; +}; +export const DropdownContainer = styled(animated.ol)` + box-sizing: border-box; + position: absolute; + width: ${({ width }) => width ?? 250}px; + border: 5px solid ${({ theme }) => theme.dropdown.bg}; + border-radius: 7px; + box-shadow: 0px 3px 6px 2px rgba(0, 0, 0, 0.2); + background-color: ${({ theme }) => theme.dropdown.bg}; + padding: 2px; + color: ${({ theme }) => theme.dropdown.text}; + z-index: 100; +`; + +export const Border = styled.hr` + background-color: ${({ theme }) => theme.dropdown.border}; + height: 1px; + border: none; + margin: 7px 0px; + padding: 0 5px; +`; diff --git a/src/components/dropdownMenu/toggleButtonItem/index.tsx b/src/components/dropdownMenu/toggleButtonItem/index.tsx new file mode 100644 index 0000000..b5ce7a1 --- /dev/null +++ b/src/components/dropdownMenu/toggleButtonItem/index.tsx @@ -0,0 +1,59 @@ +import React, { + ComponentProps, + ReactNode, + useCallback, + useMemo, + useState, +} from "react"; +import { ToggleButton } from "../../toggleButton"; +import { DropdownItem } from "../dropdownItem"; +import { FlexEnd } from "./styles"; + +type Contents = { + icon?: ReactNode; + text?: string; +}; + +type Props = { + contents: Contents | ((isOn: boolean) => Contents); + children?: ReactNode | ((isOn: boolean) => ReactNode); +} & ComponentProps; + +export const ToggleButtonItem: React.FC = ({ + contents: _contents, + children: _children, + onChange: _onChange, + initState = false, + disabled, +}) => { + const [isOn, setOn] = useState(initState); + const { contents, children } = useMemo( + () => ({ + contents: typeof _contents === "function" ? _contents(isOn) : _contents, + children: typeof _children === "function" ? _children(isOn) : _children, + }), + [_contents, isOn], + ); + + const onChange = useCallback( + (isOn: boolean) => { + setOn(isOn); + _onChange(isOn); + }, + [_onChange], + ); + + return ( + + {children} + + + + + ); +}; diff --git a/src/components/dropdownMenu/toggleButtonItem/styles.tsx b/src/components/dropdownMenu/toggleButtonItem/styles.tsx new file mode 100644 index 0000000..4032a7c --- /dev/null +++ b/src/components/dropdownMenu/toggleButtonItem/styles.tsx @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export const FlexEnd = styled.div` + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx new file mode 100644 index 0000000..d398fc8 --- /dev/null +++ b/src/components/header/index.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import logo from "../../logo.png"; +import { SettingMenu } from "../settingMenu"; +import { Container, Icon, Title, TitleText, DropdownWrapper } from "./styles"; + +export const Header: React.FC = () => { + return ( + + + <Icon src={logo} alt="logo" /> + <TitleText>Vspo stream schedule</TitleText> + + + + + + ); +}; diff --git a/src/components/header/styles.tsx b/src/components/header/styles.tsx new file mode 100644 index 0000000..e919885 --- /dev/null +++ b/src/components/header/styles.tsx @@ -0,0 +1,51 @@ +import { breakpointMediaQueries } from "src/configs"; +import styled from "styled-components"; + +export const Container = styled.div` + width: 100%; + margin: 25px 0; + /* position: sticky; + top: 0; + left: 0; */ + display: flex; + align-items: center; + border-radius: 10px; + z-index: 10; +`; + +export const Title = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-left: 40px; + + ${breakpointMediaQueries.tablet` + justify-content: start; + margin-left: 0px; + `} +`; + +export const Icon = styled.img` + width: 50px; + height: 50px; +`; + +export const TitleText = styled.div` + margin-left: 10px; + margin-top: 8px; + font-size: 28px; + font-family: "Itim", cursive; + letter-spacing: -0.05em; + color: ${({ theme }) => theme.header.text}; + display: none; + + ${breakpointMediaQueries.tablet` + display: block; + `} +`; + +export const DropdownWrapper = styled.div` + width: 40px; + display: flex; + justify-content: flex-end; +`; diff --git a/src/components/index.ts b/src/components/index.ts index 3504bb0..784bb04 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,9 +1,7 @@ -export * from "./providers"; -export * from "./card"; -export * from "./buttons"; - -export * from "./MainContainer"; -export * from "./StreamingTable"; -export * from "./DateBorder"; -export * from "./Header"; -export * from "./Background"; +export * from "./dropdownMenu"; +export * from "./header"; +export * from "./mainContainer"; +export * from "./marquee"; +export * from "./streamCard"; +export * from "./streamGrid"; +export * from "./toggleButton"; diff --git a/src/components/mainContainer/index.tsx b/src/components/mainContainer/index.tsx new file mode 100644 index 0000000..c78b22e --- /dev/null +++ b/src/components/mainContainer/index.tsx @@ -0,0 +1,127 @@ +import React, { + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Stream } from "types"; +import { Background, Container, DailyStreamContainer } from "./styles"; +import { Header } from "../header"; +import { StreamGrid } from "../streamGrid"; +import { StreamGridHeader } from "../streamGridHeader"; +import { useDisplaySize, useSetting, useVspoStream } from "src/providers"; +import { toYYYYMMDD } from "src/utils"; + +type DailyStream = { + date: string; + streams: Stream[]; +}; + +const getPixel = (style: CSSStyleDeclaration, key: string): number => { + return Number(style.getPropertyValue(key).replace("px", "")); +}; + +const calcGridProperties = ( + width: number, + cardWidth: number, + { gapRange = [20, 80] } = {}, +): { + column: number; + gap: number; + options?: { + gapRange: [number, number]; + }; +} => { + const [minGap, maxGap] = gapRange; + + const column = Math.floor((width + minGap) / (cardWidth + minGap)); + if (column <= 1) return { column, gap: 0 }; + + const gap = Math.min((width - cardWidth * column) / (column - 1), maxGap); + return { column, gap }; +}; + +export const MainContainer: FC = () => { + const [gridProperties, setGridProperties] = useState<{ + column: number; + gap: number; + }>({ column: 0, gap: 0 }); + + const displaySize = useDisplaySize(); + const { isDisplayHistory } = useSetting(); + + const streams = useVspoStream(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = useRef(null!); + useEffect(() => { + const [cardWidth, gapRange] = displaySize.mobile + ? [160, [10, 40]] + : [320, [20, 80]]; + + const resize = () => { + const style = window.getComputedStyle(containerRef.current); + const width = getPixel(style, "width"); + setGridProperties(calcGridProperties(width, cardWidth, { gapRange })); + }; + resize(); + + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [displaySize.mobile]); + + const calcStreamGridMinHeight = useCallback( + (streamNum: number) => { + const [cardHeight, expandSize, gap] = displaySize.mobile + ? [90, 30, 20] + : [180, 60, 40]; + const row = Math.ceil(streamNum / gridProperties.column); + + return row * (cardHeight + gap) - gap + expandSize; + }, + [gridProperties.column, displaySize.mobile], + ); + + const dailyStreams: DailyStream[] = useMemo(() => { + const now = Date.now(); + const isEndedStream = (s: Stream) => s.endAt && s.endAt.getTime() <= now; + + const dailyStreamObj = streams.reduce( + (result: Record, stream) => { + if (!isDisplayHistory.state && isEndedStream(stream)) return result; + + const dateStr = toYYYYMMDD(stream.startAt); + + if (result[dateStr]) result[dateStr].push(stream); + else result[dateStr] = [stream]; + + return result; + }, + {}, + ); + + return Object.entries(dailyStreamObj) + .sort((a, b) => (a[0] > b[0] ? 1 : -1)) + .map(([date, streams]) => ({ date, streams })); + }, [streams, isDisplayHistory.state]); + + return ( + + +
+ {dailyStreams.map(({ date, streams }) => ( + + + + + ))} + + + ); +}; diff --git a/src/components/mainContainer/styles.tsx b/src/components/mainContainer/styles.tsx new file mode 100644 index 0000000..82acbc8 --- /dev/null +++ b/src/components/mainContainer/styles.tsx @@ -0,0 +1,33 @@ +import { breakpointMediaQueries } from "src/configs"; +import styled from "styled-components"; + +export const Background = styled.div` + width: 100vw; + height: 100vh; + background-color: ${({ theme }) => theme.bg}; + transition: background-color 0.3s ease; +`; + +export const Container = styled.div` + width: 90%; + height: 100%; + margin: 0 auto; + padding: 0 5%; + background: rgba(240, 240, 240, 0.03); + box-shadow: 0px 0px 4px 4px rgba(0, 0, 0, 0.2); + overflow: scroll; + scrollbar-width: none; + display: flex; + flex-direction: column; + transition: width 0.3s ease-in-out; + + ${breakpointMediaQueries.desktop` + width: 85%; + `} +`; + +export const DailyStreamContainer = styled.div` + &:last-child { + padding-bottom: 30px; + } +`; diff --git a/src/components/marquee/Marquee.tsx b/src/components/marquee/Marquee.tsx deleted file mode 100644 index 3f0ad51..0000000 --- a/src/components/marquee/Marquee.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { ReactNode } from "react"; -import { useWindowSize } from "../../hooks"; -import { MarqueeForMobile } from "./MarqueeForMobile"; -import { MarqueeScroll } from "./MarqueeScroll"; - -type Props = { - children: ReactNode; - isAnimate?: boolean; - speed?: number; -}; - -export const Marquee: React.FC = ({ - children, - isAnimate = false, - speed = 0.05, - ...props -}) => { - const { isMobile } = useWindowSize(); - - return isMobile ? ( - - {children} - - ) : ( - - {children} - - ); -}; diff --git a/src/components/marquee/MarqueeForMobile.tsx b/src/components/marquee/MarqueeForMobile.tsx deleted file mode 100644 index 10a4b69..0000000 --- a/src/components/marquee/MarqueeForMobile.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { ReactNode, useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { animated, useSpring, useSpringRef } from "@react-spring/web"; -import { useWindowSize } from "../../hooks"; - -const Container = styled.div` - width: 100%; - display: flex; - overflow: hidden; - mask-image: linear-gradient( - to right, - transparent, - #fff 5%, - #fff 95%, - transparent - ); -`; - -const Item = styled(animated.div)` - white-space: nowrap; - padding: 0 20% 0 3%; -`; - -type Props = { - children: ReactNode; - isAnimate: boolean; - speed?: number; -}; - -export const MarqueeForMobile: React.FC = ({ - children, - isAnimate, - speed = 0.05, - ...props -}) => { - const { isPhoneSize } = useWindowSize(); - const refParent = useRef(null!); - const refChild = useRef(null!); - const rect = useRef(null!); - const [canMarquee, setCanMarquee] = useState(false); - - const animation = useSpringRef(); - const transform = useSpring({ - ref: animation, - from: { - x: "0%", - }, - }); - - useEffect(() => { - animation.start({ - from: { - x: "0%", - }, - immediate: true, - }); - const parent = refParent.current.getBoundingClientRect(); - const child = refChild.current.getBoundingClientRect(); - rect.current = child; - setCanMarquee(parent.width < child.width); - }, [children, isPhoneSize]); - - useEffect(() => { - animation.start({ - from: { - x: "0%", - }, - ...(canMarquee && isAnimate - ? { - to: { - x: "-100%", - }, - reset: true, - loop: true, - delay: 1500, - config: { - duration: (rect.current.width * 15) / speed, - }, - } - : { immediate: true }), - }); - }, [canMarquee && isAnimate, speed]); - - return ( - - - {children} - - {canMarquee && {children}} - - ); -}; diff --git a/src/components/marquee/MarqueeItem.tsx b/src/components/marquee/MarqueeItem.tsx deleted file mode 100644 index 17b650c..0000000 --- a/src/components/marquee/MarqueeItem.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { ReactNode, forwardRef, useLayoutEffect, useRef } from "react"; -import styled from "styled-components"; -import { useAnimationFrame, useWindowSize } from "../../hooks"; -import { mergeRefs } from "react-merge-refs"; - -const Container = styled.div` - white-space: nowrap; - padding: 0 20% 0 3%; -`; - -type Props = { - children: ReactNode; - isAnimate: boolean; - speed?: number; - waitTime?: number; -}; - -export const MarqueeItem = forwardRef( - ({ children, isAnimate, speed = 1, waitTime = 1500 }, forwardedRef) => { - const { isPhoneSize } = useWindowSize(); - const item = useRef(null!); - const rect = useRef(null!); - const start = useRef(null); - const x = useRef(0); - - useLayoutEffect(() => { - rect.current = item.current.getBoundingClientRect(); - }, [children, isPhoneSize]); - - useLayoutEffect(() => { - item.current.style.transform = `translateX(0)`; - x.current = 0; - start.current = null; - }, [isAnimate]); - - useAnimationFrame((timestamp) => { - if (!(isAnimate && item.current && rect.current)) return; - - if (!start.current) start.current = timestamp; - - if (timestamp - start.current < waitTime) return; - - x.current -= speed; - if (x.current < -rect.current.width) { - x.current = 0; - start.current = null; - } - - item.current.style.transform = `translateX(${ - (x.current / rect.current.width) * 100 - }%)`; - }); - - return ( - {children} - ); - } -); diff --git a/src/components/marquee/MarqueeScroll.tsx b/src/components/marquee/MarqueeScroll.tsx deleted file mode 100644 index 0f005fd..0000000 --- a/src/components/marquee/MarqueeScroll.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { ReactNode, useLayoutEffect, useRef, useState } from "react"; -import { MarqueeItem } from "./MarqueeItem"; -import styled from "styled-components"; -import { useWindowSize } from "../../hooks"; - -const Container = styled.div` - width: 100%; - display: flex; - overflow: hidden; - mask-image: linear-gradient( - to right, - transparent, - #fff 5%, - #fff 95%, - transparent - ); -`; - -type Props = { - children: ReactNode; - isAnimate: boolean; - speed?: number; -}; - -export const MarqueeScroll: React.FC = ({ - children, - isAnimate, - speed = 1, - ...props -}) => { - const { isPhoneSize } = useWindowSize(); - const parentRef = useRef(null!); - const childRef = useRef(null!); - const [canMarquee, setCanMarquee] = useState(false); - - useLayoutEffect(() => { - setCanMarquee( - parentRef.current.getBoundingClientRect().width < - childRef.current.getBoundingClientRect().width - ); - }, [children, isPhoneSize]); - - return ( - - - {children} - - {canMarquee && ( - - {children} - - )} - - ); -}; diff --git a/src/components/marquee/index.ts b/src/components/marquee/index.ts deleted file mode 100644 index 198a0d0..0000000 --- a/src/components/marquee/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MarqueeScroll } from "./MarqueeScroll"; -export { MarqueeForMobile } from "./MarqueeForMobile"; -export { Marquee } from "./Marquee"; diff --git a/src/components/marquee/index.tsx b/src/components/marquee/index.tsx new file mode 100644 index 0000000..b03626c --- /dev/null +++ b/src/components/marquee/index.tsx @@ -0,0 +1,31 @@ +import React, { FC, useLayoutEffect, useRef, useState } from "react"; +import { Container, ContainerProps, MarqueeItem } from "./styles"; + +type Props = { + children: string; +} & ContainerProps; + +export const Marquee: FC = ({ children, ...props }: Props) => { + const parentRef = useRef(null!); + const childRef = useRef(null!); + const [canMarquee, setCanMarquee] = useState(false); + + useLayoutEffect(() => { + // TODO marquee + // setCanMarquee( + // parentRef.current.getBoundingClientRect().width < + // childRef.current.getBoundingClientRect().width, + // ); + }, [children]); + + return ( + + + {children} + + {canMarquee && ( + {children} + )} + + ); +}; diff --git a/src/components/marquee/styles.tsx b/src/components/marquee/styles.tsx new file mode 100644 index 0000000..d70b33e --- /dev/null +++ b/src/components/marquee/styles.tsx @@ -0,0 +1,40 @@ +import styled, { css, CSSProperties, keyframes } from "styled-components"; + +export type ContainerProps = { + fontSize?: CSSProperties["fontSize"]; +}; +export const Container = styled.div` + display: flex; + font-size: ${({ fontSize }) => fontSize ?? "20px"}; + /* overflow: hidden; */ + mask-image: linear-gradient( + to right, + transparent, + #fff 5%, + #fff 95%, + transparent + ); +`; + +type MarqueeItemProps = { + isActive?: boolean; +}; +const marqueeAnimation = keyframes` + from { + transform: translateX(0%); + } + to { + transform: translateX(-100%); + } +`; +export const MarqueeItem = styled.div` + white-space: nowrap; + padding: 0 20% 0 0; + margin-left: 3%; + ${({ isActive }) => + isActive && + css` + animation: ${marqueeAnimation} 10s linear infinite; + animation-delay: 1.5s; + `} +`; diff --git a/src/components/providers/ConfigProvider.tsx b/src/components/providers/ConfigProvider.tsx deleted file mode 100644 index 1b61010..0000000 --- a/src/components/providers/ConfigProvider.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { createContext, createRef } from "react"; -import { ChildrenNode } from "../../types"; -import { useBoolStateCache } from "../../hooks"; - -type ConfigSetter = { - setExpandAlways: React.Dispatch>; - setMarquee: React.Dispatch>; -}; - -type Config = { - scrollContainerRef: React.RefObject; - isExpandAlways: boolean; - isMarquee: boolean; -}; - -type ContextType = { - config: Config; - configSetter: ConfigSetter; -}; - -const initState = { - config: { - scrollContainerRef: null!, - isExpandAlways: true, - isMarquee: true, - }, - configSetter: { - setExpandAlways: null!, - setMarquee: null!, - }, -}; - -export const ConfigContext = createContext(initState); -const scrollContainerRef = createRef(); - -export const ConfigProvider: React.FC = ({ children }) => { - const [isExpandAlways, setExpandAlways] = useBoolStateCache( - "isExpandAlways", - initState.config.isExpandAlways - ); - - const [isMarquee, setMarquee] = useBoolStateCache( - "isMarquee", - initState.config.isMarquee - ); - - return ( - - {children} - - ); -}; diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx deleted file mode 100644 index 4e5ea84..0000000 --- a/src/components/providers/ThemeProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { createContext, useEffect, useMemo, useState } from "react"; -import { - ChildrenNode, - ColorTheme, - ThemeTypes, - ThemeContextType, -} from "../../types"; -import themes from "../../theme"; -import { ThemeProvider as StyledThemeProvider } from "styled-components"; - -const cacheKey = "themeType"; - -export const ThemeContext = createContext({ - themeType: "light", - theme: {} as ColorTheme, - setThemeDark: (isOn) => {}, -}); - -export const ThemeProvider: React.FC = ({ children }) => { - const [themeType, setTheme] = useState( - (localStorage.getItem(cacheKey) as ThemeTypes) ?? "light" - ); - - useEffect(() => { - localStorage.setItem(cacheKey, themeType); - }, [themeType]); - - const context = useMemo( - () => ({ - themeType, - theme: themes[themeType], - setThemeDark: (isOn) => { - setTheme(isOn ? "dark" : "light"); - }, - }), - [themeType] - ); - - return ( - - - {children} - - - ); -}; diff --git a/src/components/providers/VspoStreamingProvider.tsx b/src/components/providers/VspoStreamingProvider.tsx deleted file mode 100644 index fbfecb8..0000000 --- a/src/components/providers/VspoStreamingProvider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { createContext } from "react"; -import { ChildrenNode, StreamInfo } from "../../types"; -import { useStreamInfo } from "../../hooks"; - -export const VspoStreamingContext = createContext([]); - -export const VspoStreamingProvider: React.FC = ({ children }) => { - const youtubeStreamsInfo = useStreamInfo("youtube"); - const twitchStreamsInfo = useStreamInfo("twitch"); - const twitCastingStreamsInfo = useStreamInfo("twitCasting"); - - return ( - - {children} - - ); -}; diff --git a/src/components/providers/WindowSizeProvider.tsx b/src/components/providers/WindowSizeProvider.tsx deleted file mode 100644 index 0693ec1..0000000 --- a/src/components/providers/WindowSizeProvider.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/// -import React, { - createContext, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import { ChildrenNode, WindowSize, ClientType } from "../../types"; -import { breakpoints } from "../../configs"; - -export const WindowSizeContext = createContext(null!); - -export const WindowSizeProvider: React.FC = ({ children }) => { - const [size, setSize] = useState({ width: 0, height: 0 }); - const [isMobile, setIsMobile] = useState(false); - - const handleWindowSizeChange = () => { - setSize({ - width: window.innerWidth, - height: window.innerHeight, - }); - }; - const checkMoble = useCallback(() => { - if (navigator.userAgentData) { - return navigator.userAgentData.mobile; - } else { - return ( - /android|ipod|ipad|iphone|macintosh/.test( - navigator.userAgent.toLowerCase() - ) && "ontouchend" in document - ); - } - }, []); - - useEffect(() => { - setIsMobile(checkMoble()); - - handleWindowSizeChange(); - window.addEventListener("resize", handleWindowSizeChange); - return () => window.removeEventListener("resize", handleWindowSizeChange); - }, []); - - const clientType = useMemo( - () => ({ - isMobile, - isPhoneSize: size.width < breakpoints.values.md, - isTabletSize: breakpoints.values.md <= size.width, - isDesktopSize: breakpoints.values.lg <= size.width, - }), - [size, isMobile] - ); - - return ( - - {children} - - ); -}; diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts deleted file mode 100644 index 6954a10..0000000 --- a/src/components/providers/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { ThemeProvider } from "./ThemeProvider"; -export { VspoStreamingProvider } from "./VspoStreamingProvider"; -export { WindowSizeProvider } from "./WindowSizeProvider"; -export { ConfigProvider } from "./ConfigProvider"; diff --git a/src/components/settingMenu/MenuItem.tsx b/src/components/settingMenu/MenuItem.tsx deleted file mode 100644 index e6304fe..0000000 --- a/src/components/settingMenu/MenuItem.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { CSSProperties, ReactNode } from "react"; -import styled, { css } from "styled-components"; - -const Item = styled.li<{ hoverable: boolean }>` - cursor: pointer; - list-style: none; - display: flex; - transition: background-color 0.3s ease; - padding: 7px 14px; - border-radius: 7px; - - ${(p) => - p.hoverable && - css` - &:hover { - background-color: ${(p) => p.theme.hoverd.secondary}; - } - `} -`; - -const IconContainer = styled.div` - width: 20px; - display: flex; - align-items: center; - margin-right: 5px; -`; - -const ItemText = styled.span``; - -type Props = { - children?: ReactNode; - icon?: ReactNode; - text?: string; - onClick?: () => void; - style?: CSSProperties; -}; - -export const MenuItem: React.FC = ({ - children, - icon, - text, - onClick, - style, -}) => { - return ( - - {icon && {icon}} - {text && {text}} - {children} - - ); -}; diff --git a/src/components/settingMenu/SettingMenu.tsx b/src/components/settingMenu/SettingMenu.tsx deleted file mode 100644 index b4e5fe8..0000000 --- a/src/components/settingMenu/SettingMenu.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { MenuItem } from "./MenuItem"; -import { ToggleButtonItem } from "./ToggleButtonItem"; -import { animated, useTransition } from "@react-spring/web"; -import styled from "styled-components"; -import { useConfig, useTheme, useWindowSize } from "../../hooks"; - -import { BiMenu, BiExpandAlt } from "react-icons/bi"; -import { FaGithub } from "react-icons/fa"; -import { TbMoonFilled } from "react-icons/tb"; -import { TbMarquee2 } from "react-icons/tb"; - -const MenuButton = styled(animated.button)` - border: 0; - border-radius: 5px; - background-color: transparent; - transition: 0.3s ease; - color: ${(p) => p.theme.text.primary}; - - &:hover { - background-color: ${(p) => p.theme.hoverd.primary}; - } - - &:active { - background-color: ${(p) => p.theme.hoverd.secondary}; - } -`; - -const Container = styled(animated.ol)<{ right: number }>` - position: absolute; - top: 60px; - right: ${(p) => p.right}px; - z-index: 100; - width: 250px; - border: 5px solid ${(p) => p.theme.bg.secondary}; - border-radius: 7px; - box-shadow: 0px 3px 6px 2px rgba(0, 0, 0, 0.2); - background-color: ${(p) => p.theme.bg.secondary}; - padding: 2px; -`; - -const Border = styled.hr` - border-top: 1px solid ${(p) => p.theme.border.primary}; - margin: 7px 0px; - padding: 0 5px; -`; - -export const SettingMenu: React.FC = () => { - const [isOpen, setOpen] = useState(false); - const refOl = useRef(null!); - const refBtn = useRef(null!); - const { width, isMobile, isDesktopSize } = useWindowSize(); - - const { themeType, setThemeDark } = useTheme(); - const { config, configSetter } = useConfig(); - - const right = useMemo( - () => width - refBtn.current?.getBoundingClientRect().right, - [width] - ); - - const checkIfClickedOutside = useCallback( - (e: MouseEvent) => { - if (!(e.target instanceof Node)) return; - - const isOutSideMenu = refOl.current && !refOl.current.contains(e.target); - const isOutSideBtn = refBtn.current && !refBtn.current.contains(e.target); - - if (isOutSideMenu && isOutSideBtn) setOpen(false); - }, - [setOpen] - ); - - useEffect(() => { - if (config.scrollContainerRef.current !== null) { - config.scrollContainerRef.current.style.overflow = isOpen - ? "hidden" - : "scroll"; - } - - if (isOpen) document.addEventListener("click", checkIfClickedOutside); - return () => document.removeEventListener("click", checkIfClickedOutside); - }, [isOpen]); - - const transitions = useTransition(isOpen, { - from: { opacity: 0, y: -10 }, - enter: { opacity: 1, y: 0 }, - leave: { opacity: 0, y: -10 }, - }); - - return ( - <> - setOpen((o) => !o)}> - - - {transitions( - (style, item) => - item && ( - - } - text="Github" - onClick={() => - window.open("https://github.com/mnsinri/vspo-stream-schedule") - } - /> - - - , - text: "Dark theme", - }} - onChange={(isOn) => setThemeDark(isOn)} - /> - , - text: "Expand always", - }} - onChange={(isOn) => configSetter.setExpandAlways(isOn)} - /> - , - text: "Marquee title", - }} - onChange={(isOn) => configSetter.setMarquee(isOn)} - /> - - ) - )} - - ); -}; diff --git a/src/components/settingMenu/ToggleButtonItem.tsx b/src/components/settingMenu/ToggleButtonItem.tsx deleted file mode 100644 index 1a6069e..0000000 --- a/src/components/settingMenu/ToggleButtonItem.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { ReactNode, useEffect, useMemo, useState } from "react"; -import styled from "styled-components"; -import { ToggleButton } from "./ToggleButton"; -import { MenuItem } from "./MenuItem"; - -const FlexEnd = styled.div` - margin-left: auto; - display: flex; - align-items: center; -`; - -type Contents = { - children?: ReactNode; - icon?: ReactNode; - text?: string; -}; - -type FuncContents = (isOn: boolean) => Contents; - -type Props = { - contents: Contents | FuncContents; - onChange: (isOn: boolean) => void; - initState?: boolean; - disabled?: boolean; -}; - -export const ToggleButtonItem: React.FC = ({ - contents, - onChange, - initState = false, - disabled, -}) => { - const [isOn, setOn] = useState(initState); - const { children, icon, text } = useMemo( - () => (typeof contents === "function" ? contents(isOn) : contents), - [contents, isOn] - ); - - useEffect(() => { - onChange(isOn); - }, [isOn]); - - return ( - - {children} - - setOn(isOn)} - initState={initState} - disabled={disabled} - /> - - - ); -}; diff --git a/src/components/settingMenu/index.ts b/src/components/settingMenu/index.ts deleted file mode 100644 index 501d497..0000000 --- a/src/components/settingMenu/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./SettingMenu"; -export * from "./MenuItem"; -export * from "./ToggleButton"; -export * from "./ToggleButtonItem"; diff --git a/src/components/settingMenu/index.tsx b/src/components/settingMenu/index.tsx new file mode 100644 index 0000000..de79125 --- /dev/null +++ b/src/components/settingMenu/index.tsx @@ -0,0 +1,119 @@ +import React, { FC, memo } from "react"; +import { + Border, + Dropdown, + DropdownItem, + ToggleButtonItem, +} from "../dropdownMenu"; +import { Button } from "./styles"; +import { StreamerFilter } from "../streamerFilter"; +import { useDisplaySize, useSetting, useSettingDispatch } from "src/providers"; +import { BiExpandAlt, BiMenu } from "react-icons/bi"; +import { FaGithub } from "react-icons/fa"; +import { TbMoonFilled, TbMarquee2, TbHistory } from "react-icons/tb"; +import { IoIosArrowBack } from "react-icons/io"; + +export const SettingMenu: FC = memo(() => { + const setting = useSetting(); + const configDispatch = useSettingDispatch(); + const { mobile } = useDisplaySize(); + + const MenuButton = memo(() => ( + + )); + + const GithubLink = memo(() => ( + , + text: "Github", + }} + onClick={() => + window.open("https://github.com/mnsinri/vspo-stream-schedule") + } + /> + )); + + const DropdownHeader = memo(({ text }: { text: string }) => ( + + )); + + const ThemeSetting = memo(() => ( + , + text: "Dark theme", + }} + onChange={(payload) => configDispatch({ target: "isDarkTheme", payload })} + disabled={setting.isDarkTheme.isReadOnly} + /> + )); + + const ExpandSetting = memo(() => ( + , + text: "Expand always", + }} + onChange={(payload) => + configDispatch({ target: "isExpandAlways", payload }) + } + disabled={setting.isExpandAlways.isReadOnly} + /> + )); + + const MarqueeSetting = memo(() => ( + , + text: "Marquee title", + }} + onChange={(payload) => + configDispatch({ target: "isMarqueeTitle", payload }) + } + disabled={setting.isMarqueeTitle.isReadOnly} + /> + )); + + const HistorySetting = memo(() => ( + , + text: "Stream history", + }} + onChange={(payload: boolean) => + configDispatch({ target: "isDisplayHistory", payload }) + } + disabled={setting.isDisplayHistory.isReadOnly} + /> + )); + + return ( + }> + + + + + + + {!mobile && } + , + text: "Streamer", + }} + width={300} + position={{ x: -310, y: -50 }} + entry={{ from: "right" }} + /> + + + + ); +}); diff --git a/src/components/settingMenu/styles.tsx b/src/components/settingMenu/styles.tsx new file mode 100644 index 0000000..4772930 --- /dev/null +++ b/src/components/settingMenu/styles.tsx @@ -0,0 +1,13 @@ +import styled from "styled-components"; + +export const Button = styled.button` + border: 0; + border-radius: 5px; + background-color: ${({ theme }) => theme.dropdown.input.bg.normal}; + transition: 0.3s ease; + color: ${({ theme }) => theme.dropdown.input.icon}; + + &:hover { + background-color: ${({ theme }) => theme.dropdown.input.bg.hover}; + } +`; diff --git a/src/components/streamCard/index.tsx b/src/components/streamCard/index.tsx new file mode 100644 index 0000000..e54468e --- /dev/null +++ b/src/components/streamCard/index.tsx @@ -0,0 +1,106 @@ +import React, { FC, useCallback, useMemo, useState } from "react"; +import { useTheme } from "styled-components"; +import { iconColor } from "src/configs/colors"; +import { FaYoutube, FaTwitch } from "react-icons/fa"; +import { TbBroadcast } from "react-icons/tb"; +import { Stream } from "types"; + +import { useHover, useInterval } from "src/hooks"; +import { + TextContainer, + Details, + StreamerIcon, + Name, + Card, + Thumbnail, + StreamInfo, + PlatformIconContainer, + StateText, +} from "./styles"; +import { Marquee } from "../marquee"; +import { useDisplaySize, useSetting } from "src/providers"; +import { toJstHHMM } from "src/utils"; + +type Props = { + stream: Stream; +}; + +type StreamState = "upcoming" | "live" | "ended"; + +export const StreamCard: FC = ({ stream }) => { + const theme = useTheme(); + const [streamState, setStreamState] = useState("upcoming"); + const { hovered, hoverParams } = useHover(); + + const { isExpandAlways: expand } = useSetting(); + const isExpand = useMemo( + () => hovered || expand.state, + [hovered, expand.state], + ); + + const displaySize = useDisplaySize(); + const titleFontSize = useMemo( + () => (displaySize.mobile ? "11px" : "20px"), + [displaySize.mobile], + ); + + const isLive = streamState === "live"; + const isEnded = streamState === "ended"; + + const checkLiveState = (): StreamState => { + const now = Date.now(); + const { endAt, startAt } = stream; + + if (endAt && endAt.getTime() <= now) return "ended"; + if (startAt.getTime() < now) return "live"; + return "upcoming"; + }; + useInterval(() => { + const state = checkLiveState(); + if (streamState !== state) setStreamState(state); + + return state === "ended"; + }, 3000); + + const scheduledTimeText = useMemo(() => { + if (isLive) return "LIVE"; + return toJstHHMM(stream.startAt); + }, [streamState, stream.startAt.toString()]); + + const PlatformIcon = useCallback(() => { + const color = isLive ? iconColor[stream.platform] : theme.card.text; + switch (stream.platform) { + case "youtube": + return ; + case "twitch": + return ; + case "twitCasting": + return ; + } + }, [streamState, stream.platform, theme.card.text]); + + return ( + window.open(stream.url)} + > + + + + + + + {scheduledTimeText} + + +
+ + + {stream.title} + {stream.streamerName} + +
+
+ ); +}; diff --git a/src/components/streamCard/styles.tsx b/src/components/streamCard/styles.tsx new file mode 100644 index 0000000..a4347b9 --- /dev/null +++ b/src/components/streamCard/styles.tsx @@ -0,0 +1,170 @@ +import { breakpointMediaQueries } from "src/configs"; +import styled, { css, keyframes } from "styled-components"; + +const baseTransition = css` + transition: 0.2s ease; +`; + +type StyleProps = { + isExpand: boolean; +}; + +export const Card = styled.div` + width: 160px; + height: ${({ isExpand }) => (isExpand ? 120 : 90)}px; + border-radius: 5px; + background-color: ${({ theme }) => theme.card.bg}; + color: ${({ theme }) => theme.card.text}; + position: relative; + overflow: hidden; + filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); + ${baseTransition} + + @starting-style { + opacity: 0; + } + + ${({ isExpand }) => breakpointMediaQueries.tablet` + width: 320px; + height: ${isExpand ? 240 : 180}px; + border-radius: 10px; + `} +`; + +export const Thumbnail = styled.img` + width: 100%; + aspect-ratio: 1.777777778; + position: relative; + z-index: 1; +`; + +export const StreamInfo = styled.div` + width: ${({ isExpand }) => (isExpand ? 50 : 20)}px; + height: 20px; + position: absolute; + top: 5px; + right: 5px; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + background-color: ${({ theme }) => theme.card.bg}; + border-radius: 10px; + box-shadow: inset 0px 2px 2px rgba(0, 0, 0, 0.25); + z-index: 10; + ${baseTransition} + + ${({ isExpand }) => breakpointMediaQueries.tablet` + width: ${isExpand ? 85 : 30}px; + height: 28px; + gap: 4px; + border-radius: 15px; + box-shadow: inset 0px 3px 3px rgba(0, 0, 0, 0.25); + `} +`; + +export const PlatformIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + + ${breakpointMediaQueries.tablet` + font-size: 20px; + `} +`; + +const stateTextFadeIn = keyframes` + 0%, 30% { + opacity: 0; + transform: translateX(10px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +`; +export const StateText = styled.div` + font-weight: bold; + font-size: 11px; + animation: ${stateTextFadeIn} 0.3s ease-in-out; + ${({ isExpand }) => + !isExpand && + css` + display: none; + `}; + + ${breakpointMediaQueries.tablet` + font-size: 16px; + `} +`; + +export const Details = styled.div` + height: 30px; + position: absolute; + left: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 3px; + padding: 0 3px; + + ${breakpointMediaQueries.tablet` + height: 60px; + gap: 6px; + padding: 0 6px; + `} +`; + +export const StreamerIcon = styled.img` + height: 25px; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; + z-index: 1; + + ${breakpointMediaQueries.tablet` + height: 50px; + `} +`; + +const textContainerFadeIn = keyframes` + 0%, + 25% { + opacity: 0; + display: none; + transform: translateY(-10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +`; +export const TextContainer = styled.div` + width: 125px; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + ${({ isExpand }) => + isExpand && + css` + animation: ${textContainerFadeIn} 0.3s ease-in-out; + `}; + + ${breakpointMediaQueries.tablet` + width: 250px; + `} +`; + +export const Name = styled.div` + font-size: 7.5px; + transform-origin: 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ${breakpointMediaQueries.tablet` + font-size: 15px; + `} +`; diff --git a/src/components/streamGrid/index.tsx b/src/components/streamGrid/index.tsx new file mode 100644 index 0000000..cd818b9 --- /dev/null +++ b/src/components/streamGrid/index.tsx @@ -0,0 +1,41 @@ +import React, { FC, useMemo } from "react"; +import { Stream } from "types"; +import { Container, ColumnContainer, DummyCard } from "./styles"; +import { StreamCard } from "../streamCard"; + +type Props = { + streams: Stream[]; + column: number; + gap: number; + minHeight: number; +}; +export const StreamGrid: FC = ({ streams, column, gap, minHeight }) => { + const streamsMatrix = useMemo(() => { + const sortedStreams = [...streams].sort( + (a, b) => + a.startAt.getTime() - b.startAt.getTime() || + a.streamerName.localeCompare(b.streamerName), + ); + + return Array.from({ length: column }, (_, i) => { + const st = sortedStreams.filter((_, j) => j % column === i); + return st.length > 0 ? st : null; + }); + }, [streams, column]); + + return ( + + {streamsMatrix.map((columnStreams, i) => ( + + {columnStreams ? ( + columnStreams.map((stream) => ( + + )) + ) : ( + + )} + + ))} + + ); +}; diff --git a/src/components/streamGrid/styles.tsx b/src/components/streamGrid/styles.tsx new file mode 100644 index 0000000..afc998b --- /dev/null +++ b/src/components/streamGrid/styles.tsx @@ -0,0 +1,28 @@ +import styled from "styled-components"; +import { breakpointMediaQueries } from "src/configs"; + +export const Container = styled.div<{ gap: number; minHeight: number }>` + min-height: ${({ minHeight }) => minHeight}px; + display: flex; + flex-direction: row; + justify-content: center; + gap: ${({ gap }) => gap}px; +`; + +export const ColumnContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + + ${breakpointMediaQueries.tablet` + gap: 40px; + `} +`; + +export const DummyCard = styled.div` + width: 160px; + + ${breakpointMediaQueries.tablet` + width: 320px; + `} +`; diff --git a/src/components/streamGridHeader/index.tsx b/src/components/streamGridHeader/index.tsx new file mode 100644 index 0000000..9e43352 --- /dev/null +++ b/src/components/streamGridHeader/index.tsx @@ -0,0 +1,58 @@ +import React, { FC, useMemo } from "react"; +import { useTheme } from "styled-components"; +import { Bar, Container, DateLabel, Icon } from "./styles"; +import { toYYYYMMDD } from "src/utils"; + +type Props = { + dateString: string; +}; +export const StreamGridHeader: FC = ({ dateString }) => { + const theme = useTheme(); + + const parseToViewDate = (dateString: string) => { + const today = new Date(); + if (toYYYYMMDD(today) === dateString) { + return "Today"; + } + + const tomorrow = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + 1, + ); + if (toYYYYMMDD(tomorrow) === dateString) { + return "Tomorrow"; + } + + const yesterday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 1, + ); + if (toYYYYMMDD(yesterday) === dateString) { + return "Yesterday"; + } + + return dateString; + }; + + const barParams = useMemo(() => { + const calcHeight = (max: number) => max - 7 * Math.random(); + return [ + { height: calcHeight(30), bgColor: theme.cardHeader.icon[0] }, + { height: calcHeight(20), bgColor: theme.cardHeader.icon[1] }, + { height: calcHeight(16), bgColor: theme.cardHeader.icon[2] }, + ]; + }, [theme]); + + return ( + + + {barParams.map((param) => ( + + ))} + + {parseToViewDate(dateString)} + + ); +}; diff --git a/src/components/streamGridHeader/styles.tsx b/src/components/streamGridHeader/styles.tsx new file mode 100644 index 0000000..716e200 --- /dev/null +++ b/src/components/streamGridHeader/styles.tsx @@ -0,0 +1,35 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + height: 50px; + margin: 25px 0; +`; + +export const Icon = styled.div` + display: flex; + gap: 5px; + width: 30px; + aspect-ratio: 1; +`; + +export const Bar = styled.div<{ height: number; bgColor: string }>` + width: 5px; + height: ${({ height }) => height}px; + margin-top: auto; + border-radius: 0 5px 0 3px; + background-color: ${({ bgColor }) => bgColor}; + transition: 0.5s ease-out; + + @starting-style { + height: 0px; + } +`; + +export const DateLabel = styled.div` + font-size: 48px; + font-family: "Itim", cursive; + letter-spacing: -0.02em; + margin-top: 5px; + color: ${({ theme }) => theme.cardHeader.text}; +`; diff --git a/src/components/streamerFilter/index.tsx b/src/components/streamerFilter/index.tsx new file mode 100644 index 0000000..0ab9d44 --- /dev/null +++ b/src/components/streamerFilter/index.tsx @@ -0,0 +1,113 @@ +import React, { ComponentProps, FC, memo, useMemo } from "react"; +import { + Button, + Container, + PreviewContainer, + PreviewStreamerIcon, + StreamerIcon, +} from "./styles"; +import { Dropdown, DropdownItem } from "../dropdownMenu"; +import { + useDisplaySize, + useVspoStreamer, + useVspoStreamFilter, +} from "src/providers"; +import { MdFilterListOff } from "react-icons/md"; +import { Streamer } from "types"; + +type DropdownItemContents = { + triggerContents: ComponentProps["contents"]; +}; +type Props = Omit, "trigger"> & + DropdownItemContents; +type StreamerIcons = Streamer["youtube"]["icon"][]; + +const StreamerFilterTrigger = memo( + ({ + triggerContents, + icons, + }: DropdownItemContents & { icons: StreamerIcons }) => { + return ( + + + {icons.slice(0, 7).map((src, n, arr) => ( + + ))} + + + ); + }, +); + +export const StreamerFilter: FC = ({ + triggerContents, + ...dropdownProps +}) => { + const streamers = useVspoStreamer(); + const { filter, streamerIds } = useVspoStreamFilter(); + const { mobile } = useDisplaySize(); + + const filteredStreamerIcons = streamerIds.reduce( + (result: StreamerIcons, streamerId) => { + // TODO: use Map + const streamer = streamers.find(({ id }) => id === streamerId); + return result.concat(streamer?.youtube.icon ?? []); + }, + [], + ); + + const sortedStreamers = useMemo( + () => [...streamers].sort((a, b) => a.id.localeCompare(b.id)), + [streamers], + ); + + const checkClicked = (id: string) => streamerIds.includes(id); + const onClick = (id: string) => { + const type = checkClicked(id) + ? "removeStreamerFilter" + : "addStreamerFilter"; + filter({ type, payload: [id] }); + }; + const onClear = () => { + filter({ type: "clearStreamerFilter" }); + }; + + const Contents = (isMobile: boolean) => ( + <> + + + + + {sortedStreamers.map((s) => ( + onClick(s.id)} + /> + ))} + + + ); + + if (mobile) return Contents(mobile); + + return ( + + } + > + {Contents(mobile)} + + ); +}; diff --git a/src/components/streamerFilter/styles.tsx b/src/components/streamerFilter/styles.tsx new file mode 100644 index 0000000..9a9099f --- /dev/null +++ b/src/components/streamerFilter/styles.tsx @@ -0,0 +1,94 @@ +import { breakpointMediaQueries } from "src/configs"; +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + gap: 10px; + flex-wrap: wrap; + width: auto; + padding: 7px 14px; + overflow-y: scroll; + scrollbar-width: none; + max-height: 120px; + + ${breakpointMediaQueries.tablet` + width: 254px; + overflow-y: hidden; + max-height: max-content; + `} +`; + +export const Button = styled.button` + margin-left: auto; + + border: 0; + border-radius: 10px; + background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.normal}; + transition: 0.3s ease; + color: ${({ theme }) => theme.dropdown.input.icon}; + display: flex; + justify-content: center; + align-items: center; + padding: 5px; + + &:hover { + background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.hover}; + } + + &:active { + background-color: ${({ theme }) => theme.dropdown.filter.clear.bg.active}; + } +`; + +export const StreamerIcon = styled.img<{ isClicked: boolean }>` + height: 40px; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; + cursor: pointer; + transition: 0.1s ease-in-out; + border: 2px outset + ${({ theme, isClicked }) => + isClicked ? theme.cardHeader.icon[0] : "transparent"}; + box-shadow: ${({ isClicked }) => + isClicked + ? "none" + : `-1px -1px 3px rgba(240, 240, 240, 0.5), + 1.5px 1.5px 3px rgba(15, 15, 15, 0.5)`}; + + &:hover { + border-radius: 40%; + } + + ${({ theme, isClicked }) => breakpointMediaQueries.tablet` + height: 50px; + border: 3px outset ${isClicked ? theme.cardHeader.icon[0] : "transparent"}; + box-shadow: ${ + isClicked + ? "none" + : `-2px -2px 6px rgba(240, 240, 240, 0.5), + 3px 3px 6px rgba(15, 15, 15, 0.5)` + }; + `} +`; + +export const PreviewContainer = styled.div` + width: 100%; + height: 22px; + position: relative; + overflow-x: hidden; +`; + +export const PreviewStreamerIcon = styled.img<{ n: number }>` + position: absolute; + top: 0; + right: ${({ n }) => 13 * n}px; + z-index: ${({ n }) => n}; + height: calc(100% - 2px); + aspect-ratio: 1; + border: 1px solid ${({ theme }) => theme.dropdown.bg}; + border-radius: 50%; + object-fit: cover; + transition: 0.2s ease-in-out; + opacity: 1; +`; diff --git a/src/components/settingMenu/ToggleButton.tsx b/src/components/toggleButton/index.tsx similarity index 51% rename from src/components/settingMenu/ToggleButton.tsx rename to src/components/toggleButton/index.tsx index e61b5cd..ddc02c4 100644 --- a/src/components/settingMenu/ToggleButton.tsx +++ b/src/components/toggleButton/index.tsx @@ -1,36 +1,7 @@ -import { animated, useSpring } from "@react-spring/web"; import React, { useCallback, useMemo, useState } from "react"; -import { useShakeAnimation } from "../../hooks/useShakeAnimation"; -import styled from "styled-components"; -import { useTheme } from "../../hooks"; - -const Container = styled(animated.div)<{ width: number; height: number }>` - position: relative; - border-radius: ${(p) => p.height / 2 - 1}px; - height: ${(p) => p.height}px; - width: ${(p) => p.width}px; -`; - -const Feild = styled(animated.input)<{ cursor: string }>` - appearance: none; - outline: none; - border: none; - border-radius: inherit; - margin: 0; - padding: 0; - width: 100%; - height: 100%; - transition: 0.3s ease; - cursor: ${(p) => p.cursor}; -`; - -const Knob = styled(animated.label)` - position: absolute; - pointer-events: none; - background-color: #fafafa; - border-radius: inherit; - z-index: 1; -`; +import { useSpring } from "@react-spring/web"; +import { useTheme } from "styled-components"; +import { Area, Container, Knob } from "./styles"; type Props = { onChange: (on: boolean) => void; @@ -45,26 +16,14 @@ export const ToggleButton: React.FC = ({ initState = false, disabled = false, }) => { - const { theme } = useTheme(); + const theme = useTheme(); const [isOn, setOn] = useState(initState); const rect = useMemo( () => ({ width: size * 1.8, height: size, }), - [size] - ); - - const [shakeStyle, shakeAnim] = useShakeAnimation(5); - - const onClick = useCallback( - (e: React.MouseEvent) => { - if (!disabled) return; - - e.preventDefault(); - shakeAnim(); - }, - [disabled, shakeAnim] + [size], ); const onChangeStatus = useCallback( @@ -77,17 +36,19 @@ export const ToggleButton: React.FC = ({ setOn(e.target.checked); onChange(e.target.checked); }, - [disabled, onChange] + [disabled, onChange], ); - const { x, backgroundColor, opacity } = useSpring({ + const { x, btnBg, knobBg, opacity } = useSpring({ from: { x: initState ? 1 : 0, - backgroundColor: initState ? theme.vspo.primary : "#c7cbdf", + btnBg: theme.dropdown.item.toggle(initState).bg.normal, + knobBg: theme.dropdown.item.toggle(initState).icon, opacity: disabled ? 0.5 : 1, }, x: isOn ? 1 : 0, - backgroundColor: isOn ? theme.vspo.primary : "#c7cbdf", + btnBg: theme.dropdown.item.toggle(isOn).bg.normal, + knobBg: theme.dropdown.item.toggle(isOn).icon, opacity: disabled ? 0.5 : 1, config: { duration: 150, @@ -122,16 +83,15 @@ export const ToggleButton: React.FC = ({ }, [size]); return ( - - - + + ); }; diff --git a/src/components/toggleButton/styles.tsx b/src/components/toggleButton/styles.tsx new file mode 100644 index 0000000..f1f789f --- /dev/null +++ b/src/components/toggleButton/styles.tsx @@ -0,0 +1,36 @@ +import styled from "styled-components"; +import { animated } from "@react-spring/web"; + +export const Container = styled(animated.div)<{ + width: number; + height: number; +}>` + position: relative; + border-radius: ${(p) => p.height / 2 - 1}px; + height: ${(p) => p.height}px; + width: ${(p) => p.width}px; +`; + +export const Area = styled(animated.input)` + appearance: none; + outline: none; + border: none; + border-radius: inherit; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + transition: 0.3s ease; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + } +`; + +export const Knob = styled(animated.label)` + position: absolute; + pointer-events: none; + background-color: #fafafa; + border-radius: inherit; +`; diff --git a/src/configs/breakpoints.ts b/src/configs/breakpoints.ts index d5a9171..b05c217 100644 --- a/src/configs/breakpoints.ts +++ b/src/configs/breakpoints.ts @@ -1,74 +1,30 @@ -import { - CSSObject, - FlattenSimpleInterpolation, - SimpleInterpolation, - css, -} from "styled-components"; -import { - BreakpointMediaQueries, - BreakpointValues, - Breakpoints, -} from "../types"; +import { BreakpointMediaQueries, Breakpoints, BreakpointKey } from "types"; +import { css } from "styled-components"; -const values: BreakpointValues = { - xs: 0, - sm: 576, - md: 768, - lg: 992, - xl: 1200, - xxl: 1600, +export const breakpoints: Breakpoints = { + mobile: 0, + tablet: 768, + desktop: 1024, }; -const mediaQueries: BreakpointMediaQueries = { - xs: ( - xs: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - ${css(xs, ...interpolations)} - `, - sm: ( - sm: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - @media (min-width: ${values.sm}px) { - ${css(sm, ...interpolations)} - } - `, - md: ( - md: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - @media (min-width: ${values.md}px) { - ${css(md, ...interpolations)} - } - `, - lg: ( - lg: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - @media (min-width: ${values.lg}px) { - ${css(lg, ...interpolations)} - } +export const breakpointMediaQueries: BreakpointMediaQueries = { + mobile: (mobile, ...interpolations) => css` + ${css(mobile, ...interpolations)} `, - xl: ( - xl: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - @media (min-width: ${values.xl}px) { - ${css(xl, ...interpolations)} + tablet: (tablet, ...interpolations) => css` + @media (min-width: ${breakpoints.tablet}px) { + ${css(tablet, ...interpolations)} } `, - xxl: ( - xxl: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] - ): FlattenSimpleInterpolation => css` - @media (min-width: ${values.xxl}px) { - ${css(xxl, ...interpolations)} + desktop: (desktop, ...interpolations) => css` + @media (min-width: ${breakpoints.desktop}px) { + ${css(desktop, ...interpolations)} } `, }; -export const breakpoints: Breakpoints = { - values, - mediaQueries, +export const calcBreakPoint = (width: number): BreakpointKey => { + if (breakpoints.desktop <= width) return "desktop"; + if (breakpoints.tablet <= width) return "tablet"; + return "mobile"; }; diff --git a/src/colors/blue.ts b/src/configs/colors/blue.ts similarity index 83% rename from src/colors/blue.ts rename to src/configs/colors/blue.ts index 6d5b191..6227c0b 100644 --- a/src/colors/blue.ts +++ b/src/configs/colors/blue.ts @@ -1,4 +1,4 @@ -const blue = { +export const blue = { 50: "#ebeaf9", 100: "#ccc9ef", 200: "#aaa6e4", @@ -10,5 +10,3 @@ const blue = { 800: "#482aa2", 900: "#3d128b", }; - -export default blue; diff --git a/src/configs/colors/common.ts b/src/configs/colors/common.ts new file mode 100644 index 0000000..a7baf4e --- /dev/null +++ b/src/configs/colors/common.ts @@ -0,0 +1,4 @@ +export const common = { + black: "#000", + white: "#fff", +}; diff --git a/src/colors/grey.ts b/src/configs/colors/grey.ts similarity index 66% rename from src/colors/grey.ts rename to src/configs/colors/grey.ts index 9ae57db..2d66710 100644 --- a/src/colors/grey.ts +++ b/src/configs/colors/grey.ts @@ -1,5 +1,4 @@ -// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/colors/grey.js -const grey = { +export const grey = { 50: "#fafafa", 100: "#f5f5f5", 200: "#eeeeee", @@ -15,5 +14,3 @@ const grey = { A400: "#bdbdbd", A700: "#616161", }; - -export default grey; diff --git a/src/colors/icon.ts b/src/configs/colors/iconColor.ts similarity index 66% rename from src/colors/icon.ts rename to src/configs/colors/iconColor.ts index 30ec18c..1072539 100644 --- a/src/colors/icon.ts +++ b/src/configs/colors/iconColor.ts @@ -1,7 +1,5 @@ -const icon = { +export const iconColor = { youtube: "#ff0000", twitch: "#9146FF", twitCasting: "#0092fa", }; - -export default icon; diff --git a/src/configs/colors/index.ts b/src/configs/colors/index.ts new file mode 100644 index 0000000..683473c --- /dev/null +++ b/src/configs/colors/index.ts @@ -0,0 +1,5 @@ +export { common } from "./common"; +export { grey } from "./grey"; +export { pink } from "./pink"; +export { blue } from "./blue"; +export { iconColor } from "./iconColor"; diff --git a/src/colors/pink.ts b/src/configs/colors/pink.ts similarity index 83% rename from src/colors/pink.ts rename to src/configs/colors/pink.ts index a3639d2..cd489ca 100644 --- a/src/colors/pink.ts +++ b/src/configs/colors/pink.ts @@ -1,4 +1,4 @@ -const pink = { +export const pink = { 50: "#fee6ef", 100: "#fec1d8", 200: "#fe98be", @@ -10,5 +10,3 @@ const pink = { 800: "#c02b6a", 900: "#992462", }; - -export default pink; diff --git a/src/configs/index.ts b/src/configs/index.ts index 93e0723..608e1e0 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -1,8 +1,3 @@ -import { easings, SpringConfig } from "@react-spring/web"; - -export { breakpoints } from "./breakpoints"; - -export const springConfig: SpringConfig = { - duration: 500, - easing: easings.easeInOutSine, -}; +export * from "./colors"; +export * from "./theme"; +export * from "./breakpoints"; diff --git a/src/configs/theme/dark.ts b/src/configs/theme/dark.ts new file mode 100644 index 0000000..93ffa5c --- /dev/null +++ b/src/configs/theme/dark.ts @@ -0,0 +1,65 @@ +import { DefaultTheme } from "styled-components"; +import * as colors from "../colors"; + +export const darkTheme: DefaultTheme = { + bg: colors.grey[900], + card: { + text: colors.grey[100], + bg: colors.grey[800], + }, + cardHeader: { + icon: { + 0: colors.pink[300], + 1: colors.blue[400], + 2: colors.pink[300], + }, + text: colors.grey[100], + }, + header: { + text: colors.grey[100], + }, + dropdown: { + input: { + icon: colors.grey[100], + bg: { + normal: "transparent", + hover: colors.grey[700], + }, + }, + border: colors.grey[400], + text: colors.grey[100], + bg: colors.grey[800], + item: { + default: { + bg: { + normal: "transparent", + hover: colors.grey[700], + }, + }, + toggle: (isOn) => + isOn + ? { + icon: colors.grey[200], + bg: { + normal: colors.pink[300], + }, + } + : { + icon: colors.grey[200], + bg: { + normal: colors.pink[300], + }, + }, + }, + filter: { + clear: { + icon: colors.grey[100], + bg: { + normal: "transparent", + hover: colors.grey[600], + active: colors.grey[700], + }, + }, + }, + }, +}; diff --git a/src/configs/theme/index.ts b/src/configs/theme/index.ts new file mode 100644 index 0000000..5bb219a --- /dev/null +++ b/src/configs/theme/index.ts @@ -0,0 +1,8 @@ +import { Themes } from "styled-components"; +import { lightTheme } from "./light"; +import { darkTheme } from "./dark"; + +export const themes: Themes = { + light: lightTheme, + dark: darkTheme, +}; diff --git a/src/configs/theme/light.ts b/src/configs/theme/light.ts new file mode 100644 index 0000000..db865ee --- /dev/null +++ b/src/configs/theme/light.ts @@ -0,0 +1,65 @@ +import { DefaultTheme } from "styled-components"; +import * as colors from "../colors"; + +export const lightTheme: DefaultTheme = { + bg: colors.grey[50], + card: { + text: colors.grey[900], + bg: colors.common.white, + }, + cardHeader: { + icon: { + 0: colors.blue[400], + 1: colors.pink[300], + 2: colors.blue[400], + }, + text: colors.grey[900], + }, + header: { + text: colors.grey[900], + }, + dropdown: { + input: { + icon: colors.grey[900], + bg: { + normal: "transparent", + hover: colors.grey[200], + }, + }, + border: colors.grey[500], + text: colors.grey[900], + bg: colors.grey[100], + item: { + default: { + bg: { + normal: "transparent", + hover: colors.grey[200], + }, + }, + toggle: (isOn) => + isOn + ? { + icon: colors.grey[50], + bg: { + normal: colors.blue[400], + }, + } + : { + icon: colors.grey[50], + bg: { + normal: colors.blue[100], + }, + }, + }, + filter: { + clear: { + icon: colors.grey[900], + bg: { + normal: "transparent", + hover: colors.grey[300], + active: colors.grey[200], + }, + }, + }, + }, +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ce1c1e3..cd64044 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,9 +1,3 @@ -export { useVspoStreams } from "./useVspoStreams"; +export { useInterval } from "./useInterval"; export { useHover } from "./useHover"; -export { useTheme } from "./useTheme"; -export { useDB } from "./useDB"; -export { useStreamInfo } from "./useStreamInfo"; -export { useWindowSize } from "./useWindowSize"; -export { useConfig } from "./useConfig"; -export { useBoolStateCache } from "./useBoolStateCache"; -export { useAnimationFrame } from "./useAnimationFrame"; +export { useStreamFilter } from "./useStreamFilter"; diff --git a/src/hooks/useAnimationFrame.ts b/src/hooks/useAnimationFrame.ts deleted file mode 100644 index 744d26c..0000000 --- a/src/hooks/useAnimationFrame.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useRef } from "react"; - -export const useAnimationFrame = ( - callback = (timestamp: DOMHighResTimeStamp) => {} -) => { - const ref = useRef(0); - const rafCallback = useRef<(timestamp: DOMHighResTimeStamp) => void>(null!); - - useEffect(() => { - rafCallback.current = callback; - }, [callback]); - - useEffect(() => { - const loop = (timestamp: DOMHighResTimeStamp) => { - rafCallback.current(timestamp); - ref.current = requestAnimationFrame(loop); - }; - ref.current = requestAnimationFrame(loop); - return () => { - ref.current && cancelAnimationFrame(ref.current); - }; - }, []); -}; diff --git a/src/hooks/useBoolStateCache.ts b/src/hooks/useBoolStateCache.ts deleted file mode 100644 index 3791277..0000000 --- a/src/hooks/useBoolStateCache.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect, useState } from "react"; - -export const useBoolStateCache = (key: string, initState: boolean) => { - const [value, setter] = useState( - (() => { - const item = localStorage.getItem(key); - return item == null ? initState : item.toLocaleLowerCase() === "true"; - })() - ); - - useEffect(() => { - localStorage.setItem(key, value.toString()); - }, [value]); - - return [value, setter] as const; -}; diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts deleted file mode 100644 index e3c8a7e..0000000 --- a/src/hooks/useConfig.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { ConfigContext } from "../components/providers/ConfigProvider"; - -export const useConfig = () => { - const context = useContext(ConfigContext); - - if (typeof context === "undefined") { - throw new Error("useConfig must be within a VspoStreamingProvider"); - } - - return context; -}; diff --git a/src/hooks/useDB.ts b/src/hooks/useDB.ts deleted file mode 100644 index 48d5b22..0000000 --- a/src/hooks/useDB.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from "react"; -import { database } from "../Firebase"; -import { get, onValue, ref } from "firebase/database"; - -const cacheVersion = "vspo"; -const eventName = "OnUnmounted"; -const event = new CustomEvent(eventName); - -export const useDB = ( - path: string, - cacheAvailableTime: number //sec -): T[] => { - const [value, setValue] = useState([]); - const resp = ref(database, path); - cacheAvailableTime *= 1000; - - const doCache = (data: T[]) => { - const newCache = { - data, - timestamp: new Date().toString(), - }; - - caches.open(cacheVersion).then((cache) => { - cache.put(path, new Response(JSON.stringify(newCache))); - }); - - // console.log(`[useDB:${path}] Cached`); - }; - - useEffect(() => { - (async () => { - //check cache - const cache = await caches - .match(path) - .then((r) => r?.text()) - .then((r) => JSON.parse(r ?? "null")); - - //if cache is available - if ( - cache?.timestamp && - Date.now() - Date.parse(cache.timestamp) <= cacheAvailableTime - ) { - // console.log(`[useDB:${path}] Use Cache`); - setValue(cache.data); - } else { - // console.log(`[useDB:${path}] Get from DB`); - const data = await get(resp); - if (data.exists()) { - const val = data.val() as T[]; - doCache(val); - setValue([...val]); - } - } - - //subscribe DB listener after 2 min - const timerId = setTimeout(() => { - const unsubscriber = onValue(resp, (snap) => { - // console.log(`[useDB:${path}] OnValue`); - if (snap.exists()) { - const val = snap.val() as T[]; - doCache(val); - setValue([...val]); - } - }); - - //called on unmounted - const handleOnce = () => { - unsubscriber(); - document.removeEventListener(eventName, handleOnce); - // console.log(`[useDB:${path}] Remove listener onValue`); - }; - document.addEventListener(eventName, handleOnce); - }, 2 * 60 * 1000); - - //called on unmounted - const handleOnce = () => { - clearTimeout(timerId); - document.removeEventListener(eventName, handleOnce); - // console.log(`[useDB:${path}] Remove listener timeout`); - }; - document.addEventListener(eventName, handleOnce); - - // console.log(`[useDB:${path}] init`); - })(); - - return () => { - document.dispatchEvent(event); - // console.log(`[useDB:${path}] dispatch events on unmounted`); - }; - }, []); - - return value; -}; diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts index 95fd44b..64baf17 100644 --- a/src/hooks/useHover.ts +++ b/src/hooks/useHover.ts @@ -1,11 +1,12 @@ -import { useState } from "react"; +import { useState, PointerEvent } from "react"; export const useHover = () => { const [hovered, setHover] = useState(false); + return { hovered, - hoverSpread: { - onPointerOver: (e: any) => { + hoverParams: { + onPointerOver: (e: PointerEvent) => { e.stopPropagation(); setHover(true); }, diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts new file mode 100644 index 0000000..c6d4249 --- /dev/null +++ b/src/hooks/useInterval.ts @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +export const useInterval = (fn: () => boolean, ms: number) => { + useEffect(() => { + const timerId = setInterval(() => { + if (fn()) clearInterval(timerId); + }, ms); + + if (fn()) clearInterval(timerId); + + return () => clearInterval(timerId); + }, []); +}; diff --git a/src/hooks/useShakeAnimation.ts b/src/hooks/useShakeAnimation.ts deleted file mode 100644 index 4edac04..0000000 --- a/src/hooks/useShakeAnimation.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useSpring, useSpringRef } from "@react-spring/web"; - -export const useShakeAnimation = (x: number) => { - const api = useSpringRef(); - const style = useSpring({ - ref: api, - x: 0, - }); - - const shake = () => { - api.start({ - x: 0, - from: { x }, - config: { - mass: 1, - tension: 500, - friction: 15, - }, - }); - }; - - return [style, shake] as const; -}; diff --git a/src/hooks/useStreamFilter.ts b/src/hooks/useStreamFilter.ts new file mode 100644 index 0000000..f03647c --- /dev/null +++ b/src/hooks/useStreamFilter.ts @@ -0,0 +1,55 @@ +import { useCallback, useMemo, useEffect } from "react"; +import { Stream, Streamer } from "types"; +import { deduplication } from "src/utils"; +import { useSetting, useSettingDispatch } from "src/providers"; + +type StreamerFilterAction = { + type: "addStreamerFilter" | "removeStreamerFilter" | "clearStreamerFilter"; + payload?: Streamer["id"][]; +}; + +type FilterAction = StreamerFilterAction; + +export const useStreamFilter = (streams: Stream[]) => { + const { + filter: { streamerIds }, + } = useSetting(); + const settingDispatch = useSettingDispatch(); + + const filter = useCallback( + (action: FilterAction): void => { + if (action.type === "addStreamerFilter") { + const ids = deduplication(streamerIds.concat(action.payload ?? [])); + settingDispatch({ target: "streamerIds", payload: ids }); + return; + } + + if (action.type === "removeStreamerFilter") { + const ids = streamerIds.filter( + (id) => !(action.payload ?? []).includes(id), + ); + settingDispatch({ target: "streamerIds", payload: ids }); + return; + } + + if (action.type === "clearStreamerFilter") { + settingDispatch({ target: "streamerIds", payload: [] }); + return; + } + }, + [streamerIds], + ); + + useEffect(() => { + if (streamerIds.length > 0) + filter({ type: "addStreamerFilter", payload: streamerIds }); + }, []); + + const filteredStreams = useMemo(() => { + return streams.filter((s) => { + return streamerIds.length === 0 || streamerIds.includes(s.streamerId); + }); + }, [streams, streamerIds]); + + return { filteredStreams, streamerIds, filter }; +}; diff --git a/src/hooks/useStreamInfo.ts b/src/hooks/useStreamInfo.ts deleted file mode 100644 index 029e66d..0000000 --- a/src/hooks/useStreamInfo.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useDB } from "./useDB"; -import { ChannelDTO, StreamDTO, StreamInfo, Service } from "../types"; -import { getFormattedDate, parseToJST } from "../utils"; -import { useMemo } from "react"; - -const DB_DATA_PATH = process.env.REACT_APP_DB_DATA_PATH; - -export const useStreamInfo = ( - service: Service, - channelCacheAvailableTime = 24 * 60 * 60, //sec - streamCacheAvailableTime = 5 * 60 //sec -): StreamInfo[] => { - const channels = useDB( - `${DB_DATA_PATH}/${service}/channels`, - channelCacheAvailableTime - ); - - const streams = useDB( - `${DB_DATA_PATH}/${service}/streams`, - streamCacheAvailableTime - ); - - const streamsInfo = useMemo( - () => - streams.reduce((state, s) => { - const ch = channels.find((c) => c.id === s.channelId); - - if (ch !== undefined) { - state.push({ - id: s.id, - title: s.title, - thumbnail: s.thumbnail, - url: s.url, - startAt: s.startAt, - scheduledDate: getFormattedDate(parseToJST(Date.parse(s.startAt))), - channelId: ch.id, - name: ch.name, - icon: ch.thumbnail, - gameName: s.gameName, - service, - }); - } - - return state; - }, []), - [streams] - ); - - return streamsInfo; -}; diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts deleted file mode 100644 index a1acbcd..0000000 --- a/src/hooks/useTheme.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { ThemeContext } from "../components/providers/ThemeProvider"; - -export const useTheme = () => { - const context = useContext(ThemeContext); - - if (typeof context === "undefined") { - throw new Error("useTheme must be within a ThemeProvider"); - } - - return context; -}; diff --git a/src/hooks/useVspoStreams.ts b/src/hooks/useVspoStreams.ts deleted file mode 100644 index 69d5e0d..0000000 --- a/src/hooks/useVspoStreams.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { VspoStreamingContext } from "../components/providers/VspoStreamingProvider"; - -export const useVspoStreams = () => { - const context = useContext(VspoStreamingContext); - - if (typeof context === "undefined") { - throw new Error("useVspoStreams must be within a VspoStreamingProvider"); - } - - return context; -}; diff --git a/src/hooks/useWindowSize.ts b/src/hooks/useWindowSize.ts deleted file mode 100644 index 48db1e6..0000000 --- a/src/hooks/useWindowSize.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { WindowSizeContext } from "../components/providers/WindowSizeProvider"; - -export const useWindowSize = () => { - const context = useContext(WindowSizeContext); - - if (typeof context === "undefined") { - throw new Error("useWindowSize must be within a VspoStreamingProvider"); - } - - return context; -}; diff --git a/src/index.css b/src/index.css index 0b71428..aa57bd4 100644 --- a/src/index.css +++ b/src/index.css @@ -3,22 +3,8 @@ body { margin: 0; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: -apple-system, "Zen Kaku Gothic New", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overflow: hidden; - -ms-overflow-style: none; - scrollbar-width: none; -} - -body::-webkit-scrollbar { - display: none; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; } diff --git a/src/index.js b/src/index.js index be0d40b..ab9a00d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,12 @@ -import './Firebase'; -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import reportWebVitals from './reportWebVitals'; -import { App } from './App'; +import "./firebase"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import reportWebVitals from "./reportWebVitals"; +import { App } from "./app"; -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - -); +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/src/providers/deviceTypeProvider/context.ts b/src/providers/deviceTypeProvider/context.ts new file mode 100644 index 0000000..735747e --- /dev/null +++ b/src/providers/deviceTypeProvider/context.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import { DisplaySizeInfo } from "types"; + +export const displaySizeContext = createContext( + {} as DisplaySizeInfo, +); diff --git a/src/providers/deviceTypeProvider/index.ts b/src/providers/deviceTypeProvider/index.ts new file mode 100644 index 0000000..1ab035b --- /dev/null +++ b/src/providers/deviceTypeProvider/index.ts @@ -0,0 +1,2 @@ +export * from "./provider"; +export * from "./use"; diff --git a/src/providers/deviceTypeProvider/provider.tsx b/src/providers/deviceTypeProvider/provider.tsx new file mode 100644 index 0000000..ea0d670 --- /dev/null +++ b/src/providers/deviceTypeProvider/provider.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode, useCallback, useEffect, useReducer } from "react"; +import { displaySizeContext } from "./context"; +import { DisplaySizeInfo } from "types"; +import { calcBreakPoint } from "src/configs"; + +type Props = { + children: ReactNode; +}; + +const baseState: DisplaySizeInfo = { + mobile: false, + tablet: false, + desktop: false, +}; + +export const DisplaySizeProvider = ({ children }: Props) => { + const displaySizeReducer = useCallback( + (state: DisplaySizeInfo, width: number): DisplaySizeInfo => { + const type = calcBreakPoint(width); + if (state[type]) return state; + + return { ...baseState, [type]: true }; + }, + [], + ); + + const [displaySizeInfo, dispatch] = useReducer( + displaySizeReducer, + displaySizeReducer(baseState, window.innerWidth), + ); + + useEffect(() => { + const resize = () => { + dispatch(window.innerWidth); + }; + + resize(); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/providers/deviceTypeProvider/use.ts b/src/providers/deviceTypeProvider/use.ts new file mode 100644 index 0000000..72ea254 --- /dev/null +++ b/src/providers/deviceTypeProvider/use.ts @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { displaySizeContext } from "./context"; + +export const useDisplaySize = () => useContext(displaySizeContext); diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..04ff9d5 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,4 @@ +export * from "./vspoStreamProvider"; +export * from "./settingProvider"; +export * from "./deviceTypeProvider"; +export * from "./themeProvider"; diff --git a/src/providers/settingProvider/context.ts b/src/providers/settingProvider/context.ts new file mode 100644 index 0000000..670dbe8 --- /dev/null +++ b/src/providers/settingProvider/context.ts @@ -0,0 +1,22 @@ +import { createContext } from "react"; +import { Setting, Streamer } from "types"; + +export type SettingAction = { + target: Extract< + keyof Setting, + "isDarkTheme" | "isExpandAlways" | "isMarqueeTitle" | "isDisplayHistory" + >; + payload: boolean; +}; + +export type StreamerFilterAction = { + target: Extract; + payload: Streamer["id"][]; +}; + +export type Action = SettingAction | StreamerFilterAction; + +export const settingContext = createContext({} as Setting); +export const settingDispatchContext = createContext>( + () => console.error("Dispatched action outside of an settingDispatchContext"), +); diff --git a/src/providers/settingProvider/index.ts b/src/providers/settingProvider/index.ts new file mode 100644 index 0000000..1ab035b --- /dev/null +++ b/src/providers/settingProvider/index.ts @@ -0,0 +1,2 @@ +export * from "./provider"; +export * from "./use"; diff --git a/src/providers/settingProvider/provider.tsx b/src/providers/settingProvider/provider.tsx new file mode 100644 index 0000000..f76f8a2 --- /dev/null +++ b/src/providers/settingProvider/provider.tsx @@ -0,0 +1,98 @@ +import React, { ReactNode, useCallback, useEffect, useReducer } from "react"; +import { DeepPartial, Setting } from "types"; +import { Action, settingContext, settingDispatchContext } from "./context"; +import { checkMobile } from "src/utils"; + +type Props = { + children: ReactNode; +}; + +const storageKey = "setting"; +const isMobile = checkMobile(); + +const getLocalSetting = (): DeepPartial => { + try { + return JSON.parse(localStorage.getItem(storageKey) ?? "{}"); + } catch { + return {}; + } +}; + +const setLocalSetting = (setting: Setting) => { + localStorage.setItem(storageKey, JSON.stringify(setting)); +}; + +export const getInitSetting = () => { + const localSetting = getLocalSetting(); + + const setting: Setting = { + isDarkTheme: { + state: localSetting.isDarkTheme?.state ?? false, + isReadOnly: false, + }, + isExpandAlways: { + state: isMobile || (localSetting.isExpandAlways?.state ?? false), + isReadOnly: isMobile, + }, + isMarqueeTitle: { + state: localSetting.isMarqueeTitle?.state ?? false, + isReadOnly: true, // TODO marquee + }, + isDisplayHistory: { + state: localSetting.isDisplayHistory?.state ?? false, + isReadOnly: false, + }, + filter: { + streamerIds: localSetting.filter?.streamerIds ?? [], + }, + }; + + return setting; +}; + +export const SettingProvider = ({ children }: Props) => { + const settingReducer = useCallback( + (prev: Setting, { target, payload }: Action) => { + if ( + "isDarkTheme" === target || + "isExpandAlways" === target || + "isMarqueeTitle" === target || + "isDisplayHistory" === target + ) { + const setting = prev[target]; + if (setting.isReadOnly) return prev; + + return { ...prev, [target]: { ...setting, state: payload } }; + } + + if ("streamerIds" === target) { + return { ...prev, filter: { [target]: payload } }; + } + + return prev; + }, + [], + ); + + const [setting, dispatch] = useReducer(settingReducer, getInitSetting()); + + useEffect(() => { + const onUnmount = () => { + setLocalSetting(setting); + }; + window.addEventListener("beforeunload", onUnmount); + + return () => { + setLocalSetting(setting); + window.removeEventListener("beforeunload", onUnmount); + }; + }, [setting]); + + return ( + + + {children} + + + ); +}; diff --git a/src/providers/settingProvider/use.ts b/src/providers/settingProvider/use.ts new file mode 100644 index 0000000..a9135af --- /dev/null +++ b/src/providers/settingProvider/use.ts @@ -0,0 +1,5 @@ +import { useContext } from "react"; +import { settingContext, settingDispatchContext } from "./context"; + +export const useSetting = () => useContext(settingContext); +export const useSettingDispatch = () => useContext(settingDispatchContext); diff --git a/src/providers/themeProvider/index.tsx b/src/providers/themeProvider/index.tsx new file mode 100644 index 0000000..c381b2a --- /dev/null +++ b/src/providers/themeProvider/index.tsx @@ -0,0 +1,19 @@ +import React, { FC, ReactNode, useMemo } from "react"; +import { ThemeProvider as TProvider } from "styled-components"; +import { themes } from "src/configs"; +import { useSetting } from "../settingProvider"; + +type Props = { + children: ReactNode; +}; + +export const ThemeProvider: FC = ({ children }) => { + const { isDarkTheme } = useSetting(); + + const theme = useMemo( + () => (isDarkTheme.state ? themes["dark"] : themes["light"]), + [isDarkTheme.state], + ); + + return {children}; +}; diff --git a/src/providers/vspoStreamProvider/context.ts b/src/providers/vspoStreamProvider/context.ts new file mode 100644 index 0000000..9208fb3 --- /dev/null +++ b/src/providers/vspoStreamProvider/context.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; +import { useStreamFilter } from "src/hooks"; +import { Stream, Streamer } from "types"; + +export const vspoStreamContext = createContext([]); +export const vspoStreamerContext = createContext([]); +export const vspoStreamFilterContext = createContext< + Pick, "filter" | "streamerIds"> +>(null!); diff --git a/src/providers/vspoStreamProvider/index.ts b/src/providers/vspoStreamProvider/index.ts new file mode 100644 index 0000000..1ab035b --- /dev/null +++ b/src/providers/vspoStreamProvider/index.ts @@ -0,0 +1,2 @@ +export * from "./provider"; +export * from "./use"; diff --git a/src/providers/vspoStreamProvider/provider.tsx b/src/providers/vspoStreamProvider/provider.tsx new file mode 100644 index 0000000..6df0774 --- /dev/null +++ b/src/providers/vspoStreamProvider/provider.tsx @@ -0,0 +1,132 @@ +import React, { ReactNode, useEffect, useMemo, useState } from "react"; +import { collection, onSnapshot } from "firebase/firestore"; +import { firestore } from "src/firebase"; +import { + vspoStreamContext, + vspoStreamerContext, + vspoStreamFilterContext, +} from "./context"; +import { + Channel, + Stream, + Streamer, + StreamerMap, + StreamerResponse, + StreamResponse, +} from "types"; +import { useStreamFilter } from "src/hooks"; + +type Props = { + children: ReactNode; +}; + +const parseToStream = ( + streamRes: StreamResponse, + streamerId: string, + channel: Channel, +): Stream => { + const endAt = streamRes.endTime ? new Date(streamRes.endTime) : undefined; + + return { + id: streamRes.id, + title: streamRes.title, + thumbnail: streamRes.thumbnail, + url: streamRes.url, + streamerId, + streamerName: channel.name, + icon: channel.icon, + platform: streamRes.platform, + startAt: new Date(streamRes.scheduledStartTime), + endAt, + }; +}; + +const parseToStreamer = (streamerRes: StreamerResponse): Streamer => { + const entries = Object.entries(streamerRes).map( + ([key, { id, name, icon }]) => [key, { id, name, icon }], + ); + + return Object.fromEntries(entries); +}; + +export const VspoStreamProvider = ({ children }: Props) => { + const [streamResponses, setStreamsResponse] = useState([]); + const [streamerMap, setStreamerMap] = useState({}); + + useEffect(() => { + const streamCollectionName = process.env.REACT_APP_STREAM_COLLECTION_NAME; + const streamerCollectionName = + process.env.REACT_APP_STREAMER_COLLECTION_NAME; + + if (!streamCollectionName || !streamerCollectionName) { + throw new Error( + "Environment variable not found: REACT_APP_STREAM_COLLECTION_NAME, REACT_APP_STREAMER_COLLECTION_NAME", + ); + } + + const unSubStream = onSnapshot( + collection(firestore, streamCollectionName), + (snapshot) => { + setStreamsResponse((prev) => { + const newStreams = snapshot.docs.map( + (doc) => doc.data() as StreamResponse, + ); + return [ + ...newStreams, + ...prev.filter((s) => !newStreams.some(({ id }) => id === s.id)), + ]; + }); + }, + ); + + const unSubStreamer = onSnapshot( + collection(firestore, streamerCollectionName), + (snapshot) => { + const map = Object.fromEntries( + snapshot.docs.map((doc) => { + return [doc.id, parseToStreamer(doc.data() as StreamerResponse)]; + }), + ); + setStreamerMap(map); + }, + ); + + return () => { + unSubStreamer(); + unSubStream(); + }; + }, []); + + const streams = useMemo(() => { + return streamResponses.reduce((results: Stream[], streamRes) => { + const streamerId = streamRes.streamerId; + const channel = streamerMap[streamerId][streamRes.platform]; + + if (!channel) { + console.error(`streamerId is not found: ${streamerId}`); + return results; + } + + return results.concat(parseToStream(streamRes, streamerId, channel)); + }, []); + }, [streamResponses, streamerMap]); + + const { filteredStreams, ...filterContext } = useStreamFilter(streams); + + const streamers = useMemo(() => { + return Object.entries(streamerMap).map(([id, channels]) => ({ + ...channels, + id, + })); + }, [streamerMap]); + + return ( + + + + {children} + + + + ); +}; diff --git a/src/providers/vspoStreamProvider/use.ts b/src/providers/vspoStreamProvider/use.ts new file mode 100644 index 0000000..f106e60 --- /dev/null +++ b/src/providers/vspoStreamProvider/use.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { + vspoStreamContext, + vspoStreamerContext, + vspoStreamFilterContext, +} from "./context"; + +export const useVspoStreamFilter = () => useContext(vspoStreamFilterContext); +export const useVspoStream = () => useContext(vspoStreamContext); +export const useVspoStreamer = () => useContext(vspoStreamerContext); diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js index 5253d3a..9ecd33f 100644 --- a/src/reportWebVitals.js +++ b/src/reportWebVitals.js @@ -1,6 +1,6 @@ -const reportWebVitals = onPerfEntry => { +const reportWebVitals = (onPerfEntry) => { if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { getCLS(onPerfEntry); getFID(onPerfEntry); getFCP(onPerfEntry); diff --git a/src/setupTests.js b/src/setupTests.js index 8f2609b..1dd407a 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -2,4 +2,4 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/src/theme/dark.ts b/src/theme/dark.ts deleted file mode 100644 index 7059857..0000000 --- a/src/theme/dark.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ColorTheme } from "../types"; -import { common, grey, pink, blue } from "../colors"; - -export const dark: ColorTheme = { - text: { - primary: common.white, - }, - bg: { - primary: grey[900], - secondary: grey[800], - }, - hoverd: { - primary: grey[700], - secondary: grey[600], - }, - border: { - primary: grey[100], - }, - vspo: { - primary: pink[300], - secondary: blue[400], - }, -}; diff --git a/src/theme/index.ts b/src/theme/index.ts deleted file mode 100644 index 3d2f010..0000000 --- a/src/theme/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { dark } from "./dark"; -import { light } from "./light"; - -export default { - dark, - light, -}; diff --git a/src/theme/light.ts b/src/theme/light.ts deleted file mode 100644 index b438a3c..0000000 --- a/src/theme/light.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ColorTheme } from "../types"; -import { common, grey, pink, blue } from "../colors"; - -export const light: ColorTheme = { - text: { - primary: common.black, - }, - bg: { - primary: grey[50], - secondary: grey[100], - }, - hoverd: { - primary: grey[200], - secondary: grey[300], - }, - border: { - primary: grey[900], - }, - vspo: { - primary: blue[400], - secondary: pink[300], - }, -}; diff --git a/src/types/configs.ts b/src/types/configs.ts deleted file mode 100644 index 3dbadc2..0000000 --- a/src/types/configs.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - CSSObject, - FlattenSimpleInterpolation, - SimpleInterpolation, -} from "styled-components"; - -export type BreakpointValues = { - xs: number; - sm: number; - md: number; - lg: number; - xl: number; - xxl: number; -}; - -export type BreakpointMediaQuery = ( - base: CSSObject | TemplateStringsArray, - ...interpolations: SimpleInterpolation[] -) => FlattenSimpleInterpolation; - -export type BreakpointMediaQueries = { - xs: BreakpointMediaQuery; - sm: BreakpointMediaQuery; - md: BreakpointMediaQuery; - lg: BreakpointMediaQuery; - xl: BreakpointMediaQuery; - xxl: BreakpointMediaQuery; -}; - -export type Breakpoints = { - values: BreakpointValues; - mediaQueries: BreakpointMediaQueries; -}; diff --git a/src/types/frontLogic.ts b/src/types/frontLogic.ts deleted file mode 100644 index f4e8245..0000000 --- a/src/types/frontLogic.ts +++ /dev/null @@ -1,46 +0,0 @@ -export type Service = "youtube" | "twitch" | "twitCasting"; - -export type ChannelDTO = { - id: string; - name: string; - thumbnail: string; - uploads?: string; -}; - -export type StreamDTO = { - channelId: string; - id: string; - title: string; - thumbnail: string; - url: string; - startAt: string; - gameName?: string; -}; - -export type ChannelCache = { - channels: ChannelDTO[]; - timestamp: string; -}; - -export type StreamingCache = { - streams: StreamDTO[]; - timestamp: string; -}; - -export type ChildrenNode = { - children: React.ReactNode; -}; - -export type StreamInfo = { - id: string; - title: string; - thumbnail: string; - url: string; - startAt: string; - scheduledDate: string; - channelId: string; - name: string; - icon: string; - gameName?: string; - service: Service; -}; diff --git a/src/types/frontUI.ts b/src/types/frontUI.ts deleted file mode 100644 index b25b04a..0000000 --- a/src/types/frontUI.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { StreamInfo, Service, ChildrenNode } from "./frontLogic"; - -export type WindowSize = { - width: number; - height: number; -}; - -export type ClientType = { - isMobile: boolean; - isPhoneSize: boolean; - isTabletSize: boolean; - isDesktopSize: boolean; -}; - -export type ThumbnailBlockProps = { - title: string; - thumbnail: string; - name: string; - icon: string; - isExpand: boolean; - hovered: boolean; -} & React.HTMLAttributes; - -export type ServiceIconProps = { - startAt: string; - isExpand: boolean; - service: Service; -} & React.HTMLAttributes; - -export type StreamingHeaderProps = Omit; - -export type StreamingCardProps = Omit< - ServiceIconProps & Omit, - "isExpand" -> & { - url: string; -}; - -export type StreamingTableProps = { - streams: StreamInfo[]; -} & React.HTMLAttributes; - -export type DateBorderProps = { - dateString: string; -} & React.HTMLAttributes; - -export type BaseButtonProps = { - onClickHandler: () => void; - children: React.ReactNode; -} & React.HTMLAttributes; - -export type LinkButtonProps = { - url: string; -} & Omit; - -export type StreamList = { - date: string; - streams: StreamInfo[]; -}; diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 051e7d1..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./frontLogic"; -export * from "./frontUI"; -export * from "./theme"; -export * from "./configs"; diff --git a/src/types/theme.ts b/src/types/theme.ts deleted file mode 100644 index b676d77..0000000 --- a/src/types/theme.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type ColorLevel = { - primary: string; - secondary?: string; -}; - -export type ColorTheme = { - text: ColorLevel; - bg: ColorLevel; - hoverd: ColorLevel; - border: ColorLevel; - vspo: ColorLevel; -}; - -export type Theme = { - dark: ColorTheme; - light: ColorTheme; -}; - -export type ThemeTypes = keyof Theme; - -export type ThemeContextType = { - themeType: ThemeTypes; - theme: ColorTheme; - setThemeDark: (isOn: boolean) => void; -}; diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts new file mode 100644 index 0000000..74dae28 --- /dev/null +++ b/src/utils/arrayUtils.ts @@ -0,0 +1,3 @@ +export const deduplication = (array: any[]) => { + return Array.from(new Set(array)); +}; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..7ecc85b --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,18 @@ +export const toJST = (utcDate: Date) => { + utcDate.setHours(utcDate.getHours() + 9); + return utcDate; +}; + +export const toYYYYMMDD = (date: Date) => + date.toLocaleDateString("ja-JP", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + +export const toJstHHMM = (date: Date) => + date.toLocaleString("ja-JP", { + timeZone: "Asia/Tokyo", + hour: "2-digit", + minute: "2-digit", + }); diff --git a/src/utils/deviceUtils.ts b/src/utils/deviceUtils.ts new file mode 100644 index 0000000..414dd44 --- /dev/null +++ b/src/utils/deviceUtils.ts @@ -0,0 +1,12 @@ +/// + +export const checkMobile = (): boolean => { + if (navigator.userAgentData) { + return navigator.userAgentData.mobile; + } else { + return ( + /android|ipod|ipad|iphone/.test(navigator.userAgent.toLowerCase()) && + "ontouchend" in document + ); + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index eed7dfe..6c0c937 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,10 +1,3 @@ -export const parseToJST = (utcmilisec: number) => - new Date(utcmilisec + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000); - -export const getFormattedDate = (date: Date) => { - const y = date.getFullYear(); - const m = (date.getMonth() + 1).toString().padStart(2, "0"); - const d = date.getDate().toString().padStart(2, "0"); - - return `${y}/${m}/${d}`.replace(/\n|\r/g, ""); -}; +export * from "./dateUtils"; +export * from "./deviceUtils"; +export * from "./arrayUtils"; diff --git a/tsconfig.json b/tsconfig.json index e3642af..f31d7ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,12 +15,9 @@ "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, - "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "baseUrl": ".", }, - "include": [ - "src", - "@types" - ] + "include": ["src", "types"] } \ No newline at end of file diff --git a/types/config.ts b/types/config.ts new file mode 100644 index 0000000..f0f6c18 --- /dev/null +++ b/types/config.ts @@ -0,0 +1,24 @@ +import { + CSSObject, + FlattenSimpleInterpolation, + SimpleInterpolation, +} from "styled-components"; + +export type BreakpointKey = "mobile" | "tablet" | "desktop"; + +export type BreakpointMediaQuery = ( + base: CSSObject | TemplateStringsArray, + ...interpolations: SimpleInterpolation[] +) => FlattenSimpleInterpolation; + +export type BreakpointMediaQueries = { + [key in BreakpointKey]: BreakpointMediaQuery; +}; + +export type Breakpoints = { + [key in BreakpointKey]: number; +}; + +export type DisplaySizeInfo = { + [key in BreakpointKey]: boolean; +}; diff --git a/types/custom.ts b/types/custom.ts new file mode 100644 index 0000000..22958bf --- /dev/null +++ b/types/custom.ts @@ -0,0 +1,9 @@ +declare module "*.png" { + const content: string; + export default content; +} + +declare module "*.svg" { + const content: string; + export default content; +} diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..ecf35a1 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,12 @@ +export * from "./stream"; +export * from "./setting"; +export * from "./config"; +export * from "./theme"; + +export type DeepPartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? DeepPartial[] + : T[P] extends Readonly[] + ? Readonly>[] + : DeepPartial; +}; diff --git a/types/setting.ts b/types/setting.ts new file mode 100644 index 0000000..eb8e598 --- /dev/null +++ b/types/setting.ts @@ -0,0 +1,18 @@ +import { Streamer } from "./stream"; + +export type SettingState = { + state: boolean; + isReadOnly: boolean; +}; + +export type FilterInfo = { + streamerIds: Streamer["id"][]; +}; + +export type Setting = { + isDarkTheme: SettingState; + isExpandAlways: SettingState; + isMarqueeTitle: SettingState; + isDisplayHistory: SettingState; + filter: FilterInfo; +}; diff --git a/types/stream.ts b/types/stream.ts new file mode 100644 index 0000000..79229da --- /dev/null +++ b/types/stream.ts @@ -0,0 +1,47 @@ +export type Platform = "youtube" | "twitch" | "twitCasting"; + +export type ChannelResponse = { + id: string; + name: string; + icon: string; + platform: Platform; +}; + +export type StreamerResponse = { [k in Platform]: ChannelResponse }; + +export type StreamResponse = { + id: string; + streamerId: string; + channelId: string; + platform: Platform; + title: string; + thumbnail: string; + url: string; + scheduledStartTime: string; // date string + startTime?: string; // date string + endTime?: string; // date string + ttl: Date; +}; + +export type Channel = { + id: string; + name: string; + icon: string; +}; + +export type Streamer = { [key in Platform]: Channel } & { id: string }; + +export type StreamerMap = { [id in string]: Streamer }; + +export type Stream = { + id: string; + title: string; + thumbnail: string; + url: string; + streamerId: string; + streamerName: Channel["name"]; + icon: Channel["icon"]; + platform: Platform; + startAt: Date; + endAt?: Date; +}; diff --git a/types/theme.ts b/types/theme.ts new file mode 100644 index 0000000..720c983 --- /dev/null +++ b/types/theme.ts @@ -0,0 +1,63 @@ +import "styled-components"; + +export type ThemeType = "light" | "dark"; + +export type CardTheme = { + text: string; + bg: string; +}; + +export type CardHeaderTheme = { + icon: { + 0: string; + 1: string; + 2: string; + }; + text: string; +}; + +export type HeaderTheme = { + text: string; +}; + +export type ButtonTheme = { + icon: string; + bg: { + normal: string; + hover?: string; + active?: string; + }; +}; + +export type DropdownTheme = { + input: ButtonTheme; + filter: { + clear: ButtonTheme; + }; + border: string; + text: string; + bg: string; + item: { + default: { + bg: { + normal: string; + hover: string; + }; + }; + toggle: (isOn: boolean) => ButtonTheme; + }; +}; + +declare module "styled-components" { + export interface DefaultTheme { + bg: string; + card: CardTheme; + cardHeader: CardHeaderTheme; + header: HeaderTheme; + dropdown: DropdownTheme; + } + + export type Themes = { + [key in ThemeType]: DefaultTheme; + }; +}