completely removed mantine-react-table, added empty / dummy output for VOD endpoints

This commit is contained in:
dekzter 2025-05-23 13:40:11 -04:00
parent 0b0373f4ee
commit 9daa764fbb
17 changed files with 1224 additions and 1163 deletions

View file

@ -489,6 +489,11 @@ def xc_player_api(request):
if action == "get_live_streams":
return xc_get_live_streams(request, user, request.GET.get("category_id"))
if action == "get_vod_categories":
return JsonResponse([], safe=False)
if action == "get_vod_streams":
return JsonResponse([], safe=False)
def xc_get(request):
action = request.GET.get("action")

View file

@ -12,13 +12,13 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mantine/charts": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/dates": "^7.17.2",
"@mantine/dropzone": "^7.17.2",
"@mantine/form": "^7.17.3",
"@mantine/hooks": "^7.17.2",
"@mantine/notifications": "^7.17.2",
"@mantine/charts": "~8.0.1",
"@mantine/core": "~8.0.1",
"@mantine/dates": "~8.0.1",
"@mantine/dropzone": "~8.0.1",
"@mantine/form": "~8.0.1",
"@mantine/hooks": "~8.0.1",
"@mantine/notifications": "~8.0.1",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
@ -27,8 +27,7 @@
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.479.0",
"mantine-react-table": "^2.0.0-beta.9",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
@ -753,72 +752,72 @@
"license": "Apache-2.0"
},
"node_modules/@mantine/charts": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-7.17.2.tgz",
"integrity": "sha512-ckB23pIqRjzysUz2EiWZD9AVyf7t0r7o7zfJbl01nzOezFgYq5RGeRoxvpcsfBC+YoSbB/43rjNcXtYhtA7QzA==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.0.1.tgz",
"integrity": "sha512-yntk4siXpQGSj83tDwftJw6fHTOBS6c/VWinjvTW29ptEdjBCxbKFfyyDc9UGVVuO7ovbdtpfCZBpuN2I7HPCA==",
"license": "MIT",
"peerDependencies": {
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x",
"recharts": "^2.13.3"
}
},
"node_modules/@mantine/core": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz",
"integrity": "sha512-R6MYhitJ0JEgrhadd31Nw9FhRaQwDHjXUs5YIlitKH/fTOz9gKSxKjzmNng3bEBQCcbEDOkZj3FRcBgTUh/F0Q==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.0.1.tgz",
"integrity": "sha512-4ezaxKjChSPtawamQ3KrJq+x506uTouXlL0Z5fP+t105KnyxMrAJUENhbh2ivD4pq9Zh1BFiD9IWzyu3IXFR8w==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.28",
"clsx": "^2.1.1",
"react-number-format": "^5.4.3",
"react-remove-scroll": "^2.6.2",
"react-textarea-autosize": "8.5.6",
"react-textarea-autosize": "8.5.9",
"type-fest": "^4.27.0"
},
"peerDependencies": {
"@mantine/hooks": "7.17.2",
"@mantine/hooks": "8.0.1",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dates": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.17.2.tgz",
"integrity": "sha512-7bB992j8f+uEi280jab0/8i5yfsN/3oSrMDFwatZ+7XSDUwiP0YFib/FVX0pNSSqdFpbXhUmsZEECX71QtHw+Q==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz",
"integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"dayjs": ">=1.0.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dropzone": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.17.2.tgz",
"integrity": "sha512-NMQ1SDmnW0sf3GO6p1r/VIcg/xWqlRmfnWCr00/bGRbBEGbyaUwL3LSn+KYBJdY+3/jNGvGa+xflWDvnby5tzw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz",
"integrity": "sha512-8PH5yrtA/ebCIwjs0m4J9qOvEyS/P4XmNlHrw0E389/qq64Ol7+/ZH7Xtiq64IaY8kvsMW1XHaV0c+bdYrijiA==",
"license": "MIT",
"dependencies": {
"react-dropzone-esm": "15.2.0"
"react-dropzone": "14.3.8"
},
"peerDependencies": {
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/form": {
"version": "7.17.3",
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.17.3.tgz",
"integrity": "sha512-ktERldD8f9lrjjz6wIbwMnNbAZq8XEWPx4K5WuFyjXaK0PI8D+gsXIGKMtA5rVrAUFHCWCdbK3yLgtjJNki8ew==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.1.tgz",
"integrity": "sha512-lQ94gn/9p60C+tKEW7psQ1tZHod58Q0bXLbRDadRKMwnqBb2WFoIuaQWPDo7ox+PqyOv28dtflgS+Lm95EbBhg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -829,34 +828,34 @@
}
},
"node_modules/@mantine/hooks": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.2.tgz",
"integrity": "sha512-tbErVcGZu0E4dSmE6N0k6Tv1y9R3SQmmQgwqorcc+guEgKMdamc36lucZGlJnSGUmGj+WLUgELkEQ0asdfYBDA==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.1.tgz",
"integrity": "sha512-GvLdM4Ro3QcDyIgqrdXsUZmeeKye2TNL/k3mEr9JhM5KacHQjr83JPp0u9eLobn7kiyBqpLTYmVYAbmjJdCxHw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@mantine/notifications": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.17.2.tgz",
"integrity": "sha512-vg0L8cmihz0ODg4WJ9MAyK06WPt/6g67ksIUFxd4F8RfdJbIMLTsNG9yWoSfuhtXenUg717KaA917IWLjDSaqw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.0.1.tgz",
"integrity": "sha512-7TX9OyAmUcok3qffnheS7gTAMKDczETy8XEYDr38Sy/XIoXLjM+3CwO+a/vfd1F9oW2LvkahkHT0Ey+vBOVd0Q==",
"license": "MIT",
"dependencies": {
"@mantine/store": "7.17.2",
"@mantine/store": "8.0.1",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"@mantine/core": "8.0.1",
"@mantine/hooks": "8.0.1",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.17.2.tgz",
"integrity": "sha512-UoMUYQK/z58hMueCkpDIXc49gPgrVO/zcpb0k+B7MFU51EIUiFzHLxLFBmWrgCAM6rzJORqN8JjyCd/PB9j4aw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.0.1.tgz",
"integrity": "sha512-3wfUDeiERXJEI+MGgRAbh+9aY35D9oE4UzquLqZh8cIiH5i5g64Y/eJx3PfjHgO5+Zeu6lbgTgL6k4lg4a2SBQ==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
@ -1016,22 +1015,6 @@
"react": ">= 16"
}
},
"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": {
"remove-accents": "0.5.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@ -1052,23 +1035,6 @@
"react-dom": ">=16.8"
}
},
"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/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@ -1082,22 +1048,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"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/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -1392,6 +1342,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
@ -2126,6 +2085,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -2139,6 +2113,18 @@
"node": ">=16.0.0"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@ -2243,6 +2229,21 @@
"react": ">=16.8.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -2682,9 +2683,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.479.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz",
"integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==",
"version": "0.511.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz",
"integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -2701,67 +2702,6 @@
"global": "^4.4.0"
}
},
"node_modules/mantine-react-table": {
"version": "2.0.0-beta.9",
"resolved": "https://registry.npmjs.org/mantine-react-table/-/mantine-react-table-2.0.0-beta.9.tgz",
"integrity": "sha512-ZdfcwebWaPERoDvAuk43VYcBCzamohARVclnbuepT0PHZ0wRcDPMBR+zgaocL+pFy8EXUGwvWTOKNh25ITpjNQ==",
"license": "MIT",
"dependencies": {
"@tanstack/match-sorter-utils": "8.19.4",
"@tanstack/react-table": "8.20.5",
"@tanstack/react-virtual": "3.11.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kevinvandy"
},
"peerDependencies": {
"@mantine/core": "^7.9",
"@mantine/dates": "^7.9",
"@mantine/hooks": "^7.9",
"@tabler/icons-react": ">=2.23.0",
"clsx": ">=2",
"dayjs": ">=1.11",
"react": ">=18.0",
"react-dom": ">=18.0"
}
},
"node_modules/mantine-react-table/node_modules/@tanstack/react-table": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz",
"integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==",
"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/mantine-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/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3023,6 +2963,19 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
@ -3181,12 +3134,14 @@
"node": ">=6"
}
},
"node_modules/react-dropzone-esm": {
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/react-dropzone-esm/-/react-dropzone-esm-15.2.0.tgz",
"integrity": "sha512-pPwR8xWVL+tFLnbAb8KVH5f6Vtl397tck8dINkZ1cPMxHWH+l9dFmIgRWgbh7V7jbjIcuKXCsVrXbhQz68+dVA==",
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
@ -3288,15 +3243,13 @@
}
},
"node_modules/react-router": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
"integrity": "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
@ -3312,12 +3265,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.3.0.tgz",
"integrity": "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz",
"integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==",
"license": "MIT",
"dependencies": {
"react-router": "7.3.0"
"react-router": "7.6.0"
},
"engines": {
"node": ">=20.0.0"
@ -3365,9 +3318,9 @@
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz",
"integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==",
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
@ -3495,12 +3448,6 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"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/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -3691,6 +3638,23 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
@ -3703,12 +3667,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -3780,9 +3738,9 @@
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz",
"integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -3906,15 +3864,18 @@
}
},
"node_modules/vite": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.30.1"
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
@ -4008,21 +3969,6 @@
"node": ">=0.10.0"
}
},
"node_modules/yaml": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -14,13 +14,13 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mantine/charts": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/dates": "^7.17.2",
"@mantine/dropzone": "^7.17.2",
"@mantine/form": "^7.17.3",
"@mantine/hooks": "^7.17.2",
"@mantine/notifications": "^7.17.2",
"@mantine/charts": "~8.0.1",
"@mantine/core": "~8.0.1",
"@mantine/dates": "~8.0.1",
"@mantine/dropzone": "~8.0.1",
"@mantine/form": "~8.0.1",
"@mantine/hooks": "~8.0.1",
"@mantine/notifications": "~8.0.1",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
@ -29,8 +29,7 @@
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.479.0",
"mantine-react-table": "^2.0.0-beta.9",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",

View file

@ -21,7 +21,6 @@ import { WebsocketProvider } from './WebSocket';
import { Box, AppShell, MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css'; // Ensure Mantine global styles load
import '@mantine/notifications/styles.css';
import 'mantine-react-table/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/dates/styles.css';
import './index.css';

View file

@ -18,60 +18,60 @@ import useWarningsStore from '../store/warnings';
* @param {string} [props.size='md'] - Size of the modal
*/
const ConfirmationDialog = ({
opened,
onClose,
onConfirm,
title = 'Confirm Action',
message = 'Are you sure you want to proceed?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
actionKey,
onSuppressChange,
size = 'md', // Add default size parameter - md is a medium width
opened,
onClose,
onConfirm,
title = 'Confirm Action',
message = 'Are you sure you want to proceed?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
actionKey,
onSuppressChange,
size = 'md', // Add default size parameter - md is a medium width
}) => {
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [suppressChecked, setSuppressChecked] = useState(
isWarningSuppressed(actionKey)
);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [suppressChecked, setSuppressChecked] = useState(
isWarningSuppressed(actionKey)
);
const handleToggleSuppress = (e) => {
setSuppressChecked(e.currentTarget.checked);
if (onSuppressChange) {
onSuppressChange(e.currentTarget.checked);
}
};
const handleToggleSuppress = (e) => {
setSuppressChecked(e.currentTarget.checked);
if (onSuppressChange) {
onSuppressChange(e.currentTarget.checked);
}
};
const handleConfirm = () => {
if (suppressChecked) {
suppressWarning(actionKey);
}
onConfirm();
};
const handleConfirm = () => {
if (suppressChecked) {
suppressWarning(actionKey);
}
onConfirm();
};
return (
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
<Box mb={20}>{message}</Box>
return (
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
<Box mb={20}>{message}</Box>
{actionKey && (
<Checkbox
label="Don't ask me again"
checked={suppressChecked}
onChange={handleToggleSuppress}
mb={20}
/>
)}
{actionKey && (
<Checkbox
label="Don't ask me again"
checked={suppressChecked}
onChange={handleToggleSuppress}
mb={20}
/>
)}
<Group justify="flex-end">
<Button variant="outline" onClick={onClose}>
{cancelLabel}
</Button>
<Button color="red" onClick={handleConfirm}>
{confirmLabel}
</Button>
</Group>
</Modal>
);
<Group justify="flex-end">
<Button variant="outline" onClick={onClose}>
{cancelLabel}
</Button>
<Button color="red" onClick={handleConfirm}>
{confirmLabel}
</Button>
</Group>
</Modal>
);
};
export default ConfirmationDialog;

View file

@ -5,7 +5,6 @@ import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import useStreamsStore from '../../store/streams';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
@ -224,134 +223,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
);
};
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// {
// header: 'M3U',
// accessorKey: 'group_name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// ],
// []
// ),
// enableSorting: false,
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// enableRowOrdering: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// positionActionsColumn: 'last',
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="error" // Red color for delete actions
// onClick={() => removeStream(row.original)}
// >
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// mantineRowDragHandleProps: ({ table }) => ({
// onDragEnd: () => {
// const { draggingRow, hoveredRow } = table.getState();
// if (hoveredRow && draggingRow) {
// channelStreams.splice(
// hoveredRow.index,
// 0,
// channelStreams.splice(draggingRow.index, 1)[0]
// );
// setChannelStreams([...channelStreams]);
// }
// },
// }),
// });
// const availableStreamsTable = useMantineReactTable({
// data: streams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// },
// {
// header: 'M3U',
// accessorFn: (row) =>
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
// },
// ],
// []
// ),
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="success" // Red color for delete actions
// onClick={() => addStream(row.original)}
// >
// <AddIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// positionActionsColumn: 'last',
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// });
// Update the handler for when channel group modal is closed
const handleChannelGroupModalClose = (newGroup) => {
setChannelGroupModalOpen(false);
@ -839,18 +710,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
</Stack>
</Group>
{/* <Grid gap={2}>
<Grid.Col span={6}>
<Typography>Active Streams</Typography>
<MantineReactTable table={activeStreamsTable} />
</Grid.Col>
<Grid.Col span={6}>
<Typography>Available Streams</Typography>
<MantineReactTable table={availableStreamsTable} />
</Grid.Col>
</Grid> */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"

View file

@ -220,134 +220,6 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
);
};
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// {
// header: 'M3U',
// accessorKey: 'group_name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// ],
// []
// ),
// enableSorting: false,
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// enableRowOrdering: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// positionActionsColumn: 'last',
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="error" // Red color for delete actions
// onClick={() => removeStream(row.original)}
// >
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// mantineRowDragHandleProps: ({ table }) => ({
// onDragEnd: () => {
// const { draggingRow, hoveredRow } = table.getState();
// if (hoveredRow && draggingRow) {
// channelStreams.splice(
// hoveredRow.index,
// 0,
// channelStreams.splice(draggingRow.index, 1)[0]
// );
// setChannelStreams([...channelStreams]);
// }
// },
// }),
// });
// const availableStreamsTable = useMantineReactTable({
// data: streams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// },
// {
// header: 'M3U',
// accessorFn: (row) =>
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
// },
// ],
// []
// ),
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="success" // Red color for delete actions
// onClick={() => addStream(row.original)}
// >
// <AddIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// positionActionsColumn: 'last',
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// });
// Update the handler for when channel group modal is closed
const handleChannelGroupModalClose = (newGroup) => {
setChannelGroupModalOpen(false);

View file

@ -23,7 +23,7 @@ import {
Center,
ActionIcon,
} from '@mantine/core';
import { RotateCcw, X } from 'lucide-react';
import { RotateCcwKey, X } from 'lucide-react';
import { isNotEmpty, useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
@ -32,6 +32,7 @@ import useAuthStore from '../../store/auth';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [enableXC, setEnableXC] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
@ -108,7 +109,11 @@ const User = ({ user = null, isOpen, onClose }) => {
delete values.password;
}
await API.updateUser(user.id, values);
const response = await API.updateUser(user.id, values);
if (user.id == authUser.id) {
setUser(response);
}
}
form.reset();
@ -123,7 +128,10 @@ const User = ({ user = null, isOpen, onClose }) => {
username: user.username,
email: user.email,
user_level: `${user.user_level}`,
channel_profiles: user.channel_profiles.map((id) => `${id}`),
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
});
@ -197,7 +205,6 @@ const User = ({ user = null, isOpen, onClose }) => {
label="XC Password"
description="Auto-generated - clear to disable XC API"
{...form.getInputProps('xc_password')}
onChange={onChannelProfilesChange}
key={form.key('xc_password')}
style={{ flex: 1 }}
rightSectionWidth={30}
@ -208,7 +215,7 @@ const User = ({ user = null, isOpen, onClose }) => {
color="white"
onClick={generateXCPassword}
>
<RotateCcw />
<RotateCcwKey />
</ActionIcon>
}
/>

View file

@ -27,6 +27,7 @@ const CustomTable = ({ table }) => {
table.onSelectAllChange ? table.onSelectAllChange : null
}
selectedTableIds={table.selectedTableIds}
tableCellProps={table.tableCellProps}
/>
<CustomTableBody
getRowModel={table.getRowModel}
@ -36,6 +37,8 @@ const CustomTable = ({ table }) => {
renderBodyCell={table.renderBodyCell}
getExpandedRowHeight={table.getExpandedRowHeight}
getRowStyles={table.getRowStyles} // Pass the getRowStyles function
tableBodyProps={table.tableBodyProps}
tableCellProps={table.tableCellProps}
/>
</Box>
);

View file

@ -1,6 +1,7 @@
import { Box, Flex } from '@mantine/core';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import table from '../../../helpers/table';
const CustomTableBody = ({
getRowModel,
@ -9,6 +10,8 @@ const CustomTableBody = ({
renderBodyCell,
getExpandedRowHeight,
getRowStyles, // Add this prop to receive row styles
tableBodyProps,
tableCellProps,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
@ -25,7 +28,10 @@ const CustomTableBody = ({
if (virtualized) {
return (
<Box className="tbody" style={{ flex: 1 }}>
<Box
className="tbody"
style={{ flex: 1, ...(tableBodyProps && tableBodyProps()) }}
>
<AutoSizer disableWidth>
{({ height }) => {
const getItemSize = (index) => {
@ -105,6 +111,7 @@ const CustomTableBody = ({
? cell.column.getSize()
: undefined,
minWidth: 0,
...(tableCellProps && tableCellProps({ cell })),
}}
>
<Flex align="center" style={{ height: '100%' }}>

View file

@ -8,6 +8,7 @@ const CustomTableHeader = ({
selectedTableIds,
headerCellRenderFns,
onSelectAllChange,
tableCellProps,
}) => {
const renderHeaderCell = (header) => {
if (headerCellRenderFns[header.id]) {
@ -66,6 +67,7 @@ const CustomTableHeader = ({
? header.getSize()
: undefined,
minWidth: 0,
// ...(tableCellProps && tableCellProps({ cell: header })),
}}
>
<Flex

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
@ -17,15 +16,24 @@ import {
Badge,
Progress,
Stack,
Group,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSquarePlus } from '@tabler/icons-react';
import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
RefreshCcw,
SquareMinus,
SquarePen,
} from 'lucide-react';
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
// Helper function to format status text
const formatStatusText = (status) => {
@ -36,15 +44,59 @@ const formatStatusText = (status) => {
// Helper function to get status text color
const getStatusColor = (status) => {
switch (status) {
case 'idle': return 'gray.5';
case 'fetching': return 'blue.5';
case 'parsing': return 'indigo.5';
case 'error': return 'red.5';
case 'success': return 'green.5';
default: return 'gray.5';
case 'idle':
return 'gray.5';
case 'fetching':
return 'blue.5';
case 'parsing':
return 'indigo.5';
case 'error':
return 'red.5';
case 'success':
return 'green.5';
default:
return 'gray.5';
}
};
const RowActions = ({ tableSize, row, editEPG, deleteEPG, refreshEPG }) => {
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="yellow.5" // Red color for delete actions
onClick={() => editEPG(row.original)}
>
<SquarePen size={tableSize === 'compact' ? 16 : 18} />{' '}
{/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="red.9" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />{' '}
{/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="blue.5" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />{' '}
{/* Small icon size */}
</ActionIcon>
</>
);
};
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
@ -52,6 +104,7 @@ const EPGsTable = () => {
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [epgToDelete, setEpgToDelete] = useState(null);
const [data, setData] = useState([]);
const epgs = useEPGsStore((s) => s.epgs);
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
@ -61,10 +114,12 @@ const EPGsTable = () => {
const [tableSize] = useLocalStorage('table-size', 'default');
// Get proper size for action icons to match ChannelsTable
const iconSize = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm';
const iconSize =
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm';
// Calculate density for Mantine Table
const tableDensity = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
const tableDensity =
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -72,10 +127,13 @@ const EPGsTable = () => {
const toggleActive = async (epg) => {
try {
// Send only the is_active field to trigger our special handling
await API.updateEPG({
id: epg.id,
is_active: !epg.is_active,
}, true); // Add a new parameter to indicate this is just a toggle
await API.updateEPG(
{
id: epg.id,
is_active: !epg.is_active,
},
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
console.error('Error toggling active state:', error);
}
@ -103,36 +161,46 @@ const EPGsTable = () => {
return (
<Stack spacing={2}>
<Text size="xs">{label}: {parseInt(progress.progress)}%</Text>
<Progress value={parseInt(progress.progress)} size="xs" style={{ margin: '2px 0' }} />
{progress.speed && <Text size="xs">Speed: {parseInt(progress.speed)} KB/s</Text>}
<Text size="xs">
{label}: {parseInt(progress.progress)}%
</Text>
<Progress
value={parseInt(progress.progress)}
size="xs"
style={{ margin: '2px 0' }}
/>
{progress.speed && (
<Text size="xs">Speed: {parseInt(progress.speed)} KB/s</Text>
)}
</Stack>
);
};
console.log(epgs);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
size: 150,
minSize: 100,
size: 200,
},
{
header: 'Source Type',
accessorKey: 'source_type',
size: 120,
minSize: 100,
size: 150,
},
{
header: 'URL / API Key / File Path',
accessorKey: 'url',
size: 200,
minSize: 120,
enableSorting: false,
Cell: ({ cell, row }) => {
const value = cell.getValue() || row.original.api_key || row.original.file_path || '';
cell: ({ cell, row }) => {
const value =
cell.getValue() ||
row.original.api_key ||
row.original.file_path ||
'';
return (
<Tooltip label={value} disabled={!value}>
<div
@ -152,18 +220,13 @@ const EPGsTable = () => {
{
header: 'Status',
accessorKey: 'status',
size: 100,
minSize: 80,
Cell: ({ row }) => {
size: 150,
cell: ({ row }) => {
const data = row.original;
// Always show status text, even when there's progress happening
return (
<Text
size="sm"
fw={500}
c={getStatusColor(data.status)}
>
<Text size="sm" fw={500} c={getStatusColor(data.status)}>
{formatStatusText(data.status)}
</Text>
);
@ -172,14 +235,15 @@ const EPGsTable = () => {
{
header: 'Status Message',
accessorKey: 'last_message',
size: 250,
minSize: 150,
enableSorting: false,
Cell: ({ row }) => {
cell: ({ row }) => {
const data = row.original;
// Check if there's an active progress for this EPG - show progress first if active
if (refreshProgress[data.id] && refreshProgress[data.id].progress < 100) {
if (
refreshProgress[data.id] &&
refreshProgress[data.id].progress < 100
) {
return buildProgressDisplay(data);
}
@ -187,7 +251,12 @@ const EPGsTable = () => {
if (data.status === 'error' && data.last_message) {
return (
<Tooltip label={data.last_message} multiline width={300}>
<Text c="dimmed" size="xs" lineClamp={2} style={{ color: theme.colors.red[6], lineHeight: 1.3 }}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
>
{data.last_message}
</Text>
</Tooltip>
@ -197,7 +266,11 @@ const EPGsTable = () => {
// Show success message for successful sources
if (data.status === 'success') {
return (
<Text c="dimmed" size="xs" style={{ color: theme.colors.green[6], lineHeight: 1.3 }}>
<Text
c="dimmed"
size="xs"
style={{ color: theme.colors.green[6], lineHeight: 1.3 }}
>
EPG data refreshed successfully
</Text>
);
@ -210,24 +283,26 @@ const EPGsTable = () => {
{
header: 'Updated',
accessorKey: 'updated_at',
size: 180,
minSize: 100,
size: 150,
enableSorting: false,
Cell: ({ cell }) => {
cell: ({ cell }) => {
const value = cell.getValue();
return value ? dayjs(value).format('MMMM D, YYYY h:mma') : 'Never';
return value ? (
<Text size="xs">{new Date(value).toLocaleString()}</Text>
) : (
<Text size="xs">Never</Text>
);
},
},
{
header: 'Active',
accessorKey: 'is_active',
size: 80,
minSize: 60,
size: 50,
sortingFn: 'basic',
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ row, cell }) => (
cell: ({ row, cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Switch
size="xs"
@ -237,13 +312,15 @@ const EPGsTable = () => {
</Box>
),
},
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshProgress]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
@ -286,118 +363,133 @@ const EPGsTable = () => {
};
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 = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
// Sort data before passing to table: active first, then by name
data: Object.values(epgs)
.sort((a, b) => {
setData(
Object.values(epgs).sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
})
);
}, [epgs]);
const renderBodyCell = ({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
tableSize={tableSize}
row={row}
editEPG={editEPG}
deleteEPG={deleteEPG}
refreshEPG={refreshEPG}
/>
);
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
switch (header.id) {
default:
return (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
}
};
const onSortingChange = (column) => {
console.log(column);
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
const newSorting = [];
if (sortField == column) {
if (sortDirection == false) {
newSorting[0] = {
id: column,
desc: true,
};
}
} else {
newSorting[0] = {
id: column,
desc: false,
};
}
setSorting(newSorting);
if (newSorting.length > 0) {
const compareColumn = newSorting[0].id;
const compareDesc = newSorting[0].desc;
setData(
epgs.sort((a, b) => {
console.log(a);
console.log(newSorting[0].id);
if (a[compareColumn] !== b[compareColumn]) {
return compareDesc ? 1 : -1;
}
return 0;
})
);
}
};
const table = useTable({
columns,
data,
allRowIds: data.map((epg) => epg.id),
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
density: tableDensity,
manualSorting: true,
bodyCellRenderFns: {
actions: renderBodyCell,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: tableDensity,
},
enableRowActions: true,
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
size: 120, // Make action column wider
minSize: 120, // Ensure minimum width for action buttons
},
},
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="yellow.5" // Red color for delete actions
onClick={() => editEPG(row.original)}
>
<SquarePen size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="red.9" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize} // Use standardized icon size
color="blue.5" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 10px)',
overflowX: 'auto', // Ensure horizontal scrolling works
},
},
mantineTableProps: {
...TableHelper.defaultProperties.mantineTableProps,
className: `table-size-${tableSize}`,
headerCellRenderFns: {
name: renderHeaderCell,
source_type: renderHeaderCell,
url: renderHeaderCell,
status: renderHeaderCell,
last_message: renderHeaderCell,
updated_at: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},
// Add custom cell styles to match CustomTable's sizing
mantineTableBodyCellProps: ({ cell }) => {
// Check if this is a status message cell with active progress
const progressData = cell.column.id === 'last_message' &&
refreshProgress[cell.row.original.id] &&
refreshProgress[cell.row.original.id].progress < 100 ?
refreshProgress[cell.row.original.id] : null;
// Only expand height for certain actions that need more space
const needsExpandedHeight = progressData &&
['downloading', 'parsing_channels', 'parsing_programs'].includes(progressData.action);
tableCellProps: ({ cell }) => {
return {
style: {
// Apply taller height for progress cells (except initializing), otherwise use standard height
height: needsExpandedHeight ? '80px' : (
tableSize === 'compact' ? '28px' : tableSize === 'large' ? '48px' : '40px'
),
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px'
}
// Apply taller height for progress cells (except initializing), otherwise use standard height
fontSize:
tableSize === 'compact'
? 'var(--mantine-font-size-xs)'
: 'var(--mantine-font-size-sm)',
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px',
};
},
});
@ -467,7 +559,26 @@ const EPGsTable = () => {
</Box>
</Paper>
<MantineReactTable table={table} />
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(40vh - 10px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
</Box>
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
<ConfirmationDialog
@ -482,9 +593,15 @@ const EPGsTable = () => {
Name: ${epgToDelete.name}
Source Type: ${epgToDelete.source_type}
${epgToDelete.url ? `URL: ${epgToDelete.url}` :
epgToDelete.api_key ? `API Key: ${epgToDelete.api_key}` :
epgToDelete.file_path ? `File Path: ${epgToDelete.file_path}` : ''}
${
epgToDelete.url
? `URL: ${epgToDelete.url}`
: epgToDelete.api_key
? `API Key: ${epgToDelete.api_key}`
: epgToDelete.file_path
? `File Path: ${epgToDelete.file_path}`
: ''
}
This will remove all related program information and channel associations.
This action cannot be undone.`}

View file

@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import React, {
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from 'react';
import API from '../../api';
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
@ -18,41 +23,111 @@ import {
Stack,
Badge,
Group,
Center,
} from '@mantine/core';
import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react';
import {
SquareMinus,
SquarePen,
RefreshCcw,
Check,
X,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
} from 'lucide-react';
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
// Helper function to format status text
const formatStatusText = (status) => {
switch (status) {
case 'idle': return 'Idle';
case 'fetching': return 'Fetching';
case 'parsing': return 'Parsing';
case 'error': return 'Error';
case 'success': return 'Success';
case 'pending_setup': return 'Pending Setup';
default: return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown';
case 'idle':
return 'Idle';
case 'fetching':
return 'Fetching';
case 'parsing':
return 'Parsing';
case 'error':
return 'Error';
case 'success':
return 'Success';
case 'pending_setup':
return 'Pending Setup';
default:
return status
? status.charAt(0).toUpperCase() + status.slice(1)
: 'Unknown';
}
};
// Helper function to get status text color
const getStatusColor = (status) => {
switch (status) {
case 'idle': return 'gray.5';
case 'fetching': return 'blue.5';
case 'parsing': return 'indigo.5';
case 'error': return 'red.5';
case 'success': return 'green.5';
case 'pending_setup': return 'orange.5'; // Orange to indicate action needed
default: return 'gray.5';
case 'idle':
return 'gray.5';
case 'fetching':
return 'blue.5';
case 'parsing':
return 'indigo.5';
case 'error':
return 'red.5';
case 'success':
return 'green.5';
case 'pending_setup':
return 'orange.5'; // Orange to indicate action needed
default:
return 'gray.5';
}
};
const RowActions = ({
tableSize,
editPlaylist,
deletePlaylist,
row,
refreshPlaylist,
}) => {
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<>
<ActionIcon
variant="transparent"
size={iconSize}
color="yellow.5"
onClick={() => {
editPlaylist(row.original);
}}
>
<SquarePen size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize}
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={iconSize}
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
</>
);
};
const M3UTable = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
@ -63,6 +138,8 @@ const M3UTable = () => {
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [playlistToDelete, setPlaylistToDelete] = useState(null);
const [data, setData] = useState([]);
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
const playlists = usePlaylistsStore((s) => s.playlists);
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
@ -94,7 +171,9 @@ const M3UTable = () => {
return buildParsingStats(data);
default:
return data.status === 'error' ? buildErrorStats(data) : `${data.action || 'Processing'}...`;
return data.status === 'error'
? buildErrorStats(data)
: `${data.action || 'Processing'}...`;
}
};
@ -108,28 +187,35 @@ const M3UTable = () => {
}
// Format time remaining in minutes:seconds
const timeRemaining = data.time_remaining ?
`${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` :
'calculating...';
const timeRemaining = data.time_remaining
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
: 'calculating...';
// Format speed with appropriate unit (KB/s or MB/s)
const speed = data.speed >= 1024 ?
`${(data.speed / 1024).toFixed(2)} MB/s` :
`${Math.round(data.speed)} KB/s`;
const speed =
data.speed >= 1024
? `${(data.speed / 1024).toFixed(2)} MB/s`
: `${Math.round(data.speed)} KB/s`;
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Downloading:</Text>
<Text size="xs" fw={500}>
Downloading:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Speed:</Text>
<Text size="xs" fw={500}>
Speed:
</Text>
<Text size="xs">{speed}</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Time left:</Text>
<Text size="xs" fw={500}>
Time left:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
</Flex>
@ -147,26 +233,32 @@ const M3UTable = () => {
}
// Format time displays if available
const elapsedTime = data.elapsed_time ?
`${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` :
null;
const elapsedTime = data.elapsed_time
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
: null;
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Processing groups:</Text>
<Text size="xs" fw={500}>
Processing groups:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{elapsedTime && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Elapsed:</Text>
<Text size="xs" fw={500}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.groups_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Groups:</Text>
<Text size="xs" fw={500}>
Groups:
</Text>
<Text size="xs">{data.groups_processed}</Text>
</Flex>
)}
@ -180,9 +272,13 @@ const M3UTable = () => {
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500} color="red">Error:</Text>
<Text size="xs" fw={500} color="red">
Error:
</Text>
</Flex>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>{data.error || "Unknown error occurred"}</Text>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
{data.error || 'Unknown error occurred'}
</Text>
</Flex>
</Box>
);
@ -198,36 +294,44 @@ const M3UTable = () => {
}
// Format time displays
const timeRemaining = data.time_remaining ?
`${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` :
'calculating...';
const timeRemaining = data.time_remaining
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
: 'calculating...';
const elapsedTime = data.elapsed_time ?
`${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` :
'0:00';
const elapsedTime = data.elapsed_time
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
: '0:00';
return (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Parsing:</Text>
<Text size="xs" fw={500}>
Parsing:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{data.elapsed_time && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Elapsed:</Text>
<Text size="xs" fw={500}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.time_remaining && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Remaining:</Text>
<Text size="xs" fw={500}>
Remaining:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
)}
{data.streams_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>Streams:</Text>
<Text size="xs" fw={500}>
Streams:
</Text>
<Text size="xs">{data.streams_processed}</Text>
</Flex>
)}
@ -241,7 +345,9 @@ const M3UTable = () => {
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500}>Initializing refresh...</Text>
<Text size="xs" fw={500}>
Initializing refresh...
</Text>
</Flex>
</Flex>
</Box>
@ -261,7 +367,7 @@ const M3UTable = () => {
action: 'initializing',
progress: 0,
account: id,
type: 'm3u_refresh'
type: 'm3u_refresh',
});
try {
@ -275,14 +381,14 @@ const M3UTable = () => {
account: id,
type: 'm3u_refresh',
error: 'Failed to start refresh task',
status: 'error'
status: 'error',
});
}
};
const deletePlaylist = async (id) => {
// Get playlist details for the confirmation dialog
const playlist = playlists.find(p => p.id === id);
const playlist = playlists.find((p) => p.id === id);
setPlaylistToDelete(playlist);
setDeleteTarget(id);
@ -304,10 +410,13 @@ const M3UTable = () => {
const toggleActive = async (playlist) => {
try {
// Send only the is_active field to trigger our special handling
await API.updatePlaylist({
id: playlist.id,
is_active: !playlist.is_active,
}, true); // Add a new parameter to indicate this is just a toggle
await API.updatePlaylist(
{
id: playlist.id,
is_active: !playlist.is_active,
},
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
console.error('Error toggling active state:', error);
}
@ -318,14 +427,15 @@ const M3UTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 150,
minSize: 100, // Minimum width
size: 200,
sortable: true,
},
{
header: 'Account Type',
accessorKey: 'account_type',
size: 100,
Cell: ({ cell }) => {
sortable: true,
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
return value === 'XC' ? 'XC' : 'M3U';
},
@ -333,9 +443,7 @@ const M3UTable = () => {
{
header: 'URL / File',
accessorKey: 'server_url',
size: 200,
minSize: 120,
Cell: ({ cell, row }) => {
cell: ({ cell, row }) => {
const value = cell.getValue() || row.original.file_path || '';
return (
<Tooltip label={value} disabled={!value}>
@ -356,13 +464,14 @@ const M3UTable = () => {
{
header: 'Max Streams',
accessorKey: 'max_streams',
size: 100,
sortable: true,
size: 150,
},
{
header: 'Status',
accessorKey: 'status',
size: 100,
Cell: ({ cell }) => {
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
if (!value) return null;
@ -377,10 +486,7 @@ const M3UTable = () => {
{
header: 'Status Message',
accessorKey: 'last_message',
size: 250, // Increase default size
minSize: 200, // Set minimum size
maxSize: 400, // Allow expansion up to this size
Cell: ({ cell, row }) => {
cell: ({ cell, row }) => {
const value = cell.getValue();
const data = row.original;
@ -391,16 +497,18 @@ const M3UTable = () => {
// If we have active progress data for this account, show that instead
if (progressData && progressData.progress < 100) {
return (
<Box style={{
// Use full height of the cell with proper spacing
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
// Add some padding to give content room to breathe
padding: '4px 0'
}}>
<Box
style={{
// Use full height of the cell with proper spacing
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
// Add some padding to give content room to breathe
padding: '4px 0',
}}
>
{generateStatusString(progressData)}
</Box>
);
@ -413,7 +521,12 @@ const M3UTable = () => {
if (data.status === 'error') {
return (
<Tooltip label={value} multiline width={300}>
<Text c="dimmed" size="xs" lineClamp={2} style={{ color: theme.colors.red[6], lineHeight: 1.3 }}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
>
{value}
</Text>
</Tooltip>
@ -424,7 +537,17 @@ const M3UTable = () => {
if (data.status === 'success') {
return (
<Tooltip label={value} multiline width={300}>
<Text c="dimmed" size="xs" style={{ color: theme.colors.green[6], lineHeight: 1.3 }}>
<Text
c="dimmed"
size="xs"
style={{
color: theme.colors.green[6],
lineHeight: 1.3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{value}
</Text>
</Tooltip>
@ -434,7 +557,12 @@ const M3UTable = () => {
// For all other status values, just use dimmed text
return (
<Tooltip label={value} multiline width={300}>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.3 }}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ lineHeight: 1.3 }}
>
{value}
</Text>
</Tooltip>
@ -444,22 +572,21 @@ const M3UTable = () => {
{
header: 'Updated',
accessorKey: 'updated_at',
size: 120,
Cell: ({ cell }) => {
size: 150,
cell: ({ cell }) => {
const value = cell.getValue();
return value ? <Text size="xs">{new Date(value).toLocaleString()}</Text> : <Text size="xs">Never</Text>;
return value ? (
<Text size="xs">{new Date(value).toLocaleString()}</Text>
) : (
<Text size="xs">Never</Text>
);
},
},
{
header: 'Active',
accessorKey: 'is_active',
size: 80,
minSize: 60,
sortingFn: 'basic',
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell, row }) => {
size: 50,
cell: ({ cell, row }) => {
return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Switch
@ -471,7 +598,11 @@ const M3UTable = () => {
);
},
},
// Remove the custom Actions column here
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
);
@ -480,7 +611,6 @@ const M3UTable = () => {
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeModal = (newPlaylist = null) => {
if (newPlaylist) {
@ -493,19 +623,6 @@ const M3UTable = () => {
}
};
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 {
@ -517,8 +634,21 @@ const M3UTable = () => {
// Listen for edit playlist requests from notifications
useEffect(() => {
setData(
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
})
);
if (editPlaylistId) {
const playlistToEdit = playlists.find(p => p.id === editPlaylistId);
const playlistToEdit = playlists.find((p) => p.id === editPlaylistId);
if (playlistToEdit) {
editPlaylist(playlistToEdit);
// Reset the ID after handling
@ -527,80 +657,120 @@ const M3UTable = () => {
}
}, [editPlaylistId, playlists]);
const tableDensity = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
const onSortingChange = (column) => {
console.log(column);
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
const table = useMantineReactTable({
...TableHelper.defaultProperties,
const newSorting = [];
if (sortField == column) {
if (sortDirection == false) {
newSorting[0] = {
id: column,
desc: true,
};
}
} else {
newSorting[0] = {
id: column,
desc: false,
};
}
setSorting(newSorting);
if (newSorting.length > 0) {
const compareColumn = newSorting[0].id;
const compareDesc = newSorting[0].desc;
setData(
playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
console.log(a);
console.log(newSorting[0].id);
if (a[compareColumn] !== b[compareColumn]) {
return compareDesc ? 1 : -1;
}
return 0;
})
);
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
switch (header.id) {
default:
return (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
}
};
const renderBodyCell = useCallback(({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
tableSize={tableSize}
editPlaylist={editPlaylist}
deletePlaylist={deletePlaylist}
row={row}
refreshPlaylist={refreshPlaylist}
/>
);
}
}, []);
const table = useTable({
columns,
// Sort data before passing to table: active first, then by name
data: playlists
.filter((playlist) => playlist.locked === false)
.sort((a, b) => {
// First sort by active status (active items first)
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1;
}
// Then sort by name (case-insensitive)
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
data,
allRowIds: data.map((playlist) => playlist.id),
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
onRowSelectionChange: setRowSelection,
renderTopToolbar: false,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
// Use density directly from tableSize
density: tableDensity,
},
sorting,
manualSorting: true,
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
// Use density directly from tableSize
density: tableDensity,
bodyCellRenderFns: {
actions: renderBodyCell,
},
enableRowActions: true, // Enable row actions
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
size: 120, // Make action column wider
minSize: 120, // Ensure minimum width for action buttons
},
headerCellRenderFns: {
name: renderHeaderCell,
account_type: renderHeaderCell,
server_url: renderHeaderCell,
max_streams: renderHeaderCell,
status: renderHeaderCell,
last_message: renderHeaderCell,
updated_at: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
color="yellow.5"
onClick={() => {
editPlaylist(row.original);
}}
>
<SquarePen size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
<ActionIcon
variant="transparent"
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 10px)',
@ -612,34 +782,22 @@ const M3UTable = () => {
className: `table-size-${tableSize}`,
},
// Add custom cell styles to match CustomTable's sizing
mantineTableBodyCellProps: ({ cell }) => {
// Check if this is a status message cell with active progress
const progressData = cell.column.id === 'last_message' &&
refreshProgress[cell.row.original.id] &&
refreshProgress[cell.row.original.id].progress < 100 ?
refreshProgress[cell.row.original.id] : null;
// Only expand height for certain actions that need more space
const needsExpandedHeight = progressData &&
['downloading', 'parsing', 'processing_groups'].includes(progressData.action);
tableCellProps: ({ cell }) => {
return {
style: {
// Apply taller height for progress cells (except initializing), otherwise use standard height
height: needsExpandedHeight ? '80px' : (
tableSize === 'compact' ? '28px' : tableSize === 'large' ? '48px' : '40px'
),
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px'
}
fontSize:
tableSize === 'compact'
? 'var(--mantine-font-size-xs)'
: 'var(--mantine-font-size-sm)',
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px',
};
},
// Additional text styling to match ChannelsTable
mantineTableBodyProps: {
style: {
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
}
},
tableBodyProps: () => ({
fontSize:
tableSize === 'compact'
? 'var(--mantine-font-size-xs)'
: 'var(--mantine-font-size-sm)',
}),
});
return (
@ -702,7 +860,26 @@ const M3UTable = () => {
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(40vh - 10px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
</Box>
<M3UForm
m3uAccount={playlist}

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import StreamProfileForm from '../forms/StreamProfile';
import useStreamProfilesStore from '../../store/streamProfiles';
@ -21,57 +20,101 @@ import {
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => {
return (
<>
<ActionIcon
variant="transparent"
color="yellow.5"
size="sm"
disabled={row.original.locked}
onClick={() => editStreamProfile(row.original)}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
disabled={row.original.locked}
onClick={() => deleteStreamProfile(row.original.id)}
>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</>
);
};
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [hideInactive, setHideInactive] = useState(false);
const [data, setData] = useState([]);
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const settings = useSettingsStore((s) => s.settings);
const [tableSize] = useLocalStorage('table-size', 'default');
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
size: 50,
size: 150,
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Command',
accessorKey: 'command',
size: 100,
size: 150,
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Parameters',
accessorKey: 'parameters',
enableSorting: false,
mantineTableBodyCellProps: {
style: {
whiteSpace: 'nowrap',
// maxWidth: 400,
paddingLeft: 10,
paddingRight: 10,
},
},
// size: 200,
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Active',
accessorKey: 'is_active',
size: 10,
enableSorting: false,
mantineTableHeadCellProps: {
align: 'right',
},
mantineTableBodyCellProps: {
align: 'right',
},
Cell: ({ row, cell }) => (
size: 50,
cell: ({ row, cell }) => (
<Center>
<Switch
size="xs"
@ -81,24 +124,11 @@ const StreamProfiles = () => {
/>
</Center>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
data={['All', 'Active', 'Inactive']}
/>
</Box>
),
filterFn: (row, _columnId, filterValue) => {
if (filterValue == 'all') return true;
return String(row.getValue('is_active')) === filterValue;
},
},
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[]
@ -159,63 +189,48 @@ const StreamProfiles = () => {
});
};
const filteredData = streamProfiles.filter((profile) =>
hideInactive && !profile.is_active ? false : true
);
useEffect(() => {
setData(
streamProfiles.filter((profile) =>
hideInactive && !profile.is_active ? false : true
)
);
}, [streamProfiles, hideInactive]);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const renderBodyCell = ({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
row={row}
editUserAgent={editStreamProfile}
deleteUserAgent={deleteStreamProfile}
/>
);
}
};
const table = useTable({
columns,
data: filteredData,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
// rowSelection,
data,
allRowIds: data.map((d) => d.id),
bodyCellRenderFns: {
actions: renderBodyCell,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
color="yellow.5"
size="sm"
disabled={row.original.locked}
onClick={() => editStreamProfile(row.original)}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
disabled={row.original.locked}
onClick={() => deleteStreamProfile(row.original.id)}
>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
// height: 'calc(60vh - 100px)',
overflowY: 'auto',
},
headerCellRenderFns: {
name: renderHeaderCell,
command: renderHeaderCell,
parameters: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},
});
@ -275,7 +290,25 @@ const StreamProfiles = () => {
</Box>
</Paper>
<MantineReactTable table={table} />
<Box
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 300,
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
</Box>
<StreamProfileForm
profile={profile}

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
@ -20,15 +19,42 @@ import {
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X } from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
return (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
</>
);
};
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 settings = useSettingsStore((s) => s.settings);
const [tableSize] = useLocalStorage('table-size', 'default');
const columns = useMemo(
//column definitions...
@ -36,13 +62,12 @@ const UserAgentsTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 100,
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
enableSorting: false,
Cell: ({ cell }) => (
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
@ -58,7 +83,7 @@ const UserAgentsTable = () => {
header: 'Description',
accessorKey: 'description',
enableSorting: false,
Cell: ({ cell }) => (
cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
@ -73,57 +98,23 @@ const UserAgentsTable = () => {
{
header: 'Active',
accessorKey: 'is_active',
size: 10,
sortingFn: 'basic',
enableSorting: false,
mantineTableHeadCellProps: {
align: 'right',
},
mantineTableBodyCellProps: {
align: 'right',
},
Cell: ({ cell }) => (
cell: ({ cell }) => (
<Center>
{cell.getValue() ? <Check color="green" /> : <X color="red" />}
</Center>
),
Filter: ({ column }) => (
<Select
size="small"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
data={[
{
value: 'all',
label: 'All',
},
{
value: 'active',
label: 'Active',
},
{
value: 'inactive',
label: 'Inactive',
},
]}
/>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
@ -167,95 +158,54 @@ const UserAgentsTable = () => {
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
const renderHeaderCell = (header) => {
switch (header.id) {
default:
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
}
}, [sorting]);
};
const table = useMantineReactTable({
...TableHelper.defaultProperties,
const renderBodyCell = ({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<RowActions
row={row}
editUserAgent={editUserAgent}
deleteUserAgent={deleteUserAgent}
/>
);
}
};
const table = useTable({
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
// rowSelection,
allRowIds: userAgents.map((ua) => ua.id),
bodyCellRenderFns: {
actions: renderBodyCell,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
maxHeight: 300,
overflowY: 'auto',
// margin: 5,
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
headerCellRenderFns: {
name: renderHeaderCell,
user_agent: renderHeaderCell,
description: renderHeaderCell,
is_active: renderHeaderCell,
actions: renderHeaderCell,
},
});
return (
<Stack gap={0} style={{ padding: 0 }}>
<Paper
style={
{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}
}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Paper>
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
@ -280,7 +230,26 @@ const UserAgentsTable = () => {
</Box>
</Paper>
<MantineReactTable table={table} />
<Box
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 300,
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
</Box>
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}

View file

@ -15,7 +15,6 @@ import {
useMantineTheme,
Select,
} from '@mantine/core';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import { TableHelper } from '../helpers';
import API from '../api';
import useChannelsStore from '../store/channels';
@ -37,6 +36,7 @@ import useStreamProfilesStore from '../store/streamProfiles';
import usePlaylistsStore from '../store/playlists'; // Add this import
import { useLocation } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import { CustomTable, useTable } from '../components/tables/CustomTable';
dayjs.extend(duration);
dayjs.extend(relativeTime);
@ -78,12 +78,19 @@ const getStartDate = (uptime) => {
};
// Create a separate component for each channel card to properly handle the hook
const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channelsByUUID }) => {
const ChannelCard = ({
channel,
clients,
stopClient,
stopChannel,
logos,
channelsByUUID,
}) => {
const location = useLocation();
const [availableStreams, setAvailableStreams] = useState([]);
const [isLoadingStreams, setIsLoadingStreams] = useState(false);
const [activeStreamId, setActiveStreamId] = useState(null);
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
@ -92,7 +99,7 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
const m3uAccountsMap = useMemo(() => {
const map = {};
if (m3uAccounts && Array.isArray(m3uAccounts)) {
m3uAccounts.forEach(account => {
m3uAccounts.forEach((account) => {
if (account.id) {
map[account.id] = account.name;
}
@ -111,7 +118,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
// If the channel data includes M3U profile information, update our state
if (channel.m3u_profile || channel.m3u_profile_name) {
setCurrentM3UProfile({
name: channel.m3u_profile?.name || channel.m3u_profile_name || 'Default M3U'
name:
channel.m3u_profile?.name ||
channel.m3u_profile_name ||
'Default M3U',
});
}
}, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]);
@ -132,8 +142,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
// If we have a channel URL, try to find the matching stream
if (channel.url && streamData.length > 0) {
// Try to find matching stream based on URL
const matchingStream = streamData.find(stream =>
channel.url.includes(stream.url) || stream.url.includes(channel.url)
const matchingStream = streamData.find(
(stream) =>
channel.url.includes(stream.url) ||
stream.url.includes(channel.url)
);
if (matchingStream) {
@ -147,7 +159,7 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
}
}
} catch (error) {
console.error("Error fetching streams:", error);
console.error('Error fetching streams:', error);
} finally {
setIsLoadingStreams(false);
}
@ -159,14 +171,16 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
// Handle stream switching
const handleStreamChange = async (streamId) => {
try {
console.log("Switching to stream ID:", streamId);
console.log('Switching to stream ID:', streamId);
// Find the selected stream in availableStreams for debugging
const selectedStream = availableStreams.find(s => s.id.toString() === streamId);
console.log("Selected stream details:", selectedStream);
const selectedStream = availableStreams.find(
(s) => s.id.toString() === streamId
);
console.log('Selected stream details:', selectedStream);
// Make sure we're passing the correct ID to the API
const response = await API.switchStream(channel.channel_id, streamId);
console.log("Stream switch API response:", response);
console.log('Stream switch API response:', response);
// Update the local active stream ID immediately
setActiveStreamId(streamId);
@ -192,21 +206,22 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
const channelId = channelsByUUID[channel.channel_id];
if (channelId) {
const updatedStreamData = await API.getChannelStreams(channelId);
console.log("Channel streams after switch:", updatedStreamData);
console.log('Channel streams after switch:', updatedStreamData);
// Update current stream information with fresh data
const updatedStream = updatedStreamData.find(s => s.id.toString() === streamId);
const updatedStream = updatedStreamData.find(
(s) => s.id.toString() === streamId
);
if (updatedStream && updatedStream.m3u_profile) {
setCurrentM3UProfile(updatedStream.m3u_profile);
}
}
} catch (error) {
console.error("Error checking streams after switch:", error);
console.error('Error checking streams after switch:', error);
}
}, 2000);
} catch (error) {
console.error("Stream switch error:", error);
console.error('Stream switch error:', error);
notifications.show({
title: 'Error switching stream',
message: error.toString(),
@ -230,7 +245,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
if (row.connected_since) {
// Calculate the actual connection time by subtracting the seconds from current time
const currentTime = dayjs();
const connectedTime = currentTime.subtract(row.connected_since, 'second');
const connectedTime = currentTime.subtract(
row.connected_since,
'second'
);
return connectedTime.format('MM/DD HH:mm:ss');
}
@ -243,7 +261,13 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
return 'Unknown';
},
Cell: ({ cell }) => (
<Tooltip label={cell.getValue() !== 'Unknown' ? `Connected at ${cell.getValue()}` : 'Unknown connection time'}>
<Tooltip
label={
cell.getValue() !== 'Unknown'
? `Connected at ${cell.getValue()}`
: 'Unknown connection time'
}
>
<Text size="xs">{cell.getValue()}</Text>
</Tooltip>
),
@ -258,27 +282,36 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
}
if (row.connection_duration) {
return dayjs.duration(row.connection_duration, 'seconds').humanize();
return dayjs
.duration(row.connection_duration, 'seconds')
.humanize();
}
return '-';
},
Cell: ({ cell, row }) => {
const exactDuration = row.original.connected_since || row.original.connection_duration;
const exactDuration =
row.original.connected_since || row.original.connection_duration;
return (
<Tooltip label={exactDuration ? `${exactDuration.toFixed(1)} seconds` : 'Unknown duration'}>
<Tooltip
label={
exactDuration
? `${exactDuration.toFixed(1)} seconds`
: 'Unknown duration'
}
>
<Text size="xs">{cell.getValue()}</Text>
</Tooltip>
);
},
size: 50,
}
},
],
[]
);
// This hook is now at the top level of this component
const channelClientsTable = useMantineReactTable({
const channelClientsTable = useTable({
...TableHelper.defaultProperties,
columns: clientsColumns,
data: clients.filter(
@ -319,8 +352,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
renderDetailPanel: ({ row }) => (
<Box p="xs">
<Group spacing="xs" align="flex-start">
<Text size="xs" fw={500} color="dimmed">User Agent:</Text>
<Text size="xs">{row.original.user_agent || "Unknown"}</Text>
<Text size="xs" fw={500} color="dimmed">
User Agent:
</Text>
<Text size="xs">{row.original.user_agent || 'Unknown'}</Text>
</Group>
</Box>
),
@ -347,8 +382,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
}
// Get logo URL from the logos object if available
const logoUrl = channel.logo_id && logos && logos[channel.logo_id] ?
logos[channel.logo_id].cache_url : null;
const logoUrl =
channel.logo_id && logos && logos[channel.logo_id]
? logos[channel.logo_id].cache_url
: null;
// Ensure these values exist to prevent errors
const channelName = channel.name || 'Unnamed Channel';
@ -360,19 +397,21 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
const streamProfileName = channel.stream_profile?.name || 'Unknown Profile';
// Use currentM3UProfile if available, otherwise fall back to channel data
const m3uProfileName = currentM3UProfile?.name ||
const m3uProfileName =
currentM3UProfile?.name ||
channel.m3u_profile?.name ||
channel.m3u_profile_name ||
'Unknown M3U Profile';
// Create select options for available streams
const streamOptions = availableStreams.map(stream => {
const streamOptions = availableStreams.map((stream) => {
// Get account name from our mapping if it exists
const accountName = stream.m3u_account && m3uAccountsMap[stream.m3u_account]
? m3uAccountsMap[stream.m3u_account]
: stream.m3u_account
? `M3U #${stream.m3u_account}`
: 'Unknown M3U';
const accountName =
stream.m3u_account && m3uAccountsMap[stream.m3u_account]
? m3uAccountsMap[stream.m3u_account]
: stream.m3u_account
? `M3U #${stream.m3u_account}`
: 'Unknown M3U';
return {
value: stream.id.toString(),
@ -394,19 +433,21 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
>
<Stack style={{ position: 'relative' }}>
<Group justify="space-between">
<Box style={{
width: '100px',
height: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<Box
style={{
width: '100px',
height: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={logoUrl || logo}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
objectFit: 'contain',
}}
alt="channel logo"
/>
@ -464,7 +505,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
<Select
size="xs"
label="Active Stream"
placeholder={isLoadingStreams ? "Loading streams..." : "Select stream"}
placeholder={
isLoadingStreams ? 'Loading streams...' : 'Select stream'
}
data={streamOptions}
value={activeStreamId || channel.stream_id?.toString() || null}
onChange={handleStreamChange}
@ -476,7 +519,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
<Group justify="space-between">
<Group gap={4}>
<Tooltip label={`Current bitrate: ${formatSpeed(bitrates.at(-1) || 0)}`}>
<Tooltip
label={`Current bitrate: ${formatSpeed(bitrates.at(-1) || 0)}`}
>
<Group gap={4} style={{ cursor: 'help' }}>
<Gauge style={{ paddingRight: 5 }} size="22" />
<Text size="sm">{formatSpeed(bitrates.at(-1) || 0)}</Text>
@ -485,7 +530,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
</Group>
<Tooltip label={`Average bitrate: ${avgBitrate}`}>
<Text size="sm" style={{ cursor: 'help' }}>Avg: {avgBitrate}</Text>
<Text size="sm" style={{ cursor: 'help' }}>
Avg: {avgBitrate}
</Text>
</Tooltip>
<Group gap={4}>
@ -498,7 +545,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
</Group>
<Group gap={5}>
<Tooltip label={`${clientCount} active client${clientCount !== 1 ? 's' : ''}`}>
<Tooltip
label={`${clientCount} active client${clientCount !== 1 ? 's' : ''}`}
>
<Group gap={4} style={{ cursor: 'help' }}>
<Users size="18" />
<Text size="sm">{clientCount}</Text>
@ -507,7 +556,7 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel
</Group>
</Group>
<MantineReactTable table={channelClientsTable} />
<CustomTable table={channelClientsTable} />
</Stack>
</Card>
);
@ -616,8 +665,13 @@ const ChannelsPage = () => {
// The main clientsTable is no longer needed since each channel card has its own table
useEffect(() => {
if (!channelStats || !channelStats.channels || !Array.isArray(channelStats.channels) || channelStats.channels.length === 0) {
console.log("No channel stats available:", channelStats);
if (
!channelStats ||
!channelStats.channels ||
!Array.isArray(channelStats.channels) ||
channelStats.channels.length === 0
) {
console.log('No channel stats available:', channelStats);
// Clear active channels when there are no stats
if (Object.keys(activeChannels).length > 0) {
setActiveChannels({});
@ -631,13 +685,13 @@ const ChannelsPage = () => {
// Track which channels are currently active according to channelStats
const currentActiveChannelIds = new Set(
channelStats.channels.map(ch => ch.channel_id).filter(Boolean)
channelStats.channels.map((ch) => ch.channel_id).filter(Boolean)
);
channelStats.channels.forEach(ch => {
channelStats.channels.forEach((ch) => {
// Make sure we have a valid channel_id
if (!ch.channel_id) {
console.warn("Found channel without channel_id:", ch);
console.warn('Found channel without channel_id:', ch);
return;
}
@ -656,12 +710,14 @@ const ChannelsPage = () => {
}
// Find corresponding channel data
const channelData = channelsByUUID && ch.channel_id ?
channels[channelsByUUID[ch.channel_id]] : null;
const channelData =
channelsByUUID && ch.channel_id
? channels[channelsByUUID[ch.channel_id]]
: null;
// Find stream profile
const streamProfile = streamProfiles.find(
profile => profile.id == parseInt(ch.stream_profile)
(profile) => profile.id == parseInt(ch.stream_profile)
);
stats[ch.channel_id] = {
@ -674,7 +730,7 @@ const ChannelsPage = () => {
};
});
console.log("Processed active channels:", stats);
console.log('Processed active channels:', stats);
setActiveChannels(stats);
const clientStats = Object.values(stats).reduce((acc, ch) => {
@ -704,8 +760,16 @@ const ChannelsPage = () => {
verticalSpacing="lg"
>
{Object.keys(activeChannels).length === 0 ? (
<Box style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '40px' }}>
<Text size="xl" color="dimmed">No active channels currently streaming</Text>
<Box
style={{
gridColumn: '1 / -1',
textAlign: 'center',
padding: '40px',
}}
>
<Text size="xl" color="dimmed">
No active channels currently streaming
</Text>
</Box>
) : (
Object.values(activeChannels).map((channel) => (

View file

@ -34,6 +34,8 @@ const useAuthStore = create((set, get) => ({
isLoading: false,
error: null,
setUser: (user) => set({ user }),
initData: async () => {
const user = await API.me();
if (user.user_level <= USER_LEVELS.STREAMER) {