big frontend push
602
frontend/package-lock.json
generated
|
|
@ -13,31 +13,19 @@
|
|||
"@fontsource/roboto": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"formik": "^2.4.6",
|
||||
"material-react-table": "^3.2.0",
|
||||
"planby": "^1.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz",
|
||||
"integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A=="
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
|
|
@ -3206,6 +3194,104 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz",
|
||||
"integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.27.0.tgz",
|
||||
"integrity": "sha512-wSx8JGk4WQ2hTObfQITc+zlmUKNleQYoH1hGocaQlpWpo1HhauDtcQfX6sDN0J0dPT2eeyxDWGj4uJmiSfQKcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0",
|
||||
"@mui/x-internals": "7.26.0",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/material": "^5.15.14 || ^6.0.0",
|
||||
"@mui/system": "^5.15.14 || ^6.0.0",
|
||||
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||
"dayjs": "^1.10.7",
|
||||
"luxon": "^3.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns-jalali": {
|
||||
"optional": true
|
||||
},
|
||||
"dayjs": {
|
||||
"optional": true
|
||||
},
|
||||
"luxon": {
|
||||
"optional": true
|
||||
},
|
||||
"moment": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-hijri": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-jalaali": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-internals": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz",
|
||||
"integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"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",
|
||||
|
|
@ -3331,38 +3417,6 @@
|
|||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.1.tgz",
|
||||
"integrity": "sha512-UHhy3p0oUpdhnSxyDjaRDYaw8Xra75UiLbCiRozVPHjfDwNYkh0TsVm/1OmTW8Md+iDAJmYPWUKMvsMc2GtpNg==",
|
||||
"dependencies": {
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
|
|
@ -3686,12 +3740,13 @@
|
|||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz",
|
||||
"integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==",
|
||||
"node_modules/@tanstack/match-sorter-utils": {
|
||||
"version": "8.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
|
||||
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.2"
|
||||
"remove-accents": "0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -3699,150 +3754,6 @@
|
|||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz",
|
||||
"integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
||||
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz",
|
||||
"integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
|
||||
"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"chalk": "^3.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"lodash": "^4.17.21",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/chalk": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
|
||||
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
|
||||
"integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
|
|
@ -3861,11 +3772,6 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -4194,11 +4100,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz",
|
||||
|
|
@ -5815,14 +5716,6 @@
|
|||
"wrap-ansi": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
|
@ -6330,11 +6223,6 @@
|
|||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="
|
||||
},
|
||||
"node_modules/cssdb": {
|
||||
"version": "7.11.2",
|
||||
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.11.2.tgz",
|
||||
|
|
@ -6562,6 +6450,22 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
|
@ -6668,14 +6572,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
|
|
@ -6778,11 +6674,6 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
|
|
@ -8830,6 +8721,16 @@
|
|||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight-words": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
|
||||
"integrity": "sha512-If5n+IhSBRXTScE7wl16VPmd+44Vy7kof24EdqhjsZsDuHikpv1OCagVcJFpB4fS4UPUniedlWqrjIO8vWOsIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20",
|
||||
"npm": ">= 9"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
|
@ -9192,14 +9093,6 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
|
@ -11046,14 +10939,6 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
|
|
@ -11092,6 +10977,94 @@
|
|||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-3.2.0.tgz",
|
||||
"integrity": "sha512-q2xWG1CQ30ji+aCLKX1XrIg3zrfHT/zZ4aR49Vk/oP4+ospCgjlIVcWLapjrPoqAIDNgB9/fRRG7d1se1Z3nUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/match-sorter-utils": "8.19.4",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"highlight-words": "2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kevinvandy"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": ">=11.13",
|
||||
"@emotion/styled": ">=11.13",
|
||||
"@mui/icons-material": ">=6",
|
||||
"@mui/material": ">=6",
|
||||
"@mui/x-date-pickers": ">=7.15",
|
||||
"react": ">=18.0",
|
||||
"react-dom": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.20.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
|
||||
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.11.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/table-core": {
|
||||
"version": "8.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/material-react-table/node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
|
||||
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -11203,14 +11176,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz",
|
||||
|
|
@ -11921,6 +11886,28 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/planby": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/planby/-/planby-1.1.7.tgz",
|
||||
"integrity": "sha512-Z/hZqMYTQ+uYbuEuvHVkUtzGw0A51mobb/zO4S9gy4TuUjiY848jXpceC8InsbNRicRhIcark1+XlM1HdVoxhg==",
|
||||
"license": "Custom License",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"use-debounce": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/planby"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
@ -13550,33 +13537,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||
|
|
@ -13718,23 +13678,6 @@
|
|||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized": {
|
||||
"version": "9.22.6",
|
||||
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz",
|
||||
"integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"clsx": "^1.0.4",
|
||||
"dom-helpers": "^5.1.3",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
|
@ -13778,31 +13721,6 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -13928,6 +13846,12 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
|
||||
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/renderkid": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
||||
|
|
@ -13961,11 +13885,6 @@
|
|||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
|
|
@ -15156,17 +15075,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
|
@ -16103,10 +16011,24 @@
|
|||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-debounce": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-7.0.1.tgz",
|
||||
"integrity": "sha512-fOrzIw2wstbAJuv8PC9Vg4XgwyTLEOdq4y/Z3IhVl8DAE4svRcgyEUvrEXu+BMNgMoc3YND6qLT61kkgEKXh7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
|
||||
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,14 @@
|
|||
"@fontsource/roboto": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"formik": "^2.4.6",
|
||||
"material-react-table": "^3.2.0",
|
||||
"planby": "^1.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
|
|
|
|||
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 778 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -2,7 +2,10 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
|
|
|
|||
1
frontend/public/site.webmanifest
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
@ -1,51 +1,30 @@
|
|||
// src/App.js
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate, Link } from 'react-router-dom';
|
||||
import HeaderBar from './components/HeaderBar';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import useAuthStore from './store/auth';
|
||||
import Channels from './pages/Channels';
|
||||
import M3U from './pages/M3U';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles'; // Import theme tools
|
||||
import { ThemeProvider } from '@mui/material/styles'; // Import theme tools
|
||||
import {
|
||||
Box,
|
||||
CssBaseline,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import theme from './theme'
|
||||
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
Settings as SettingsIcon,
|
||||
Info as InfoIcon,
|
||||
Description as DescriptionIcon,
|
||||
Tv as TvIcon,
|
||||
CalendarMonth as CalendarMonthIcon,
|
||||
} from '@mui/icons-material';
|
||||
import EPG from './pages/EPG';
|
||||
import Guide from './pages/Guide';
|
||||
import StreamProfiles from './pages/StreamProfiles';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
|
||||
const items = [
|
||||
{ text: 'Channels', icon: <TvIcon />, route: "/channels" },
|
||||
{ text: 'M3U', icon: <DescriptionIcon />, route: "/m3u" },
|
||||
{ text: 'EPG', icon: <CalendarMonthIcon />, route: "/epg" },
|
||||
];
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ element, ...rest }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
|
@ -64,54 +43,46 @@ const App = () => {
|
|||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
open={open}
|
||||
sx={{
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
open={open}
|
||||
sx={{
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
transition: 'width 0.3s',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Drawer Toggle Button */}
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={toggleDrawer}>
|
||||
<img src="/images/logo.png" width="35x" />
|
||||
{open && <ListItemText primary="Dispatcharr" sx={{paddingLeft: 3}}/>}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
transition: 'width 0.3s',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Drawer Toggle Button */}
|
||||
<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>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<Sidebar open />
|
||||
</Drawer>
|
||||
|
||||
{/* Drawer Navigation Items */}
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton component={Link} to={item.route}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
{open && <ListItemText primary={item.text} />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box sx={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
|
||||
transition: 'width 0.3s, margin-left 0.3s',
|
||||
backgroundColor: '#495057',
|
||||
}}>
|
||||
{/* Fixed Header */}
|
||||
<Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
|
||||
{/* <Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
|
||||
|
||||
</Box>
|
||||
</Box> */}
|
||||
|
||||
{/* Main Content Area between Header and Footer */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
|
|
@ -120,9 +91,11 @@ const App = () => {
|
|||
<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 />}/>} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import useChannelsStore from './store/channels';
|
|||
import useUserAgentsStore from './store/userAgents';
|
||||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
import useStreamsStore from './store/streams';
|
||||
import useStreamProfilesStore from './store/streamProfiles';
|
||||
|
||||
const axios = Axios.create({
|
||||
withCredentials: true,
|
||||
|
|
@ -116,17 +118,73 @@ export default class API {
|
|||
useChannelsStore.getState().removeChannels([id])
|
||||
}
|
||||
|
||||
static async deleteChannels(channel_ids) {
|
||||
const response = await fetch(`${host}/api/channels/bulk-delete-channels/`, {
|
||||
method: 'DELETE',
|
||||
// @TODO: the bulk delete endpoint is currently broken
|
||||
// static async deleteChannels(channel_ids) {
|
||||
// const response = await fetch(`${host}/api/channels/bulk-delete-channels/0/`, {
|
||||
// method: 'DELETE',
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${await getAuthToken()}`,
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify({ channel_ids }),
|
||||
// });
|
||||
|
||||
// useChannelsStore.getState().removeChannels(channel_ids)
|
||||
// }
|
||||
|
||||
static async updateChannel(values) {
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel_ids }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels(channel_ids)
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().updateChannel(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async assignChannelNumbers(ids) {
|
||||
const response = await fetch(`${host}/api/channels/channels/assign/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel_order: ids }),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addChannel(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async createChannelFromStream(values) {
|
||||
const response = await fetch(`${host}/api/channels/channels/from-stream/`, {
|
||||
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().addChannel(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getStreams() {
|
||||
|
|
@ -141,6 +199,68 @@ export default class API {
|
|||
return retval;
|
||||
}
|
||||
|
||||
static async addStream(values) {
|
||||
const response = await fetch(`${host}/api/channels/streams/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamsStore.getState().addStream(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateStream(values) {
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamsStore.getState().updateStream(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteStream(id) {
|
||||
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams([id])
|
||||
}
|
||||
|
||||
static async deleteStreams(ids) {
|
||||
const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ stream_ids: ids })
|
||||
});
|
||||
|
||||
useStreamsStore.getState().removeStreams(ids)
|
||||
}
|
||||
|
||||
static async getUserAgents() {
|
||||
const response = await fetch(`${host}/api/core/useragents/`, {
|
||||
headers: {
|
||||
|
|
@ -172,7 +292,6 @@ export default class API {
|
|||
}
|
||||
|
||||
static async updateUserAgent(values) {
|
||||
console.log(values)
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -233,6 +352,32 @@ export default class API {
|
|||
return retval;
|
||||
}
|
||||
|
||||
static async refreshPlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async refreshAllPlaylist() {
|
||||
const response = await fetch(`${host}/api/m3u/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deletePlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -242,7 +387,7 @@ export default class API {
|
|||
},
|
||||
});
|
||||
|
||||
useUserAgentsStore.getState().removePlaylists([id])
|
||||
usePlaylistsStore.getState().removePlaylists([id])
|
||||
}
|
||||
|
||||
static async updatePlaylist(values) {
|
||||
|
|
@ -258,7 +403,7 @@ export default class API {
|
|||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().updatePlaylist(retval)
|
||||
usePlaylistsStore.getState().updatePlaylist(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
|
|
@ -346,4 +491,77 @@ export default class API {
|
|||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getStreamProfiles() {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addStreamProfile(values) {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamProfilesStore.getState().addStreamProfile(retval)
|
||||
}
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateStreamProfile(values) {
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useStreamProfilesStore.getState().updateStreamProfile(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteStreamProfile(id) {
|
||||
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useStreamProfilesStore.getState().removeStreamProfiles([id])
|
||||
}
|
||||
|
||||
static async getGrid() {
|
||||
const response = await fetch(`${host}/api/epg/grid/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json()
|
||||
console.log(retval)
|
||||
return retval
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,49 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
} 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';
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
const Sidebar = ({ open }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<>
|
||||
<div class="sidebar-brand">
|
||||
<Link to="/daskboard" className="brand-link">
|
||||
<img src="/images/logo.png" alt="Dispatcharr Logo" class="brand-image opacity-75 shadow" />
|
||||
<span class="brand-text fw-light">Dispatcharr</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="sidebar-wrapper">
|
||||
<nav class="mt-2">
|
||||
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
|
||||
<li class="nav-item">
|
||||
<Link to="/dashboard" className="nav-link">
|
||||
<i class="nav-icon bi bi-speedometer"></i>
|
||||
<p>Dashboard</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/channels" className="nav-link">
|
||||
<i class="nav-icon bi bi-tv"></i>
|
||||
<p>Channels</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/m3u" className="nav-link">
|
||||
<i class="nav-icon bi bi-file-earmark-text"></i>
|
||||
<p>M3U</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/epg" className="nav-link">
|
||||
<i class="nav-icon bi bi-calendar3"></i>
|
||||
<p>EPG</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/settings" className="nav-link">
|
||||
<i class="nav-icon bi bi-gear"></i>
|
||||
<p>Settings</p>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
<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>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { ButtonGroup, Button } from "@mui/material"
|
||||
|
||||
const StreamTableToolbar = ({ selectedItems }) => {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{/* <Button size="small">Create Channels</Button> */}
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default StreamTableToolbar;
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table'
|
||||
import useStreamsStore from '../store/streams'
|
||||
import ChannelForm from './forms/Channel'
|
||||
import API from '../api'
|
||||
import { Button, ButtonGroup, Checkbox, Table, TableHead, TableRow, TableCell, TableBody, Box, TextField, IconButton } from '@mui/material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import Filter from './tables/Filter';
|
||||
|
||||
// Styles for fixed header and table container
|
||||
const styles = {
|
||||
fixedHeader: {
|
||||
position: "sticky", // Make it sticky
|
||||
top: 0, // Stick to the top
|
||||
backgroundColor: "#fff", // Ensure it has a background
|
||||
zIndex: 10, // Ensure it sits above the table
|
||||
padding: "10px",
|
||||
borderBottom: "2px solid #ccc",
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: "400px", // Limit the height for scrolling
|
||||
overflowY: "scroll", // Make it scrollable
|
||||
marginTop: "50px", // Adjust margin to avoid overlap with fixed header
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
};
|
||||
|
||||
const StreamsTable = () => {
|
||||
const sterams = useStreamsStore(state => state.streams)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); // State to control modal visibility
|
||||
const [columnFilters, setColumnFilters] = useState([])
|
||||
|
||||
// Define columns with useMemo, this is a stable object and doesn't change unless explicitly modified
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
console.log(row)
|
||||
return (
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteStream(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
},
|
||||
], []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: streams,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
debugTable: true,
|
||||
|
||||
filterFns: {},
|
||||
|
||||
onRowSelectionChange: setRowSelection, //hoist up the row selection state to your own scope
|
||||
state: {
|
||||
rowSelection, //pass the row selection state back to the table instance
|
||||
columnFilters,
|
||||
},
|
||||
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
|
||||
const deleteStream = async (id) => {
|
||||
// await API.deleteChannel(id)
|
||||
}
|
||||
|
||||
const parentRef = React.useRef(null)
|
||||
|
||||
const { rows } = table.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 34,
|
||||
overscan: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={parentRef}>
|
||||
{/* Fixed header */}
|
||||
<div style={styles.fixedHeader}>
|
||||
<ButtonGroup size="small">
|
||||
<Button size="small" onClick={() => setIsModalOpen(true)} variant="contained">Add Channel</Button>
|
||||
</ButtonGroup>
|
||||
{/* Add more buttons as needed */}
|
||||
</div>
|
||||
<Box sx={{ height: '500px', overflow: 'auto' }} ref={parentRef}>
|
||||
<Table stickyHeader style={{ width: '100%' }} size="">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} className="text-xs" sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}} >
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} />
|
||||
</div>
|
||||
) : header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{/* Virtualized rows */}
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<TableRow key={row.original.id} sx={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: index % 2 === 0 ? 'grey.100' : 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
// onClick={() => onClickRow?.(cell, row)}
|
||||
key={cell.id}
|
||||
className="text-xs font-graphik"
|
||||
sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<ChannelForm
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StreamsTable;
|
||||
|
|
@ -1,16 +1,56 @@
|
|||
// 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 React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid2,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
} 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";
|
||||
|
||||
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 [logo, setLogo] = useState(null)
|
||||
const [logoPreview, setLogoPreview] = useState(null)
|
||||
|
||||
const [channelStreams, setChannelStreams] = useState([])
|
||||
|
||||
const addStream = (stream) => {
|
||||
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 handleLogoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
|
|
@ -24,6 +64,9 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
channel_name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
stream_profile_id: '',
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
channel_name: Yup.string().required('Name is required'),
|
||||
|
|
@ -32,11 +75,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (channel?.id) {
|
||||
await API.updateChannel({id: channel.id, ...values, logo_file: logo})
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -53,13 +97,101 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
formik.setValues({
|
||||
channel_name: channel.channel_name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group_id,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_name: channel.tvg_name,
|
||||
});
|
||||
|
||||
setChannelStreams(streams.filter(stream => channel.streams.includes(stream.id)))
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const activeStreamsTable = useMaterialReactTable({
|
||||
data: channelStreams,
|
||||
columns: useMemo(() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
], []),
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => removeStream(row.original)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
positionActionsColumn: 'last',
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const availableStreamsTable = useMaterialReactTable({
|
||||
data: streams,
|
||||
columns: useMemo(() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
], []),
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
onClick={() => addStream(row.original)}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
positionActionsColumn: 'last',
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
|
@ -102,7 +234,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
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}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
|
||||
variant="standard"
|
||||
>
|
||||
{channelGroups.map((option, index) => (
|
||||
|
|
@ -113,6 +245,28 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
</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}
|
||||
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.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="channel_number"
|
||||
|
|
@ -129,6 +283,32 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
</Grid2>
|
||||
|
||||
<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}>
|
||||
|
|
@ -137,7 +317,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
<Box mb={2}>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
src={logoPreview}
|
||||
alt="Selected"
|
||||
style={{ maxWidth: 50, height: 'auto' }}
|
||||
/>
|
||||
|
|
@ -174,6 +354,18 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
</Button>
|
||||
</Box>
|
||||
</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>
|
||||
);
|
||||
|
|
@ -184,7 +376,7 @@ const style = {
|
|||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 600,
|
||||
width: "1200px",
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
useEffect(() => {
|
||||
if (epg) {
|
||||
formik.setValues({
|
||||
name: epg.name,
|
||||
source_type: epg.source_type,
|
||||
url: epg.url,
|
||||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
if (playlist?.id) {
|
||||
await API.updatePlaylist({id: playlist.id, ...values, uploaded_file: file})
|
||||
} else {
|
||||
await API.addChannel({
|
||||
await API.addPlaylist({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
})
|
||||
|
|
@ -137,9 +137,9 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="channel-group-label">User-Agent</InputLabel>
|
||||
<InputLabel id="user-agent-label">User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="channel-group-label"
|
||||
labelId="user-agent-label"
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
|
|
|
|||
0
frontend/src/components/forms/NewTable.js
Normal file
152
frontend/src/components/forms/Stream.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// 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 { 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";
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
console.log(stream)
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
stream_profile_id: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
stream_profile_id: Yup.string().required('Stream profile is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (stream?.id) {
|
||||
await API.updateStream({id: stream.id, ...values})
|
||||
} else {
|
||||
await API.addStream(values)
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
formik.setValues({
|
||||
name: stream.name,
|
||||
url: stream.url,
|
||||
stream_profile_id: stream.stream_profile_id,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
Stream
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Stream 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"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="url"
|
||||
name="url"
|
||||
label="Stream URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
helperText={formik.touched.url && formik.errors.url}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: "500px",
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
156
frontend/src/components/forms/StreamProfile.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// 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 useUserAgentsStore from "../../store/userAgents";
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
profile_name: '',
|
||||
command: '',
|
||||
parameters: '',
|
||||
is_active: true,
|
||||
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'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({id: profile.id, ...values})
|
||||
} else {
|
||||
await API.addStreamProfile(values)
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
formik.setValues({
|
||||
profile_name: profile.profile_name,
|
||||
command: profile.command,
|
||||
parameters: profile.parameters,
|
||||
is_active: profile.is_active,
|
||||
user_agent: profile.user_agent,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
Stream Profile
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="profile_name"
|
||||
name="profile_name"
|
||||
label="Name"
|
||||
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}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="command"
|
||||
name="command"
|
||||
label="Command"
|
||||
value={formik.values.command}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.command && Boolean(formik.errors.command)}
|
||||
helperText={formik.touched.command && formik.errors.command}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="parameters"
|
||||
name="parameters"
|
||||
label="Parameters"
|
||||
value={formik.values.parameters}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.parameters && Boolean(formik.errors.parameters)}
|
||||
helperText={formik.touched.parameters && formik.errors.parameters}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="channel-group-label">User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="channel-group-label"
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
|
||||
// helperText={formik.touched.user_agent && formik.errors.user_agent}
|
||||
variant="standard"
|
||||
>
|
||||
{userAgents.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.user_agent_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default StreamProfile;
|
||||
|
|
@ -47,8 +47,6 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
|||
return <></>
|
||||
}
|
||||
|
||||
console.log(userAgent)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
|
|
|
|||
309
frontend/src/components/tables/ChannelsTable.js
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
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 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'
|
||||
import utils from '../../utils';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
|
||||
const Example = () => {
|
||||
const [channel, setChannel] = useState(null)
|
||||
const [channelModelOpen, setChannelModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([])
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [textToCopy, setTextToCopy] = useState('');
|
||||
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("")
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false)
|
||||
|
||||
const channels = useChannelsStore((state) => state.channels);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: '#',
|
||||
size: 50,
|
||||
accessorKey: 'channel_number',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'channel_name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorFn: row => row.channel_group?.name || '',
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
size: 50,
|
||||
cell: (info) => (
|
||||
<Grid2
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img src={info.getValue() || "/images/logo.png"} width="20"/>
|
||||
</Grid2>
|
||||
),
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const closeSnackbar = () => {
|
||||
setSnackbarOpen(false)
|
||||
}
|
||||
|
||||
const editChannel = async (channel = null) => {
|
||||
setChannel(channel)
|
||||
setChannelModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteChannel = async (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)
|
||||
}
|
||||
|
||||
const assignChannels = async () => {
|
||||
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
|
||||
await API.assignChannelNumbers(selected.map(sel => sel.id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const closePopover = () => {
|
||||
setAnchorEl(null);
|
||||
setSnackbarMessage('');
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setSnackbarMessage('Copied!');
|
||||
} catch (err) {
|
||||
setSnackbarMessage('Failed to copy');
|
||||
}
|
||||
|
||||
setSnackbarOpen(true)
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const copyM3UUrl = async (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy('m3u url')
|
||||
}
|
||||
|
||||
const copyEPGUrl = async (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy('epg url')
|
||||
}
|
||||
|
||||
const copyHDHRUrl = async (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy('hdhr url')
|
||||
}
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: channels,
|
||||
// enableGlobalFilterModes: true,
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editChannel(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff
|
||||
overflowY: 'auto', // Internal scrolling for the table
|
||||
},
|
||||
},
|
||||
muiSearchTextFieldProps: {
|
||||
variant: "standard",
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack direction="row" sx={{
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Typography>Channels</Typography>
|
||||
<Tooltip title="Add New Channel">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editChannel()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Channels">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={deleteChannels}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Assign Channels">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={assignChannels}
|
||||
>
|
||||
<SwapVertIcon fontSize="small" /> {/* Small icon size */}
|
||||
</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>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<MaterialReactTable table={table} />
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModelOpen}
|
||||
onClose={() => setChannelModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={closePopover}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px', display: 'flex', alignItems: 'center' }}>
|
||||
<TextField
|
||||
value={textToCopy}
|
||||
InputProps={{ readOnly: true }}
|
||||
variant="standard"
|
||||
disabled
|
||||
size="small"
|
||||
sx={{ marginRight: 1 }}
|
||||
/>
|
||||
<IconButton onClick={handleCopy} color="primary">
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/* {copySuccess && <Typography variant="caption" sx={{ paddingLeft: 2 }}>{copySuccess}</Typography>} */}
|
||||
</Popover>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right"}}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Example;
|
||||
194
frontend/src/components/tables/EPGsTable.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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'
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material'
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import EPGForm from '../forms/EPG'
|
||||
|
||||
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 epgs = useEPGsStore(state => state.epgs)
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const closeSnackbar = () => {
|
||||
setSnackbarOpen(false)
|
||||
}
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg)
|
||||
setEPGModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
await API.deleteEPG(id)
|
||||
}
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id)
|
||||
setSnackbarMessage("EPG refresh initiated")
|
||||
setSnackbarOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: epgs,
|
||||
enableBottomToolbar: false,
|
||||
// enableGlobalFilterModes: true,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</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,
|
||||
}}>
|
||||
<Typography>EPGs</Typography>
|
||||
<Tooltip title="Add New EPG">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editEPG()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
{/* <MRT_ToggleFullScreenButton table={table} /> */}
|
||||
</Grid2>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{
|
||||
padding: 2,
|
||||
}}>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
<EPGForm
|
||||
epg={epg}
|
||||
isOpen={epgModalOpen}
|
||||
onClose={() => setEPGModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right"}}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGsTable;
|
||||
223
frontend/src/components/tables/M3UsTable.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
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'
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material'
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import M3UForm from '../forms/M3U'
|
||||
|
||||
const Example = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([])
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const playlists = usePlaylistsStore((state) => state.playlists);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
accessorKey: 'max_streams',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (!activeFilterValue) return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
setPlaylist(playlist)
|
||||
setPlaylistModalOpen(true)
|
||||
}
|
||||
|
||||
const refreshPlaylist = async (id) => {
|
||||
await API.refreshPlaylist(id)
|
||||
}
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
await API.deletePlaylist(id)
|
||||
}
|
||||
|
||||
const deletePlaylists = async (ids) => {
|
||||
const selected = table.getRowModel().rows.filter(row => row.getIsSelected())
|
||||
// await API.deleteStreams(selected.map(stream => stream.original.id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: playlists,
|
||||
enableBottomToolbar: false,
|
||||
// enableGlobalFilterModes: true,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editPlaylist(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deletePlaylist(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => refreshPlaylist(row.original.id)}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</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,
|
||||
}}>
|
||||
<Typography>M3U Accounts</Typography>
|
||||
<Tooltip title="Add New M3U Account">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editPlaylist()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
{/* <MRT_ToggleFullScreenButton table={table} /> */}
|
||||
</Grid2>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{
|
||||
padding: 2,
|
||||
}}>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Example;
|
||||
207
frontend/src/components/tables/StreamProfilesTable.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
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'
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
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';
|
||||
|
||||
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 streamProfiles = useStreamProfilesStore(state => state.profiles)
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'profile_name',
|
||||
},
|
||||
{
|
||||
header: 'Command',
|
||||
accessorKey: 'command',
|
||||
},
|
||||
{
|
||||
header: 'Parameters',
|
||||
accessorKey: 'parameters',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, filterValue) => {
|
||||
if (filterValue == "all") return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === filterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editStreamProfile = async (profile = null) => {
|
||||
setProfile(profile)
|
||||
setProfileModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteStreamProfile = async (ids) => {
|
||||
await API.deleteStreamProfile(ids)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: streamProfiles,
|
||||
enableBottomToolbar: false,
|
||||
// enableGlobalFilterModes: true,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => editStreamProfile(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteStreamProfile(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</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,
|
||||
}}>
|
||||
<Typography>Stream Profiles</Typography>
|
||||
<Tooltip title="Add New Stream Profile">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editStreamProfile()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
{/* <MRT_ToggleFullScreenButton table={table} /> */}
|
||||
</Grid2>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{
|
||||
padding: 2,
|
||||
}}>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
|
||||
<StreamProfileForm
|
||||
profile={profile}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfiles;
|
||||
196
frontend/src/components/tables/StreamsTable.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { TableHelper } from '../../helpers'
|
||||
import utils from '../../utils';
|
||||
import StreamForm from '../forms/Stream'
|
||||
|
||||
const Example = () => {
|
||||
const [rowSelection, setRowSelection] = useState([])
|
||||
const [stream, setStream] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const createChannelFromStream = async (stream) => {
|
||||
await API.createChannelFromStream({
|
||||
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)
|
||||
}
|
||||
|
||||
const editStream = async (stream = null) => {
|
||||
setStream(stream)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteStream = async (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))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
|
||||
columns,
|
||||
data: streams,
|
||||
// enableGlobalFilterModes: true,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => editStream(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteStream(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
onClick={() => createChannelFromStream(row.original)}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff
|
||||
overflowY: 'auto', // Internal scrolling for the table
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack direction="row" sx={{
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Typography>Streams</Typography>
|
||||
<Tooltip title="Add New Stream">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editStream()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Streams">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={deleteStreams}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={createChannelsFromStreams}
|
||||
// disabled={rowSelection.length === 0}
|
||||
sx={{
|
||||
marginLeft: 1,
|
||||
}}
|
||||
>Create Channels</Button>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
// paddingTop: 2,
|
||||
// paddingLeft: 1,
|
||||
// paddingRight: 2,
|
||||
// paddingBottom: 2,
|
||||
}}>
|
||||
<MaterialReactTable table={table} />
|
||||
<StreamForm
|
||||
stream={stream}
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Example;
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table'
|
||||
import API from '../../api'
|
||||
import { Checkbox, Table as MuiTable, TableHead, TableRow, TableCell, TableBody, Box, TextField, IconButton, TableContainer, Paper, Button, ButtonGroup, Typography, Grid2 } from '@mui/material'
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material'
|
||||
import Filter from './Filter'
|
||||
|
||||
// Styles for fixed header and table container
|
||||
const styles = {
|
||||
fixedHeader: {
|
||||
position: "sticky", // Make it sticky
|
||||
top: 0, // Stick to the top
|
||||
backgroundColor: "#fff", // Ensure it has a background
|
||||
zIndex: 10, // Ensure it sits above the table
|
||||
padding: "10px",
|
||||
borderBottom: "2px solid #ccc",
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: "400px", // Limit the height for scrolling
|
||||
overflowY: "scroll", // Make it scrollable
|
||||
marginTop: "50px", // Adjust margin to avoid overlap with fixed header
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
};
|
||||
|
||||
const Table = ({ customToolbar: CustomToolbar , name = null, tableHeight = null, data, columnDef, addAction = null, bulkDeleteAction = null }) => {
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState([])
|
||||
|
||||
// Define columns with useMemo, this is a stable object and doesn't change unless explicitly modified
|
||||
const columns = useMemo(() => columnDef, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
debugTable: true,
|
||||
|
||||
filterFns: {},
|
||||
|
||||
onRowSelectionChange: setRowSelection, //hoist up the row selection state to your own scope
|
||||
state: {
|
||||
rowSelection, //pass the row selection state back to the table instance
|
||||
columnFilters,
|
||||
},
|
||||
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
|
||||
const parentRef = React.useRef(null)
|
||||
|
||||
const { rows } = table.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 34,
|
||||
overscan: 20,
|
||||
})
|
||||
|
||||
const deleteSelected = async () => {
|
||||
const ids = Object.keys(rowSelection).map(index => data[parseInt(index)].id)
|
||||
bulkDeleteAction(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sticky Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1100,
|
||||
backgroundColor: "white",
|
||||
borderTop: "1px solid #ddd",
|
||||
borderBottom: "1px solid #ddd",
|
||||
padding: "8px",
|
||||
}}
|
||||
>
|
||||
<Grid2 container direction="row" spacing={3} sx={{
|
||||
// justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: 30,
|
||||
}}>
|
||||
{name && <Typography>{name}</Typography>}
|
||||
{CustomToolbar && <CustomToolbar rowSelection={rowSelection} />}
|
||||
{!CustomToolbar && <Grid2>
|
||||
{addAction && <IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={addAction}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>}
|
||||
{bulkDeleteAction && <IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>}
|
||||
</Grid2>
|
||||
}
|
||||
</Grid2>
|
||||
</Box>
|
||||
<div ref={parentRef}>
|
||||
<Box
|
||||
ref={parentRef}
|
||||
sx={{
|
||||
height: tableHeight || "calc(100vh - 40px)", // 50% of the viewport height
|
||||
overflow: "auto", // Enable scrollbars
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<TableContainer component={Paper} sx={{ maxHeight: "calc(100vh - 64px - 40px)" }}>
|
||||
<MuiTable stickyHeader style={{ width: '100%' }} size="small">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} className="text-xs" sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}} >
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} />
|
||||
</div>
|
||||
) : header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{/* Virtualized rows */}
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<TableRow key={row.original.id} sx={{
|
||||
// transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: index % 2 === 0 ? 'grey.100' : 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
// onClick={() => onClickRow?.(cell, row)}
|
||||
key={cell.id}
|
||||
className="text-xs"
|
||||
sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</MuiTable>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Table;
|
||||
209
frontend/src/components/tables/UserAgentsTable.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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'
|
||||
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'
|
||||
|
||||
const UserAgentsTable = () => {
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([])
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? <CheckIcon color="success" /> : <CloseIcon color="error" />}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (activeFilterValue == "all") return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent)
|
||||
setUserAgentModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteUserAgents(ids)
|
||||
} else {
|
||||
await API.deleteUserAgent(ids)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: userAgents,
|
||||
enableBottomToolbar: false,
|
||||
// enableGlobalFilterModes: true,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</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,
|
||||
}}>
|
||||
<Typography>User-Agents</Typography>
|
||||
<Tooltip title="Add New User Agent">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editUserAgent()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
{/* <MRT_ToggleFullScreenButton table={table} /> */}
|
||||
</Grid2>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{
|
||||
padding: 2,
|
||||
}}>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgentsTable;
|
||||
3
frontend/src/helpers/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import table from "./table"
|
||||
|
||||
export const TableHelper = table
|
||||
14
frontend/src/helpers/table.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export default {
|
||||
defaultProperties: {
|
||||
enableBottomToolbar: false,
|
||||
enableDensityToggle: false,
|
||||
enableFullScreenToggle: false,
|
||||
positionToolbarAlertBanner: "none",
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enableRowNumbers: false,
|
||||
positionActionsColumn: 'last',
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ 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';
|
||||
|
||||
// Create a root element
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
|
@ -18,10 +19,10 @@ const streamsStore = useStreamsStore.getState();
|
|||
const userAgentsStore = useUserAgentsStore.getState();
|
||||
const playlistsStore = usePlaylistsStore.getState();
|
||||
const epgsStore = useEPGsStore.getState()
|
||||
const streamProfilesStore = useStreamProfilesStore.getState()
|
||||
|
||||
await authStore.initializeAuth();
|
||||
|
||||
console.log(authStore)
|
||||
// if (authStore.isAuthenticated) {
|
||||
await Promise.all([
|
||||
authStore.initializeAuth(),
|
||||
|
|
@ -31,6 +32,7 @@ console.log(authStore)
|
|||
userAgentsStore.fetchUserAgents(),
|
||||
playlistsStore.fetchPlaylists(),
|
||||
epgsStore.fetchEPGs(),
|
||||
streamProfilesStore.fetchProfiles(),
|
||||
])
|
||||
// }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,197 +1,42 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import useStreamsStore from '../store/streams';
|
||||
import Table from '../components/tables/Table';
|
||||
import { ButtonGroup, Button, Checkbox, IconButton, Stack, Grid2, Grow } from '@mui/material';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
} from '@mui/icons-material'
|
||||
import ChannelForm from '../components/forms/Channel'
|
||||
import API from '../api'
|
||||
import StreamTableToolbar from '../components/StreamTableToolbar';
|
||||
import React from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Grid2, Box } from '@mui/material';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const [channel, setChannel] = useState(null)
|
||||
const [channelModelOpen, setChannelModalOpen] = useState(false);
|
||||
|
||||
const channels = useChannelsStore((state) => state.channels);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const isLoading = useChannelsStore((state) => state.isLoading);
|
||||
const error = useChannelsStore((state) => state.error);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
const editChannel = async (channel = null) => {
|
||||
setChannel(channel)
|
||||
setChannelModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteChannel = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteChannels(ids)
|
||||
} else {
|
||||
await API.deleteChannel(ids)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid2 container>
|
||||
<Grid2 size={6}>
|
||||
<Table
|
||||
name="Channels"
|
||||
data={channels}
|
||||
addAction={editChannel}
|
||||
bulkDeleteAction={deleteChannel}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '#',
|
||||
size: 10,
|
||||
accessorKey: 'channel_number',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'channel_name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
// accessorFn: row => row.original.channel_group.name,
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
size: 50,
|
||||
cell: (info) => (
|
||||
<Grid2
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img src={info.getValue() || "/images/logo.png"} width="20"/>
|
||||
</Grid2>
|
||||
),
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
console.log(row)
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editChannel(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
<Table
|
||||
name="Streams"
|
||||
customToolbar={StreamTableToolbar}
|
||||
data={streams}
|
||||
// addAction={editChannel}
|
||||
// bulkDeleteAction={deleteChannel}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
// {
|
||||
// id: 'actions',
|
||||
// header: 'Actions',
|
||||
// cell: ({ row }) => {
|
||||
// console.log(row)
|
||||
// return (
|
||||
// <IconButton
|
||||
// size="small" // Makes the button smaller
|
||||
// color="error" // Red color for delete actions
|
||||
// onClick={() => deleteStream(row.original.id)}
|
||||
// >
|
||||
// <DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
// </IconButton>
|
||||
// )
|
||||
// }
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 container>
|
||||
<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
|
||||
}}
|
||||
>
|
||||
<ChannelsTable />
|
||||
</Box>
|
||||
</Grid2>
|
||||
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModelOpen}
|
||||
onClose={() => setChannelModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
<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
|
||||
}}
|
||||
>
|
||||
<StreamsTable />
|
||||
</Box>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +1,9 @@
|
|||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import { Box, Checkbox, IconButton, ButtonGroup, Button, Snackbar } from '@mui/material';
|
||||
import Table from '../components/tables/Table';
|
||||
import useEPGsStore from '../store/epgs';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../api'
|
||||
import EPGForm from '../components/forms/EPG'
|
||||
import UserAgentForm from '../components/forms/UserAgent'
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Snackbar } from '@mui/material';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import EPGsTable from '../components/tables/EPGsTable';
|
||||
|
||||
const EPGPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const epgs = useEPGsStore(state => state.epgs)
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("")
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false)
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent)
|
||||
setUserAgentModalOpen(true)
|
||||
}
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg)
|
||||
setEPGModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteUserAgents(ids)
|
||||
} else {
|
||||
await API.deleteUserAgent(ids)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
await API.deleteEPG(id)
|
||||
}
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id)
|
||||
setSnackbarMessage("EPG refresh initiated")
|
||||
setSnackbarOpen(true)
|
||||
}
|
||||
|
||||
const closeSnackbar = () => {
|
||||
setSnackbarOpen(false)
|
||||
}
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -72,181 +14,12 @@ const EPGPage = () => {
|
|||
}}
|
||||
>
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="EPG Sources"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={epgs}
|
||||
addAction={editEPG}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]} />
|
||||
<EPGsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="User-Agents"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={userAgents}
|
||||
addAction={editUserAgent}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
|
||||
<EPGForm
|
||||
epg={epg}
|
||||
isOpen={epgModalOpen}
|
||||
onClose={() => setEPGModalOpen(false)}
|
||||
/>
|
||||
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right"}}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
};
|
||||
|
|
|
|||
93
frontend/src/pages/Guide.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React from "react";
|
||||
import { useEpg, Epg, Layout } from 'planby';
|
||||
import API from '../api'
|
||||
|
||||
function App() {
|
||||
const [channels, setChannels] = React.useState([])
|
||||
const [epg, setEpg] = React.useState([])
|
||||
|
||||
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({
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
setChannels(retval)
|
||||
return retval;
|
||||
}
|
||||
|
||||
const fetchEpg = async () => {
|
||||
const programs = await API.getGrid();
|
||||
const retval = []
|
||||
console.log(programs)
|
||||
for (const program of programs.data) {
|
||||
retval.push({
|
||||
id: program.id,
|
||||
channelUuid: "Nickelodeon (East).us",
|
||||
description: program.description,
|
||||
title: program.title,
|
||||
since: program.start_time,
|
||||
till: program.end_time,
|
||||
})
|
||||
}
|
||||
|
||||
setEpg(retval)
|
||||
return retval;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const channels = await fetchChannels()
|
||||
const epg = await fetchEpg()
|
||||
|
||||
setChannels(channels)
|
||||
setEpg(epg)
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const formatDate = (date) => date.toISOString().split('T')[0] + 'T00:00:00';
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
|
||||
const {
|
||||
getEpgProps,
|
||||
getLayoutProps,
|
||||
onScrollToNow,
|
||||
onScrollLeft,
|
||||
onScrollRight,
|
||||
} = useEpg({
|
||||
epg,
|
||||
channels,
|
||||
startDate: '2025/02/24', // or 2022-02-02T00:00:00
|
||||
width: '100%',
|
||||
height: 600,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Epg {...getEpgProps()}>
|
||||
<Layout
|
||||
{...getLayoutProps()}
|
||||
/>
|
||||
</Epg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
16
frontend/src/pages/Guide/components/ChannelItem.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
45
frontend/src/pages/Guide/components/ProgramItem.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
47
frontend/src/pages/Guide/components/Timeline.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/pages/Guide/components/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./ChannelItem";
|
||||
export * from "./ProgramItem";
|
||||
export * from "./Timeline";
|
||||
43
frontend/src/pages/Guide/theme.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
};
|
||||
143
frontend/src/pages/Guide/useApp.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,22 +1,17 @@
|
|||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import { Box, Checkbox, IconButton, ButtonGroup, Button } from '@mui/material';
|
||||
import Table from '../components/tables/Table';
|
||||
import { Box } from '@mui/material';
|
||||
import M3UsTable from '../components/tables/M3UsTable';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import usePlaylistsStore from '../store/playlists';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Check as CheckIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../api'
|
||||
import M3UForm from '../components/forms/M3U'
|
||||
import UserAgentForm from '../components/forms/UserAgent'
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const playlists = usePlaylistsStore(state => state.playlists)
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
|
|
@ -59,169 +54,11 @@ const M3UPage = () => {
|
|||
}}
|
||||
>
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="M3U Accounts"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={playlists}
|
||||
addAction={editPlaylist}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {(
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
)},
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editPlaylist(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deletePlaylist(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]} />
|
||||
<M3UsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="User-Agents"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={userAgents}
|
||||
addAction={editUserAgent}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
|
||||
<M3UForm
|
||||
|
|
@ -230,11 +67,7 @@ const M3UPage = () => {
|
|||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
)
|
||||
};
|
||||
|
|
|
|||
10
frontend/src/pages/StreamProfiles.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React, { useState } from 'react';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return (
|
||||
<StreamProfilesTable />
|
||||
)
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
|
|
@ -35,6 +35,10 @@ const useChannelsStore = create((set) => ({
|
|||
channels: [...state.channels, newChannel],
|
||||
})),
|
||||
|
||||
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)),
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const useEPGsStore = create((set) => ({
|
|||
epgs: [...state.epgs, newPlaylist],
|
||||
})),
|
||||
|
||||
removeEGPs: (epgIds) => set((state) => ({
|
||||
removeEPGs: (epgIds) => set((state) => ({
|
||||
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
|
||||
})),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ const usePlaylistsStore = create((set) => ({
|
|||
playlists: [...state.playlists, newPlaylist],
|
||||
})),
|
||||
|
||||
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)),
|
||||
})),
|
||||
|
|
|
|||
33
frontend/src/store/streamProfiles.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const useStreamProfilesStore = create((set) => ({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchProfiles: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const profiles = await api.getStreamProfiles();
|
||||
set({ profiles: profiles, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error);
|
||||
set({ error: 'Failed to load profiles.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStreamProfile: (profile) => set((state) => ({
|
||||
profiles: [...state.profiles, profile],
|
||||
})),
|
||||
|
||||
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)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useStreamProfilesStore;
|
||||
|
|
@ -16,6 +16,18 @@ const useStreamsStore = create((set) => ({
|
|||
set({ error: 'Failed to load streams.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStream: (stream) => set((state) => ({
|
||||
streams: [...state.streams, stream],
|
||||
})),
|
||||
|
||||
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)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useStreamsStore;
|
||||
|
|
|
|||
38
frontend/src/utils.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export default {
|
||||
Limiter: (concurrency, promiseList) => {
|
||||
if (!promiseList || promiseList.length === 0) {
|
||||
return Promise.resolve([]); // Return a resolved empty array if no promises
|
||||
}
|
||||
|
||||
let index = 0; // Keeps track of the current promise to be processed
|
||||
const results = []; // Stores the results of all promises
|
||||
const totalPromises = promiseList.length;
|
||||
|
||||
// Helper function to process promises one by one, respecting concurrency
|
||||
const processNext = () => {
|
||||
// If we've processed all promises, resolve with the results
|
||||
if (index >= totalPromises) {
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
// Execute the current promise and store the result
|
||||
const currentPromise = promiseList[index]();
|
||||
results.push(currentPromise);
|
||||
|
||||
// Once the current promise resolves, move on to the next one
|
||||
return currentPromise.then(() => {
|
||||
index++; // Move to the next promise
|
||||
return processNext(); // Process the next promise
|
||||
});
|
||||
};
|
||||
|
||||
// Start processing promises up to the given concurrency
|
||||
const concurrencyPromises = [];
|
||||
for (let i = 0; i < concurrency && i < totalPromises; i++) {
|
||||
concurrencyPromises.push(processNext());
|
||||
}
|
||||
|
||||
// Wait for all promises to resolve
|
||||
return Promise.all(concurrencyPromises).then(() => results);
|
||||
}
|
||||
}
|
||||