big frontend push

This commit is contained in:
kappa118 2025-02-24 19:42:59 -05:00
parent 0ca783719d
commit 73b48acb9e
49 changed files with 2987 additions and 1492 deletions

View file

@ -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"
}

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -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

View 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"}

View file

@ -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>
);

View file

@ -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
}
}

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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();

View file

@ -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"

View 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;

View 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;

View file

@ -47,8 +47,6 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
return <></>
}
console.log(userAgent)
return (
<Modal
open={isOpen}

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View 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;

View file

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

View 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',
},
}
}

View file

@ -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(),
])
// }

View file

@ -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>
)
};

View file

@ -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>
)
};

View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
}

View file

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

View 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"
}
}
};

View 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 };
}

View file

@ -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>
)
};

View file

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

View file

@ -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)),
})),

View file

@ -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)),
})),
}));

View file

@ -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)),
})),

View 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;

View file

@ -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
View 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);
}
}