diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f388917..cadc68e8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index 10097b50..e150a95a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png new file mode 100644 index 00000000..41a99864 Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png new file mode 100644 index 00000000..4a448aed Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..c4d863b1 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 00000000..26bd66ae Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 00000000..f8801a86 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc..d6beceff 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html index ec0e2fa3..cf7eea9b 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,7 +2,10 @@ - + + + + , route: "/channels" }, - { text: 'M3U', icon: , route: "/m3u" }, - { text: 'EPG', icon: , route: "/epg" }, -]; - // Protected Route Component const ProtectedRoute = ({ element, ...rest }) => { const { isAuthenticated } = useAuthStore(); @@ -64,54 +43,46 @@ const App = () => { - - {/* Drawer Toggle Button */} - - - - - {open && } - - - + transition: 'width 0.3s', + overflowX: 'hidden', + }, + }} + > + {/* Drawer Toggle Button */} + + + + + {open && } + + + - + + + + - {/* Drawer Navigation Items */} - - {items.map((item) => ( - - - {item.icon} - {open && } - - - ))} - - {/* Fixed Header */} - + {/* - + */} {/* Main Content Area between Header and Footer */} @@ -120,9 +91,11 @@ const App = () => { }/>} /> }/>} /> }/>} /> + }/>} /> + }/>} /> - + ); diff --git a/frontend/src/api.js b/frontend/src/api.js index c74498b7..510b4e36 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 + } } diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 5043be37..3ecc7bb4 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -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: , route: "/channels" }, + { text: 'M3U', icon: , route: "/m3u" }, + { text: 'EPG', icon: , route: "/epg" }, + { text: 'Stream Profiles', icon: , route: "/stream-profiles" }, + { text: 'TV Guide', icon: , route: "/guide" }, +]; + +const Sidebar = ({ open }) => { + const location = useLocation(); -const Sidebar = () => { return ( - <> - - - + + {items.map((item) => ( + + + {item.icon} + {open && } + + + ))} + ); }; diff --git a/frontend/src/components/StreamTableToolbar.js b/frontend/src/components/StreamTableToolbar.js deleted file mode 100644 index b4bb2f42..00000000 --- a/frontend/src/components/StreamTableToolbar.js +++ /dev/null @@ -1,11 +0,0 @@ -import { ButtonGroup, Button } from "@mui/material" - -const StreamTableToolbar = ({ selectedItems }) => { - return ( - - {/* */} - - ) -} - -export default StreamTableToolbar; diff --git a/frontend/src/components/StreamsTable.js b/frontend/src/components/StreamsTable.js deleted file mode 100644 index 62655577..00000000 --- a/frontend/src/components/StreamsTable.js +++ /dev/null @@ -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 }) => ( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - header: 'Name', - accessorKey: 'name', - }, - { - header: 'Group', - accessorKey: 'group_name', - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => { - console.log(row) - return ( - deleteStream(row.original.id)} - > - {/* Small icon size */} - - ) - } - }, - ], []); - - 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 ( -
- {/* Fixed header */} -
- - - - {/* Add more buttons as needed */} -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.column.getCanFilter() ? ( -
- -
- ) : header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- ))} -
- ))} -
- - {/* Virtualized rows */} - {rowVirtualizer.getVirtualItems().map((virtualRow, index) => { - const row = rows[virtualRow.index]; - return ( - - {row.getVisibleCells().map((cell) => ( - onClickRow?.(cell, row)} - key={cell.id} - className="text-xs font-graphik" - sx={{ - paddingTop: 0, - paddingBottom: 0, - }} - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ); - })} - -
-
- - setIsModalOpen(false)} - /> -
- ) -} - -export default StreamsTable; diff --git a/frontend/src/components/forms/Channel.js b/frontend/src/components/forms/Channel.js index e4c64394..33e511a0 100644 --- a/frontend/src/components/forms/Channel.js +++ b/frontend/src/components/forms/Channel.js @@ -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 }) => ( + <> + removeStream(row.original)} + > + {/* Small icon size */} + + + ), + 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 }) => ( + <> + addStream(row.original)} + > + {/* Small icon size */} + + + ), + 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 }) => { + + Stream Profile + + + { + + + + {/* File upload input */} @@ -137,7 +317,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { {logo && ( Selected @@ -174,6 +354,18 @@ const Channel = ({ channel = null, isOpen, onClose }) => { + + + + Active Streams + + + + + Available Streams + + + ); @@ -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, diff --git a/frontend/src/components/forms/EPG.js b/frontend/src/components/forms/EPG.js index 7ec9fc7f..3481dd20 100644 --- a/frontend/src/components/forms/EPG.js +++ b/frontend/src/components/forms/EPG.js @@ -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(); diff --git a/frontend/src/components/forms/M3U.js b/frontend/src/components/forms/M3U.js index 445bafe7..f34bf3fa 100644 --- a/frontend/src/components/forms/M3U.js +++ b/frontend/src/components/forms/M3U.js @@ -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 }) => { /> - User-Agent + User-Agent + {streamProfiles.map((option, index) => ( + + {option.profile_name} + + ))} + + + + + + {/* Submit button */} + + + + + + ); +}; + +const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: "500px", + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, +}; + +export default Stream; diff --git a/frontend/src/components/forms/StreamProfile.js b/frontend/src/components/forms/StreamProfile.js new file mode 100644 index 00000000..793c9005 --- /dev/null +++ b/frontend/src/components/forms/StreamProfile.js @@ -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 ( + + + + Stream Profile + + +
+ + + + + + User-Agent + + + + + {/* Submit button */} + + + +
+
+ ); +}; + +const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, +}; + +export default StreamProfile; diff --git a/frontend/src/components/forms/UserAgent.js b/frontend/src/components/forms/UserAgent.js index 5ea55faf..dee370bb 100644 --- a/frontend/src/components/forms/UserAgent.js +++ b/frontend/src/components/forms/UserAgent.js @@ -47,8 +47,6 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => { return <> } - console.log(userAgent) - return ( { + 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) => ( + + + + ), + 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 }) => ( + <> + { + editChannel(row.original) + }} + > + {/* Small icon size */} + + deleteChannel(row.original.id)} + > + {/* Small icon size */} + + + ), + muiTableContainerProps: { + sx: { + height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff + overflowY: 'auto', // Internal scrolling for the table + }, + }, + muiSearchTextFieldProps: { + variant: "standard", + }, + renderTopToolbarCustomActions: ({ table }) => ( + + Channels + + editChannel()} + > + {/* Small icon size */} + + + + + {/* Small icon size */} + + + + + {/* Small icon size */} + + + + + + + + + + ), + }); + + return ( + + + setChannelModalOpen(false)} + /> + + +
+ + + + +
+ {/* {copySuccess && {copySuccess}} */} +
+ + +
+ ); +}; + +export default Example; diff --git a/frontend/src/components/tables/EPGsTable.js b/frontend/src/components/tables/EPGsTable.js new file mode 100644 index 00000000..a0ffdbe3 --- /dev/null +++ b/frontend/src/components/tables/EPGsTable.js @@ -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 }) => ( + <> + editEPG(row.original)} + > + {/* Small icon size */} + + deleteEPG(row.original.id)} + > + {/* Small icon size */} + + refreshEPG(row.original.id)} + > + {/* Small icon size */} + + + ), + positionActionsColumn: 'last', + muiTableContainerProps: { + sx: { + height: "calc(42vh - 0px)", + }, + }, + renderTopToolbar: ({ table }) => ( + + EPGs + + editEPG()} + > + {/* Small icon size */} + + + + {/* */} + + ), + }); + + return ( + <> + + + + setEPGModalOpen(false)} + /> + + + + ); +}; + +export default EPGsTable; diff --git a/frontend/src/components/tables/M3UsTable.js b/frontend/src/components/tables/M3UsTable.js new file mode 100644 index 00000000..84ed41a6 --- /dev/null +++ b/frontend/src/components/tables/M3UsTable.js @@ -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 }) => ( + + {cell.getValue() ? : } + + ), + Filter: ({ column }) => ( + + + + ), + 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 }) => ( + <> + { + editPlaylist(row.original) + }} + > + {/* Small icon size */} + + deletePlaylist(row.original.id)} + > + {/* Small icon size */} + + refreshPlaylist(row.original.id)} + > + {/* Small icon size */} + + + ), + positionActionsColumn: 'last', + muiTableContainerProps: { + sx: { + height: "calc(42vh - 0px)", + }, + }, + renderTopToolbar: ({ table }) => ( + + M3U Accounts + + editPlaylist()} + > + {/* Small icon size */} + + + + {/* */} + + ), + }); + + return ( + <> + + + + setPlaylistModalOpen(false)} + /> + + ); +}; + +export default Example; diff --git a/frontend/src/components/tables/StreamProfilesTable.js b/frontend/src/components/tables/StreamProfilesTable.js new file mode 100644 index 00000000..33aeb933 --- /dev/null +++ b/frontend/src/components/tables/StreamProfilesTable.js @@ -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 }) => ( + + {cell.getValue() ? : } + + ), + Filter: ({ column }) => ( + + + + ), + 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 }) => ( + <> + editStreamProfile(row.original)} + > + {/* Small icon size */} + + deleteStreamProfile(row.original.id)} + > + {/* Small icon size */} + + + ), + positionActionsColumn: 'last', + muiTableContainerProps: { + sx: { + // height: "calc(42vh - 0px)", + }, + }, + renderTopToolbar: ({ table }) => ( + + Stream Profiles + + editStreamProfile()} + > + {/* Small icon size */} + + + + {/* */} + + ), + }); + + return ( + <> + + + + + setProfileModalOpen(false)} + /> + + ); +}; + +export default StreamProfiles; diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js new file mode 100644 index 00000000..6a54f6fa --- /dev/null +++ b/frontend/src/components/tables/StreamsTable.js @@ -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 }) => ( + <> + editStream(row.original)} + > + {/* Small icon size */} + + deleteStream(row.original.id)} + > + {/* Small icon size */} + + createChannelFromStream(row.original)} + > + {/* Small icon size */} + + + ), + muiTableContainerProps: { + sx: { + height: 'calc(100vh - 100px)', // Subtract padding to avoid cutoff + overflowY: 'auto', // Internal scrolling for the table + }, + }, + renderTopToolbarCustomActions: ({ table }) => ( + + Streams + + editStream()} + > + {/* Small icon size */} + + + + + {/* Small icon size */} + + + + + ), + }); + + return ( + + + setModalOpen(false)} + /> + + ); +}; + +export default Example; diff --git a/frontend/src/components/tables/Table.js b/frontend/src/components/tables/Table.js deleted file mode 100644 index f42859dd..00000000 --- a/frontend/src/components/tables/Table.js +++ /dev/null @@ -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 */} - - - {name && {name}} - {CustomToolbar && } - {!CustomToolbar && - {addAction && - {/* Small icon size */} - } - {bulkDeleteAction && - {/* Small icon size */} - } - - } - - -
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.column.getCanFilter() ? ( -
- -
- ) : header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- ))} -
- ))} -
- - {/* Virtualized rows */} - {rowVirtualizer.getVirtualItems().map((virtualRow, index) => { - const row = rows[virtualRow.index]; - return ( - - {row.getVisibleCells().map((cell) => ( - onClickRow?.(cell, row)} - key={cell.id} - className="text-xs" - sx={{ - paddingTop: 0, - paddingBottom: 0, - }} - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - ); - })} - -
-
-
-
- - ) -} - -export default Table; diff --git a/frontend/src/components/tables/UserAgentsTable.js b/frontend/src/components/tables/UserAgentsTable.js new file mode 100644 index 00000000..1a316b69 --- /dev/null +++ b/frontend/src/components/tables/UserAgentsTable.js @@ -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 }) => ( + + {cell.getValue() ? : } + + ), + Filter: ({ column }) => ( + + + + ), + 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 }) => ( + <> + { + editUserAgent(row.original) + }} + > + {/* Small icon size */} + + deleteUserAgent(row.original.id)} + > + {/* Small icon size */} + + + ), + positionActionsColumn: 'last', + muiTableContainerProps: { + sx: { + height: "calc(42vh - 0px)", + }, + }, + renderTopToolbar: ({ table }) => ( + + User-Agents + + editUserAgent()} + > + {/* Small icon size */} + + + + {/* */} + + ), + }); + + return ( + <> + + + + setUserAgentModalOpen(false)} + /> + + ); +}; + +export default UserAgentsTable; diff --git a/frontend/src/helpers/index.js b/frontend/src/helpers/index.js new file mode 100644 index 00000000..3053a5d7 --- /dev/null +++ b/frontend/src/helpers/index.js @@ -0,0 +1,3 @@ +import table from "./table" + +export const TableHelper = table diff --git a/frontend/src/helpers/table.js b/frontend/src/helpers/table.js new file mode 100644 index 00000000..99a5ca99 --- /dev/null +++ b/frontend/src/helpers/table.js @@ -0,0 +1,14 @@ +export default { + defaultProperties: { + enableBottomToolbar: false, + enableDensityToggle: false, + enableFullScreenToggle: false, + positionToolbarAlertBanner: "none", + columnFilterDisplayMode: 'popover', + enableRowNumbers: false, + positionActionsColumn: 'last', + initialState: { + density: 'compact', + }, + } +} diff --git a/frontend/src/index.js b/frontend/src/index.js index 2f7354ee..2f72fa71 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -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(), ]) // } diff --git a/frontend/src/pages/Channels.js b/frontend/src/pages/Channels.js index ad491ecf..9ee5d438 100644 --- a/frontend/src/pages/Channels.js +++ b/frontend/src/pages/Channels.js @@ -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
Loading...
; - if (error) return
Error: {error}
; - - 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 ( - <> - - - ( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - 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) => ( - - - - ), - meta: { - filterVariant: null, - }, - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => { - console.log(row) - return ( - <> - { - editChannel(row.original) - }} - > - {/* Small icon size */} - - deleteChannel(row.original.id)} - > - {/* Small icon size */} - - - ) - } - }, - ]} - /> - - -
( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - header: 'Name', - accessorKey: 'name', - }, - { - header: 'Group', - accessorKey: 'group_name', - }, - // { - // id: 'actions', - // header: 'Actions', - // cell: ({ row }) => { - // console.log(row) - // return ( - // deleteStream(row.original.id)} - // > - // {/* Small icon size */} - // - // ) - // } - // }, - ]} - /> - + + + + + - - setChannelModalOpen(false)} - /> - + + + + + + ) }; diff --git a/frontend/src/pages/EPG.js b/frontend/src/pages/EPG.js index 0ed002d0..0442c711 100644 --- a/frontend/src/pages/EPG.js +++ b/frontend/src/pages/EPG.js @@ -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
Loading...
; - if (error) return
Error: {error}
; - return ( { }} > -
( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - 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 ( - <> - editEPG(row.original)} - > - {/* Small icon size */} - - deleteEPG(row.original.id)} - > - {/* Small icon size */} - - refreshEPG(row.original.id)} - > - {/* Small icon size */} - - - ) - } - }, - ]} /> + -
( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - 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 }) => { - - } - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => { - return ( - <> - { - editUserAgent(row.original) - }} - > - {/* Small icon size */} - - deleteUserAgent(row.original.id)} - > - {/* Small icon size */} - - - ) - } - }, - ]} - /> + - - setEPGModalOpen(false)} - /> - - setUserAgentModalOpen(false)} - /> - - ) }; diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js new file mode 100644 index 00000000..fdfc51b9 --- /dev/null +++ b/frontend/src/pages/Guide.js @@ -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 ( +
+ + + +
+ ); +} + +export default App; diff --git a/frontend/src/pages/Guide/components/ChannelItem.js b/frontend/src/pages/Guide/components/ChannelItem.js new file mode 100644 index 00000000..fcb67a26 --- /dev/null +++ b/frontend/src/pages/Guide/components/ChannelItem.js @@ -0,0 +1,16 @@ +import { ChannelBox, ChannelLogo } from "planby"; + +export const ChannelItem = ({ channel }) => { + const { position, logo } = channel; + return ( + + {/* Overwrite styles by add eg. style={{ maxHeight: 52, maxWidth: 52,... }} */} + {/* Or stay with default styles */} + + + ); +}; diff --git a/frontend/src/pages/Guide/components/ProgramItem.js b/frontend/src/pages/Guide/components/ProgramItem.js new file mode 100644 index 00000000..80b4d5c6 --- /dev/null +++ b/frontend/src/pages/Guide/components/ProgramItem.js @@ -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 ( + + + + {isLive && isMinWidth && } + + {title} + + {sinceTime} - {tillTime} + + + + + + ); +}; diff --git a/frontend/src/pages/Guide/components/Timeline.js b/frontend/src/pages/Guide/components/Timeline.js new file mode 100644 index 00000000..3fbcb24b --- /dev/null +++ b/frontend/src/pages/Guide/components/Timeline.js @@ -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) => ( + + + {formatTime(index + offsetStartHoursRange).toLowerCase()} + + {renderDividers()} + + ); + + const renderDividers = () => + dividers.map((_, index) => ( + + )); + + return ( + + {time.map((_, index) => renderTime(index))} + + ); +} diff --git a/frontend/src/pages/Guide/components/index.js b/frontend/src/pages/Guide/components/index.js new file mode 100644 index 00000000..961300fc --- /dev/null +++ b/frontend/src/pages/Guide/components/index.js @@ -0,0 +1,3 @@ +export * from "./ChannelItem"; +export * from "./ProgramItem"; +export * from "./Timeline"; diff --git a/frontend/src/pages/Guide/theme.js b/frontend/src/pages/Guide/theme.js new file mode 100644 index 00000000..1e004563 --- /dev/null +++ b/frontend/src/pages/Guide/theme.js @@ -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" + } + } +}; diff --git a/frontend/src/pages/Guide/useApp.js b/frontend/src/pages/Guide/useApp.js new file mode 100644 index 00000000..cba2fbad --- /dev/null +++ b/frontend/src/pages/Guide/useApp.js @@ -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 }; +} diff --git a/frontend/src/pages/M3U.js b/frontend/src/pages/M3U.js index 296e8448..ea4bde55 100644 --- a/frontend/src/pages/M3U.js +++ b/frontend/src/pages/M3U.js @@ -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 = () => { }} > -
( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - 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 }) => {( - - )}, - meta: { - filterVariant: null, - }, - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => { - return ( - <> - { - editPlaylist(row.original) - }} - > - {/* Small icon size */} - - deletePlaylist(row.original.id)} - > - {/* Small icon size */} - - - ) - } - }, - ]} /> + -
( - - ), - size: 10, - cell: ({ row }) => ( - - ), - }, - { - 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 }) => { - - } - }, - { - id: 'actions', - header: 'Actions', - cell: ({ row }) => { - return ( - <> - { - editUserAgent(row.original) - }} - > - {/* Small icon size */} - - deleteUserAgent(row.original.id)} - > - {/* Small icon size */} - - - ) - } - }, - ]} - /> + { onClose={() => setPlaylistModalOpen(false)} /> - setUserAgentModalOpen(false)} - /> + ) }; diff --git a/frontend/src/pages/StreamProfiles.js b/frontend/src/pages/StreamProfiles.js new file mode 100644 index 00000000..2305f5c6 --- /dev/null +++ b/frontend/src/pages/StreamProfiles.js @@ -0,0 +1,10 @@ +import React, { useState } from 'react'; +import StreamProfilesTable from '../components/tables/StreamProfilesTable'; + +const StreamProfilesPage = () => { + return ( + + ) +}; + +export default StreamProfilesPage; diff --git a/frontend/src/store/channels.js b/frontend/src/store/channels.js index 835b12a3..24a5aa8a 100644 --- a/frontend/src/store/channels.js +++ b/frontend/src/store/channels.js @@ -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)), })), diff --git a/frontend/src/store/epgs.js b/frontend/src/store/epgs.js index 1de3e518..c4c52d64 100644 --- a/frontend/src/store/epgs.js +++ b/frontend/src/store/epgs.js @@ -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)), })), })); diff --git a/frontend/src/store/playlists.js b/frontend/src/store/playlists.js index 3e70e6a1..ae5fde77 100644 --- a/frontend/src/store/playlists.js +++ b/frontend/src/store/playlists.js @@ -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)), })), diff --git a/frontend/src/store/streamProfiles.js b/frontend/src/store/streamProfiles.js new file mode 100644 index 00000000..94576f26 --- /dev/null +++ b/frontend/src/store/streamProfiles.js @@ -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; diff --git a/frontend/src/store/streams.js b/frontend/src/store/streams.js index 91212dd9..c330ef20 100644 --- a/frontend/src/store/streams.js +++ b/frontend/src/store/streams.js @@ -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; diff --git a/frontend/src/utils.js b/frontend/src/utils.js new file mode 100644 index 00000000..938dee83 --- /dev/null +++ b/frontend/src/utils.js @@ -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); + } +}