near-complete feature parity with current frontend, another big push of features, some styling to dialogs / forms

This commit is contained in:
kappa118 2025-02-25 17:13:56 -05:00
parent 04584d7b1e
commit 5e2125d5e7
48 changed files with 1853 additions and 1666 deletions

View file

@ -0,0 +1,12 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
/** @type {import('eslint').Linter.Config[]} */
export default [
{files: ["**/*.{js,mjs,cjs,jsx}"]},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
pluginReact.configs.flat.recommended,
];

View file

@ -14,9 +14,11 @@
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"axios": "^1.7.9",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"material-react-table": "^3.2.0",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0",
@ -111,6 +113,7 @@
"version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.26.8.tgz",
"integrity": "sha512-3tBctaHRW6xSub26z7n8uyOTwwUsCdvIug/oxBH9n6yCO5hMj2vwDJAo7RbBMKrM7P+W2j61zLKviJQFGOYKMg==",
"license": "MIT",
"dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
@ -128,6 +131,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
"integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
@ -136,6 +140,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@ -2393,6 +2398,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@ -2414,12 +2420,14 @@
"node_modules/@eslint/eslintrc/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
@ -2434,6 +2442,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@ -2445,6 +2454,7 @@
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
@ -2456,6 +2466,7 @@
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
@ -2470,6 +2481,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"license": "Apache-2.0",
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1",
@ -2495,7 +2507,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead"
"deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
@ -3296,6 +3309,7 @@
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
"integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==",
"license": "MIT",
"dependencies": {
"eslint-scope": "5.1.1"
}
@ -3304,6 +3318,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -3316,6 +3331,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
@ -3494,12 +3510,14 @@
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"license": "MIT"
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.5.tgz",
"integrity": "sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A=="
"integrity": "sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A==",
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.24.51",
@ -3968,7 +3986,8 @@
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
@ -4053,7 +4072,8 @@
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ=="
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "0.17.4",
@ -4125,6 +4145,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@ -4158,6 +4179,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz",
"integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "5.62.0"
},
@ -4176,6 +4198,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@ -4202,6 +4225,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0"
@ -4218,6 +4242,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
"integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "5.62.0",
"@typescript-eslint/utils": "5.62.0",
@ -4244,6 +4269,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@ -4256,6 +4282,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0",
@ -4282,6 +4309,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9",
@ -4307,6 +4335,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -4319,6 +4348,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
@ -4327,6 +4357,7 @@
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"eslint-visitor-keys": "^3.3.0"
@ -4342,7 +4373,8 @@
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
@ -4546,6 +4578,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@ -4740,6 +4773,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
@ -4768,6 +4802,7 @@
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
"integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -4795,6 +4830,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
"integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -4814,6 +4850,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
"integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -4833,6 +4870,7 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
"integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
@ -4850,6 +4888,7 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
"integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
@ -4887,6 +4926,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
"integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -4926,7 +4966,8 @@
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
@ -5008,6 +5049,7 @@
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
@ -5040,6 +5082,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
@ -5914,7 +5957,8 @@
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
"integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA=="
"integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==",
"license": "MIT"
},
"node_modules/connect-history-api-fallback": {
"version": "2.0.0",
@ -6387,7 +6431,8 @@
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"license": "BSD-2-Clause"
},
"node_modules/data-urls": {
"version": "2.0.0",
@ -6667,6 +6712,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
@ -6988,6 +7034,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
"integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
@ -7044,6 +7091,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
"integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
@ -7125,6 +7173,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7179,6 +7228,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz",
"integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.16.0",
"@babel/eslint-parser": "^7.16.3",
@ -7206,6 +7256,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.7",
"is-core-module": "^2.13.0",
@ -7216,6 +7267,7 @@
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
@ -7224,6 +7276,7 @@
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.7"
},
@ -7240,6 +7293,7 @@
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
@ -7248,6 +7302,7 @@
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz",
"integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==",
"license": "BSD-3-Clause",
"dependencies": {
"lodash": "^4.17.21",
"string-natural-compare": "^3.0.1"
@ -7265,6 +7320,7 @@
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"license": "MIT",
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -7297,6 +7353,7 @@
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
@ -7305,6 +7362,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
@ -7316,6 +7374,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@ -7324,6 +7383,7 @@
"version": "25.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz",
"integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/experimental-utils": "^5.0.0"
},
@ -7347,6 +7407,7 @@
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
"integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
"license": "MIT",
"dependencies": {
"aria-query": "^5.3.2",
"array-includes": "^3.1.8",
@ -7375,6 +7436,7 @@
"version": "7.37.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz",
"integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==",
"license": "MIT",
"dependencies": {
"array-includes": "^3.1.8",
"array.prototype.findlast": "^1.2.5",
@ -7406,6 +7468,7 @@
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
@ -7417,6 +7480,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
@ -7428,6 +7492,7 @@
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
"integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
@ -7444,6 +7509,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@ -7452,6 +7518,7 @@
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.1.tgz",
"integrity": "sha512-5eX9e1Kc2PqVRed3taaLnAAqPZGEX75C+M/rXzUAI3wIg/ZxzUm1OVAwfe/O+vE+6YXOLetSe9g5GKD2ecXipw==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^5.58.0"
},
@ -7467,6 +7534,7 @@
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@ -7493,6 +7561,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz",
"integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==",
"license": "MIT",
"dependencies": {
"@types/eslint": "^7.29.0 || ^8.4.1",
"jest-worker": "^28.0.2",
@ -7516,6 +7585,7 @@
"version": "28.1.3",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
"integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@ -7529,6 +7599,7 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
@ -7542,7 +7613,8 @@
"node_modules/eslint/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/eslint/node_modules/find-up": {
"version": "5.0.0",
@ -7563,6 +7635,7 @@
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
@ -7577,6 +7650,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@ -7630,6 +7704,7 @@
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
@ -7641,6 +7716,7 @@
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
@ -7918,6 +7994,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"license": "MIT",
"dependencies": {
"flat-cache": "^3.0.4"
},
@ -8074,6 +8151,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.3",
@ -8086,7 +8164,8 @@
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
@ -9400,6 +9479,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
@ -9695,6 +9775,7 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
"integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-object-atoms": "^1.0.0",
@ -10667,7 +10748,8 @@
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
@ -10745,6 +10827,7 @@
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
"license": "MIT",
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flat": "^1.3.1",
@ -10759,6 +10842,7 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
@ -10790,12 +10874,14 @@
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
"integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="
"integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
"license": "CC0-1.0"
},
"node_modules/language-tags": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
"integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
"license": "MIT",
"dependencies": {
"language-subtag-registry": "^0.3.20"
},
@ -11290,7 +11376,8 @@
"node_modules/natural-compare-lite": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g=="
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.4",
@ -11444,6 +11531,7 @@
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz",
"integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -11457,6 +11545,7 @@
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
"integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -11494,6 +11583,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
"integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -13153,6 +13243,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -14875,7 +14980,8 @@
"node_modules/string-natural-compare": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz",
"integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw=="
"integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
@ -14918,6 +15024,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
"integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
@ -14957,6 +15064,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
"integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
"license": "MIT",
"dependencies": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
@ -15673,6 +15781,7 @@
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
"integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
"license": "MIT",
"dependencies": {
"@types/json5": "^0.0.29",
"json5": "^1.0.2",
@ -15684,6 +15793,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.0"
},
@ -15695,6 +15805,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
@ -15708,6 +15819,7 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
"license": "MIT",
"dependencies": {
"tslib": "^1.8.1"
},
@ -15721,7 +15833,8 @@
"node_modules/tsutils/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "2.4.0",

View file

@ -9,9 +9,11 @@
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"axios": "^1.7.9",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"material-react-table": "^3.2.0",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0",
@ -24,7 +26,8 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"format": "prettier --write ."
},
"eslintConfig": {
"extends": [

View file

@ -0,0 +1,10 @@
// prettier.config.js or .prettierrc.js
module.exports = {
semi: true, // Add semicolons at the end of statements
singleQuote: true, // Use single quotes instead of double
tabWidth: 2, // Set the indentation width
trailingComma: "es5", // Add trailing commas where valid in ES5
printWidth: 80, // Wrap lines at 80 characters
bracketSpacing: true, // Add spaces inside object braces
arrowParens: "always", // Always include parentheses around arrow function parameters
};

View file

@ -1,13 +1,19 @@
// src/App.js
import React, { useState } from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import {
BrowserRouter as Router,
Route,
Routes,
Navigate,
} from 'react-router-dom';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import useAuthStore from './store/auth';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import { ThemeProvider } from '@mui/material/styles'; // Import theme tools
import { ThemeProvider } from '@mui/material/styles'; // Import theme tools
import {
AppBar,
Toolbar,
Typography,
Box,
CssBaseline,
Drawer,
@ -17,32 +23,63 @@ import {
ListItemText,
Divider,
} from '@mui/material';
import theme from './theme'
import theme from './theme';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import API from './api';
const drawerWidth = 240;
const miniDrawerWidth = 60;
// Protected Route Component
const ProtectedRoute = ({ element, ...rest }) => {
const { isAuthenticated } = useAuthStore();
return isAuthenticated ? element : <Navigate to="/login" />;
};
const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const {
isAuthenticated,
setIsAuthenticated,
logout,
initData,
initializeAuth,
} = useAuthStore();
const toggleDrawer = () => {
setOpen(!open);
};
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
} else {
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
{/* <AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
width: `calc(100% - ${open ? drawerWidth : miniDrawerWidth}px)`,
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
}}
>
<Toolbar variant="dense"></Toolbar>
</AppBar> */}
<Drawer
variant="permanent"
open={open}
@ -57,11 +94,20 @@ const App = () => {
}}
>
{/* Drawer Toggle Button */}
<List sx={{backgroundColor: '#495057', color: 'white'}}>
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton onClick={toggleDrawer}>
<img src="/images/logo.png" width="35x" />
{open && <ListItemText primary="Dispatcharr" sx={{paddingLeft: 3}}/>}
<ListItemButton
onClick={toggleDrawer}
size="small"
sx={{
pt: 0,
pb: 0,
}}
>
<img src="/images/logo.png" width="33x" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
@ -71,28 +117,57 @@ const App = () => {
<Sidebar open />
</Drawer>
<Box sx={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
}}>
<Box
sx={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
// pt: '64px',
}}
>
{/* Fixed Header */}
{/* <Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
</Box> */}
{/* Main Content Area between Header and Footer */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route exact path="/channels" element={<ProtectedRoute element={<Channels />}/>} />
<Route exact path="/m3u" element={<ProtectedRoute element={<M3U />}/>} />
<Route exact path="/epg" element={<ProtectedRoute element={<EPG />}/>} />
<Route exact path="/stream-profiles" element={<ProtectedRoute element={<StreamProfiles />}/>} />
<Route exact path="/guide" element={<ProtectedRoute element={<Guide />}/>} />
{isAuthenticated ? (
<>
<Route exact path="/channels" element={<Channels />} />
<Route exact path="/m3u" element={<M3U />} />
<Route exact path="/epg" element={<EPG />} />
<Route
exact
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route exact path="/guide" element={<Guide />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
{/* Redirect if no match */}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</Box>

View file

@ -1,4 +1,4 @@
import Axios from 'axios'
import Axios from 'axios';
import useAuthStore from './store/auth';
import useChannelsStore from './store/channels';
import useUserAgentsStore from './store/userAgents';
@ -7,11 +7,11 @@ import useEPGsStore from './store/epgs';
import useStreamsStore from './store/streams';
import useStreamProfilesStore from './store/streamProfiles';
const axios = Axios.create({
withCredentials: true,
})
// const axios = Axios.create({
// withCredentials: true,
// });
const host = "http://192.168.1.151:9191"
const host = 'http://192.168.1.151:9191';
const getAuthToken = async () => {
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
@ -28,14 +28,14 @@ export default class API {
body: JSON.stringify({ username, password }),
});
return await response.json()
return await response.json();
}
static async refreshToken(refreshToken) {
const response = await fetch(`${host}/api/accounts/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken })
body: JSON.stringify({ refresh: refreshToken }),
});
const retval = await response.json();
@ -45,9 +45,9 @@ export default class API {
static async logout() {
const response = await fetch(`${host}/api/accounts/auth/logout/`, {
method: 'POST',
})
});
return response.data.data
return response.data.data;
}
static async getChannels() {
@ -74,33 +74,72 @@ export default class API {
return retval;
}
static async addChannelGroup(values) {
const response = await fetch(`${host}/api/channels/groups/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannelGroup(retval);
}
return retval;
}
static async updateChannelGroup(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/groups/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().updateChannelGroup(retval);
}
return retval;
}
static async addChannel(channel) {
let body = null
let body = null;
if (channel.logo_file) {
body = new FormData();
for (const prop in channel) {
body.append(prop, channel[prop])
body.append(prop, channel[prop]);
}
} else {
body = {...channel}
delete body.logo_file
body = JSON.stringify(body)
body = { ...channel };
delete body.logo_file;
body = JSON.stringify(body);
}
const response = await fetch(`${host}/api/channels/channels/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
...(channel.logo_file ? {} :{
'Content-Type': 'application/json',
})
...(channel.logo_file
? {}
: {
'Content-Type': 'application/json',
}),
},
body: body,
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval)
useChannelsStore.getState().addChannel(retval);
}
return retval;
@ -115,7 +154,7 @@ export default class API {
},
});
useChannelsStore.getState().removeChannels([id])
useChannelsStore.getState().removeChannels([id]);
}
// @TODO: the bulk delete endpoint is currently broken
@ -133,7 +172,7 @@ export default class API {
// }
static async updateChannel(values) {
const {id, ...payload} = values
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
method: 'PUT',
headers: {
@ -145,7 +184,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().updateChannel(retval)
useChannelsStore.getState().updateChannel(retval);
}
return retval;
@ -163,7 +202,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval)
useChannelsStore.getState().addChannel(retval);
}
return retval;
@ -181,7 +220,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval)
useChannelsStore.getState().addChannel(retval);
}
return retval;
@ -211,14 +250,14 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useStreamsStore.getState().addStream(retval)
useStreamsStore.getState().addStream(retval);
}
return retval;
}
static async updateStream(values) {
const {id, ...payload} = values
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
method: 'PUT',
headers: {
@ -230,7 +269,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useStreamsStore.getState().updateStream(retval)
useStreamsStore.getState().updateStream(retval);
}
return retval;
@ -245,7 +284,7 @@ export default class API {
},
});
useStreamsStore.getState().removeStreams([id])
useStreamsStore.getState().removeStreams([id]);
}
static async deleteStreams(ids) {
@ -255,10 +294,10 @@ export default class API {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stream_ids: ids })
body: JSON.stringify({ stream_ids: ids }),
});
useStreamsStore.getState().removeStreams(ids)
useStreamsStore.getState().removeStreams(ids);
}
static async getUserAgents() {
@ -285,14 +324,14 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useUserAgentsStore.getState().addUserAgent(retval)
useUserAgentsStore.getState().addUserAgent(retval);
}
return retval;
}
static async updateUserAgent(values) {
const {id, ...payload} = values
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
method: 'PUT',
headers: {
@ -304,7 +343,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useUserAgentsStore.getState().updateUserAgent(retval)
useUserAgentsStore.getState().updateUserAgent(retval);
}
return retval;
@ -319,7 +358,7 @@ export default class API {
},
});
useUserAgentsStore.getState().removeUserAgents([id])
useUserAgentsStore.getState().removeUserAgents([id]);
}
static async getPlaylists() {
@ -346,7 +385,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
usePlaylistsStore.getState().addPlaylist(retval)
usePlaylistsStore.getState().addPlaylist(retval);
}
return retval;
@ -387,11 +426,11 @@ export default class API {
},
});
usePlaylistsStore.getState().removePlaylists([id])
usePlaylistsStore.getState().removePlaylists([id]);
}
static async updatePlaylist(values) {
const {id, ...payload} = values
const { id, ...payload } = values;
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
method: 'PUT',
headers: {
@ -403,7 +442,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
usePlaylistsStore.getState().updatePlaylist(retval)
usePlaylistsStore.getState().updatePlaylist(retval);
}
return retval;
@ -435,32 +474,34 @@ export default class API {
}
static async addEPG(values) {
let body = null
let body = null;
if (values.epg_file) {
body = new FormData();
for (const prop in values) {
body.append(prop, values[prop])
body.append(prop, values[prop]);
}
} else {
body = {...values}
delete body.epg_file
body = JSON.stringify(body)
body = { ...values };
delete body.epg_file;
body = JSON.stringify(body);
}
const response = await fetch(`${host}/api/epg/sources/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
...(values.epg_file ? {} :{
'Content-Type': 'application/json',
})
...(values.epg_file
? {}
: {
'Content-Type': 'application/json',
}),
},
body,
});
const retval = await response.json();
if (retval.id) {
useEPGsStore.getState().addEPG(retval)
useEPGsStore.getState().addEPG(retval);
}
return retval;
@ -475,7 +516,7 @@ export default class API {
},
});
useEPGsStore.getState().removeEPGs([id])
useEPGsStore.getState().removeEPGs([id]);
}
static async refreshEPG(id) {
@ -511,18 +552,18 @@ export default class API {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values)
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useStreamProfilesStore.getState().addStreamProfile(retval)
useStreamProfilesStore.getState().addStreamProfile(retval);
}
return retval;
}
static async updateStreamProfile(values) {
const {id, ...payload} = values
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
method: 'PUT',
headers: {
@ -534,7 +575,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
useStreamProfilesStore.getState().updateStreamProfile(retval)
useStreamProfilesStore.getState().updateStreamProfile(retval);
}
return retval;
@ -549,7 +590,7 @@ export default class API {
},
});
useStreamProfilesStore.getState().removeStreamProfiles([id])
useStreamProfilesStore.getState().removeStreamProfiles([id]);
}
static async getGrid() {
@ -560,8 +601,8 @@ export default class API {
},
});
const retval = await response.json()
console.log(retval)
return retval
const retval = await response.json();
console.log(retval);
return retval;
}
}

View file

@ -1,55 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
const HeaderBar = () => {
return (
<div class="container-fluid">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
<i class="bi bi-list"></i>
</a>
</li>
{/* <li class="nav-item d-none d-md-block">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">Home</a>
</li> */}
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<Link to="/login" class="nav-link">Login</Link>
</li>
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="themeToggleBtn" type="button" aria-expanded="false"
data-bs-toggle="dropdown" data-bs-display="static">
<span class="theme-icon-active"><i class="bi bi-sun-fill my-1"></i></span>
<span class="d-lg-none ms-2" id="theme-toggle-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggleBtn">
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="light">
<i class="bi bi-sun-fill me-2"></i> Light
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i class="bi bi-moon-fill me-2"></i> Dark
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
<i class="bi bi-circle-half me-2"></i> Auto
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
</ul>
</li>
</ul>
</div>
);
};
export default HeaderBar;

View file

@ -1,33 +1,30 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import React from "react";
import { Link, useLocation } from "react-router-dom";
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Divider,
} from '@mui/material';
} from "@mui/material";
import {
Menu as MenuIcon,
Home as HomeIcon,
Settings as SettingsIcon,
Info as InfoIcon,
Description as DescriptionIcon,
Tv as TvIcon,
CalendarMonth as CalendarMonthIcon,
VideoFile as VideoFileIcon,
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
} from '@mui/icons-material';
} from "@mui/icons-material";
const items = [
{ text: 'Channels', icon: <TvIcon />, route: "/channels" },
{ text: 'M3U', icon: <PlaylistPlayIcon />, route: "/m3u" },
{ text: 'EPG', icon: <CalendarMonthIcon />, route: "/epg" },
{ text: 'Stream Profiles', icon: <VideoFileIcon />, route: "/stream-profiles" },
{ text: 'TV Guide', icon: <LiveTvIcon />, route: "/guide" },
{ text: "Channels", icon: <TvIcon />, route: "/channels" },
{ text: "M3U", icon: <PlaylistPlayIcon />, route: "/m3u" },
{ text: "EPG", icon: <CalendarMonthIcon />, route: "/epg" },
{
text: "Stream Profiles",
icon: <VideoFileIcon />,
route: "/stream-profiles",
},
{ text: "TV Guide", icon: <LiveTvIcon />, route: "/guide" },
];
const Sidebar = ({ open }) => {
@ -37,10 +34,14 @@ const Sidebar = ({ open }) => {
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={Link} to={item.route} selected={location.pathname == item.route}>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>

View file

@ -1,8 +1,6 @@
// Modal.js
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Modal,
Typography,
Stack,
TextField,
@ -14,47 +12,53 @@ import {
FormControl,
CircularProgress,
IconButton,
} from "@mui/material";
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormHelperText,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from "../../store/channels";
import API from "../../api"
import useStreamProfilesStore from "../../store/streamProfiles";
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import {
Add as AddIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import useStreamsStore from "../../store/streams";
import usePlaylistsStore from "../../store/playlists";
import { MaterialReactTable, useMaterialReactTable } from "material-react-table";
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore(state => state.streams)
const playlists = usePlaylistsStore(state => state.playlists)
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logo, setLogo] = useState(null)
const [logoPreview, setLogoPreview] = useState(null)
const [channelStreams, setChannelStreams] = useState([])
const [logo, setLogo] = useState(null);
const [logoPreview, setLogoPreview] = useState('/images/logo.png');
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const addStream = (stream) => {
const streamSet = new Set(channelStreams)
streamSet.add(stream)
setChannelStreams(Array.from(streamSet))
}
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams)
streamSet.delete(stream)
setChannelStreams(Array.from(streamSet))
}
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (file) {
setLogo(file)
setLogo(file);
setLogoPreview(URL.createObjectURL(file));
}
};
@ -74,23 +78,29 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
if (channel?.id) {
await API.updateChannel({id: channel.id, ...values, logo_file: logo, streams: channelStreams.map(stream => stream.id)})
await API.updateChannel({
id: channel.id,
...values,
logo_file: logo,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logo,
streams: channelStreams.map(stream => stream.id),
})
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogo(null)
setLogoPreview(null)
setLogo(null);
setLogoPreview('/images/logo.png');
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (channel) {
@ -98,11 +108,22 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id,
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
setChannelStreams(streams.filter(stream => channel.streams.includes(stream.id)))
console.log('channel streams');
console.log(channel.streams);
const filteredStreams = streams
.filter((stream) => channel.streams.includes(stream.id))
.sort(
(a, b) =>
channel.streams.indexOf(a.id) - channel.streams.indexOf(b.id)
);
console.log('filtered streams');
console.log(filteredStreams);
setChannelStreams(filteredStreams);
} else {
formik.resetForm();
}
@ -110,27 +131,32 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const activeStreamsTable = useMaterialReactTable({
data: channelStreams,
columns: useMemo(() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
], []),
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
],
[]
),
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
enableRowOrdering: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
positionActionsColumn: 'last',
renderRowActions: ({ row }) => (
<>
<IconButton
@ -138,36 +164,53 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
color="error" // Red color for delete actions
onClick={() => removeStream(row.original)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<RemoveIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: '200px',
},
},
})
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
setChannelStreams([...channelStreams]);
}
},
}),
});
const availableStreamsTable = useMaterialReactTable({
data: streams,
columns: useMemo(() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
], []),
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[]
),
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
@ -190,196 +233,242 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
height: '200px',
},
},
})
});
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
<>
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Channel
</Typography>
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<TextField
fullWidth
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.channel_name && Boolean(formik.errors.channel_name)}
helperText={formik.touched.channel_name && formik.errors.channel_name}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">Channel Group</InputLabel>
<Select
labelId="channel-group-label"
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<TextField
fullWidth
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.channel_group_id && Boolean(formik.errors.channel_group_id)}
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
error={
formik.touched.channel_name &&
Boolean(formik.errors.channel_name)
}
helperText={
formik.touched.channel_name && formik.errors.channel_name
}
variant="standard"
>
{channelGroups.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">Stream Profile</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
<Grid2
container
spacing={1}
sx={{
alignItems: 'center',
}}
>
<Grid2 size={11}>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">
Channel Group
</InputLabel>
<Select
labelId="channel-group-label"
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_group_id &&
Boolean(formik.errors.channel_group_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
variant="standard"
>
{channelGroups.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
<FormHelperText sx={{ color: 'error.main' }}>
{formik.touched.channel_group_id &&
formik.errors.channel_group_id}
</FormHelperText>
</FormControl>
</Grid2>
<Grid2 size={1}>
<IconButton
color="success"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="filled"
>
<AddIcon fontSize="small" />
</IconButton>
</Grid2>
</Grid2>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.stream_profile_id && Boolean(formik.errors.stream_profile_id)}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
error={
formik.touched.channel_number &&
Boolean(formik.errors.channel_number)
}
helperText={
formik.touched.channel_number &&
formik.errors.channel_number
}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
/>
</Grid2>
<TextField
fullWidth
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.channel_number && Boolean(formik.errors.channel_number)}
helperText={formik.touched.channel_number && formik.errors.channel_number}
variant="standard"
/>
<Grid2 size={6}>
<TextField
fullWidth
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
}
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
variant="standard"
/>
</Grid2>
<TextField
fullWidth
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<Grid2 size={6}>
<TextField
fullWidth
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_name && Boolean(formik.errors.tvg_name)}
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
variant="standard"
/>
<TextField
fullWidth
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<Box mb={2}>
{/* File upload input */}
<Stack direction="row" spacing={2}>
<Typography>Logo</Typography>
{/* Display selected image */}
<Box mb={2}>
{logo && (
<Box mt={2} mb={2}>
{/* File upload input */}
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
}}
>
<Typography>Logo</Typography>
{/* Display selected image */}
<Box mb={2}>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
)}
</Box>
</Stack>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Box>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Stack>
</Box>
</Grid2>
</Grid2>
</Grid2>
<Box mb={2}>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<Typography>Active Streams</Typography>
<MaterialReactTable table={activeStreamsTable} />
</Grid2>
<Grid2 size={6}>
<Typography>Available Streams</Typography>
<MaterialReactTable table={availableStreamsTable} />
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</DialogActions>
</form>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<Typography>Active Streams</Typography>
<MaterialReactTable table={activeStreamsTable} />
</Grid2>
<Grid2 size={6}>
<Typography>Available Streams</Typography>
<MaterialReactTable table={availableStreamsTable} />
</Grid2>
</Grid2>
</Box>
</Modal>
</Dialog>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: "1200px",
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default Channel;

View file

@ -0,0 +1,94 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Channel Group
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default ChannelGroup;

View file

@ -1,50 +1,67 @@
// Modal.js
import React, { useState, useEffect } from "react";
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress } from "@mui/material";
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from "../../api"
import {
Box,
Modal,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useEPGsStore from "../../store/epgs";
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore(state => state.epgs)
const [file, setFile] = useState(null)
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file)
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
source_type: '',
url: '',
api_key: '',
name: "",
source_type: "",
url: "",
api_key: "",
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
name: Yup.string().required("Name is required"),
source_type: Yup.string().required("Source type is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({id: epg.id, ...values, epg_file: file})
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
})
});
}
resetForm();
setFile(null)
setFile(null);
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (epg) {
@ -61,20 +78,21 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
}, [epg]);
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
EPG Source
</Typography>
<form onSubmit={formik.handleSubmit}>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
EPG Source
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
@ -101,7 +119,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
variant="standard"
/>
< TextField
<TextField
fullWidth
id="api_key"
name="api_key"
@ -124,8 +142,12 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
value={formik.values.source_type}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.source_type && Boolean(formik.errors.source_type)}
helperText={formik.touched.source_type && formik.errors.source_type}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
helperText={
formik.touched.source_type && formik.errors.source_type
}
variant="standard"
>
<MenuItem key="0" value="xmltv">
@ -136,35 +158,23 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
</MenuItem>
</Select>
</FormControl>
</DialogContent>
<Box mb={2}>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Modal>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default EPG;

View file

@ -1,18 +1,25 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; // For redirection
import useAuthStore from '../../store/auth'
import { Box, TextField, Button, Typography, Grid2, Paper } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Box,
TextField,
Button,
Typography,
Grid2,
Paper,
} from '@mui/material';
const LoginForm = () => {
const { login, isAuthenticated } = useAuthStore(); // Get login function from AuthContext
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels')
navigate('/channels');
}
}, [isAuthenticated, navigate])
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
@ -23,7 +30,8 @@ const LoginForm = () => {
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData)
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
@ -52,7 +60,12 @@ const LoginForm = () => {
Login
</Typography>
<form onSubmit={handleSubmit}>
<Grid2 container spacing={2} justifyContent="center" direction="column">
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 item xs={12}>
<TextField
label="Username"

View file

@ -1,51 +1,71 @@
// Modal.js
import React, { useState, useEffect } from "react";
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, FormControlLabel, Checkbox } from "@mui/material";
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from "../../api"
import usePlaylistsStore from "../../store/playlists";
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore(state => state.userAgents)
const [file, setFile] = useState(null)
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file)
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
server_url: '',
name: "",
server_url: "",
max_streams: 0,
user_agent: '',
user_agent: "",
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
name: Yup.string().required("Name is required"),
user_agent: Yup.string().required("User-Agent is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({id: playlist.id, ...values, uploaded_file: file})
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
})
});
}
resetForm();
setFile(null)
setFile(null);
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (playlist) {
@ -62,20 +82,22 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
}, [playlist]);
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
M3U Account
</Typography>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
M3U Account
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
@ -97,30 +119,38 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
value={formik.values.server_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.server_url && Boolean(formik.errors.server_url)}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
variant="standard"
/>
<Box mb={2}>
{/* File upload input */}
<Stack direction="row" spacing={2}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "center",
pt: 2,
}}
>
<Typography>File</Typography>
</Stack>
<input
type="file"
id="uploaded_file"
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
Browse...
</Button>
</label>
<input
type="file"
id="uploaded_file"
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: "none" }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
Browse...
</Button>
</label>
</Stack>
</Box>
<TextField
@ -131,7 +161,9 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.max_streams && Boolean(formik.errors.max_streams)}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
@ -146,7 +178,9 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
@ -163,39 +197,31 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={(e) => formik.setFieldValue('is_active', e.target.checked)}
onChange={(e) =>
formik.setFieldValue("is_active", e.target.checked)
}
/>
} label="Is Active"
}
label="Is Active"
/>
</DialogContent>
<Box mb={2}>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Modal>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default M3U;

View file

@ -1,22 +1,26 @@
// Modal.js
import React, { useState, useEffect, useMemo } from "react";
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, IconButton } from "@mui/material";
import React, { useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from "../../store/channels";
import API from "../../api"
import useStreamProfilesStore from "../../store/streamProfiles";
import {
Add as AddIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import useStreamsStore from "../../store/streams";
import usePlaylistsStore from "../../store/playlists";
import { MaterialReactTable, useMaterialReactTable } from "material-react-table";
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
console.log(stream)
const formik = useFormik({
initialValues: {
@ -31,16 +35,16 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({id: stream.id, ...values})
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values)
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (stream) {
@ -55,20 +59,22 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
}, [stream]);
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
Stream
</Typography>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Stream
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={12}>
<TextField
@ -98,7 +104,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">Stream Profile</InputLabel>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
@ -107,7 +115,10 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.stream_profile_id && Boolean(formik.errors.stream_profile_id)}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
@ -120,33 +131,21 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
</FormControl>
</Grid2>
</Grid2>
<Box mb={2}>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Modal>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: "500px",
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default Stream;

View file

@ -1,39 +1,51 @@
// Modal.js
import React, { useState, useEffect } from "react";
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, Checkbox, FormControlLabel } from "@mui/material";
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from "../../api"
import React, { useEffect } from "react";
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore(state => state.userAgents)
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: '',
command: '',
parameters: '',
profile_name: "",
command: "",
parameters: "",
is_active: true,
user_agent: '',
user_agent: "",
},
validationSchema: Yup.object({
profile_name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
profile_name: Yup.string().required("Name is required"),
command: Yup.string().required("Command is required"),
parameters: Yup.string().required("Parameters are is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({id: profile.id, ...values})
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values)
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (profile) {
@ -50,20 +62,22 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
}, [profile]);
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
Stream Profile
</Typography>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Stream Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="profile_name"
@ -72,8 +86,12 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
value={formik.values.profile_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.profile_name && Boolean(formik.errors.profile_name)}
helperText={formik.touched.profile_name && formik.errors.profile_name}
error={
formik.touched.profile_name && Boolean(formik.errors.profile_name)
}
helperText={
formik.touched.profile_name && formik.errors.profile_name
}
variant="standard"
/>
<TextField
@ -96,7 +114,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
value={formik.values.parameters}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.parameters && Boolean(formik.errors.parameters)}
error={
formik.touched.parameters && Boolean(formik.errors.parameters)
}
helperText={formik.touched.parameters && formik.errors.parameters}
variant="standard"
/>
@ -111,7 +131,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
@ -122,35 +144,22 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
))}
</Select>
</FormControl>
</DialogContent>
<Box mb={2}>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Modal>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default StreamProfile;

View file

@ -1,34 +1,43 @@
// Modal.js
import React, { useState, useEffect } from "react";
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, Checkbox } from "@mui/material";
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from "../../api"
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
user_agent: '',
description: '',
user_agent_name: "",
user_agent: "",
description: "",
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
user_agent_name: Yup.string().required("Name is required"),
user_agent: Yup.string().required("User-Agent is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({id: userAgent.id, ...values})
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values)
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose()
}
})
onClose();
},
});
useEffect(() => {
if (userAgent) {
@ -44,20 +53,22 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
}, [userAgent]);
if (!isOpen) {
return <></>
return <></>;
}
return (
<Modal
open={isOpen}
onClose={onClose}
>
<Box sx={style}>
<Typography id="form-modal-title" variant="h6" mb={2}>
User-Agent
</Typography>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
User-Agent
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="user_agent_name"
@ -66,8 +77,13 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
value={formik.values.user_agent_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.user_agent_name && Boolean(formik.errors.user_agent_name)}
helperText={formik.touched.user_agent_name && formik.errors.user_agent_name}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
helperText={
formik.touched.user_agent_name && formik.errors.user_agent_name
}
variant="standard"
/>
@ -79,7 +95,9 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
/>
@ -92,7 +110,9 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.description && Boolean(formik.errors.description)}
error={
formik.touched.description && Boolean(formik.errors.description)
}
helperText={formik.touched.description && formik.errors.description}
variant="standard"
/>
@ -102,34 +122,22 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
<Box mb={2}>
{/* Submit button */}
<Button
size="small"
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Modal>
</DialogContent>
<DialogActions>
<Button
size="small"
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
};
export default UserAgent;

View file

@ -1,36 +1,46 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, Tooltip, IconButton, Button, ButtonGroup, Snackbar, Popover, TextField } from '@mui/material';
import {
Box,
Grid2,
Stack,
Typography,
Tooltip,
IconButton,
Button,
ButtonGroup,
Snackbar,
Popover,
TextField,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
} from '@mui/icons-material'
import API from '../../api'
import ChannelForm from '../forms/Channel'
import { TableHelper } from '../../helpers'
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import { ContentCopy } from '@mui/icons-material';
const Example = () => {
const [channel, setChannel] = useState(null)
const [channel, setChannel] = useState(null);
const [channelModelOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([])
const [rowSelection, setRowSelection] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState("")
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const channels = useChannelsStore((state) => state.channels);
const { channels, isLoading: channelsLoading } = useChannelsStore();
const columns = useMemo(
//column definitions...
@ -46,7 +56,7 @@ const Example = () => {
},
{
header: 'Group',
accessorFn: row => row.channel_group?.name || '',
accessorFn: (row) => row.channel_group?.name || '',
},
{
header: 'Logo',
@ -57,11 +67,11 @@ const Example = () => {
container
direction="row"
sx={{
justifyContent: "center",
alignItems: "center",
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={info.getValue() || "/images/logo.png"} width="20"/>
<img src={info.getValue() || '/images/logo.png'} width="20" />
</Grid2>
),
meta: {
@ -69,7 +79,7 @@ const Example = () => {
},
},
],
[],
[]
);
//optionally access the underlying virtualizer instance
@ -79,32 +89,39 @@ const Example = () => {
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false)
}
setSnackbarOpen(false);
};
const editChannel = async (channel = null) => {
setChannel(channel)
setChannelModalOpen(true)
}
setChannel(channel);
setChannelModalOpen(true);
};
const deleteChannel = async (id) => {
await API.deleteChannel(id)
}
await API.deleteChannel(id);
};
// @TODO: the bulk delete endpoint is currently broken
const deleteChannels = async () => {
setIsLoading(true)
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
await utils.Limiter(4, selected.map(chan => () => {
return deleteChannel(chan.original.id)
}))
setIsLoading(false)
}
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => {
return deleteChannel(chan.original.id);
})
);
setIsLoading(false);
};
const assignChannels = async () => {
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
await API.assignChannelNumbers(selected.map(sel => sel.id))
}
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.assignChannelNumbers(selected.map((sel) => sel.id));
};
useEffect(() => {
if (typeof window !== 'undefined') {
@ -134,25 +151,25 @@ const Example = () => {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true)
setSnackbarOpen(true);
};
const open = Boolean(anchorEl);
const copyM3UUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('m3u url')
}
setTextToCopy('m3u url');
};
const copyEPGUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('epg url')
}
setTextToCopy('epg url');
};
const copyHDHRUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('hdhr url')
}
setTextToCopy('hdhr url');
};
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
@ -166,7 +183,7 @@ const Example = () => {
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
@ -182,7 +199,7 @@ const Example = () => {
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editChannel(row.original)
editChannel(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
@ -198,17 +215,20 @@ const Example = () => {
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff
height: 'calc(100vh - 90px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
},
},
muiSearchTextFieldProps: {
variant: "standard",
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{
alignItems: "center",
}}>
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
@ -241,21 +261,20 @@ const Example = () => {
</IconButton>
</Tooltip>
<ButtonGroup sx={{
marginLeft: 1,
}}>
<Button
variant="contained"
onClick={copyHDHRUrl}
>HDHR URL</Button>
<Button
variant="contained"
onClick={copyM3UUrl}
>M3U URL</Button>
<Button
variant="contained"
onClick={copyEPGUrl}
>EPG</Button>
<ButtonGroup
sx={{
marginLeft: 1,
}}
>
<Button variant="contained" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" onClick={copyEPGUrl}>
EPG
</Button>
</ButtonGroup>
</Stack>
),
@ -282,7 +301,6 @@ const Example = () => {
<div style={{ padding: '16px', display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
InputProps={{ readOnly: true }}
variant="standard"
disabled
size="small"
@ -296,7 +314,7 @@ const Example = () => {
</Popover>
<Snackbar
anchorOrigin={{ vertical: "top", horizontal: "right"}}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}

View file

@ -1,12 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, IconButton, Tooltip, Checkbox, Select, MenuItem, Snackbar } from '@mui/material';
import API from '../../api'
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
Snackbar,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
@ -15,35 +26,36 @@ import {
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG'
} from "@mui/icons-material";
import useEPGsStore from "../../store/epgs";
import EPGForm from "../forms/EPG";
import { TableHelper } from "../../helpers";
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([])
const [snackbarMessage, setSnackbarMessage] = useState("")
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarOpen, setSnackbarOpen] = useState(false);
const epgs = useEPGsStore(state => state.epgs)
const epgs = useEPGsStore((state) => state.epgs);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
header: "Name",
size: 10,
accessorKey: 'name',
accessorKey: "name",
},
{
header: 'Source Type',
accessorKey: 'source_type',
header: "Source Type",
accessorKey: "source_type",
size: 50,
},
{
header: 'URL / API Key',
accessorKey: 'max_streams',
header: "URL / API Key",
accessorKey: "max_streams",
},
],
[],
@ -56,26 +68,26 @@ const EPGsTable = () => {
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false)
}
setSnackbarOpen(false);
};
const editEPG = async (epg = null) => {
setEPG(epg)
setEPGModalOpen(true)
}
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
await API.deleteEPG(id)
}
await API.deleteEPG(id);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id)
setSnackbarMessage("EPG refresh initiated")
setSnackbarOpen(true)
}
await API.refreshEPG(id);
setSnackbarMessage("EPG refresh initiated");
setSnackbarOpen(true);
};
useEffect(() => {
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
@ -90,13 +102,10 @@ const EPGsTable = () => {
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enableBottomToolbar: false,
// enableGlobalFilterModes: true,
columnFilterDisplayMode: 'popover',
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@ -109,7 +118,7 @@ const EPGsTable = () => {
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
@ -137,19 +146,18 @@ const EPGsTable = () => {
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: "calc(42vh - 0px)",
},
},
renderTopToolbar: ({ table }) => (
<Grid2 container direction="row" spacing={3} sx={{
justifyContent: "left",
alignItems: "center",
// height: 30,
ml: 2,
}}>
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>EPGs</Typography>
<Tooltip title="Add New EPG">
<IconButton
@ -161,19 +169,18 @@ const EPGsTable = () => {
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<MRT_ShowHideColumnsButton table={table} />
{/* <MRT_ToggleFullScreenButton table={table} /> */}
</Grid2>
</Stack>
),
});
return (
<>
<Box sx={{
<Box
sx={{
padding: 2,
}}>
<MaterialReactTable table={table} />
</Box>
}}
>
<MaterialReactTable table={table} />
<EPGForm
epg={epg}
isOpen={epgModalOpen}
@ -181,13 +188,13 @@ const EPGsTable = () => {
/>
<Snackbar
anchorOrigin={{ vertical: "top", horizontal: "right"}}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</>
</Box>
);
};

View file

@ -1,86 +0,0 @@
import React from 'react';
import { TableCell, TextField } from '@mui/material'
const Filter = ({ column }) => {
const columnFilterValue = column.getFilterValue()
const { filterVariant } = column.columnDef.meta ?? {}
if (filterVariant === null) {
return <TableCell>{column.columnDef.header}</TableCell>
}
return filterVariant === 'range' ? (
<div>
<div className="flex space-x-2">
{/* See faceted column filters example for min max values functionality */}
<DebouncedInput
type="number"
value={(columnFilterValue)?.[0] ?? ''}
onChange={value =>
column.setFilterValue((old) => [value, old?.[1]])
}
placeholder={`Min`}
className="w-24 border shadow rounded"
/>
<DebouncedInput
type="number"
value={(columnFilterValue)?.[1] ?? ''}
onChange={value =>
column.setFilterValue((old) => [old?.[0], value])
}
placeholder={`Max`}
className="w-24 border shadow rounded"
/>
</div>
<div className="h-1" />
</div>
) : filterVariant === 'select' ? (
<select
onChange={e => column.setFilterValue(e.target.value)}
value={columnFilterValue?.toString()}
>
{/* See faceted column filters example for dynamic select options */}
<option value="">All</option>
<option value="complicated">complicated</option>
<option value="relationship">relationship</option>
<option value="single">single</option>
</select>
) : (
<DebouncedInput
className="w-36 border shadow rounded"
onChange={value => column.setFilterValue(value)}
placeholder={column.columnDef.header}
type="text"
value={(columnFilterValue ?? '')}
/>
// See faceted column filters example for datalist search suggestions
)
}
// A typical debounced input react component
const DebouncedInput = ({
value: initialValue,
onChange,
debounce = 500,
...props
}) => {
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => {
setValue(initialValue)
}, [initialValue])
React.useEffect(() => {
const timeout = setTimeout(() => {
onChange(value)
}, debounce)
return () => clearTimeout(timeout)
}, [value])
return (
<TextField size="small" variant="standard" {...props} value={value} onChange={e => setValue(e.target.value)} />
)
}
export default Filter;

View file

@ -1,12 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, IconButton, Tooltip, Checkbox, Select, MenuItem } from '@mui/material';
import API from '../../api'
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
@ -15,15 +25,16 @@ import {
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U'
} from "@mui/icons-material";
import usePlaylistsStore from "../../store/playlists";
import M3UForm from "../forms/M3U";
import { TableHelper } from "../../helpers";
const Example = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([])
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const playlists = usePlaylistsStore((state) => state.playlists);
@ -31,29 +42,33 @@ const Example = () => {
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
header: "Name",
accessorKey: "name",
},
{
header: 'URL / File',
accessorKey: 'server_url',
header: "URL / File",
accessorKey: "server_url",
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
header: "Max Streams",
accessorKey: "max_streams",
size: 200,
},
{
header: 'Active',
accessorKey: 'is_active',
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: 'basic',
sortingFn: "basic",
muiTableBodyCellProps: {
align: 'left',
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
@ -77,7 +92,7 @@ const Example = () => {
),
filterFn: (row, _columnId, activeFilterValue) => {
if (!activeFilterValue) return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
return String(row.getValue("is_active")) === activeFilterValue;
},
},
],
@ -91,25 +106,27 @@ const Example = () => {
const [sorting, setSorting] = useState([]);
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist)
setPlaylistModalOpen(true)
}
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const refreshPlaylist = async (id) => {
await API.refreshPlaylist(id)
}
await API.refreshPlaylist(id);
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id)
}
await API.deletePlaylist(id);
};
const deletePlaylists = async (ids) => {
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
// await API.deleteStreams(selected.map(stream => stream.original.id))
}
};
useEffect(() => {
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
@ -124,13 +141,10 @@ const Example = () => {
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: playlists,
enableBottomToolbar: false,
// enableGlobalFilterModes: true,
columnFilterDisplayMode: 'popover',
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@ -143,7 +157,7 @@ const Example = () => {
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
@ -152,7 +166,7 @@ const Example = () => {
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editPlaylist(row.original)
editPlaylist(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
@ -174,19 +188,18 @@ const Example = () => {
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: "calc(42vh - 0px)",
},
},
renderTopToolbar: ({ table }) => (
<Grid2 container direction="row" spacing={3} sx={{
justifyContent: "left",
alignItems: "center",
// height: 30,
ml: 2,
}}>
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
@ -198,25 +211,23 @@ const Example = () => {
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<MRT_ShowHideColumnsButton table={table} />
{/* <MRT_ToggleFullScreenButton table={table} /> */}
</Grid2>
</Stack>
),
});
return (
<>
<Box sx={{
<Box
sx={{
padding: 2,
}}>
<MaterialReactTable table={table} />
</Box>
}}
>
<MaterialReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
/>
</>
</Box>
);
};

View file

@ -1,12 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, IconButton, Tooltip, Checkbox, Select, MenuItem } from '@mui/material';
import API from '../../api'
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
@ -15,47 +25,52 @@ import {
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import useEPGsStore from '../../store/epgs';
import StreamProfileForm from '../forms/StreamProfile'
import useStreamProfilesStore from '../../store/streamProfiles';
} from "@mui/icons-material";
import useEPGsStore from "../../store/epgs";
import StreamProfileForm from "../forms/StreamProfile";
import useStreamProfilesStore from "../../store/streamProfiles";
import { TableHelper } from "../../helpers";
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([])
const [snackbarMessage, setSnackbarMessage] = useState("")
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const streamProfiles = useStreamProfilesStore(state => state.profiles)
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'profile_name',
header: "Name",
accessorKey: "profile_name",
},
{
header: 'Command',
accessorKey: 'command',
header: "Command",
accessorKey: "command",
},
{
header: 'Parameters',
accessorKey: 'parameters',
header: "Parameters",
accessorKey: "parameters",
},
{
header: 'Active',
accessorKey: 'is_active',
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: 'basic',
sortingFn: "basic",
muiTableBodyCellProps: {
align: 'left',
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
@ -79,7 +94,7 @@ const StreamProfiles = () => {
),
filterFn: (row, _columnId, filterValue) => {
if (filterValue == "all") return true; // Show all if no filter
return String(row.getValue('is_active')) === filterValue;
return String(row.getValue("is_active")) === filterValue;
},
},
],
@ -93,16 +108,16 @@ const StreamProfiles = () => {
const [sorting, setSorting] = useState([]);
const editStreamProfile = async (profile = null) => {
setProfile(profile)
setProfileModalOpen(true)
}
setProfile(profile);
setProfileModalOpen(true);
};
const deleteStreamProfile = async (ids) => {
await API.deleteStreamProfile(ids)
}
await API.deleteStreamProfile(ids);
};
useEffect(() => {
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
@ -117,13 +132,10 @@ const StreamProfiles = () => {
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streamProfiles,
enableBottomToolbar: false,
// enableGlobalFilterModes: true,
columnFilterDisplayMode: 'popover',
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@ -136,7 +148,7 @@ const StreamProfiles = () => {
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
@ -157,19 +169,19 @@ const StreamProfiles = () => {
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
// height: "calc(42vh - 0px)",
height: "calc(100vh - 100px)", // Subtract padding to avoid cutoff
overflowY: "auto", // Internal scrolling for the table
},
},
renderTopToolbar: ({ table }) => (
<Grid2 container direction="row" spacing={3} sx={{
justifyContent: "left",
alignItems: "center",
// height: 30,
ml: 2,
}}>
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>Stream Profiles</Typography>
<Tooltip title="Add New Stream Profile">
<IconButton
@ -181,26 +193,24 @@ const StreamProfiles = () => {
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<MRT_ShowHideColumnsButton table={table} />
{/* <MRT_ToggleFullScreenButton table={table} /> */}
</Grid2>
</Stack>
),
});
return (
<>
<Box sx={{
<Box
sx={{
padding: 2,
}}>
<MaterialReactTable table={table} />
</Box>
}}
>
<MaterialReactTable table={table} />
<StreamProfileForm
profile={profile}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
</>
</Box>
);
};

View file

@ -3,23 +3,32 @@ import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, IconButton, Tooltip, Button } from '@mui/material';
import useStreamsStore from '../../store/streams'
import API from '../../api'
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
} from '@mui/icons-material'
import { TableHelper } from '../../helpers'
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import StreamForm from '../forms/Stream'
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const Example = () => {
const [rowSelection, setRowSelection] = useState([])
const [stream, setStream] = useState(null)
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const streams = useStreamsStore((state) => state.streams);
const { streams, isLoading: streamsLoading } = useStreamsStore();
const { playlists } = usePlaylistsStore();
const columns = useMemo(
//column definitions...
@ -32,8 +41,14 @@ const Example = () => {
header: 'Group',
accessorKey: 'group_name',
},
{
header: 'M3U',
size: 100,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[],
[playlists]
);
//optionally access the underlying virtualizer instance
@ -47,32 +62,39 @@ const Example = () => {
channel_name: stream.name,
channel_number: 0,
stream_id: stream.id,
})
}
});
};
// @TODO: bulk create is broken, returning a 404
const createChannelsFromStreams = async () => {
setIsLoading(true)
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
await utils.Limiter(4, selected.map(stream => () => {
return createChannelFromStream(stream.original)
}))
setIsLoading(false)
}
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((stream) => () => {
return createChannelFromStream(stream.original);
})
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream)
setModalOpen(true)
}
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id)
}
await API.deleteStream(id);
};
const deleteStreams = async () => {
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
await API.deleteStreams(selected.map(stream => stream.original.id))
}
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.deleteStreams(selected.map((stream) => stream.original.id));
};
useEffect(() => {
if (typeof window !== 'undefined') {
@ -101,7 +123,7 @@ const Example = () => {
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
isLoading: isLoading || streamsLoading,
sorting,
rowSelection,
},
@ -110,13 +132,20 @@ const Example = () => {
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => editStream(row.original)}
<Tooltip
title={
row.original.m3u_account ? 'M3U streams locked' : 'Edit Stream'
}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
@ -135,14 +164,17 @@ const Example = () => {
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff
height: 'calc(100vh - 90px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{
alignItems: "center",
}}>
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
@ -171,18 +203,24 @@ const Example = () => {
sx={{
marginLeft: 1,
}}
>Create Channels</Button>
>
Create Channels
</Button>
</Stack>
),
});
return (
<Box sx={{
// paddingTop: 2,
// paddingLeft: 1,
// paddingRight: 2,
// paddingBottom: 2,
}}>
<Box
sx={
{
// paddingTop: 2,
// paddingLeft: 1,
// paddingRight: 2,
// paddingBottom: 2,
}
}
>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}

View file

@ -1,58 +1,72 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import { Box, Grid2, Stack, Typography, IconButton, Tooltip, Select, MenuItem } from '@mui/material';
import API from '../../api'
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material'
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent'
} from "@mui/icons-material";
import useUserAgentsStore from "../../store/userAgents";
import UserAgentForm from "../forms/UserAgent";
import { TableHelper } from "../../helpers";
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([])
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const userAgents = useUserAgentsStore(state => state.userAgents)
const userAgents = useUserAgentsStore((state) => state.userAgents);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
header: "Name",
size: 10,
accessorKey: 'user_agent_name',
accessorKey: "user_agent_name",
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
header: "User-Agent",
accessorKey: "user_agent",
size: 50,
},
{
header: 'Desecription',
accessorKey: 'description',
header: "Desecription",
accessorKey: "description",
},
{
header: 'Active',
accessorKey: 'is_active',
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: 'basic',
sortingFn: "basic",
muiTableBodyCellProps: {
align: 'left',
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
@ -76,7 +90,7 @@ const UserAgentsTable = () => {
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == "all") return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
return String(row.getValue("is_active")) === activeFilterValue;
},
},
],
@ -90,20 +104,20 @@ const UserAgentsTable = () => {
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent)
setUserAgentModalOpen(true)
}
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
await API.deleteUserAgents(ids)
await API.deleteUserAgents(ids);
} else {
await API.deleteUserAgent(ids)
await API.deleteUserAgent(ids);
}
}
};
useEffect(() => {
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
@ -118,13 +132,10 @@ const UserAgentsTable = () => {
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enableBottomToolbar: false,
// enableGlobalFilterModes: true,
columnFilterDisplayMode: 'popover',
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@ -137,7 +148,7 @@ const UserAgentsTable = () => {
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
@ -146,7 +157,7 @@ const UserAgentsTable = () => {
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editUserAgent(row.original)
editUserAgent(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
@ -160,19 +171,18 @@ const UserAgentsTable = () => {
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: "calc(42vh - 0px)",
height: "calc(42vh - 10px)",
},
},
renderTopToolbar: ({ table }) => (
<Grid2 container direction="row" spacing={3} sx={{
justifyContent: "left",
alignItems: "center",
// height: 30,
ml: 2,
}}>
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>User-Agents</Typography>
<Tooltip title="Add New User Agent">
<IconButton
@ -184,17 +194,17 @@ const UserAgentsTable = () => {
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<MRT_ShowHideColumnsButton table={table} />
{/* <MRT_ToggleFullScreenButton table={table} /> */}
</Grid2>
</Stack>
),
});
return (
<>
<Box sx={{
padding: 2,
}}>
<Box
sx={{
padding: 2,
}}
>
<MaterialReactTable table={table} />
</Box>
<UserAgentForm

View file

@ -1,3 +1,3 @@
import table from "./table"
import table from "./table";
export const TableHelper = table
export const TableHelper = table;

View file

@ -4,11 +4,11 @@ export default {
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: "none",
columnFilterDisplayMode: 'popover',
columnFilterDisplayMode: "popover",
enableRowNumbers: false,
positionActionsColumn: 'last',
positionActionsColumn: "last",
initialState: {
density: 'compact',
density: "compact",
},
}
}
},
};

View file

@ -1,41 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client'; // Import the "react-dom/client" for React 18
import './index.css'; // Optional styles
import App from './App'; // Import your App component
import useAuthStore from './store/auth';
import useChannelsStore from './store/channels';
import useStreamsStore from './store/streams';
import useUserAgentsStore from './store/userAgents';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useStreamProfilesStore from './store/streamProfiles';
import './index.css'; // Optional styles
import App from './App'; // Import your App component
// Create a root element
const root = ReactDOM.createRoot(document.getElementById('root'));
const authStore = useAuthStore.getState();
const channelsStore = useChannelsStore.getState();
const streamsStore = useStreamsStore.getState();
const userAgentsStore = useUserAgentsStore.getState();
const playlistsStore = usePlaylistsStore.getState();
const epgsStore = useEPGsStore.getState()
const streamProfilesStore = useStreamProfilesStore.getState()
await authStore.initializeAuth();
// if (authStore.isAuthenticated) {
await Promise.all([
authStore.initializeAuth(),
channelsStore.fetchChannels(),
channelsStore.fetchChannelGroups(),
streamsStore.fetchStreams(),
userAgentsStore.fetchUserAgents(),
playlistsStore.fetchPlaylists(),
epgsStore.fetchEPGs(),
streamProfilesStore.fetchProfiles(),
])
// }
// Render your app using the "root.render" method
root.render(
<React.StrictMode>

View file

@ -1,7 +1,7 @@
import React from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Grid2, Box } from '@mui/material';
import React from "react";
import ChannelsTable from "../components/tables/ChannelsTable";
import StreamsTable from "../components/tables/StreamsTable";
import { Grid2, Box } from "@mui/material";
const ChannelsPage = () => {
return (
@ -9,13 +9,13 @@ const ChannelsPage = () => {
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: '20px', // Top padding
paddingBottom: '20px', // Bottom padding
paddingRight: '10px',
paddingLeft: '20px',
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
height: "100vh", // Full viewport height
paddingTop: "20px", // Top padding
paddingBottom: "20px", // Bottom padding
paddingRight: "10px",
paddingLeft: "20px",
boxSizing: "border-box", // Include padding in height calculation
overflow: "hidden", // Prevent parent scrolling
}}
>
<ChannelsTable />
@ -24,20 +24,20 @@ const ChannelsPage = () => {
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: '20px', // Top padding
paddingBottom: '20px', // Bottom padding
paddingRight: '20px',
paddingLeft: '10px',
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
height: "100vh", // Full viewport height
paddingTop: "20px", // Top padding
paddingBottom: "20px", // Bottom padding
paddingRight: "20px",
paddingLeft: "10px",
boxSizing: "border-box", // Include padding in height calculation
overflow: "hidden", // Prevent parent scrolling
}}
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
)
);
};
export default ChannelsPage;

View file

@ -1,8 +1,8 @@
// src/components/Dashboard.js
import React, { useState } from 'react';
import React, { useState } from "react";
const Dashboard = () => {
const [newStream, setNewStream] = useState('');
const [newStream, setNewStream] = useState("");
return (
<div>

View file

@ -1,27 +1,27 @@
import React, { useState } from 'react';
import { Box, Snackbar } from '@mui/material';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import EPGsTable from '../components/tables/EPGsTable';
import React from "react";
import { Box } from "@mui/material";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import EPGsTable from "../components/tables/EPGsTable";
const EPGPage = () => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<EPGsTable />
</Box>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
</Box>
)
);
};
export default EPGPage;

View file

@ -1,63 +1,63 @@
import React from "react";
import React from 'react';
import { useEpg, Epg, Layout } from 'planby';
import API from '../api'
import API from '../api';
function App() {
const [channels, setChannels] = React.useState([])
const [epg, setEpg] = React.useState([])
const [channels, setChannels] = React.useState([]);
const [epg, setEpg] = React.useState([]);
const fetchChannels = async () => {
const channels = await API.getChannels()
const retval = []
const channels = await API.getChannels();
const retval = [];
for (const channel of channels) {
if (!channel.channel_name.includes("Nickelod")) {
continue
if (!channel.tvg_id) {
continue;
}
console.log(channel)
console.log(channel);
retval.push({
uuid: "Nickelodeon (East).us",
type: "channel",
uuid: channel.tvg_id,
type: 'channel',
title: channel.channel_name,
country: "USA",
provider: channel.channel_group?.name || "Default",
logo: channel.logo_url || "/images/logo.png",
country: 'USA',
provider: channel.channel_group?.name || 'Default',
logo: channel.logo_url || '/images/logo.png',
year: 2025,
})
});
}
setChannels(retval)
setChannels(retval);
return retval;
}
};
const fetchEpg = async () => {
const programs = await API.getGrid();
const retval = []
console.log(programs)
const retval = [];
console.log(programs);
for (const program of programs.data) {
retval.push({
id: program.id,
channelUuid: "Nickelodeon (East).us",
channelUuid: 'Nickelodeon (East).us',
description: program.description,
title: program.title,
since: program.start_time,
till: program.end_time,
})
});
}
setEpg(retval)
setEpg(retval);
return retval;
}
};
const fetchData = async () => {
const channels = await fetchChannels()
const epg = await fetchEpg()
const channels = await fetchChannels();
const epg = await fetchEpg();
setChannels(channels)
setEpg(epg)
}
setChannels(channels);
setEpg(epg);
};
if (channels.length === 0) {
fetchData()
fetchData();
}
const formatDate = (date) => date.toISOString().split('T')[0] + 'T00:00:00';
@ -74,19 +74,17 @@ function App() {
} = useEpg({
epg,
channels,
startDate: '2025/02/24', // or 2022-02-02T00:00:00
startDate: '2025-02-25T11:00:00', // or 2022-02-02T00:00:00
width: '100%',
height: 600,
});
return (
<div>
<Epg {...getEpgProps()}>
<Layout
{...getLayoutProps()}
/>
</Epg>
</div>
<Epg {...getEpgProps()}>
<Layout {...getLayoutProps()} />
</Epg>
</div>
);
}

View file

@ -1,16 +0,0 @@
import { ChannelBox, ChannelLogo } from "planby";
export const ChannelItem = ({ channel }) => {
const { position, logo } = channel;
return (
<ChannelBox {...position}>
{/* Overwrite styles by add eg. style={{ maxHeight: 52, maxWidth: 52,... }} */}
{/* Or stay with default styles */}
<ChannelLogo
src={logo}
alt="Logo"
style={{ maxHeight: 52, maxWidth: 52 }}
/>
</ChannelBox>
);
};

View file

@ -1,45 +0,0 @@
import {
ProgramBox,
ProgramContent,
ProgramFlex,
ProgramStack,
ProgramTitle,
ProgramText,
ProgramImage,
useProgram
} from "planby";
export const ProgramItem = ({ program, ...rest }) => {
const {
styles,
formatTime,
set12HoursTimeFormat,
isLive,
isMinWidth
} = useProgram({
program,
...rest
});
const { data } = program;
const { image, title, since, till } = data;
const sinceTime = formatTime(since, set12HoursTimeFormat()).toLowerCase();
const tillTime = formatTime(till, set12HoursTimeFormat()).toLowerCase();
return (
<ProgramBox width={styles.width} style={styles.position}>
<ProgramContent width={styles.width} isLive={isLive}>
<ProgramFlex>
{isLive && isMinWidth && <ProgramImage src={image} alt="Preview" />}
<ProgramStack>
<ProgramTitle>{title}</ProgramTitle>
<ProgramText>
{sinceTime} - {tillTime}
</ProgramText>
</ProgramStack>
</ProgramFlex>
</ProgramContent>
</ProgramBox>
);
};

View file

@ -1,47 +0,0 @@
import {
TimelineWrapper,
TimelineBox,
TimelineTime,
TimelineDivider,
TimelineDividers,
useTimeline
} from "planby";
export function Timeline({
isBaseTimeFormat,
isSidebar,
dayWidth,
hourWidth,
numberOfHoursInDay,
offsetStartHoursRange,
sidebarWidth
}) {
const { time, dividers, formatTime } = useTimeline(
numberOfHoursInDay,
isBaseTimeFormat
);
const renderTime = (index) => (
<TimelineBox key={index} width={hourWidth}>
<TimelineTime>
{formatTime(index + offsetStartHoursRange).toLowerCase()}
</TimelineTime>
<TimelineDividers>{renderDividers()}</TimelineDividers>
</TimelineBox>
);
const renderDividers = () =>
dividers.map((_, index) => (
<TimelineDivider key={index} width={hourWidth} />
));
return (
<TimelineWrapper
dayWidth={dayWidth}
sidebarWidth={sidebarWidth}
isSidebar={isSidebar}
>
{time.map((_, index) => renderTime(index))}
</TimelineWrapper>
);
}

View file

@ -1,3 +0,0 @@
export * from "./ChannelItem";
export * from "./ProgramItem";
export * from "./Timeline";

View file

@ -1,43 +0,0 @@
export const theme = {
primary: {
600: "#1a202c",
900: "#171923"
},
grey: { 300: "#d1d1d1" },
white: "#fff",
green: {
300: "#2c7a7b"
},
scrollbar: {
border: "#ffffff",
thumb: {
bg: "#e1e1e1"
}
},
loader: {
teal: "#5DDADB",
purple: "#3437A2",
pink: "#F78EB6",
bg: "#171923db"
},
gradient: {
blue: {
300: "#002eb3",
600: "#002360",
900: "#051937"
}
},
text: {
grey: {
300: "#a0aec0",
500: "#718096"
}
},
timeline: {
divider: {
bg: "#718096"
}
}
};

View file

@ -1,143 +0,0 @@
import React from "react";
import { useEpg } from "planby";
// Import theme
import { theme } from "./theme";
import API from "../../api";
import { Description } from "@mui/icons-material";
const sampleChannel = {
uuid: "16fdfefe-e466-4090-bc1a-57c43937f826",
type: "channel",
title: "r tv",
country: "USA",
provider: 7427,
logo:
"https://raw.githubusercontent.com/karolkozer/planby-demo-resources/master/resources/channel-logos/png/r-channel.png",
year: 2002
}
const fetchChannels = async () => {
const channels = await API.getChannels()
const retval = []
for (const channel of channels) {
if (!channel.channel_name.includes("Nickelod")) {
continue
}
console.log(channel)
retval.push({
...sampleChannel,
uuid: "Nickelodeon (East).us",
type: "channel",
title: channel.channel_name,
country: "USA",
provider: channel.channel_group?.name || "Default",
logo: channel.logo_url || "/images/logo.png",
year: 2025,
})
}
return retval;
}
const sample = {
"id": "6f3caa7f-5b11-4edb-998e-80d4baa03373",
"description": "Bounty hunter Boba Fett & mercenary Fennec Shand navigate the underworld when they return to Tatooine to claim Jabba the Hutt's old turf.",
"title": "The Book of Boba Fett",
"isYesterday": true,
"since": "2022-10-17T23:50:00",
"till": "2022-10-18T00:55:00",
"channelUuid": "16fdfefe-e466-4090-bc1a-57c43937f826",
"image": "https://www.themoviedb.org/t/p/w1066_and_h600_bestv2/sjx6zjQI2dLGtEL0HGWsnq6UyLU.jpg",
"country": "Ghana",
"Year": "2021",
"Rated": "TV-14",
"Released": "29 Dec 2021",
"Runtime": "N/A",
"Genre": "Action, Adventure, Sci-Fi",
"Director": "N/A",
"Writer": "Jon Favreau",
"Actors": "Temuera Morrison, Ming-Na Wen, Matt Berry",
"Language": "English",
"Country": "United States",
"Awards": "N/A",
"Metascore": "N/A",
"imdbRating": "8.0",
"imdbVotes": "20,147",
"imdbID": "tt13668894",
"Type": "series",
"totalSeasons": "1",
"Response": "True",
"Ratings": [
{
"Source": "Internet Movie Database",
"Value": "8.0/10"
}
],
"rating": 3
}
const fetchEpg = async () => {
const programs = await API.getGrid();
const retval = []
console.log(programs)
for (const program of programs.data) {
retval.push({
...sample,
id: program.id,
channelUuid: "Nickelodeon (East).us",
description: program.description,
title: program.title,
since: program.start_time.replace('Z', ''),
till: program.end_time.replace('Z', ''),
})
}
return retval;
}
export function useApp() {
const [channels, setChannels] = React.useState([]);
const [epg, setEpg] = React.useState([]);
const [isLoading, setIsLoading] = React.useState(false);
const channelsData = React.useMemo(() => channels, [channels]);
const epgData = React.useMemo(() => epg, [epg]);
const formatDate = (date) => date.toISOString().split('T')[0] + 'T00:00:00';
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const { getEpgProps, getLayoutProps } = useEpg({
channels: channelsData,
epg: epgData,
dayWidth: 7200,
sidebarWidth: 100,
itemHeight: 80,
isSidebar: true,
isTimeline: true,
isLine: true,
startDate: today,
endDate: tomorrow,
isBaseTimeFormat: true,
theme
});
const handleFetchResources = React.useCallback(async () => {
setIsLoading(true);
const epg = await fetchEpg();
const channels = await fetchChannels();
setEpg(epg);
setChannels(channels);
setIsLoading(false);
}, []);
React.useEffect(() => {
handleFetchResources();
}, [handleFetchResources]);
return { getEpgProps, getLayoutProps, isLoading };
}

View file

@ -1,8 +1,8 @@
// src/components/Home.js
import React, { useState } from 'react';
import React, { useState } from "react";
const Home = () => {
const [newChannel, setNewChannel] = useState('');
const [newChannel, setNewChannel] = useState("");
return (
<div>

View file

@ -1,8 +1,8 @@
import React from 'react';
import LoginForm from '../components/forms/LoginForm';
import React from "react";
import LoginForm from "../components/forms/LoginForm";
const Login = () => {
return <LoginForm/>
return <LoginForm />;
};
export default Login;

View file

@ -1,17 +1,16 @@
import React, { useState } from 'react';
import useUserAgentsStore from '../store/userAgents';
import { Box } from '@mui/material';
import M3UsTable from '../components/tables/M3UsTable';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import usePlaylistsStore from '../store/playlists';
import API from '../api'
import M3UForm from '../components/forms/M3U'
import React, { useState } from "react";
import useUserAgentsStore from "../store/userAgents";
import { Box } from "@mui/material";
import M3UsTable from "../components/tables/M3UsTable";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import usePlaylistsStore from "../store/playlists";
import API from "../api";
import M3UForm from "../components/forms/M3U";
const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
const error = useUserAgentsStore((state) => state.error);
const playlists = usePlaylistsStore(state => state.playlists)
const playlists = usePlaylistsStore((state) => state.playlists);
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
@ -20,26 +19,26 @@ const M3UPage = () => {
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent)
setUserAgentModalOpen(true)
}
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist)
setPlaylistModalOpen(true)
}
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
await API.deleteUserAgents(ids)
await API.deleteUserAgents(ids);
} else {
await API.deleteUserAgent(ids)
await API.deleteUserAgent(ids);
}
}
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id)
}
await API.deletePlaylist(id);
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
@ -47,17 +46,17 @@ const M3UPage = () => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<M3UsTable />
</Box>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
@ -66,10 +65,8 @@ const M3UPage = () => {
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
/>
</Box>
)
);
};
export default M3UPage;

View file

@ -1,10 +1,8 @@
import React, { useState } from 'react';
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
import React from "react";
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
const StreamProfilesPage = () => {
return (
<StreamProfilesTable />
)
return <StreamProfilesTable />;
};
export default StreamProfilesPage;

View file

@ -1,7 +1,11 @@
// src/auth/authStore.js
import {create} from 'zustand';
import API from '../api'
import { create } from 'zustand';
import API from '../api';
import useChannelsStore from './channels';
import useStreamsStore from './streams';
import useUserAgentsStore from './userAgents';
import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
const decodeToken = (token) => {
if (!token) return null;
@ -21,30 +25,45 @@ const useAuthStore = create((set, get) => ({
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
isAuthenticated: false,
getToken: async () => {
const expiration = localStorage.getItem('tokenExpiration')
const tokenExpiration = localStorage.getItem('tokenExpiration');
let accessToken = null;
if (isTokenExpired(tokenExpiration)) {
accessToken = await get().refreshToken();
} else {
accessToken = localStorage.getItem('accessToken');
}
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
return accessToken;
initData: async () => {
console.log('fetching data');
await Promise.all([
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useStreamsStore.getState().fetchStreams(),
useUserAgentsStore.getState().fetchUserAgents(),
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),
useStreamProfilesStore.getState().fetchProfiles(),
]);
},
getToken: async () => {
const expiration = localStorage.getItem('tokenExpiration');
const tokenExpiration = localStorage.getItem('tokenExpiration');
let accessToken = null;
if (isTokenExpired(tokenExpiration)) {
accessToken = await get().refreshToken();
} else {
accessToken = localStorage.getItem('accessToken');
}
return accessToken;
},
// Action to login
login: async ({username, password}) => {
login: async ({ username, password }) => {
try {
const response = await API.login(username, password)
const response = await API.login(username, password);
if (response.access) {
const expiration = decodeToken(response.access)
const expiration = decodeToken(response.access);
set({
accessToken: response.access,
refreshToken: response.refresh,
tokenExpiration: expiration, // 1 hour from now
isAuthenticated: true
isAuthenticated: true,
});
// Store in localStorage
localStorage.setItem('accessToken', response.access);
@ -62,7 +81,7 @@ const useAuthStore = create((set, get) => ({
if (!refreshToken) return;
try {
const data = await API.refreshToken(refreshToken)
const data = await API.refreshToken(refreshToken);
if (data.access) {
set({
accessToken: data.access,
@ -76,7 +95,7 @@ const useAuthStore = create((set, get) => ({
}
} catch (error) {
console.error('Token refresh failed:', error);
get().logout()
get().logout();
}
return false;
@ -88,7 +107,7 @@ const useAuthStore = create((set, get) => ({
accessToken: null,
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false
isAuthenticated: false,
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
@ -99,10 +118,13 @@ const useAuthStore = create((set, get) => ({
const refreshToken = localStorage.getItem('refreshToken') || null;
if (refreshToken) {
await get().refreshToken()
} else {
await get().logout()
const loggedIn = await get().refreshToken();
if (loggedIn) {
return true;
}
}
return false;
},
}));

View file

@ -1,7 +1,5 @@
// src/stores/channelsStore.js
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import { create } from "zustand";
import api from "../api";
const useChannelsStore = create((set) => ({
channels: [],
@ -15,8 +13,8 @@ const useChannelsStore = create((set) => ({
const channels = await api.getChannels();
set({ channels: channels, isLoading: false });
} catch (error) {
console.error('Failed to fetch channels:', error);
set({ error: 'Failed to load channels.', isLoading: false });
console.error("Failed to fetch channels:", error);
set({ error: "Failed to load channels.", isLoading: false });
}
},
@ -26,22 +24,41 @@ const useChannelsStore = create((set) => ({
const channelGroups = await api.getChannelGroups();
set({ channelGroups: channelGroups, isLoading: false });
} catch (error) {
console.error('Failed to fetch channel groups:', error);
set({ error: 'Failed to load channel groups.', isLoading: false });
console.error("Failed to fetch channel groups:", error);
set({ error: "Failed to load channel groups.", isLoading: false });
}
},
addChannel: (newChannel) => set((state) => ({
channels: [...state.channels, newChannel],
})),
addChannel: (newChannel) =>
set((state) => ({
channels: [...state.channels, newChannel],
})),
updateChannel: (userAgent) => set((state) => ({
channels: state.channels.map(chan => chan.id === userAgent.id ? userAgent : chan),
})),
updateChannel: (userAgent) =>
set((state) => ({
channels: state.channels.map((chan) =>
chan.id === userAgent.id ? userAgent : chan,
),
})),
removeChannels: (channelIds) => set((state) => ({
channels: state.channels.filter((channel) => !channelIds.includes(channel.id)),
})),
removeChannels: (channelIds) =>
set((state) => ({
channels: state.channels.filter(
(channel) => !channelIds.includes(channel.id),
),
})),
addChannelGroup: (newChannelGroup) =>
set((state) => ({
channelGroups: [...state.channelGroups, newChannelGroup],
})),
updateChannelGroup: (channelGroup) =>
set((state) => ({
channelGroups: state.channelGroups.map((group) =>
group.id === channelGroup.id ? channelGroup : group,
),
})),
}));
export default useChannelsStore;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import { create } from "zustand";
import api from "../api";
const useEPGsStore = create((set) => ({
epgs: [],
@ -12,18 +12,20 @@ const useEPGsStore = create((set) => ({
const epgs = await api.getEPGs();
set({ epgs: epgs, isLoading: false });
} catch (error) {
console.error('Failed to fetch epgs:', error);
set({ error: 'Failed to load epgs.', isLoading: false });
console.error("Failed to fetch epgs:", error);
set({ error: "Failed to load epgs.", isLoading: false });
}
},
addEPG: (newPlaylist) => set((state) => ({
epgs: [...state.epgs, newPlaylist],
})),
addEPG: (newPlaylist) =>
set((state) => ({
epgs: [...state.epgs, newPlaylist],
})),
removeEPGs: (epgIds) => set((state) => ({
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
})),
removeEPGs: (epgIds) =>
set((state) => ({
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
})),
}));
export default useEPGsStore;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import { create } from "zustand";
import api from "../api";
const usePlaylistsStore = create((set) => ({
playlists: [],
@ -12,22 +12,29 @@ const usePlaylistsStore = create((set) => ({
const playlists = await api.getPlaylists();
set({ playlists: playlists, isLoading: false });
} catch (error) {
console.error('Failed to fetch playlists:', error);
set({ error: 'Failed to load playlists.', isLoading: false });
console.error("Failed to fetch playlists:", error);
set({ error: "Failed to load playlists.", isLoading: false });
}
},
addPlaylist: (newPlaylist) => set((state) => ({
playlists: [...state.playlists, newPlaylist],
})),
addPlaylist: (newPlaylist) =>
set((state) => ({
playlists: [...state.playlists, newPlaylist],
})),
updatePlaylist: (playlist) => set((state) => ({
playlists: state.playlists.map(pl => pl.id === playlist.id ? playlist : pl),
})),
updatePlaylist: (playlist) =>
set((state) => ({
playlists: state.playlists.map((pl) =>
pl.id === playlist.id ? playlist : pl,
),
})),
removePlaylists: (playlistIds) => set((state) => ({
playlists: state.playlists.filter((playlist) => !playlistIds.includes(playlist.id)),
})),
removePlaylists: (playlistIds) =>
set((state) => ({
playlists: state.playlists.filter(
(playlist) => !playlistIds.includes(playlist.id),
),
})),
}));
export default usePlaylistsStore;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import api from '../api';
const useStreamProfilesStore = create((set) => ({
profiles: [],
@ -17,17 +17,24 @@ const useStreamProfilesStore = create((set) => ({
}
},
addStreamProfile: (profile) => set((state) => ({
profiles: [...state.profiles, profile],
})),
addStreamProfile: (profile) =>
set((state) => ({
profiles: [...state.profiles, profile],
})),
updateStreamProfile: (profile) => set((state) => ({
profiles: state.profiles.map(prof => prof.id === profile.id ? profile : prof),
})),
updateStreamProfile: (profile) =>
set((state) => ({
profiles: state.profiles.map((prof) =>
prof.id === profile.id ? profile : prof
),
})),
removeStreamProfiles: (propfileIds) => set((state) => ({
profiles: state.profiles.filter((profile) => !propfileIds.includes(profile.id)),
})),
removeStreamProfiles: (propfileIds) =>
set((state) => ({
profiles: state.profiles.filter(
(profile) => !propfileIds.includes(profile.id)
),
})),
}));
export default useStreamProfilesStore;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import { create } from "zustand";
import api from "../api";
const useStreamsStore = create((set) => ({
streams: [],
@ -12,22 +12,25 @@ const useStreamsStore = create((set) => ({
const streams = await api.getStreams();
set({ streams: streams, isLoading: false });
} catch (error) {
console.error('Failed to fetch streams:', error);
set({ error: 'Failed to load streams.', isLoading: false });
console.error("Failed to fetch streams:", error);
set({ error: "Failed to load streams.", isLoading: false });
}
},
addStream: (stream) => set((state) => ({
streams: [...state.streams, stream],
})),
addStream: (stream) =>
set((state) => ({
streams: [...state.streams, stream],
})),
updateStream: (stream) => set((state) => ({
streams: state.streams.map(st => st.id === stream.id ? stream : st),
})),
updateStream: (stream) =>
set((state) => ({
streams: state.streams.map((st) => (st.id === stream.id ? stream : st)),
})),
removeStreams: (streamIds) => set((state) => ({
streams: state.streams.filter((stream) => !streamIds.includes(stream.id)),
})),
removeStreams: (streamIds) =>
set((state) => ({
streams: state.streams.filter((stream) => !streamIds.includes(stream.id)),
})),
}));
export default useStreamsStore;

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import api from '../api'; // Your API helper that manages token & requests
import { create } from "zustand";
import api from "../api";
const useUserAgentsStore = create((set) => ({
userAgents: [],
@ -12,22 +12,29 @@ const useUserAgentsStore = create((set) => ({
const userAgents = await api.getUserAgents();
set({ userAgents: userAgents, isLoading: false });
} catch (error) {
console.error('Failed to fetch userAgents:', error);
set({ error: 'Failed to load userAgents.', isLoading: false });
console.error("Failed to fetch userAgents:", error);
set({ error: "Failed to load userAgents.", isLoading: false });
}
},
addUserAgent: (userAgent) => set((state) => ({
userAgents: [...state.userAgents, userAgent],
})),
addUserAgent: (userAgent) =>
set((state) => ({
userAgents: [...state.userAgents, userAgent],
})),
updateUserAgent: (userAgent) => set((state) => ({
userAgents: state.userAgents.map(ua => ua.id === userAgent.id ? userAgent : ua),
})),
updateUserAgent: (userAgent) =>
set((state) => ({
userAgents: state.userAgents.map((ua) =>
ua.id === userAgent.id ? userAgent : ua,
),
})),
removeUserAgents: (userAgentIds) => set((state) => ({
userAgents: state.userAgents.filter((userAgent) => !userAgentIds.includes(userAgent.id)),
})),
removeUserAgents: (userAgentIds) =>
set((state) => ({
userAgents: state.userAgents.filter(
(userAgent) => !userAgentIds.includes(userAgent.id),
),
})),
}));
export default useUserAgentsStore;