diff --git a/README.md b/README.md index 858c633..0e298fb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Phenix Web Control Center ## Setup + ``` nvm use npm i ``` ## Development + ``` npm run start ``` @@ -14,11 +16,13 @@ npm run start ## Build ### Development + ``` npm run build ``` ### Production + ``` npm run build:prod -``` \ No newline at end of file +``` diff --git a/bun.lock b/bun.lock index 777e558..fcaea01 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,16 @@ "name": "webcontrolcenter-zinn-2", "dependencies": { "@phenixrts/sdk": "2025.2.2", - "@reduxjs/toolkit": "2.8.2", + "@reduxjs/toolkit": "2.9.0", + "@techniker-me/pcast-api": "2025.1.5", "@techniker-me/tools": "2025.0.16", + "moment": "2.30.1", "phenix-web-proto": "2020.0.3", "react": "19.1.1", "react-dom": "19.1.1", "react-redux": "9.2.0", + "react-router-dom": "7.8.2", + "styled-components": "6.1.19", }, "devDependencies": { "@eslint/js": "9.34.0", @@ -18,6 +22,7 @@ "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@vitejs/plugin-react-swc": "4.0.1", + "babel-plugin-styled-components": "2.1.4", "babel-plugin-transform-amd-to-commonjs": "1.6.0", "eslint": "9.34.0", "eslint-plugin-react": "7.37.5", @@ -25,8 +30,9 @@ "eslint-plugin-react-refresh": "0.4.20", "globals": "16.3.0", "prettier": "3.6.2", - "typescript": "~5.9.2", + "typescript": "5.9.2", "typescript-eslint": "8.42.0", + "typescript-plugin-styled-components": "3.0.0", "vite": "7.1.4", "vite-plugin-babel": "1.3.2", "vite-plugin-commonjs": "0.10.4", @@ -44,6 +50,8 @@ "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], @@ -52,6 +60,8 @@ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], @@ -62,12 +72,20 @@ "@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.2.2", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="], + + "@emotion/memoize": ["@emotion/memoize@0.8.1", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="], + + "@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.9", "", { "os": "aix", "cpu": "ppc64" }, "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.9", "", { "os": "android", "cpu": "arm" }, "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ=="], @@ -162,7 +180,7 @@ "@phenixrts/sdk": ["@phenixrts/sdk@2025.2.2", "", {}, "sha512-thxg7IE3a8qE/hk1KM6zMZcZXww54/Hy8UdR4C4J43itp4r+JfD3jKLMqrHajPazbz7IaqX8ihiMDgrmRBGI7w=="], - "@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.9.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.32", "", {}, "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g=="], @@ -238,6 +256,8 @@ "@swc/types": ["@swc/types@0.1.24", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng=="], + "@techniker-me/pcast-api": ["@techniker-me/pcast-api@2025.1.5", "https://registry-node.techniker.me/@techniker-me/pcast-api/-/pcast-api-2025.1.5.tgz", { "dependencies": { "phenix-edge-auth": "1.2.7" } }, "sha512-2e/ufy6rUx4fm9g8RMmzYXLUd+Tq8fQvpTCUQbgf7612u/tAZtmWujs3OUK4QzdIPF1W2GuPPWI3NzObb0paog=="], + "@techniker-me/tools": ["@techniker-me/tools@2025.0.16", "https://registry-node.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz", {}, "sha512-Ul2yj1vd4lCO8g7IW2pHkAsdeRVEUMqGpiIvSedCc1joVXEWPbh4GESW83kMHtisjFjjlZIzb3EVlCE0BCiBWQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -250,6 +270,8 @@ "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], + "@types/stylis": ["@types/stylis@4.2.5", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.42.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.42.0", "@typescript-eslint/type-utils": "8.42.0", "@typescript-eslint/utils": "8.42.0", "@typescript-eslint/visitor-keys": "8.42.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.42.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ=="], @@ -302,6 +324,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "babel-plugin-styled-components": ["babel-plugin-styled-components@2.1.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-module-imports": "^7.22.5", "@babel/plugin-syntax-jsx": "^7.22.5", "lodash": "^4.17.21", "picomatch": "^2.3.1" }, "peerDependencies": { "styled-components": ">= 2" } }, "sha512-Xgp9g+A/cG47sUyRwwYxGM4bR/jDRg5N6it/8+HxCnbT5XNKSKDT9xm4oag/osgqjC2It/vH0yXsomOG6k558g=="], + "babel-plugin-transform-amd-to-commonjs": ["babel-plugin-transform-amd-to-commonjs@1.6.0", "", { "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-Dwvn+0BM6hdLMA5sfD9QzMICo8NnqqyqCyiNeKPruAuEZDdDVWbPkPh26ckJqfL/hYIkzAvK3Zj2H/7pBzIpig=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -320,6 +344,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001739", "", {}, "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -332,8 +358,14 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -556,6 +588,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -574,6 +608,8 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -614,6 +650,8 @@ "pbf": ["pbf@3.1.0", "", { "dependencies": { "ieee754": "^1.1.6", "resolve-protobuf-schema": "^2.0.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-/hYJmIsTmh7fMkHAWWXJ5b8IKLWdjdlAFb3IHkRBn1XUhIYBChVGfVwmHEAV3UfXTxsP/AKfYTXTS/dCPxJd5w=="], + "phenix-edge-auth": ["phenix-edge-auth@1.2.7", "", {}, "sha512-hmIA4iKrR6Pf+EoIu/k7kxKYXkiD7I8PQL7iZb+9NkC676hCeGPZK+OsRc9uNW+fDZgZlN1qoQjBxDiT5JRe+A=="], + "phenix-web-assert": ["phenix-web-assert@2020.0.2", "", { "dependencies": { "phenix-web-lodash-light": "^2020.0.2" } }, "sha512-WRgWqXsL1Du/ty/dq/vkooOd3e2BhLCw24vVLWmWZjM/o5TjeOhxQSMSklrDuVRamVGiEkvuQpjL8lIeP/W4TQ=="], "phenix-web-batch-http": ["phenix-web-batch-http@2020.0.2", "", { "dependencies": { "phenix-web-assert": "^2020.0.2", "phenix-web-disposable": "^2020.0.2", "phenix-web-event": "^2020.0.2", "phenix-web-global": "^2020.0.2", "phenix-web-http": "^2020.0.2", "phenix-web-lodash-light": "^2020.0.2", "phenix-web-network-connection-monitor": "^2020.0.2", "phenix-web-observable": "^2020.0.2" } }, "sha512-SG9/9RerkYcXIEH9Vk6DRosWF2229DXyeUa0hiU7jawj8xrOnAE7CMVdKcbaK4GGbZWMrT4HetIAMkqxSaHGFA=="], @@ -638,12 +676,14 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -664,6 +704,10 @@ "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-router": ["react-router@7.8.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ=="], + + "react-router-dom": ["react-router-dom@7.8.2", "", { "dependencies": { "react-router": "7.8.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], @@ -696,12 +740,16 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -730,6 +778,10 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "styled-components": ["styled-components@6.1.19", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA=="], + + "stylis": ["stylis@4.3.2", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -740,6 +792,8 @@ "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -754,6 +808,8 @@ "typescript-eslint": ["typescript-eslint@8.42.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.42.0", "@typescript-eslint/parser": "8.42.0", "@typescript-eslint/typescript-estree": "8.42.0", "@typescript-eslint/utils": "8.42.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ozR/rQn+aQXQxh1YgbCzQWDFrsi9mcg+1PM3l/z5o1+20P7suOIaNg515bpr/OYt6FObz/NHcBstydDLHWeEKg=="], + "typescript-plugin-styled-components": ["typescript-plugin-styled-components@3.0.0", "", { "peerDependencies": { "typescript": "~4.8 || 5" } }, "sha512-QWlhTl6NqsFxtJyxn7pJjm3RhgzXSByUftZ3AoQClrMMpa4yAaHuJKTN1gFpH3Ti+Rwm56fNUfG9pXSBU+WW3A=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], @@ -802,7 +858,13 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], } diff --git a/package.json b/package.json index 0b99586..f2b6cd6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,16 @@ }, "dependencies": { "@phenixrts/sdk": "2025.2.2", - "@reduxjs/toolkit": "2.8.2", + "@reduxjs/toolkit": "2.9.0", + "@techniker-me/pcast-api": "2025.1.5", "@techniker-me/tools": "2025.0.16", + "moment": "2.30.1", "phenix-web-proto": "2020.0.3", "react": "19.1.1", "react-dom": "19.1.1", - "react-redux": "9.2.0" + "react-redux": "9.2.0", + "react-router-dom": "7.8.2", + "styled-components": "6.1.19" }, "devDependencies": { "@eslint/js": "9.34.0", @@ -27,6 +31,7 @@ "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@vitejs/plugin-react-swc": "4.0.1", + "babel-plugin-styled-components": "2.1.4", "babel-plugin-transform-amd-to-commonjs": "1.6.0", "eslint": "9.34.0", "eslint-plugin-react": "7.37.5", @@ -34,8 +39,9 @@ "eslint-plugin-react-refresh": "0.4.20", "globals": "16.3.0", "prettier": "3.6.2", - "typescript": "~5.9.2", + "typescript": "5.9.2", "typescript-eslint": "8.42.0", + "typescript-plugin-styled-components": "3.0.0", "vite": "7.1.4", "vite-plugin-babel": "1.3.2", "vite-plugin-commonjs": "0.10.4" diff --git a/src/App.tsx b/src/App.tsx index dbd512e..be7bb75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,12 @@ -import {JSX, useState} from 'react'; -import {useAppDispatch} from './store'; -import {authenticateCredentialsThunk} from './store/slices/Authentication.slice'; - -export default function App(): JSX.Element { - const dispatch = useAppDispatch(); - const [applicationId, setApplicationId] = useState('phenixrts.com-alex.zinn'); - const [secret, setSecret] = useState('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'); - - const handleAuthenticate = async () => { - const response = await dispatch(authenticateCredentialsThunk({applicationId, secret})); - console.log(`${new Date().toISOString()} AuthenticationResponse [%o]`, response.payload); - }; +import {JSX} from 'react'; +import Router from './routers'; +const App = (): JSX.Element => { return ( <> -

Hello World

-
- setApplicationId(e.target.value)} /> -
- setSecret(e.target.value)} /> -
- + ); -} +}; + +export default App; diff --git a/src/assets/images/spinners/loading-icon-32x32.gif b/src/assets/images/spinners/loading-icon-32x32.gif deleted file mode 100644 index 5331edb..0000000 Binary files a/src/assets/images/spinners/loading-icon-32x32.gif and /dev/null differ diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..7a8bf53 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import {JSX} from 'react'; +import {Navigate, useLocation} from 'react-router-dom'; +import {useAppSelector} from 'store'; +import {selectIsAuthenticated} from 'store/slices/Authentication.slice'; + +export function ProtectedRoute({component}: {component: JSX.Element}): JSX.Element { + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + return component; +} diff --git a/src/components/buttons/copy-icon-button/index.tsx b/src/components/buttons/copy-icon-button/index.tsx new file mode 100644 index 0000000..0344ff1 --- /dev/null +++ b/src/components/buttons/copy-icon-button/index.tsx @@ -0,0 +1,32 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {useState} from 'react'; +import {faCopy, faCheck} from '@fortawesome/free-solid-svg-icons'; +import IconButton from 'components/buttons/icon-button'; + +import {CopyButtonContainer} from './styles'; + +const iconChangeTimeout = 2000; + +export const CopyIconButton = (props: {text: string; quoted?: boolean; displayText?: boolean; className?: string}): JSX.Element => { + const {text, quoted = false, displayText = true, className} = props; + const [copied, setCopied] = useState(false); + const copyToClipboard = (): void => { + navigator.clipboard.writeText(text); + setCopied(true); + + setTimeout(() => setCopied(false), iconChangeTimeout); + }; + + return ( + + {displayText && (quoted ? `"${text}"` : text)} + + + ); +}; \ No newline at end of file diff --git a/src/components/buttons/copy-icon-button/styles.ts b/src/components/buttons/copy-icon-button/styles.ts new file mode 100644 index 0000000..54e30cc --- /dev/null +++ b/src/components/buttons/copy-icon-button/styles.ts @@ -0,0 +1,9 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; + +export const CopyButtonContainer = styled.default.div` + display: flex; + align-items: center; +`; \ No newline at end of file diff --git a/src/components/buttons/export-file-button/index.tsx b/src/components/buttons/export-file-button/index.tsx new file mode 100644 index 0000000..0ba7baa --- /dev/null +++ b/src/components/buttons/export-file-button/index.tsx @@ -0,0 +1,36 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {theme} from 'components/shared/theme'; +import {Button} from 'components/buttons'; + +const {colors} = theme; + +interface IExportFileButton { + label?: string; + file: string; + fileName?: string; +} + +export const ExportFileButton = ({label = 'Export File', file, fileName = 'file'}: IExportFileButton): JSX.Element => { + const handleExport = () => { + const downloadUrl = URL.createObjectURL(new Blob([file])); + const linkTag = document.createElement('a'); + + linkTag.href = downloadUrl; + linkTag.setAttribute('target', '_blank'); + linkTag.setAttribute('download', fileName); + linkTag.click(); + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/buttons/icon-button/icon-button.tsx b/src/components/buttons/icon-button/icon-button.tsx new file mode 100644 index 0000000..24e9721 --- /dev/null +++ b/src/components/buttons/icon-button/icon-button.tsx @@ -0,0 +1,32 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import {IconProp} from '@fortawesome/fontawesome-svg-core'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; + +import {Position, Tooltip} from 'components/tooltip'; + +import {IconButtonContainer} from './styles'; + +interface IIconButton { + onClick: () => void; + tooltipText: string; + icon: IconProp; + className?: string; +} + +const IconButton = ({ + onClick, + tooltipText, + icon, + className +}: IIconButton) => ( + + + + + +); + +export default IconButton; \ No newline at end of file diff --git a/src/components/buttons/icon-button/index.tsx b/src/components/buttons/icon-button/index.tsx new file mode 100644 index 0000000..35c6141 --- /dev/null +++ b/src/components/buttons/icon-button/index.tsx @@ -0,0 +1,5 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export {default} from './icon-button'; \ No newline at end of file diff --git a/src/components/buttons/icon-button/styles.ts b/src/components/buttons/icon-button/styles.ts new file mode 100644 index 0000000..43fb911 --- /dev/null +++ b/src/components/buttons/icon-button/styles.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; + +export const IconButtonContainer = styled.default.div` + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &.disabled { + cursor: not-allowed; + pointer-events: none; + opacity: 0.5; + } + &.icon-button { + width: 2.5rem; + height: 2.5rem; + } + + svg { + margin: 0; + } + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } +`; \ No newline at end of file diff --git a/src/components/buttons/icon-buttons/add-button.tsx b/src/components/buttons/icon-buttons/add-button.tsx new file mode 100644 index 0000000..f0c0c07 --- /dev/null +++ b/src/components/buttons/icon-buttons/add-button.tsx @@ -0,0 +1,16 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {IconButton} from './style'; +import addIcon from 'assets/images/icon/hash-plus.svg'; + +interface IAddButton { + onClick: () => void; + className: string; +} + +export const AddButton = ({onClick, className}: IAddButton): JSX.Element => ( + + {'Add'} + +); \ No newline at end of file diff --git a/src/components/buttons/icon-buttons/index.ts b/src/components/buttons/icon-buttons/index.ts new file mode 100644 index 0000000..10f145b --- /dev/null +++ b/src/components/buttons/icon-buttons/index.ts @@ -0,0 +1,5 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export * from './refresh-button'; +export * from './add-button'; \ No newline at end of file diff --git a/src/components/buttons/icon-buttons/refresh-button.tsx b/src/components/buttons/icon-buttons/refresh-button.tsx new file mode 100644 index 0000000..d682c83 --- /dev/null +++ b/src/components/buttons/icon-buttons/refresh-button.tsx @@ -0,0 +1,17 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {IconButton} from './style'; + +import refreshIcon from 'assets/images/icon/refresh.svg'; + +interface IRefreshButton { + onClick: () => void; + disabled?: boolean; +} + +export const RefreshButton = ({onClick, disabled = false}: IRefreshButton): JSX.Element => ( + + {'Refresh'} + +); \ No newline at end of file diff --git a/src/components/buttons/icon-buttons/style.ts b/src/components/buttons/icon-buttons/style.ts new file mode 100644 index 0000000..a36945d --- /dev/null +++ b/src/components/buttons/icon-buttons/style.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {theme} from 'components/shared/theme'; + +const {fontSizeL, colors} = theme; + +export const IconButton = styled.default.button` + border: none; + background-color: transparent; + font-size: ${fontSizeL}; + color: ${colors.white}; + opacity: ${({disabled}) => disabled ? 0.3 : 1}; + cursor: ${({disabled}) => disabled ? 'not-allowed' : 'pointer'}; + display: flex; +`; \ No newline at end of file diff --git a/src/components/buttons/index.tsx b/src/components/buttons/index.tsx new file mode 100644 index 0000000..bc980a6 --- /dev/null +++ b/src/components/buttons/index.tsx @@ -0,0 +1,57 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import Theme from 'theme'; + +export const Button = styled.default.button<{ + backgroundColor?: string; + borderColor?: string; + textColor?: string; + disabled?: boolean; +}>` + ${({backgroundColor, textColor, borderColor}) => styled.css` + color: ${textColor || Theme.colors.white}; + background-color: ${backgroundColor || Theme.colors.white}; + border-color: ${borderColor || backgroundColor || Theme.colors.lightRed}; + `} + ${({disabled}) => styled.css` + opacity: ${disabled ? 0.8 : 1}; + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + `} + min-width: 120px; + text-align: center; + outline: none; + vertical-align: middle; + border-radius: ${Theme.primaryBorderRadius}; + padding: ${Theme.spacing.small} ${Theme.spacing.medium}; + font-size: ${Theme.typography.primaryFontSize}; + transition: color .15s ease-in-out, background-color .15s ease-in-out; +`; + +export const FilterButton = styled.default(Button)` + font-weight: bolder; + justify-self: center; + margin: 0 ${Theme.spacing.xSmall}; + padding: ${Theme.spacing.small} ${Theme.spacing.xlarge}; +`; + +export const ConfirmButton = styled.default(Button)` + margin-right: 1rem; + display: flex; + align-items: center; + justify-content: center; +`; + +export const CancelButton = styled.default(ConfirmButton)``; + +export const CustomButton = styled.default(Button)` + background-color: ${Theme.dangerColor}; + border-color: ${Theme.dangerColor}; + color: ${Theme.colors.white}; + font-weight: bolder; + justify-self: center; + margin: 0.75rem; + padding: ${Theme.spacing.small} ${Theme.spacing.xlarge}; + overflow: visible; +`; \ No newline at end of file diff --git a/src/components/buttons/radio-button/index.tsx b/src/components/buttons/radio-button/index.tsx new file mode 100644 index 0000000..c7f0a60 --- /dev/null +++ b/src/components/buttons/radio-button/index.tsx @@ -0,0 +1,88 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {Fragment} from 'react'; +import {Tooltip, Position} from 'components/tooltip'; +import {Label} from 'components/label'; +import { + RadioGroup, + RadioWrapper, + RadioButtonContainer, + VisibleCheckBox +} from './style'; + +interface IRadioItems { + label: string; + value: string; + tooltipMessage?: string; + tooltipPosition?: Position; + className?: string; + children?: JSX.Element; + disabled?: boolean; +} + +interface IRadioButtonGroup { + items: IRadioItems[]; + handleOnChange: (value) => void; + currentValue: string; +} + +const RadioButton = (props: {currentValue: string; value: string}) => { + const {currentValue, value} = props; + + return ( + + +
+ + ); +}; + +const RadioButtonGroup = (props: IRadioButtonGroup): JSX.Element => { + const {items, handleOnChange, currentValue} = props; + + return ( + + {items.map(( + { + label, + value, + disabled, + tooltipPosition, + tooltipMessage, + children, + className + }: IRadioItems, + index: number + ) => ( + null} + disabled={disabled} + className="button-container" + role="button" + key={label + index} + onClick={() => handleOnChange(value)}> + + + {tooltipMessage ? ( + + + ) : + + + ))} + + ); +}; + +export default RadioButtonGroup; \ No newline at end of file diff --git a/src/components/buttons/radio-button/style.ts b/src/components/buttons/radio-button/style.ts new file mode 100644 index 0000000..5d93437 --- /dev/null +++ b/src/components/buttons/radio-button/style.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {theme, paddings} from 'components/shared/theme'; + +const { + spacing, + primaryFontSize, + colors +} = theme; + +export const RadioGroup = styled.default.div` + display: flex; +`; + +export const RadioWrapper = styled.default.div<{disabled?: boolean}>` + padding: ${paddings.small}; + display: flex; + + ${({disabled}) => disabled && styled.css` + pointer-events: none; + opacity: .5; + `} +`; + +export const RadioButtonContainer = styled.default.div` + margin-right: ${spacing.xSmall}; + align-self: center; + + input { + position: absolute; + visibility: hidden; + } +`; + +export const VisibleCheckBox = styled.default.div<{checked?: boolean}>` + border: 2px solid ${colors.gray400}; + display: flex; + align-items: center; + justify-content: center; + width: ${primaryFontSize}; + height: ${primaryFontSize}; + border-radius: 50%; + + ${({checked}) => checked && styled.css` + border: none; + background-color: ${colors.red}; + + div { + background-color: ${colors.black}; + width: 5px; + height: 5px; + border-radius: 50%; + } + `} +`; \ No newline at end of file diff --git a/src/components/buttons/scroll-buttons/index.tsx b/src/components/buttons/scroll-buttons/index.tsx new file mode 100644 index 0000000..ee70658 --- /dev/null +++ b/src/components/buttons/scroll-buttons/index.tsx @@ -0,0 +1,80 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {theme} from 'components/shared/theme'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faBackward, + faFastBackward, + faFastForward, + faForward +} from '@fortawesome/free-solid-svg-icons'; + +const {colors, spacing} = theme; +const ScrollButton = styled.default.button` + border: none; + width: 25px; + height: 25px; + color: ${colors.white}; + margin: ${spacing.xxSmall} 0; + background-color: ${colors.gray600}; + cursor: pointer; + transform: rotate(90deg) +`; +const TwinButtons = styled.default.div` + display: flex; + position: absolute; + right: 64px; + bottom: 50px; + flex-direction: column; +`; + +export const ScrollButtons = ({current}: {current: HTMLDivElement}): JSX.Element => { + const getScrollStep = current => { + const tableViewHeight = current.offsetHeight; + + return tableViewHeight / 2; + }; + + const scrollToTop = () => { + if (current) { + current.scrollTop = 0; + } + }; + + const scrollTop = () => { + if (current) { + current.scrollTop = current.scrollTop - getScrollStep(current); + } + }; + + const scrollBottom = () => { + if (current) { + current.scrollTop = current.scrollTop + getScrollStep(current); + } + }; + + const scrollToBottom = () => { + if (current) { + current.scrollTop = current.scrollHeight; + } + }; + + return ( + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/forms/Input.tsx b/src/components/forms/Input.tsx new file mode 100644 index 0000000..949b07e --- /dev/null +++ b/src/components/forms/Input.tsx @@ -0,0 +1,134 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {ChangeEvent, forwardRef, ForwardedRef, InputHTMLAttributes} from 'react'; +import * as styled from 'styled-components'; +import Theme from 'theme'; +import {Label} from '../label'; + +export interface IInput extends InputHTMLAttributes { + onChange?: (event: ChangeEvent) => void; + error?: boolean; + icon?: JSX.Element; + imagePath?: string; + imageAltText?: string; + label?: string; + labelColor?: string; + labelIcon?: JSX.Element; + labelClassName?: string; + helperText?: string; + helperTextClassName?: string; + width?: number | string; +} + +const { + colors, + typography, + formFieldWidth, + formFieldMaxWidth, + primaryBorderColor, + primaryBorderRadius, + primaryInputHeight, + inputIconWidth, + spacing +} = Theme; + +export const InputElement = styled.default.input` + background-color: ${colors.white}; + border: 1px solid ${({error}) => error ? colors.lightRed : primaryBorderColor}; + border-radius: ${primaryBorderRadius}; + display: block; + font-size: ${typography.primaryFontSize}; + height: ${primaryInputHeight}; + line-height: ${typography.primaryLineHeight}; + outline: none; + padding: ${spacing.small} ${({icon, imagePath}) => (icon || imagePath) ? spacing.xlarge : spacing.small}; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; + background-position: 1rem center; + background-repeat: no-repeat; + width: inherit; + opacity: ${({disabled}) => disabled ? 0.8 : 1}; + cursor: ${({disabled}) => disabled && 'not-allowed'}; + -webkit-text-fill-color: ${({disabled}) => disabled && colors.gray800}; +`; +const HelperText = styled.default.p` + color: ${({error}) => error ? colors.lightRed : colors.gray600}; + font-weight: 400; + font-size: ${typography.fontSizeS}; + margin-top: ${spacing.xxSmall}; +`; +const ImageWrapper = styled.default.div` + width: ${inputIconWidth}px; + height: ${inputIconWidth}px; + z-index: 1; + top: calc(50% - ${inputIconWidth / 2}px); + left: 8px; + position: absolute; + display: flex; + align-items: center; + + & img, svg { + width: ${inputIconWidth}px; + height: ${inputIconWidth}px; + } +`; +const InputWrapper = styled.default.div` + width: 100%; + position: relative; +`; + +export const InputComponentWrapper = styled.default.div` + position: relative; + width: ${({width}) => width && isNaN(+width) ? width : ((width || formFieldWidth) + 'px')}; + max-width: ${formFieldMaxWidth}px; + display: flex; + flex-direction: column; +`; +export const InputComponent = forwardRef(( + { + label, + labelColor, + labelIcon, + labelClassName, + icon, + imagePath, + imageAltText = '', + helperText, + helperTextClassName, + error, + width, + disabled, + name, + ...props + }: IInput, ref: ForwardedRef): JSX.Element => { + const InputIcon = icon || (imagePath && {imageAltText}); + + return ( + + {!!label && ( + + ); +}); + +export default InputComponent; \ No newline at end of file diff --git a/src/components/forms/label/index.tsx b/src/components/forms/label/index.tsx new file mode 100644 index 0000000..9c4ff54 --- /dev/null +++ b/src/components/forms/label/index.tsx @@ -0,0 +1,23 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {JSX} from 'react'; +import {Label as StyledLabel} from './style'; + +interface ILabel { + text: string; + htmlFor?: string; + color?: string; + icon?: JSX.Element; + className?: string; +} + +export const Label = ({text, htmlFor, color, icon, className}: ILabel): JSX.Element => ( + + {text} {icon} + +); \ No newline at end of file diff --git a/src/components/forms/label/style.ts b/src/components/forms/label/style.ts new file mode 100644 index 0000000..6ef8d51 --- /dev/null +++ b/src/components/forms/label/style.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import Theme from 'theme'; + +const {spacing, colors, typography} = Theme; + +export const Label = styled.default.label<{color?: string}>` + font-size: ${typography.fontSizeS}; + color: ${({color}) => (color || colors.gray900)}; + font-weight: bold; + margin: ${spacing.xxSmall} 0; + display: block; +`; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..a124ebd --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './buttons'; +export * from './loaders'; +export * from './ProtectedRoute'; +export * from './typography'; \ No newline at end of file diff --git a/src/components/loaders/LoadingWheel.tsx b/src/components/loaders/LoadingWheel.tsx new file mode 100644 index 0000000..282d79b --- /dev/null +++ b/src/components/loaders/LoadingWheel.tsx @@ -0,0 +1,54 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import Theme from 'theme'; + +interface LoadingWheelProps { + size?: 'small' | 'medium' | 'large'; + color?: string; + className?: string; +} + +const LoadingWheelContainer = styled.default.div<{ + size: number; + color: string; +}>` + display: inline-block; + width: ${({ size }) => size}px; + height: ${({ size }) => size}px; + border: 3px solid ${({ color }) => color}20; + border-radius: 50%; + border-top-color: ${({ color }) => color}; + animation: spin 1s ease-in-out infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +`; + +export const LoadingWheel: React.FC = ({ + size = 'medium', + color = Theme.colors.white, + className +}) => { + const sizeMap = { + small: Theme.loaderSize.small, + medium: Theme.loaderSize.medium, + large: Theme.loaderSize.large + }; + + return ( + + ); +}; + +export default LoadingWheel; diff --git a/src/components/loaders/index.ts b/src/components/loaders/index.ts new file mode 100644 index 0000000..4db1d87 --- /dev/null +++ b/src/components/loaders/index.ts @@ -0,0 +1,4 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export * from './LoadingWheel'; \ No newline at end of file diff --git a/src/components/shared/utils.ts b/src/components/shared/utils.ts new file mode 100644 index 0000000..bcda305 --- /dev/null +++ b/src/components/shared/utils.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import moment, {DurationInputArg1, unitOfTime} from 'moment'; +import PlatformDetectionService from 'services/platformDetection.service'; + +export const truncateWord = (word = '', length = 30): string => { + if (word.length >= length) { + return `${word.substring(0, length)}...`; + } + + return word; +}; + +export const isEqual = (a: any, b: any): boolean => { // eslint-disable-line + if (typeof(a) === typeof(b)) { + if (typeof(a) === 'object') { + return JSON.stringify(a) === JSON.stringify(b); + } + + return a === b; + } + + return false; +}; + +export const splitWord = (string = ' ', seperator = ' '): string => { + return string.split(/(?=[A-Z])/).join(seperator); +}; + +export const mergeSimilarCSVFiles = (formerCSV: string, latterCSV: string): string => { + const formerCsvArray = formerCSV.split('\n').filter(line => line.trim() !== ''); + const latterCsvArray = latterCSV.split('\n').filter(line => line.trim() !== ''); + + if (latterCsvArray.length > 1) { + return `${formerCsvArray.join('\n')}\n${latterCsvArray.splice(1).join('\n')}`; + } + + return formerCSV; +}; + +export const chunk = (array: any[], size: number = 1) => { // eslint-disable-line + const arrayChunks = []; + + for (let i = 0; i < array.length; i += size) { + const arrayChunk = array.slice(i, i + size); + + arrayChunks.push(arrayChunk); + } + + return arrayChunks; +}; + +export const getRangeByInterval = (start: Date, end: Date, intervalAmount: DurationInputArg1 = 24, intervalUnits: unitOfTime.DurationConstructor = 'hours') => { + const datesArray = []; + let currentDate = moment.utc(start); + const stopDate = moment.utc(end); + + while (currentDate.isSameOrBefore(stopDate)) { + datesArray.push(currentDate.toISOString()); + currentDate = currentDate.add(intervalAmount, intervalUnits); + } + + return datesArray.reverse(); +}; + +export const getBrowserLimits = (): IBrowserLimits => browserLimits[PlatformDetectionService.browserName.toLowerCase()]; + +export const getMaxByPropertyName = ( + array: Record[], + propertyName: string +): number => { + let length = array.length; + let max = -Infinity; + + while (length--) { + const element = parseFloat(array[length][propertyName] as string); + + if (isFinite(element) && element > max) { + max = element; + } + } + + return max; +}; \ No newline at end of file diff --git a/src/components/typography/index.tsx b/src/components/typography/index.tsx new file mode 100644 index 0000000..776425c --- /dev/null +++ b/src/components/typography/index.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import Theme from 'theme'; + + +export const H1 = styled.default.h1` + font-family: ${Theme.typography.primaryFont}; + font-size: ${Theme.typography.fontSizeXxl}; + font-weight: lighter; + ${Theme.screenSizes.mediaPhone}{ + font-size: ${Theme.typography.fontSizeXl}; + } +`; + +export const P = styled.default.p` + font-family: ${Theme.typography.primaryFont}; + font-size: ${Theme.typography.primaryFontSize}; + font-weight: normal; +`; + +export const Heading = styled.default.h2` + color: ${Theme.colors.white}; + font-size: ${Theme.typography.fontSizeXl}; + font-weight: bold; +`; + +export const WhiteText = styled.default(P)` + color: ${Theme.colors.white}; +`; \ No newline at end of file diff --git a/src/constants/browser-limits.ts b/src/constants/browser-limits.ts new file mode 100644 index 0000000..d2a8737 --- /dev/null +++ b/src/constants/browser-limits.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface IBrowserLimits { + bytes: number; + value: string; +} + +export const browserLimits = { + chrome: { + bytes: 563085312, + value: '537 MB' + }, + firefox: { + bytes: 1073741824, + value: '1 G' + }, + edge: { + bytes: 524288000, + value: '500 MB' + }, + safari: { + bytes: 1342177280, + value: '1.25G' + } +}; diff --git a/src/constants/capabilities.ts b/src/constants/capabilities.ts new file mode 100644 index 0000000..941d9fb --- /dev/null +++ b/src/constants/capabilities.ts @@ -0,0 +1,268 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import { IAdvancedSelectItem } from 'components/ui/advanced-select'; + +export enum VideoQuality { + Xhd = 'xhd', + Fhd = 'fhd', + Hd = 'hd', + Sd = 'sd', + Ld = 'ld', + Vld = 'vld', + Uld = 'uld', + AudioOnly = 'audio-only' +} + +export enum CapabilitiesSet { + TrackIsolation = 'track-isolation', + AspectRatio = 'aspect-ratio', + ResolutionLimit = 'resolution-limit', + EncodingJitterBuffer = 'encoding-jitter-buffer', + EncodingProfile = 'encoding-profile', + PlayoutBuffer = 'playout-buffer', + Ingest = 'ingest', + MultiBitrateMode = 'multi-bitrate-mode', + Quality = 'quality' +} + +export enum CapabilitiesType { + RemoteUriPublishing = 'remote-uri-publishing', + Publishing = 'publishing', + Viewing = 'viewing', + Forking = 'forking', + Quality = 'quality' +} + +export const capabilities: IAdvancedSelectItem[] = [ + { + value: VideoQuality.Xhd, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Fhd, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Hd, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Sd, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Ld, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Vld, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: VideoQuality.Uld, + type: [CapabilitiesType.Quality], + set: [CapabilitiesSet.Quality] + }, + { + value: 'prefer-vp8', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'prefer-vp9', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'prefer-h264', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'streaming', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking] + }, + { + value: 'on-demand', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking] + }, + { + value: 'streaming-lite', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['streaming'] + }, + { + value: 'on-demand-lite', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['on-demand'] + }, + { + value: 'multi-bitrate', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.MultiBitrateMode] + }, + { + value: 'multi-bitrate-contribution', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.MultiBitrateMode] + }, + { + value: 'multi-bitrate-codec=vp8', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'multi-bitrate-codec=vp9', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'multi-bitrate-codec=h264', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'aspect-ratio=16x9', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate'], + set: [CapabilitiesSet.AspectRatio] + }, + { + value: 'aspect-ratio=4x3', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate'], + set: [CapabilitiesSet.AspectRatio] + }, + { + value: 'aspect-ratio=9x16', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate'], + set: [CapabilitiesSet.AspectRatio] + }, + { + value: 'aspect-ratio=3x4', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate'], + set: [CapabilitiesSet.AspectRatio] + }, + { + value: 'resolution-limit=480', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + set: [CapabilitiesSet.ResolutionLimit] + }, + { + value: 'resolution-limit=720', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + set: [CapabilitiesSet.ResolutionLimit] + }, + { + value: 'bitrate-limit=1000000', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'encoding-jitter-buffer=PT0.5S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.EncodingJitterBuffer] + }, + { + value: 'encoding-jitter-buffer=PT1.0S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.EncodingJitterBuffer] + }, + { + value: 'encoding-jitter-buffer=PT2.0S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.EncodingJitterBuffer] + }, + { + value: 'encoding-profile=phenix-2020-1080p', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate', 'multi-bitrate-contribution'], + set: [CapabilitiesSet.EncodingProfile] + }, + { + value: 'encoding-profile=phenix-2020-720p', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate', 'multi-bitrate-contribution'], + set: [CapabilitiesSet.EncodingProfile] + }, + { + value: 'encoding-profile=phenix-2020-480p', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['multi-bitrate', 'multi-bitrate-contribution'], + set: [CapabilitiesSet.EncodingProfile] + }, + { + value: 'playout-buffer=PT0.3S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.PlayoutBuffer] + }, + { + value: 'playout-buffer=PT0.5S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.PlayoutBuffer] + }, + { + value: 'playout-buffer=PT0.8S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.PlayoutBuffer] + }, + { + value: 'playout-buffer=PT1.0S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.PlayoutBuffer] + }, + { + value: 'playout-buffer=PT2.0S', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.PlayoutBuffer] + }, + { + value: 'audio-only', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking, CapabilitiesType.Quality], + set: [CapabilitiesSet.TrackIsolation, CapabilitiesSet.Quality] + }, + { + value: 'video-only', + type: [CapabilitiesType.Viewing, CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + set: [CapabilitiesSet.TrackIsolation] + }, + { + value: 'high-fidelity', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking] + }, + { + value: 'on-demand-archive=PT1D', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking], + dependency: ['on-demand'] + }, + { + value: 'token-auth', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + dependency: ['streaming'] + }, + { + value: 'source-uri-ingest', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + set: [CapabilitiesSet.Ingest] + }, + { + value: 'mpegts-unicast-ingest', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + set: [CapabilitiesSet.Ingest] + }, + { + value: 'mpegts-multicast-ingest', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing], + set: [CapabilitiesSet.Ingest] + }, + { + value: 'time-shift-manifest', + type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing] + }, + { + value: 'replay', + type: [CapabilitiesType.Viewing] + } +]; diff --git a/src/constants/data.ts b/src/constants/data.ts new file mode 100644 index 0000000..267cda9 --- /dev/null +++ b/src/constants/data.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export const legacyStreamTokens = [ + 'streamTokenForBroadcastStream', + 'streamTokenForLiveStream', + 'streamTokenForLiveStreamWithDrmOpenAccess', + 'streamTokenForLiveStreamWithDrmHollywood', + 'streamToken' +]; + +export enum ViewContextTypes { + Channel = 'channel', + Room = 'room' +} diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts new file mode 100644 index 0000000..c0b8f56 --- /dev/null +++ b/src/constants/error-messages.ts @@ -0,0 +1,178 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +const commonErrorMessages = { + unauthorized: 'The streaming platform was not able to authorize the provided credentials', + capacity: 'The system is temporarily overloaded. Please try again later', + 'rate-limited': 'The system is temporarily overloaded. Please try again later', + 'time-exceeded': 'The request took to long to execute. Please try again later' +}; + +export const reportsErrorMessages = { + 'excessive-report-interval': 'The interval for the report exceed 1 month', + 'period-start-outside-supported-window': 'The year of the start of period is more than 1 year before the current year', + 'period-end-must-be-in-past': 'The end of period is in future', + 'period-end-must-be-after-start': 'The end of period is less than or equal to the start of the period', + 'malformed-version-in-request-query-parameter': 'The supplied version in HTTP query parameter does not conform to the format "YYYY-MM-dd"', + 'version-does-not-exist': 'The supplied version in HTTP query parameter does not exist', + unsupported: 'The parameter combination is not supported', + unauthorized: 'The streaming platform was not able to authorize the provided credentials', + 'request-timeout': 'Request timed out likely due to temporary resource or network conditions. Please try again', + capacity: 'The system is temporarily overloaded. Please try again later', + default: 'Unable to generate report' +}; + +export const currentActivityErrorMessages = { + 'socket-not-authenticated': 'Socket not authenticated', + 'unable-to-fetch-data': 'Unable to fetch active users data. Please try again', + 'by-country-missing': 'Missing data sorted by country' +}; + +export const summaryErrorMessages = { + 'socket-not-authenticated': 'Socket not authenticated', + 'unable-to-fetch-data': 'Unable to fetch usage statistics data. Please try again' +}; + +export const timeToFirstFrameErrorMessages = { + 'socket-not-authenticated': 'Socket not authenticated', + 'unable-to-fetch-data': 'Unable to fetch time to first frame data. Please try again' +}; + +export const usageErrorMessages = { + 'socket-not-authenticated': 'Socket not authenticated', + 'unable-to-fetch-data': 'Unable to fetch usage statistics data. Please try again' +}; + +export const concurrentAndIngestErrorMessages = { + ...reportsErrorMessages, + 'excessive-report-interval': 'The interval for the report exceed 1 day', + default: 'Unable to fetch data for the report' +}; + +export const forkingHistoryErrorMessages = { + ...reportsErrorMessages, + 'excessive-report-interval': 'The interval for the report exceed 1 day', + 'maximal-result-size-exceeded': 'The size of the result exceeds the maximum allowed (10GB)', + 'unable-to-clear-forking-history': 'Unable to clear forking history', + 'unable-to-change-forking-history-data': 'Unable to change forking history data' +}; + +export const publishingHistoryErrorMessages = { + ...reportsErrorMessages, + 'excessive-report-interval': 'The interval for the report exceed 1 year', + 'maximal-result-size-exceeded': 'The size of the result exceeds the maximum allowed (10GB)', + 'unable-to-clear-publishing-history': 'Unable to clear publishing history', + 'unable-to-change-publishing-history-data': 'Unable to change publishing history data' +}; + +export const messagesPageErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The channel does not exist', + default: 'Unable to fetch messages' +}; + +export const channelListErrorMessages = { + ...commonErrorMessages, + default: 'Unable to fetch channels list' +}; + +export const channelListPublishingStateErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The channel was not found', + default: 'Unable to fetch publishing state' +}; + +export const roomsListErrorMessages = { + ...commonErrorMessages, + default: 'Unable to fetch rooms list' +}; + +export const roomsListPublishingStateErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The room was not found', + default: 'Unable to fetch publishing state' +}; + +export const forkChannelErrorMessages = { + ...commonErrorMessages, + 'not-found': 'One or both of the source and destination channels are not found', + default: 'Unable to fork a channel' +}; + +export const deleteChannelErrorMessages = { + ...commonErrorMessages, + default: 'Unable to delete a channel' +}; + +export const deleteRoomErrorMessages = { + ...commonErrorMessages, + default: 'Unable to delete a room' +}; + +export const createChannelErrorMessages = { + ...commonErrorMessages, + 'already-exists': 'The channel already exists', + 'type-conflict': 'A room with the provided alias already exists', + default: 'Unable to create a channel' +}; + +export const createRoomErrorMessages = { + ...commonErrorMessages, + 'already-exists': 'The room already exists', + 'type-conflict': 'A channel with the provided alias already exists', + default: 'Unable to create a room' +}; + +export const killChannelErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The channel was not found', + default: 'Unable to kill a channel' +}; + +export const tokenErrorMessages = { + ...commonErrorMessages, + default: 'Unable to generate a token' +}; + +export const playlistErrorMessages = { + ...commonErrorMessages, + default: 'Unable to fetch playlist data' +}; + +export const channelDetailsErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The channel does not exist', + default: 'Unable to fetch channel details' +}; + +export const channelStreamsErrorMessages = { + ...commonErrorMessages, + 'not found': 'The channel was not found', + default: 'Unable to fetch streams for this channel' +}; + +export const roomDetailsErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The room does not exist', + default: 'Unable to fetch room details' +}; + +export const roomMembersErrorMessages = { + ...commonErrorMessages, + 'not-found': 'The room was not found', + default: 'Unable to fetch members for this room' +}; + +export const publishPullErrorMessages = { + ...commonErrorMessages, + default: 'Unable to publish with uri' +}; + +export const qosErrorMessages = { + ...reportsErrorMessages, + ...commonErrorMessages, + 'excessive-report-interval': 'The interval for the report exceed 1 day', + unsupported: 'The parameter combination is not supported', + 'request-timeout': 'Request timed out likely due to temporary resource or network conditions. Please try again', + capacity: 'The system is temporarily overloaded. Please try again later' +}; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..b97209e --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export * from './capabilities'; +export * from './data'; +export * from './links'; +export * from './error-messages'; diff --git a/src/constants/links.ts b/src/constants/links.ts new file mode 100644 index 0000000..61e8583 --- /dev/null +++ b/src/constants/links.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export const documentationLinks = { + portal: 'https://phenixrts.com/docs/portal/', + createChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#creating-a-channel', + deleteChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#deleting-a-channel', + forkChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#fork-a-channel', + killChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#kill-a-channel', + createRoom: 'https://phenixrts.com/docs/sdk_ref/rest-api/room/#creating-a-room', + deleteRoom: 'https://phenixrts.com/docs/sdk_ref/rest-api/room/#deleting-a-room', + terminateStream: 'https://phenixrts.com/docs/sdk_ref/rest-api/overview/#terminating-a-stream', + RTMP: 'https://phenixrts.com/docs/integration-guides/rtmp/', + FFmpeg: 'https://phenixrts.com/docs/integration-guides/3rd-party-encoders/ffmpeg/', + WHIP: 'https://phenixrts.com/docs/integration-guides/whip/', + supportedStreamCapabilities: 'https://phenixrts.com/docs/knowledge-base/reference/capabilities/#supported-stream-capabilities', + releaseNotes: 'https://phenixrts.com/docs/portal/portal-release-notes/' +}; + +export const phenixWebSiteLinks = { + privacyPolicyProduction: 'https://www.phenixrts.com/privacy-policy', + privacyPolicyStaging: 'https://www-stg.phenixrts.com/privacy-policy', + termsOfServiceProduction: 'https://www.phenixrts.com/terms-of-service', + termsOfServiceStaging: 'https://www-stg.phenixrts.com/terms-of-service', + mainWebSite: 'https://www.phenixrts.com' +}; diff --git a/src/declaredTypes.d.ts b/src/declaredTypes.d.ts new file mode 100644 index 0000000..14639bd --- /dev/null +++ b/src/declaredTypes.d.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +type WindowProps = Window & typeof globalThis & {URL: URL}; + +type JsonSimpleType = number | string | boolean | null | undefined; + +type JsonObjType = { + [key: string]: JsonSimpleType | JsonSimpleType[] | JsonObjType | JsonObjType[]; +}; + +type JsonType = JsonSimpleType | JsonSimpleType[] | JsonObjType | JsonObjType[]; + +declare module 'config/version.json' { + const value: {version: string}; + + export default value; +} +declare module '*.gif'; +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.svg'; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 08a3ac9..a3c84f0 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,4 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} + padding: 0; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 130eb9f..fb37962 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,7 @@ import {createRoot} from 'react-dom/client'; import {Provider} from 'react-redux'; import store from './store'; -import App from './App.tsx'; - +import App from './App'; import './index.css'; createRoot(document.getElementById('root')!).render( diff --git a/src/routers/index.tsx b/src/routers/index.tsx new file mode 100644 index 0000000..5d6ae3e --- /dev/null +++ b/src/routers/index.tsx @@ -0,0 +1,21 @@ +import {BrowserRouter, Route, Routes, Navigate} from 'react-router-dom'; +import {ProtectedRoute} from 'components'; +import {LoginForm, ChannelList} from 'views'; + +export default function Router() { + return ( + + + {/* Public routes */} + } /> + + {/* Protected routes */} + } /> + } />} /> + + {/* Fallback route */} + } /> + + + ); +} diff --git a/src/services/PCastApi.service.ts b/src/services/PCastApi.service.ts new file mode 100644 index 0000000..0262cd8 --- /dev/null +++ b/src/services/PCastApi.service.ts @@ -0,0 +1,41 @@ +import {ApplicationCredentials, Channels, PCastApi, Reporting, Streams} from '@techniker-me/pcast-api'; + +export default class PCastApiService { + private static _instance: PCastApi; + + public static initialize(pcastUri: string, applciationCredentials: ApplicationCredentials) { + PCastApiService._instance = PCastApi.create(pcastUri, applciationCredentials); + } + + public static getInstance(): PCastApiService { + if (!PCastApiService._instance) { + throw new Error('PCastApiService has not been initialized'); + } + + return PCastApiService._instance; + } + + static get channels(): Channels { + if (!PCastApiService._instance) { + throw new Error('PCastApiService has not been initialized'); + } + + return PCastApiService._instance.channels; + } + + static get streams(): Streams { + if (!PCastApiService._instance) { + throw new Error('PCastApiService has not been initialized'); + } + + return PCastApiService._instance.streams; + } + + static get reporting(): Reporting { + if (!PCastApiService._instance) { + throw new Error('PCastApiService has not been initialized'); + } + + return PCastApiService._instance.reporting; + } +} diff --git a/src/services/UserDataStore/IUserDataStore.ts b/src/services/UserDataStore/IUserDataStore.ts new file mode 100644 index 0000000..a4d5d9d --- /dev/null +++ b/src/services/UserDataStore/IUserDataStore.ts @@ -0,0 +1,6 @@ +export default interface IUserDataStore { + setItem(key: string, value: string): void; + getItem(key: string): string | null; + removeItem(key: string): void; + clear(): void; +} diff --git a/src/services/UserDataStore/IndexedDB.ts b/src/services/UserDataStore/IndexedDB.ts new file mode 100644 index 0000000..2a6cc78 --- /dev/null +++ b/src/services/UserDataStore/IndexedDB.ts @@ -0,0 +1,23 @@ +import IUserDataStore from './IUserDataStore'; + +export class IndexedDB implements IUserDataStore { + static isSupported(): boolean { + return 'indexedDB' in window; + } + + public getItem(key: string): string | null { + throw new Error('Not Implemented'); + } + + public setItem(key: string, value: string): void { + throw new Error('Not Implemented'); + } + + public removeItem(key: string): void { + throw new Error('Not Implemented'); + } + + public clear(): void { + throw new Error('Not Implemented'); + } +} diff --git a/src/services/UserDataStore/LocalStorage.ts b/src/services/UserDataStore/LocalStorage.ts new file mode 100644 index 0000000..2cfcc33 --- /dev/null +++ b/src/services/UserDataStore/LocalStorage.ts @@ -0,0 +1,23 @@ +import IUserDataStore from './IUserDataStore'; + +export class LocalStorage implements IUserDataStore { + static isSupported(): boolean { + return 'localStorage' in window; + } + + public getItem(key: string): string | null { + throw new Error('Not Implemented'); + } + + public setItem(key: string, value: string): void { + throw new Error('Not Implemented'); + } + + public removeItem(key: string): void { + throw new Error('Not Implemented'); + } + + public clear(): void { + throw new Error('Not Implemented'); + } +} diff --git a/src/services/UserDataStore/ObjectStore.ts b/src/services/UserDataStore/ObjectStore.ts new file mode 100644 index 0000000..92cb50a --- /dev/null +++ b/src/services/UserDataStore/ObjectStore.ts @@ -0,0 +1,23 @@ +import IUserDataStore from './IUserDataStore'; + +export class ObjectStrore implements IUserDataStore { + static isSupported(): boolean { + return true; + } + + public getItem(key: string): string | null { + throw new Error('Not Implemented'); + } + + public setItem(key: string, value: string): void { + throw new Error('Not Implemented'); + } + + public removeItem(key: string): void { + throw new Error('Not Implemented'); + } + + public clear(): void { + throw new Error('Not Implemented'); + } +} diff --git a/src/services/UserDataStore/index.ts b/src/services/UserDataStore/index.ts new file mode 100644 index 0000000..b6a195c --- /dev/null +++ b/src/services/UserDataStore/index.ts @@ -0,0 +1,24 @@ +import IUserDataStore from './IUserDataStore'; +import {IndexedDB} from './IndexedDB'; +import {LocalStorage} from './LocalStorage'; +import {ObjectStrore} from './ObjectStore'; + +class UserDataStoreService { + private static _instance: IUserDataStore; + + static { + if (IndexedDB.isSupported()) { + this._instance = new IndexedDB(); + } else if (LocalStorage.isSupported()) { + this._instance = new LocalStorage(); + } else { + this._instance = new ObjectStrore(); + } + } + + public static getInstance(): IUserDataStore { + return this._instance; + } +} + +export default UserDataStoreService.getInstance(); diff --git a/src/services/net/websockets/PhenixWebSocket.ts b/src/services/net/websockets/PhenixWebSocket.ts index e9b2452..28d0c44 100644 --- a/src/services/net/websockets/PhenixWebSocket.ts +++ b/src/services/net/websockets/PhenixWebSocket.ts @@ -20,6 +20,7 @@ export interface IPhenixWebSocketResponse { sessionId: string; redirect: string; roles: string[]; + [key: string]: unknown; } export class PhenixWebSocket extends MQWebSocket { @@ -60,7 +61,9 @@ export class PhenixWebSocket extends MQWebSocket { public async sendMessage(kind: PhenixWebSocketMessage, message: T): Promise { if (this._status.value !== PhenixWebSocketStatus.Online) { - throw new Error(`Unable to send message, web socket is not [Online] WebSocket status [${PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(this._status.value)}]`); + throw new Error( + `Unable to send message, web socket is not [Online] WebSocket status [${PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(this._status.value)}]` + ); } this._pendingRequests++; @@ -89,27 +92,27 @@ export class PhenixWebSocket extends MQWebSocket { private initialize(): void { super.onEvent('connected', () => { this.setStatus(PhenixWebSocketStatus.Online); - }) + }); super.onEvent('disconnected', () => { this.setStatus(PhenixWebSocketStatus.Offline); - }) + }); super.onEvent('error', (error: unknown) => { this._logger.error('Error [%s]', error); this.setStatus(PhenixWebSocketStatus.Error); - }) + }); super.onEvent('reconnecting', () => { this.setStatus(PhenixWebSocketStatus.Reconnecting); - }) + }); super.onEvent('reconnected', () => { this.setStatus(PhenixWebSocketStatus.Online); - }) + }); super.onEvent('timeout', () => { this.setStatus(PhenixWebSocketStatus.Error); - }) + }); } -} \ No newline at end of file +} diff --git a/src/services/platform-detection.service.ts b/src/services/platform-detection.service.ts new file mode 100644 index 0000000..a972848 --- /dev/null +++ b/src/services/platform-detection.service.ts @@ -0,0 +1,191 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +type NavigatorUAData = { + brands?: { brand: string; version: string }[]; + mobile?: boolean; + platform?: string; + getHighEntropyValues?: (hints: string[]) => Promise>; + toJSON?: () => object; +}; + +export default class PlatformDetectionService { + private static readonly _userAgent: string = globalThis.navigator?.userAgent ?? ''; + // @ts-expect-error NavigatorUAData is experimental and not defined in the lib dom yet https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData + private static readonly _userAgentData: NavigatorUAData | undefined = globalThis.navigator?.userAgentData; + + private static readonly _areClientHintsSupported: boolean = !!PlatformDetectionService._userAgentData; + private static _platform: string = 'Unknown'; + private static _platformVersion: string = ''; + private static _browserName: string = 'Unknown'; + private static _browserVersion: string = '?'; + private static _isWebview: boolean = false; + + static { + if (PlatformDetectionService._areClientHintsSupported) { + PlatformDetectionService.initFromClientHints(); + } else { + PlatformDetectionService.initFromUserAgent(); + } + } + + private constructor() { + throw new Error('PlatformDetectionService is a static class that may not be instantiated'); + } + + // ---- Public API ---- + static get platform(): string { + return PlatformDetectionService._platform; + } + static get platformVersion(): string { + return PlatformDetectionService._platformVersion; + } + static get userAgent(): string { + return PlatformDetectionService._userAgent; + } + static get browserName(): string { + return PlatformDetectionService._browserName; + } + static get browserVersion(): string { + return PlatformDetectionService._browserVersion; + } + static get isWebview(): boolean { + return PlatformDetectionService._isWebview; + } + static get areClientHintsSupported(): boolean { + return PlatformDetectionService._areClientHintsSupported; + } + + /** + * Optional async initialization for high-entropy values like platformVersion + */ + static async initAsync(): Promise { + if (PlatformDetectionService._areClientHintsSupported && PlatformDetectionService._userAgentData?.getHighEntropyValues) { + const values = await PlatformDetectionService._userAgentData.getHighEntropyValues(['platformVersion']); + + if (values.platformVersion) { + PlatformDetectionService._platformVersion = values.platformVersion; + } + } + } + + // ---- Init strategies ---- + private static initFromClientHints() { + const data = PlatformDetectionService._userAgentData as NavigatorUAData; + const nonChromiumBrand = data.brands?.find(b => b.brand !== 'Chromium'); + + PlatformDetectionService._browserName = nonChromiumBrand?.brand ?? 'Unknown'; + PlatformDetectionService._browserVersion = nonChromiumBrand?.version ?? '?'; + PlatformDetectionService._platform = data.platform ?? 'Unknown'; + PlatformDetectionService._isWebview = PlatformDetectionService.extractIsWebviewFromUserAgent(); // Fallback check + } + + private static initFromUserAgent() { + PlatformDetectionService._platform = PlatformDetectionService.extractPlatformFromUserAgent(); + PlatformDetectionService._platformVersion = PlatformDetectionService.extractPlatformVersionFromUserAgent(); + PlatformDetectionService._browserName = PlatformDetectionService.extractBrowserNameFromUserAgent(); + PlatformDetectionService._browserVersion = PlatformDetectionService.extractBrowserVersionFromUserAgent(); + PlatformDetectionService._isWebview = PlatformDetectionService.extractIsWebviewFromUserAgent(); + } + + // ---- Helpers ---- + private static extractBrowserNameFromUserAgent(): string { + if (/Edg\//.test(PlatformDetectionService._userAgent)) { + return 'Edge'; + } + + if (/OPR\//.test(PlatformDetectionService._userAgent)) { + return 'Opera'; + } + + if (/Firefox\//.test(PlatformDetectionService._userAgent)) { + return 'Firefox'; + } + + if (/Trident\/.*rv:/.test(PlatformDetectionService._userAgent)) { + return 'IE'; + } + + if (/Chrome\//.test(PlatformDetectionService._userAgent)) { + return 'Chrome'; + } + + if (/Safari\//.test(PlatformDetectionService._userAgent)) { + return 'Safari'; + } + + if (/ReactNative\//.test(PlatformDetectionService._userAgent)) { + return 'ReactNative'; + } + + return 'Unknown'; + } + + private static extractBrowserVersionFromUserAgent(): string { + return ( + PlatformDetectionService.matchVersion(/Edg\/([\d.]+)/) ?? + PlatformDetectionService.matchVersion(/OPR\/([\d.]+)/) ?? + PlatformDetectionService.matchVersion(/Firefox\/([\d.]+)/) ?? + PlatformDetectionService.matchVersion(/rv:([\d.]+)/) ?? // IE + PlatformDetectionService.matchVersion(/Chrome\/([\d.]+)/) ?? + PlatformDetectionService.matchVersion(/Version\/([\d.]+)/) ?? // Safari often uses "Version/" + PlatformDetectionService.matchVersion(/Safari\/([\d.]+)/) ?? + PlatformDetectionService.matchVersion(/ReactNative\/([\d.]+)/) ?? + '?' + ); + } + + private static extractPlatformFromUserAgent(): string { + if (/Windows/.test(PlatformDetectionService._userAgent)) { + return 'Windows'; + } + + if (/iPhone|iPad|iPod/.test(PlatformDetectionService._userAgent)) { + return 'iOS'; + } + + if (/Mac OS X/.test(PlatformDetectionService._userAgent)) { + return 'macOS'; + } + + if (/Android/.test(PlatformDetectionService._userAgent)) { + return 'Android'; + } + + if (/Linux/.test(PlatformDetectionService._userAgent)) { + return 'Linux'; + } + + return 'Unknown'; + } + + private static extractPlatformVersionFromUserAgent(): string { + switch (PlatformDetectionService._platform) { + case 'Windows': + return PlatformDetectionService.matchVersion(/Windows NT ([\d.]+)/) ?? ''; + case 'iOS': + return PlatformDetectionService.matchVersion(/OS ([\d_]+)/)?.replace(/_/g, '.') ?? ''; + case 'macOS': + return PlatformDetectionService.matchVersion(/Mac OS X ([\d_]+)/)?.replace(/_/g, '.') ?? ''; + case 'Android': + return PlatformDetectionService.matchVersion(/Android ([\d.]+)/) ?? ''; + default: + return ''; + } + } + + private static extractIsWebviewFromUserAgent(): boolean { + return ( + /; wv/.test(PlatformDetectionService._userAgent) || // Android webview + (/Android/.test(PlatformDetectionService._userAgent) && /Version\/[\d.]+/.test(PlatformDetectionService._userAgent)) || // Some Android webviews + /(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/.test(PlatformDetectionService._userAgent) // IOS webview + ); + } + + private static matchVersion(pattern: RegExp): string | null { + const match = PlatformDetectionService._userAgent.match(pattern); + + return match ? match[1] : null; + } +} \ No newline at end of file diff --git a/src/store/middlewares/authenticationMiddleware.ts b/src/store/middlewares/authenticationMiddleware.ts new file mode 100644 index 0000000..0bdeee7 --- /dev/null +++ b/src/store/middlewares/authenticationMiddleware.ts @@ -0,0 +1,73 @@ +import { + authenticateCredentialsThunk, + setError, + selectIsAuthenticated, + selectIsLoading, + selectApplicationId, + selectSecret, + setUnauthorized +} from 'store/slices/Authentication.slice'; +import {Middleware} from '@reduxjs/toolkit'; + +export const authenticateRequestMiddleware: Middleware = store => next => async action => { + const state = store.getState(); + const isAuthenticated = selectIsAuthenticated(state); + const isLoading = selectIsLoading(state); + const applicationId = selectApplicationId(state); + const secret = selectSecret(state); + + console.log( + '[authenticateRequest] action [%o] isAuthenticated [%o] isLoading [%o] applicationId [%o] secret [%o]', + action, + isAuthenticated, + isLoading, + applicationId, + secret + ); + + // Skip authentication middleware for authentication-related actions + + if ( + typeof action === 'object' && + action !== null && + 'type' in action && + typeof (action as any).type === 'string' && + (action as any).type.startsWith('authentication/') + ) { + return next(action); + } + + // If already authenticated, proceed normally + if (isAuthenticated) { + return next(action); + } + + // If currently loading, wait for it to complete + if (isLoading) { + return next(action); + } + + // If no credentials, set unauthorized + if (!applicationId || !secret) { + console.log('[authenticateRequest] No credentials available, proceeding with action'); + return next(setUnauthorized()); + } + + // We have credentials but are not authenticated, try to authenticate + try { + console.log('[authenticateRequest] Attempting auto-authentication'); + // Use the Redux thunk to properly update the state + const authResult = await store.dispatch(authenticateCredentialsThunk({applicationId, secret}) as any); + + if (authResult.type.endsWith('/rejected') || authResult.payload === 'Authentication failed') { + console.log('[authenticateRequest] Authentication failed'); + return next(setUnauthorized()); + } + + console.log('[authenticateRequest] Auto-authentication successful, proceeding with action'); + return next(action); + } catch (error) { + console.error('[authenticateRequest] Auto-authentication failed:', error); + return next(setUnauthorized()); + } +}; diff --git a/src/store/middlewares/index.ts b/src/store/middlewares/index.ts new file mode 100644 index 0000000..d1e7a97 --- /dev/null +++ b/src/store/middlewares/index.ts @@ -0,0 +1,3 @@ +export * from './authenticationMiddleware'; +export * from './promiseMiddleware'; +export * from './loggerMiddleware'; diff --git a/src/store/middlewares/loggerMiddleware.ts b/src/store/middlewares/loggerMiddleware.ts new file mode 100644 index 0000000..ecb523e --- /dev/null +++ b/src/store/middlewares/loggerMiddleware.ts @@ -0,0 +1,13 @@ +import {Middleware} from '@reduxjs/toolkit'; + +/** + * Logs all actions and states after they are dispatched. + */ +export const loggerMiddleware: Middleware = store => next => action => { + console.group((action as any).type); + console.info('dispatching', action); + const result = next(action); + console.log('next state', store.getState()); + console.groupEnd(); + return result; +}; diff --git a/src/store/middlewares/promiseMiddleware.ts b/src/store/middlewares/promiseMiddleware.ts new file mode 100644 index 0000000..a6bbeec --- /dev/null +++ b/src/store/middlewares/promiseMiddleware.ts @@ -0,0 +1,9 @@ +import {Middleware} from '@reduxjs/toolkit'; + +export const vanillaPromiseMiddleware: Middleware = store => next => (action: any) => { + if (typeof action.then !== 'function') { + return next(action); + } + + return Promise.resolve(action).then((resolvedAction: any) => store.dispatch(resolvedAction)); +}; diff --git a/src/store/slices/Authentication.slice.ts b/src/store/slices/Authentication.slice.ts index 5087a1d..cdef75d 100644 --- a/src/store/slices/Authentication.slice.ts +++ b/src/store/slices/Authentication.slice.ts @@ -46,6 +46,18 @@ export const selectSessionInfo = createSelector([selectAuthentication], authenti roles: authentication.roles })); +export const selectApplicationId = createSelector([selectAuthentication], authentication => authentication.applicationId); + +export const selectSecret = createSelector([selectAuthentication], authentication => authentication.secret); + +export const selectSessionId = createSelector([selectAuthentication], authentication => authentication.sessionId); + +export const selectRoles = createSelector([selectAuthentication], authentication => authentication.roles); + +export const selectHasRole = createSelector([selectAuthentication, (_, role: string) => role], (authentication, role) => authentication.roles.includes(role)); + +export const selectIsOnline = createSelector([selectAuthentication], authentication => authentication.status === 'Online'); + const authenticateCredentialsThunk = createAsyncThunk( 'authentication/authenticate', async (credentials, {rejectWithValue}) => { @@ -56,6 +68,7 @@ const authenticateCredentialsThunk = createAsyncThunk) => { state.sessionId = action.payload; }, - setIsAuthenticated: (state, action: PayloadAction) => { - state.isAuthenticated = action.payload; + setError: (state, action: PayloadAction) => { + state.error = action.payload; }, - setRoles: (state, action: PayloadAction) => { - state.roles = action.payload; - }, - setApplicationId: (state, action: PayloadAction) => { - state.applicationId = action.payload; + setUnauthorized: state => { + state.isAuthenticated = false; + state.isLoading = false; + state.error = 'Unauthorized'; + state.secret = null; + state.status = 'Offline'; + state.roles = []; } }, extraReducers: builder => { @@ -119,17 +135,18 @@ const authenticationSlice = createSlice({ state.sessionId = authenticationResponse.sessionId ?? null; state.isAuthenticated = true; state.roles = authenticationResponse.roles ?? []; + state.status = 'Online'; + state.isLoading = false; } else { state.applicationId = null; state.sessionId = null; state.isAuthenticated = false; state.secret = null; state.roles = []; + state.status = 'Offline'; + state.error = 'Invalid credentials. Please check your Application ID and Secret.'; + state.isLoading = false; } - - state.status = 'Online'; - state.isLoading = false; - state.error = null; }) .addCase(authenticateCredentialsThunk.rejected, (state, action) => { state.applicationId = null; @@ -164,6 +181,6 @@ const authenticationSlice = createSlice({ } }); -export const {setIsLoading, setCredentials, clearState, setSessionId, setIsAuthenticated, setRoles, setApplicationId} = authenticationSlice.actions; +export const {setUnauthorized, setIsLoading, setCredentials, clearState, setSessionId, setError} = authenticationSlice.actions; export {authenticateCredentialsThunk}; -export default authenticationSlice.reducer; \ No newline at end of file +export default authenticationSlice.reducer; diff --git a/src/store/slices/Channels.slice.ts b/src/store/slices/Channels.slice.ts new file mode 100644 index 0000000..76bf776 --- /dev/null +++ b/src/store/slices/Channels.slice.ts @@ -0,0 +1,103 @@ +import {createAsyncThunk, createSelector, createSlice, PayloadAction, WritableDraft} from '@reduxjs/toolkit'; +import {ApplicationCredentials, Channel} from '@techniker-me/pcast-api'; +import PCastApiService from 'services/PCastApi.service'; + +export interface IChannelsState { + isLoading: boolean; + channels: Channel[]; + selectedChannel: Channel | null; + error: string | null; +} + +export const initialChannelsState: IChannelsState = { + isLoading: false, + channels: [], + selectedChannel: null, + error: null +}; + +export const selectChannels = (state: {channels: IChannelsState}) => state.channels; + +export const selectChannelList = createSelector([selectChannels], channels => channels.channels); + +export const fetchChannelList = createAsyncThunk('channels/fetchChannelList', async (_, {rejectWithValue}) => { + try { + return PCastApiService.channels.list(); + } catch (error) { + return rejectWithValue(error); + } +}); + +export const fetchChannelsListPublisherStatus = createAsyncThunk( + 'channels/fetchChannelsListPublisherStatus', + async (channels: Channel[], {rejectWithValue}) => { + try { + const channelResponses = await Promise.all( + channels.map(async channel => { + const publisherCount = await PCastApiService.channels.getPublisherCount(channel.channelId); + return { + ...channel, + isActivePublisher: publisherCount > 0 + }; + }) + ); + + return channelResponses as Channel[]; + } catch (error) { + return rejectWithValue(error); + } + } +); + +const channelsSlice = createSlice({ + name: 'channels', + initialState: {...initialChannelsState}, + reducers: { + initializeChannels: (state, action: PayloadAction<{pcastUri: string; applicationCredentials: ApplicationCredentials}>) => { + PCastApiService.initialize(action.payload.pcastUri, action.payload.applicationCredentials); + state.isLoading = false; + state.error = null; + }, + setChannels: (state, action: PayloadAction) => { + state.channels = action.payload as WritableDraft[]; + }, + setIsLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setSelectedChannel: (state, action: PayloadAction) => { + state.selectedChannel = action.payload as WritableDraft | null; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + } + }, + extraReducers: builder => { + builder.addCase(fetchChannelList.pending, state => { + state.isLoading = true; + }); + builder.addCase(fetchChannelList.fulfilled, (state, action) => { + state.channels = action.payload as WritableDraft; + state.isLoading = false; + state.error = null; + }); + builder.addCase(fetchChannelList.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + builder.addCase(fetchChannelsListPublisherStatus.pending, state => { + state.isLoading = true; + }); + builder.addCase(fetchChannelsListPublisherStatus.fulfilled, (state, action) => { + state.channels = action.payload as WritableDraft; + state.isLoading = false; + state.error = null; + }); + builder.addCase(fetchChannelsListPublisherStatus.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + } +}); + +export const {initializeChannels, setChannels, setIsLoading, setSelectedChannel, setError} = channelsSlice.actions; +export default channelsSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index 1ce81d0..7a43334 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,10 +1,18 @@ import {configureStore} from '@reduxjs/toolkit'; -import AuthenticationState from './slices/Authentication.slice'; +import AuthenticationReducer from './slices/Authentication.slice'; +import ChannelsReducer from './slices/Channels.slice'; +import {authenticateRequestMiddleware, loggerMiddleware, vanillaPromiseMiddleware} from './middlewares'; const store = configureStore({ reducer: { - authentication: AuthenticationState - } + authentication: AuthenticationReducer, + channels: ChannelsReducer + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + thunk: true, + serializableCheck: false + }).concat(authenticateRequestMiddleware, vanillaPromiseMiddleware, loggerMiddleware) }); export default store; diff --git a/src/theme/README.md b/src/theme/README.md new file mode 100644 index 0000000..f5721b2 --- /dev/null +++ b/src/theme/README.md @@ -0,0 +1,214 @@ +# Theme System - SOLID Principles Refactor + +This document describes the refactored theme system that follows SOLID principles for better maintainability, extensibility, and testability. + +## Architecture Overview + +The theme system has been refactored from a monolithic class into a modular, extensible architecture: + +``` +theme/ +├── interfaces/ # Type definitions (abstractions) +│ ├── ITheme.ts +│ ├── IColorSystem.ts +│ ├── ISpacingSystem.ts +│ ├── ITypographySystem.ts +│ └── IScreenSystem.ts +├── systems/ # Concrete implementations +│ ├── ColorSystem.ts +│ ├── SpacingSystem.ts +│ ├── TypographySystem.ts +│ └── ScreenSystem.ts +├── utils/ # Utility functions +│ ├── ColorUtils.ts +│ └── ViewportUtils.ts +├── examples/ # Usage examples +│ └── ThemeUsageExamples.ts +├── ThemeFactory.ts # Factory for creating themes +└── index.ts # Main entry point +``` + +## SOLID Principles Implementation + +### 1. Single Responsibility Principle (SRP) +- **ColorSystem**: Only handles color definitions +- **SpacingSystem**: Only handles spacing values +- **TypographySystem**: Only handles typography settings +- **ScreenSystem**: Only handles responsive breakpoints +- **ColorUtils**: Only handles color-related utilities +- **ViewportUtils**: Only handles viewport-related utilities + +### 2. Open/Closed Principle (OCP) +- The system is open for extension but closed for modification +- New color systems, spacing systems, etc. can be created by implementing the respective interfaces +- No need to modify existing code when adding new themes + +### 3. Liskov Substitution Principle (LSP) +- All implementations can be substituted for their interfaces +- Custom color systems can replace the default ColorSystem without breaking functionality + +### 4. Interface Segregation Principle (ISP) +- Interfaces are focused and specific +- Clients only depend on the interfaces they actually use +- No fat interfaces that force unnecessary dependencies + +### 5. Dependency Inversion Principle (DIP) +- High-level modules depend on abstractions (interfaces) +- Low-level modules implement these abstractions +- Factory pattern enables dependency injection + +## Usage Examples + +### Basic Usage (Backward Compatible) + +```typescript +import Theme from './theme'; + +// Old way (still works) +const primaryColor = Theme.colors.blue; +const spacing = Theme.paddings.medium; + +// New way (recommended) +const theme = Theme.instance; +const newPrimaryColor = theme.colors.blue; +const newSpacing = theme.spacing.medium; +``` + +### Creating Custom Themes + +```typescript +import { ThemeFactory, ColorSystem, SpacingSystem, TypographySystem, ScreenSystem } from './theme'; + +// Create a custom color system +class DarkColorSystem extends ColorSystem { + readonly white = '#1a1a1a'; + readonly black = '#ffffff'; + // ... override other colors +} + +// Create custom theme +const darkTheme = ThemeFactory.createCustomTheme( + new DarkColorSystem(), + new SpacingSystem(), + new TypographySystem(), + new ScreenSystem() +); + +// Apply the theme +Theme.setTheme(darkTheme); +``` + +### Using in React Components + +```typescript +import Theme from './theme'; + +function MyComponent() { + const theme = Theme.instance; + + return ( +
+ Content +
+ ); +} +``` + +## API Reference + +### Theme Class + +```typescript +class Theme { + // Get the current theme instance + static get instance(): ITheme; + + // Set a custom theme + static setTheme(theme: ITheme): void; + + // Reset to default theme + static resetToDefault(): void; + + // Backward compatibility + static get colors(): IColorSystem; + static get paddings(): ISpacingSystem; + static get theme(): ITheme; +} +``` + +### ThemeFactory + +```typescript +class ThemeFactory { + // Create default theme + static createDefaultTheme(): ITheme; + + // Create custom theme + static createCustomTheme( + colorSystem: IColorSystem, + spacingSystem: IExtendedSpacingSystem, + typographySystem: ITypographySystem, + screenSystem: IScreenSystem + ): ITheme; +} +``` + +### Utility Functions + +```typescript +// Color utilities +ColorUtils.hexToRgba(hex: string, opacityPercentage: number): string | null; + +// Viewport utilities +ViewportUtils.pxToVw(screenWidthInPixels: number, elementSizeInPixels: number): string; +``` + +## Migration Guide + +### From Old Theme System + +The refactored system maintains backward compatibility. Existing code will continue to work: + +```typescript +// This still works +const color = Theme.colors.blue; +const padding = Theme.paddings.medium; +const themeConfig = Theme.theme; + +// But this is recommended +const theme = Theme.instance; +const color = theme.colors.blue; +const padding = theme.spacing.medium; +``` + +### Benefits of Migration + +1. **Better Performance**: Lazy loading and singleton pattern +2. **Extensibility**: Easy to create custom themes +3. **Testability**: Each component can be tested in isolation +4. **Maintainability**: Clear separation of concerns +5. **Type Safety**: Strong typing with interfaces + +## Best Practices + +1. **Use the new API**: Prefer `Theme.instance` over static properties +2. **Create custom themes**: Use the factory pattern for theme variations +3. **Implement interfaces**: When extending, implement the appropriate interfaces +4. **Use utility classes**: Leverage ColorUtils and ViewportUtils for common operations +5. **Test in isolation**: Each system can be unit tested independently + +## Performance Considerations + +The refactored system prioritizes performance through: + +- **Singleton Pattern**: Single theme instance across the application +- **Lazy Loading**: Theme is created only when first accessed +- **Immutable Properties**: Readonly properties prevent accidental mutations +- **Efficient Factory**: Minimal overhead in theme creation + +This architecture ensures the theme system is both performant and maintainable while following industry best practices for object-oriented design. diff --git a/src/theme/ThemeFactory.ts b/src/theme/ThemeFactory.ts new file mode 100644 index 0000000..0c3475d --- /dev/null +++ b/src/theme/ThemeFactory.ts @@ -0,0 +1,139 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { ITheme } from './interfaces/ITheme'; +import { IColorSystem } from './interfaces/IColorSystem'; +import { IExtendedSpacingSystem } from './interfaces/ISpacingSystem'; +import { ITypographySystem } from './interfaces/ITypographySystem'; +import { IScreenSystem } from './interfaces/IScreenSystem'; +import { IBackgroundSystem } from './interfaces/IBackgroundSystem'; +import { ColorSystem } from './defaultTheme/ColorSystem'; +import { SpacingSystem } from './defaultTheme/SpacingSystem'; +import { TypographySystem } from './defaultTheme/TypographySystem'; +import { ScreenSystem } from './defaultTheme/ScreenSystem'; +import { BackgroundSystem } from './defaultTheme/BackgroundSystem'; + +export class ThemeFactory { + /** + * Creates a default theme instance + * @returns A complete theme object + */ + static createDefaultTheme(): ITheme { + const colors = new ColorSystem(); + const spacing = new SpacingSystem(); + const typography = new TypographySystem(); + const screenSizes = new ScreenSystem(); + const backgrounds = new BackgroundSystem(); + + return new DefaultTheme(colors, spacing, typography, screenSizes, backgrounds); + } + + /** + * Creates a custom theme with provided systems + * @param colorSystem - Custom color system + * @param spacingSystem - Custom spacing system + * @param typographySystem - Custom typography system + * @param screenSystem - Custom screen system + * @returns A complete theme object + */ + static createCustomTheme( + colorSystem: IColorSystem, + spacingSystem: IExtendedSpacingSystem, + typographySystem: ITypographySystem, + screenSystem: IScreenSystem, + backgroundSystem?: IBackgroundSystem + ): ITheme { + const backgrounds = backgroundSystem || new BackgroundSystem(); + return new DefaultTheme(colorSystem, spacingSystem, typographySystem, screenSystem, backgrounds); + } +} + +class DefaultTheme implements ITheme { + constructor( + public readonly colors: IColorSystem, + public readonly spacing: IExtendedSpacingSystem, + public readonly typography: ITypographySystem, + public readonly screenSizes: IScreenSystem, + public readonly backgrounds: IBackgroundSystem + ) {} + + readonly footerHeight = '51px'; + readonly headerAllowance = 200; + readonly formFieldWidth = 350; + readonly formFieldMaxWidth = 500; + readonly formFieldMaxHeight = 250; + readonly inputIconWidth = 16; + + get primaryColor(): string { + return this.colors.blue; + } + + get secondaryColor(): string { + return this.colors.gray700; + } + + get successColor(): string { + return this.colors.green; + } + + get infoColor(): string { + return this.colors.cyan; + } + + get warningColor(): string { + return this.colors.yellow; + } + + get dangerColor(): string { + return this.colors.red; + } + + get secondaryLight(): string { + return this.colors.gray800; + } + + get secondaryDark(): string { + return this.colors.gray500; + } + + get primaryThemeColor(): string { + return this.colors.lightBlue; + } + + get primaryBackground(): string { + return this.colors.gray900; + } + + get linkColor(): string { + return this.colors.green; + } + + get blackWithOpacity(): string { + return this.colors.blackWithOpacity; + } + + get primaryInputHeight(): string { + return '2.2rem'; + } + + get primaryBorderColor(): string { + return this.colors.gray400; + } + + get primaryBorderWidth(): string { + return '1px'; + } + + get primaryBorderRadius(): string { + return '1.2rem'; + } + + get loaderSize() { + return { + small: 12, + medium: 20, + large: 50 + }; + } +} diff --git a/src/theme/defaultTheme/BackgroundSystem.ts b/src/theme/defaultTheme/BackgroundSystem.ts new file mode 100644 index 0000000..932f038 --- /dev/null +++ b/src/theme/defaultTheme/BackgroundSystem.ts @@ -0,0 +1,11 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { IBackgroundSystem } from '../interfaces/IBackgroundSystem'; +import bgImage from '../../assets/images/background-1415x959.png'; + +export class BackgroundSystem implements IBackgroundSystem { + readonly loginBackground = bgImage; + readonly defaultBackground = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; +} diff --git a/src/theme/defaultTheme/ColorSystem.ts b/src/theme/defaultTheme/ColorSystem.ts new file mode 100644 index 0000000..da78f86 --- /dev/null +++ b/src/theme/defaultTheme/ColorSystem.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { IColorSystem } from '../interfaces/IColorSystem'; + +export class ColorSystem implements IColorSystem { + readonly white = '#F9F9F9'; + readonly whiteWithOpacity = 'rgba(249, 249, 249, 0.5)'; + readonly gray100 = '#f8f9fa'; + readonly gray200 = '#ebebeb'; + readonly gray300 = '#dee2e6'; + readonly gray400 = '#ced4da'; + readonly gray500 = '#adb5bd'; + readonly gray600 = '#999'; + readonly gray700 = '#444'; + readonly gray800 = '#303030'; + readonly gray900 = '#222222'; + readonly gray1000 = '#1f1f1f'; + readonly black = '#000000'; + + readonly blue = '#375a7f'; + readonly indigo = '#6610f2'; + readonly purple = '#6f42c1'; + readonly pink = '#e83e8c'; + readonly lightBlue = '#66b3ff'; + readonly linkBlue = 'blue'; + readonly red = '#EE2D52'; + readonly lightRed = '#f70d1a'; + readonly orange = '#fd7e14'; + readonly yellow = '#F39C12'; + readonly green = '#00bc8c'; + readonly teal = '#20c997'; + readonly cyan = '#3498DB'; + readonly headerColor = '#2C2D37'; + readonly transparent = 'transparent'; + readonly blackWithOpacity = 'rgba(0, 0, 0, 0.95)'; + readonly halfTransparentBlack = 'rgba(0, 0, 0, 0.5)'; +} diff --git a/src/theme/defaultTheme/ScreenSystem.ts b/src/theme/defaultTheme/ScreenSystem.ts new file mode 100644 index 0000000..d9d09c7 --- /dev/null +++ b/src/theme/defaultTheme/ScreenSystem.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { IScreenSystem } from '../interfaces/IScreenSystem'; + +export class ScreenSystem implements IScreenSystem { + readonly large = 1170; + readonly desktop = 992; + readonly tablet = 768; + readonly phone = 660; + readonly smallPhone = 360; + + readonly mediaPhone = `@media only screen + and (max-device-width: 737px) + and (-webkit-min-device-pixel-ratio: 2) + `; + + readonly mediaLargeScreen = `@media only screen + and (min-width: 1920px) + `; +} diff --git a/src/theme/defaultTheme/SpacingSystem.ts b/src/theme/defaultTheme/SpacingSystem.ts new file mode 100644 index 0000000..e44699a --- /dev/null +++ b/src/theme/defaultTheme/SpacingSystem.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { IExtendedSpacingSystem } from '../interfaces/ISpacingSystem'; + +export class SpacingSystem implements IExtendedSpacingSystem { + readonly xsmall = '0.25rem'; + readonly small = '0.5rem'; + readonly medium = '1rem'; + readonly large = '1.5rem'; + readonly xlarge = '2rem'; + + readonly xxSmall = '4px'; + readonly xSmall = '8px'; + readonly xLarge = '48px'; + readonly xxLarge = '64px'; +} diff --git a/src/theme/defaultTheme/TypographySystem.ts b/src/theme/defaultTheme/TypographySystem.ts new file mode 100644 index 0000000..de34539 --- /dev/null +++ b/src/theme/defaultTheme/TypographySystem.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { ITypographySystem } from '../interfaces/ITypographySystem'; + +export class TypographySystem implements ITypographySystem { + readonly primaryFontSize = '0.9375rem'; + readonly primaryLineHeight = '1.6rem'; + readonly primaryFont = '\'Montserrat\', Helvetica, Arial, sans-serif'; + + readonly fontSizeXS = '0.65rem'; + readonly fontSizeS = '0.8rem'; + readonly fontSizeL = '1.2rem'; + readonly fontSizeXl = '1.5rem'; + readonly fontSizeXxl = '2rem'; + readonly fontSizeXxxl = '2.5rem'; + readonly fontSizeXxxxl = '3rem'; +} diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 0000000..538bbfe --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,63 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export type { ITheme } from './interfaces/ITheme'; +export type { IColorSystem } from './interfaces/IColorSystem'; +export type { ISpacingSystem, IExtendedSpacingSystem } from './interfaces/ISpacingSystem'; +export type { ITypographySystem } from './interfaces/ITypographySystem'; +export type { IScreenSystem } from './interfaces/IScreenSystem'; +export type { IBackgroundSystem } from './interfaces/IBackgroundSystem'; +export { ColorSystem } from './defaultTheme/ColorSystem'; +export { SpacingSystem } from './defaultTheme/SpacingSystem'; +export { TypographySystem } from './defaultTheme/TypographySystem'; +export { ScreenSystem } from './defaultTheme/ScreenSystem'; +export { BackgroundSystem } from './defaultTheme/BackgroundSystem'; +export { ThemeFactory } from './ThemeFactory'; +export { ColorUtils } from './utils/ColorUtils'; +export { ViewportUtils } from './utils/ViewportUtils'; + +import { ITheme } from './interfaces/ITheme'; +import { ThemeFactory } from './ThemeFactory'; +import { ColorUtils } from './utils/ColorUtils'; +import { ViewportUtils } from './utils/ViewportUtils'; + +export class Theme { + private static _instance: ITheme = ThemeFactory.createDefaultTheme(); + + static get instance(): ITheme { + return Theme._instance; + } + + static setTheme(theme: ITheme): void { + Theme._instance = theme; + } + + static resetToDefault(): void { + Theme._instance = ThemeFactory.createDefaultTheme(); + } + + // Backward compatibility properties + static get colors() { + return Theme.instance.colors; + } + + static get paddings() { + return { + xsmall: Theme.instance.spacing.xsmall, + small: Theme.instance.spacing.small, + medium: Theme.instance.spacing.medium, + large: Theme.instance.spacing.large, + xlarge: Theme.instance.spacing.xlarge + }; + } + + static get theme() { + return Theme.instance; + } +} + +export default Theme.instance +export const theme = Theme.instance; +export const pxToVw = ViewportUtils.pxToVw; +export const hexToRgba = ColorUtils.hexToRgba; \ No newline at end of file diff --git a/src/theme/interfaces/IBackgroundSystem.ts b/src/theme/interfaces/IBackgroundSystem.ts new file mode 100644 index 0000000..cc3b828 --- /dev/null +++ b/src/theme/interfaces/IBackgroundSystem.ts @@ -0,0 +1,8 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface IBackgroundSystem { + readonly loginBackground: string; + readonly defaultBackground: string; +} diff --git a/src/theme/interfaces/IColorSystem.ts b/src/theme/interfaces/IColorSystem.ts new file mode 100644 index 0000000..59adbd9 --- /dev/null +++ b/src/theme/interfaces/IColorSystem.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface IColorSystem { + readonly white: string; + readonly whiteWithOpacity: string; + readonly gray100: string; + readonly gray200: string; + readonly gray300: string; + readonly gray400: string; + readonly gray500: string; + readonly gray600: string; + readonly gray700: string; + readonly gray800: string; + readonly gray900: string; + readonly gray1000: string; + readonly black: string; + readonly blue: string; + readonly indigo: string; + readonly purple: string; + readonly pink: string; + readonly lightBlue: string; + readonly linkBlue: string; + readonly red: string; + readonly lightRed: string; + readonly orange: string; + readonly yellow: string; + readonly green: string; + readonly teal: string; + readonly cyan: string; + readonly headerColor: string; + readonly transparent: string; + readonly blackWithOpacity: string; + readonly halfTransparentBlack: string; +} diff --git a/src/theme/interfaces/IScreenSystem.ts b/src/theme/interfaces/IScreenSystem.ts new file mode 100644 index 0000000..fae71b6 --- /dev/null +++ b/src/theme/interfaces/IScreenSystem.ts @@ -0,0 +1,13 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface IScreenSystem { + readonly large: number; + readonly desktop: number; + readonly tablet: number; + readonly phone: number; + readonly smallPhone: number; + readonly mediaPhone: string; + readonly mediaLargeScreen: string; +} diff --git a/src/theme/interfaces/ISpacingSystem.ts b/src/theme/interfaces/ISpacingSystem.ts new file mode 100644 index 0000000..76c73b0 --- /dev/null +++ b/src/theme/interfaces/ISpacingSystem.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface ISpacingSystem { + readonly xsmall: string; + readonly small: string; + readonly medium: string; + readonly large: string; + readonly xlarge: string; +} + +export interface IExtendedSpacingSystem extends ISpacingSystem { + readonly xxSmall: string; + readonly xSmall: string; + readonly xLarge: string; + readonly xxLarge: string; +} diff --git a/src/theme/interfaces/ITheme.ts b/src/theme/interfaces/ITheme.ts new file mode 100644 index 0000000..b0e1305 --- /dev/null +++ b/src/theme/interfaces/ITheme.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +import { IColorSystem } from './IColorSystem'; +import { IExtendedSpacingSystem } from './ISpacingSystem'; +import { ITypographySystem } from './ITypographySystem'; +import { IScreenSystem } from './IScreenSystem'; +import { IBackgroundSystem } from './IBackgroundSystem'; + +export interface ITheme { + readonly colors: IColorSystem; + readonly spacing: IExtendedSpacingSystem; + readonly typography: ITypographySystem; + readonly screenSizes: IScreenSystem; + readonly backgrounds: IBackgroundSystem; + readonly footerHeight: string; + readonly primaryColor: string; + readonly secondaryColor: string; + readonly successColor: string; + readonly infoColor: string; + readonly warningColor: string; + readonly dangerColor: string; + readonly secondaryLight: string; + readonly secondaryDark: string; + readonly headerAllowance: number; + readonly primaryThemeColor: string; + readonly primaryBackground: string; + readonly linkColor: string; + readonly blackWithOpacity: string; + readonly primaryInputHeight: string; + readonly formFieldWidth: number; + readonly formFieldMaxWidth: number; + readonly formFieldMaxHeight: number; + readonly inputIconWidth: number; + readonly primaryBorderColor: string; + readonly primaryBorderWidth: string; + readonly primaryBorderRadius: string; + readonly loaderSize: { + readonly small: number; + readonly medium: number; + readonly large: number; + }; +} diff --git a/src/theme/interfaces/ITypographySystem.ts b/src/theme/interfaces/ITypographySystem.ts new file mode 100644 index 0000000..ef30aa7 --- /dev/null +++ b/src/theme/interfaces/ITypographySystem.ts @@ -0,0 +1,16 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export interface ITypographySystem { + readonly primaryFontSize: string; + readonly primaryLineHeight: string; + readonly primaryFont: string; + readonly fontSizeXS: string; + readonly fontSizeS: string; + readonly fontSizeL: string; + readonly fontSizeXl: string; + readonly fontSizeXxl: string; + readonly fontSizeXxxl: string; + readonly fontSizeXxxxl: string; +} diff --git a/src/theme/utils/ColorUtils.ts b/src/theme/utils/ColorUtils.ts new file mode 100644 index 0000000..e5686f8 --- /dev/null +++ b/src/theme/utils/ColorUtils.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export class ColorUtils { + /** + * Converts a hex color to RGBA format + * @param hex - The hex color string (with or without #) + * @param opacityPercentage - Opacity as percentage (1-100) + * @returns RGBA string or null if invalid hex + */ + static hexToRgba(hex: string, opacityPercentage: number): string | null { + const rgbParsed = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + + if (!rgbParsed) { + return null; + } + + const rgb = { + r: parseInt(rgbParsed[1], 16), + g: parseInt(rgbParsed[2], 16), + b: parseInt(rgbParsed[3], 16) + }; + + const rgba = opacityPercentage > 100 || opacityPercentage < 1 ? + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` : + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacityPercentage / 100})`; + + return rgba; + } +} diff --git a/src/theme/utils/ViewportUtils.ts b/src/theme/utils/ViewportUtils.ts new file mode 100644 index 0000000..bf747ee --- /dev/null +++ b/src/theme/utils/ViewportUtils.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export class ViewportUtils { + /** + * Converts pixels to viewport width units + * @param screenWidthInPixels - The screen width in pixels + * @param elementSizeInPixels - The element size in pixels + * @returns Viewport width string + */ + static pxToVw(screenWidthInPixels: number, elementSizeInPixels: number): string { + return `${100 * elementSizeInPixels / screenWidthInPixels}vw`; + } +} diff --git a/src/views/ChannelList/ChannelList.tsx b/src/views/ChannelList/ChannelList.tsx new file mode 100644 index 0000000..b56c9ac --- /dev/null +++ b/src/views/ChannelList/ChannelList.tsx @@ -0,0 +1,44 @@ +import {JSX, useEffect} from 'react'; +import {useAppDispatch, useAppSelector} from 'store'; +import {fetchChannelsListPublisherStatus, selectChannels, fetchChannelList} from 'store/slices/Channels.slice'; +import {Channel} from '@techniker-me/pcast-api'; + +type ChannelWithPublisherStatus = Channel & { + isActivePublisher: boolean; +}; + +export function ChannelList(): JSX.Element { + const channelsList = useAppSelector(selectChannels); + const dispatch = useAppDispatch(); + + useEffect(() => { + const initializeChannels = async () => { + const channelListResult = await dispatch(fetchChannelList()); + + if (channelListResult.payload && Array.isArray(channelListResult.payload) && channelListResult.payload.length > 0) { + dispatch(fetchChannelsListPublisherStatus(channelListResult.payload as ChannelWithPublisherStatus[])); + } + }; + + initializeChannels(); + }, [dispatch]); + + return ( + <> +

Channels List

+
    + {channelsList.channels?.map(channel => { + const channelWithStatus = channel as ChannelWithPublisherStatus; + return ( +
  • +
    +
    {channel.name}
    + {channelWithStatus.isActivePublisher &&
    Status: {channelWithStatus.isActivePublisher ? 'Active' : 'Inactive'}
    } +
    +
  • + ); + })} +
+ + ); +} diff --git a/src/views/ChannelList/index.ts b/src/views/ChannelList/index.ts new file mode 100644 index 0000000..119c2dd --- /dev/null +++ b/src/views/ChannelList/index.ts @@ -0,0 +1 @@ +export * from './ChannelList'; diff --git a/src/views/LoginForm/LoginForm.tsx b/src/views/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..4b86de7 --- /dev/null +++ b/src/views/LoginForm/LoginForm.tsx @@ -0,0 +1,118 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {FC, useState, useEffect} from 'react'; +import text from './text'; +import {useAppDispatch, useAppSelector} from 'store/index'; +import {authenticateCredentialsThunk, selectIsLoading, selectError, setError} from 'store/slices/Authentication.slice'; +import { + LoginFormBackground, + LogoContainer, + LoginContainer, + LoginForm as StyledForm, + LoginButton, + LoginHeader, + LoginText, + ErrorText, + InputContainer, + InputField, + InputIcon, + Footer +} from './style'; +import personImage from 'assets/images/symbol-person-24x24.png'; +import lockImage from 'assets/images/symbol-lock-24x24.png'; +import phenixLogo from 'assets/images/phenix-logo-101x41.png'; +import { LoadingWheel } from 'components'; +import links from './links'; + +export const LoginForm: FC = () => { + const {headerText, headerTextSmall, signInText} = text; + const [applicationId, setApplicationId] = useState(''); + const [secret, setSecret] = useState(''); + const dispatch = useAppDispatch(); + const error = useAppSelector(selectError); + const isLoading = useAppSelector(selectIsLoading); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(authenticateCredentialsThunk({applicationId, secret})); + }; + + const handleInputChange = (setter: (value: string) => void) => (e: React.ChangeEvent) => { + // Clear error when user starts typing + if (error) { + dispatch(setError(null)); + } + setter(e.target.value); + }; + + useEffect(() => { + if (error) { + setTimeout(() => { + dispatch(setError(null)); + }, 3000); + } + }, [error]); + + return ( + + + Phenix RTS + + + + {headerText} + + + {headerTextSmall} + + + + + + + + + + + + + + {error && ( + + {error} + + )} + + + {isLoading ? : signInText} + + + + + + + ); +}; \ No newline at end of file diff --git a/src/views/LoginForm/index.ts b/src/views/LoginForm/index.ts new file mode 100644 index 0000000..8026749 --- /dev/null +++ b/src/views/LoginForm/index.ts @@ -0,0 +1 @@ +export * from './LoginForm'; diff --git a/src/views/LoginForm/links.ts b/src/views/LoginForm/links.ts new file mode 100644 index 0000000..e024e2a --- /dev/null +++ b/src/views/LoginForm/links.ts @@ -0,0 +1,6 @@ + +export default { + privacyPolicy: 'https://www.phenixrts.com/privacy-policy', + termsOfService: 'https://www.phenixrts.com/terms-of-service', + documentation: 'https://www.phenixrts.com/docs' +} \ No newline at end of file diff --git a/src/views/LoginForm/style.ts b/src/views/LoginForm/style.ts new file mode 100644 index 0000000..9ed2676 --- /dev/null +++ b/src/views/LoginForm/style.ts @@ -0,0 +1,189 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {H1, P} from 'components/typography'; +import Theme from 'theme'; +import phenixLogo from 'assets/images/phenix-logo-101x41.png'; + +export const LoginFormBackground = styled.default.div` + width: 100%; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + background-image: url(${Theme.backgrounds.loginBackground}); + background-size: cover; + background-position: center; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + + z-index: 1; + } + + /* Ensure content is above the overlay */ + > * { + position: relative; + z-index: 2; + } +`; + +export const LogoContainer = styled.default.div` + position: absolute; + top: 2rem; + left: 2rem; + z-index: 3; + + img { + height: 40px; + width: auto; + filter: brightness(0) invert(1); /* Makes the logo white */ + } +`; + +export const LoginContainer = styled.default.div` + background: rgba(25, 25, 25, 0.9); + border-radius: 12px; + padding: 2.5rem; + min-width: 400px; + max-width: 450px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); +`; + +export const LoginHeader = styled.default(H1)` + color: ${Theme.colors.white}; + text-align: center; + font-size: 2.5rem; + font-weight: 300; + margin: 0 0 0.5rem 0; +`; + +export const LoginText = styled.default(P)` + color: ${Theme.colors.white}; + text-align: center; + margin-bottom: 2rem; + font-size: 1.1rem; + opacity: 0.9; +`; + +export const LoginForm = styled.default.form` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const InputContainer = styled.default.div` + position: relative; + margin-bottom: 1rem; +`; + +export const InputField = styled.default.input` + width: 100%; + padding: 1rem 1rem 1rem 3rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(35, 35, 35, 0.7); + color: ${Theme.colors.white}; + font-size: 1rem; + outline: none; + transition: all 0.3s ease; + box-sizing: border-box; + + &::placeholder { + color: ${Theme.colors.gray500}; + } + + &:focus { + border-color: ${Theme.colors.white}; + background: rgba(45, 45, 45, 0.9); + } +`; + +export const InputIcon = styled.default.img` + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + opacity: 0.7; +`; + +export const LoginButton = styled.default.button` + background: linear-gradient(135deg, ${Theme.colors.red}, ${Theme.colors.lightRed}); + color: ${Theme.colors.white}; + border: none; + border-radius: 8px; + padding: 1rem; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 1rem; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(238, 45, 82, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +export const LoginLink = styled.default.a` + color: ${Theme.colors.cyan}; + cursor: pointer; + text-align: center; + margin-top: 1rem; + text-decoration: none; + font-size: 0.9rem; + opacity: 0.8; + transition: opacity 0.3s ease; + + &:hover { + opacity: 1; + } +`; + +export const ErrorText = styled.default.div` + text-align: center; + color: ${Theme.colors.red}; + background: rgba(238, 45, 82, 0.1); + border: 1px solid rgba(238, 45, 82, 0.3); + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 1rem; + font-size: 0.9rem; +`; + +export const Footer = styled.default.div` + text-align: center; + color: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + + a { + color: rgba(255, 255, 255, 0.5); + text-decoration: none; + margin: 0 0.3rem; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + } +`; \ No newline at end of file diff --git a/src/views/LoginForm/text.ts b/src/views/LoginForm/text.ts new file mode 100644 index 0000000..da6ee4b --- /dev/null +++ b/src/views/LoginForm/text.ts @@ -0,0 +1,13 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export default { + phenixText: 'Phenix', + headerText: 'Customer Portal', + headerTextSmall: 'Please sign in', + loginForgottenText: 'Forgot password?', + signInText: 'Sign In', + failAuthenticationText: 'Failed to authenticate. Please check your credentials', + emptyCredentialsText: 'Please enter Application Id and Secret', + unknownErrorText: 'An error has occurred. Please try again' +}; \ No newline at end of file diff --git a/src/views/LoginForm/view.tsx b/src/views/LoginForm/view.tsx new file mode 100644 index 0000000..5e0fb57 --- /dev/null +++ b/src/views/LoginForm/view.tsx @@ -0,0 +1,93 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import { + ChangeEvent, + useRef, + FC +} from 'react'; +import text from './text'; + +import {InputWithRef} from 'components/inputs'; +import Theme from 'theme'; + +import { + LoginLayout as Layout, + LoginButton, + LoginHeader, + LoginText, + LoginLink, + ErrorText +} from './style'; + +import personImage from 'assets/images/symbol-person-24x24.png'; +import lockImage from 'assets/images/symbol-lock-24x24.png'; +import { LoadingWheel } from 'components/loaders'; + +interface ILoginContainer { + onSubmit: () => void; + onChange: (event: ChangeEvent) => void; + errorMessage: string; + isLoading?: boolean; + isWebsocketConnected?: boolean; +} + +export const Login: FC = (props: ILoginContainer) => { + const {headerText, headerTextSmall, signInText, loginForgottenText} = text; + const {onChange, onSubmit, errorMessage, isLoading, isWebsocketConnected} = props; + const applicationIdRef = useRef(null); + const secretRef = useRef(null); + const handleKeySubmit = ({key}) => { + if (key !== 'Enter') { + return; + } + + onSubmit(); + }; + + return ( + + + {headerText} + + + {headerTextSmall} + + {/* + */} + {errorMessage || null} + + {(!isWebsocketConnected || isLoading) && } + {isWebsocketConnected && signInText} + + + {loginForgottenText} + + + ); +}; \ No newline at end of file diff --git a/src/views/channels/channels.tsx b/src/views/channels/channels.tsx new file mode 100644 index 0000000..4a71691 --- /dev/null +++ b/src/views/channels/channels.tsx @@ -0,0 +1,149 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {useEffect, useRef, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import moment from 'moment'; + +import LoggerFactory from 'services/logger/LoggerFactory'; +import {channelListErrorMessages, channelListPublishingStateErrorMessages} from 'constants/index'; +import {transformToPortalError} from 'utility/error-handler'; +import {ITableSortSearch} from 'interfaces/tableProps'; + +import {IAppStore} from 'store'; +import {channelsSelector, listChannels} from 'store/action/channels'; +import {fetchChannelsPublishingState} from 'store/action/channels-publishing'; +import {StoreScreensType} from 'store/reducers/screens'; +import {screensSelector, setScreenProps} from 'store/action/screens'; + +import Loader from 'components/loaders'; +import RequireAuthentication from 'components/requiresAuth'; +import {Body, Main} from 'components/layout'; +import {TableHeaderKey, ITableWithPaginationHeader} from 'components/table'; +import {TableWithPagination} from 'components/table-with-pagination'; + +import {Error} from 'components/error-renderer/style'; +import {columns} from './columns-config'; +import {CreateChannelModal} from './create-channel'; + +const intervalFetchChannelsPublishingState = moment.duration(5, 'seconds').asMilliseconds(); + +export const ChannelsContainer = (): JSX.Element => { + const logger = LoggerFactory.getLogger('views/channels/ChannelsContainer'); + const dispatch = useDispatch(); + const interval = useRef(null); + const {channelList = [], isFetching, error} = useSelector((state: IAppStore) => channelsSelector(state)); + const {searchValue, sortDirection, sortColumn} = useSelector((state: IAppStore) => screensSelector(state)[StoreScreensType.Channels]); + const [isCreateChannelModalOpened, setCreateChannelModalOpened] = useState(false); + const [channels, setChannels] = useState([]); + const [channelsIdOnDisplay, setChannelsIdOnDisplay] = useState([]); + const channelsColumns = {...columns}; + const getChannelList = async (): Promise => { + try { + logger.info('Fetching the list of channels'); + + await dispatch(listChannels()); + + logger.info('List of channels was fetched successfully'); + } catch (e) { + const {status, message, requestPayload} = transformToPortalError(e); + + logger.error(`${channelListErrorMessages[status] || message} [%s]`, status, requestPayload); + } + }; + + const getChannelsPublishingState = async (): Promise => { + if (isFetching || !channelsIdOnDisplay?.length) { + return; + } + + logger.info('Checking channels publishing state'); + + try { + await dispatch(fetchChannelsPublishingState(channelsIdOnDisplay)); + } catch (e) { + const {status, message, requestPayload} = transformToPortalError(e); + + return logger.error( + `${channelListPublishingStateErrorMessages[status] || message || channelListPublishingStateErrorMessages['default']} [%s]`, + status, + requestPayload + ); + } + }; + + useEffect(() => { + getChannelList(); + }, []); + + useEffect(() => { + const modifiedChannels = channelList.map(channel => ({ + ...channel, + extraPath: `${encodeURIComponent(channel.channelId)}/preview` + })); + + setChannels(modifiedChannels || []); + }, [channelList]); + + useEffect(() => { + clearInterval(interval.current); + interval.current = setInterval(getChannelsPublishingState, intervalFetchChannelsPublishingState); + + return () => clearInterval(interval.current); + }, [channelsIdOnDisplay, isFetching]); + + const screenHeader: ITableWithPaginationHeader = { + [TableHeaderKey.Search]: {}, + [TableHeaderKey.AddRow]: { + openAddRowModal: () => { + setCreateChannelModalOpened(true); + } + } + }; + const getCurrentDisplayList = (data: Record[]) => { + const newChannelsIdOnDisplay: string[] = data.map(val => val?.channelId) as string[]; + + setChannelsIdOnDisplay(newChannelsIdOnDisplay); + }; + + const changeScreenProps = (data: Partial) => + dispatch( + setScreenProps({ + screen: StoreScreensType.Channels, + data + }) + ); + + if (error) { + return {channelListErrorMessages[error.status] || error.message}; + } + + return ( + <> + + {isFetching ? ( +
+ +
+ ) : ( + + )} + + {isCreateChannelModalOpened && } + + ); +}; + +export default RequireAuthentication(ChannelsContainer); diff --git a/src/views/channels/columns-config.tsx b/src/views/channels/columns-config.tsx new file mode 100644 index 0000000..547303a --- /dev/null +++ b/src/views/channels/columns-config.tsx @@ -0,0 +1,65 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {faEllipsisV} from '@fortawesome/free-solid-svg-icons'; +import {CellType, ColumnsType, DataRowType} from 'components/table'; +import {ChannelIconMenu} from 'components/channel-icon-menu'; +import {IconMenuPosition} from 'components/icon-menu/icon-menu'; +import PublishingStateIndicator from 'components/indicator-component/publishing-state-indicator'; +import {theme} from 'theme'; + +const ChannelPublishingStateIndicator = (row?: DataRowType) => ; + +export const columns: ColumnsType = { + indicator: { + title: '', + hasBorder: false, + width: 40, + type: CellType.Component, + renderCell: ChannelPublishingStateIndicator + }, + name: { + title: 'Channel Name', + type: CellType.Link, + textCell: {propName: 'name'}, + thStyle: { + textAlign: 'left', + paddingLeft: 16 + } + }, + alias: { + title: 'Alias', + textCell: {propName: 'alias'} + }, + channelId: { + title: 'Channel Id', + hideColumnAt: theme.screenSizes.desktop, + textCell: {propName: 'channelId'} + }, + streamKey: { + title: 'Stream Key', + hideColumnAt: theme.screenSizes.tablet, + textCell: {propName: 'streamKey'} + }, + created: { + title: 'Created', + hideColumnAt: theme.screenSizes.large, + type: CellType.Date, + textCell: {propName: 'created'} + }, + dropdown: { + title: '', + hasBorder: false, + width: 50, + type: CellType.DropDown, + dropdownCell: { + Component: ChannelIconMenu, + keys: ['channelId', 'name', 'alias'], + componentProps: { + icon: faEllipsisV, + showTail: false, + position: IconMenuPosition.Left + } + } + } +}; diff --git a/src/views/channels/create-channel/create-channel-modal.tsx b/src/views/channels/create-channel/create-channel-modal.tsx new file mode 100644 index 0000000..03a2138 --- /dev/null +++ b/src/views/channels/create-channel/create-channel-modal.tsx @@ -0,0 +1,124 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {ChangeEvent, useState} from 'react'; +import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons'; + +import LoggerFactory from 'services/logger/LoggerFactory'; +import {createChannel} from 'services/channel.service'; + +import {transformToPortalError} from 'utility/error-handler'; +import {createChannelErrorMessages} from 'constants/error-messages'; +import {documentationLinks} from 'constants/links'; + +import Loader from 'components/loaders'; +import {Input} from 'components/forms/Input'; +import {NewTabLink} from 'components/new-tab-link'; +import {Label} from 'components/label'; +import {DialogForm, Error, FormLoaderContainer} from 'components/modal/modal-form-response/style'; +import {Modal} from 'components/modal'; + +interface ICreateChannelInput { + setCreateChannelModalOpened: React.Dispatch>; + getChannelList: () => Promise; +} + +const CreateChannelModal = ({setCreateChannelModalOpened, getChannelList}: ICreateChannelInput): JSX.Element => { + const logger = LoggerFactory.getLogger('view/channels/create-channel/CreateChannelModal'); + const initialState = { + alias: '', + name: '', + description: '' + }; + const [inputValues, setInputValue] = useState(initialState); + const {alias, name, description} = inputValues; + const [isFormValid, setIsFormValid] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const handleChange = (event: ChangeEvent): void => { + const {name, value} = event.target; + + setInputValue({ + ...inputValues, + [name]: value + }); + }; + + const handleClose = (): void => { + setCreateChannelModalOpened(false); + }; + + const handleSubmit = async (): Promise => { + const valid = alias.trim() && name.trim() && description.trim(); + + if (!valid) { + setIsFormValid(false); + + return; + } + + setIsLoading(true); + + try { + logger.info('Creating a channel with the following parameters : [%j]', { + alias, + name, + description + }); + + await createChannel(alias, name, description); + + logger.info('Channel [%s] was created successfully', alias); + + setInputValue(initialState); + setIsLoading(false); + + await getChannelList(); + + handleClose(); + } catch (e) { + const {status, message, requestPayload} = transformToPortalError(e); + + setIsLoading(false); + setError(createChannelErrorMessages[status] || message || createChannelErrorMessages['default']); + + logger.error(`${createChannelErrorMessages[status] || message || createChannelErrorMessages['default']} [%s]`, status, requestPayload); + } + }; + + return ( + + {isLoading ? ( + + + + ) : ( + +

+ Create Channel +

+
+ )} +
+ ); +}; + +export default CreateChannelModal; diff --git a/src/views/channels/create-channel/index.tsx b/src/views/channels/create-channel/index.tsx new file mode 100644 index 0000000..ca56192 --- /dev/null +++ b/src/views/channels/create-channel/index.tsx @@ -0,0 +1,4 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export {default as CreateChannelModal} from './create-channel-modal'; diff --git a/src/views/channels/index.tsx b/src/views/channels/index.tsx new file mode 100644 index 0000000..abaef6e --- /dev/null +++ b/src/views/channels/index.tsx @@ -0,0 +1,4 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export {default} from './channels'; diff --git a/src/views/index.ts b/src/views/index.ts new file mode 100644 index 0000000..b8e2cba --- /dev/null +++ b/src/views/index.ts @@ -0,0 +1,2 @@ +export * from './LoginForm'; +export * from './ChannelList'; diff --git a/tsconfig.app.json b/tsconfig.app.json index 6dbef84..65f5964 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -9,8 +9,8 @@ "downlevelIteration": true, "sourceMap": true, "noEmit": true, - "noEmitHelpers": true, - "importHelpers": true, + "noEmitHelpers": false, + "importHelpers": false, "strictNullChecks": true, "noUnusedParameters": true, "noUnusedLocals": true, @@ -28,6 +28,7 @@ "routers/*": ["./routers/*"], "store/*": ["./store/*"], "services/*": ["./services/*"], + "theme/*": ["./theme/*"], "lang/*": ["./lang/*"], "views/*": ["./views/*"] }, diff --git a/vite.config.ts b/vite.config.ts index 7ee1f45..3a1adfd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ react(), babel({ babelConfig: { - plugins: ['transform-amd-to-commonjs'] + plugins: ['transform-amd-to-commonjs', 'babel-plugin-styled-components'] } }) ], @@ -26,6 +26,7 @@ export default defineConfig({ routers: path.resolve(process.cwd(), 'src', 'routers'), store: path.resolve(process.cwd(), 'src', 'store'), services: path.resolve(process.cwd(), 'src', 'services'), + theme: path.resolve(process.cwd(), 'src', 'theme'), lang: path.resolve(process.cwd(), 'src', 'lang'), utility: path.resolve(process.cwd(), 'src', 'utility'), views: path.resolve(process.cwd(), 'src', 'views')