mirror of
https://github.com/BreizhHardware/Jellystat.git
synced 2026-01-18 16:27:20 +01:00
Added Activity view for users/items/library
Backend and frontend changes to have an activity view per user/ item or library Switched over to MUI for the tables as the library provides alot of solutions such as pagination or sorting built in Reworked info components to allow for Activity-Table component to be reusable and remove redundant components
This commit is contained in:
@@ -199,6 +199,7 @@ router.get("/getHistory", async (req, res) => {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -212,6 +213,97 @@ router.get("/getHistory", async (req, res) => {
|
||||
|
||||
});
|
||||
|
||||
router.post("/getLibraryHistory", async (req, res) => {
|
||||
try {
|
||||
const { libraryid } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`select a.* from jf_playback_activity a join jf_library_items i on i."Id"=a."NowPlayingItemId" where i."ParentId"='${libraryid}' order by "ActivityDateInserted" desc`
|
||||
);
|
||||
const groupedResults = {};
|
||||
rows.forEach(row => {
|
||||
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
} else {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post("/getItemHistory", async (req, res) => {
|
||||
try {
|
||||
const { itemid } = req.body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`select jf_playback_activity.*
|
||||
from jf_playback_activity jf_playback_activity
|
||||
where
|
||||
("EpisodeId"='${itemid}' OR "SeasonId"='${itemid}' OR "NowPlayingItemId"='${itemid}');`
|
||||
);
|
||||
|
||||
const groupedResults = {};
|
||||
rows.forEach(row => {
|
||||
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
} else {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/getUserHistory", async (req, res) => {
|
||||
try {
|
||||
const { userid } = req.body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`select jf_playback_activity.*
|
||||
from jf_playback_activity jf_playback_activity
|
||||
where "UserId"='${userid}';`
|
||||
);
|
||||
|
||||
const groupedResults = {};
|
||||
rows.forEach(row => {
|
||||
if (groupedResults[row.NowPlayingItemId+row.EpisodeId]) {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
} else {
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId] = {
|
||||
...row,
|
||||
results: []
|
||||
};
|
||||
groupedResults[row.NowPlayingItemId+row.EpisodeId].results.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
res.send(Object.values(groupedResults));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.send(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
router.get("/getAdminUsers", async (req, res) => {
|
||||
try {
|
||||
const { rows:config } = await db.query('SELECT * FROM app_config where "ID"=1');
|
||||
|
||||
@@ -284,6 +284,8 @@ router.post("/getLibraryLastPlayed", async (req, res) => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
router.post("/getViewsOverTime", async (req, res) => {
|
||||
try {
|
||||
const { days } = req.body;
|
||||
|
||||
166
package-lock.json
generated
166
package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/material": "^5.11.10",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@mui/x-data-grid": "^6.2.1",
|
||||
"@nivo/api": "^0.74.1",
|
||||
"@nivo/bar": "^0.80.0",
|
||||
"@nivo/core": "^0.80.0",
|
||||
@@ -2121,8 +2122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.10.5",
|
||||
"license": "MIT",
|
||||
"version": "11.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.7.tgz",
|
||||
"integrity": "sha512-VLl1/2D6LOjH57Y8Vem1RoZ9haWF4jesHDGiHtKozDQuBIkJm2gimVo0I02sWCuzZtVACeixTVB4jeE8qvCBoQ==",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.8.0",
|
||||
"@emotion/sheet": "^1.2.1",
|
||||
@@ -2148,7 +2150,8 @@
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.10.6",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.6.tgz",
|
||||
"integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.10.6",
|
||||
@@ -2185,7 +2188,8 @@
|
||||
},
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.10.6",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.10.6.tgz",
|
||||
"integrity": "sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.10.6",
|
||||
@@ -2998,14 +3002,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mui/base": {
|
||||
"version": "5.0.0-alpha.118",
|
||||
"license": "MIT",
|
||||
"version": "5.0.0-alpha.127",
|
||||
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.127.tgz",
|
||||
"integrity": "sha512-FoRQd0IOH9MnfyL5yXssyQRnC4vXI+1bwkU1idr+wNkP1ZfxE+JsThHcfl1dy5azLssVUGTtQFD9edQLdbyJog==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@emotion/is-prop-valid": "^1.2.0",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"@mui/types": "^7.2.4",
|
||||
"@mui/utils": "^5.12.0",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"clsx": "^1.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-is": "^18.2.0"
|
||||
@@ -3030,29 +3035,32 @@
|
||||
},
|
||||
"node_modules/@mui/base/node_modules/react-is": {
|
||||
"version": "18.2.0",
|
||||
"license": "MIT"
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
||||
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "5.11.9",
|
||||
"license": "MIT",
|
||||
"version": "5.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.12.2.tgz",
|
||||
"integrity": "sha512-Qn7dy8tql6T0hY6gTFPkpWlnqVVFGu5Z6QzEzUSzzmLZpfAx4kf8sFz0PHiB7gU5yrqcZF9picMx1shpRY/rXw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "5.11.10",
|
||||
"license": "MIT",
|
||||
"version": "5.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.12.2.tgz",
|
||||
"integrity": "sha512-XOVy6fVC0rI2dEwDq/1s4Te2hewTUe6lznzeVnruyATGkdmM06WnHqkZOoLVIWo9hWwAxpcgTDcAIVpFtt1nrw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/base": "5.0.0-alpha.118",
|
||||
"@mui/core-downloads-tracker": "^5.11.9",
|
||||
"@mui/system": "^5.11.9",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/base": "5.0.0-alpha.127",
|
||||
"@mui/core-downloads-tracker": "^5.12.2",
|
||||
"@mui/system": "^5.12.1",
|
||||
"@mui/types": "^7.2.4",
|
||||
"@mui/utils": "^5.12.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"clsx": "^1.2.1",
|
||||
"csstype": "^3.1.1",
|
||||
"csstype": "^3.1.2",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-is": "^18.2.0",
|
||||
"react-transition-group": "^4.4.5"
|
||||
@@ -3088,11 +3096,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mui/private-theming": {
|
||||
"version": "5.11.9",
|
||||
"license": "MIT",
|
||||
"version": "5.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.0.tgz",
|
||||
"integrity": "sha512-w5dwMen1CUm1puAtubqxY9BIzrBxbOThsg2iWMvRJmWyJAPdf3Z583fPXpqeA2lhTW79uH2jajk5Ka4FuGlTPg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/utils": "^5.12.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3113,12 +3122,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/styled-engine": {
|
||||
"version": "5.11.9",
|
||||
"license": "MIT",
|
||||
"version": "5.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.12.0.tgz",
|
||||
"integrity": "sha512-frh8L7CRnvD0RDmIqEv6jFeKQUIXqW90BaZ6OrxJ2j4kIsiVLu29Gss4SbBvvrWwwatR72sBmC3w1aG4fjp9mQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"csstype": "^3.1.1",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@emotion/cache": "^11.10.7",
|
||||
"csstype": "^3.1.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3143,16 +3153,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/system": {
|
||||
"version": "5.11.9",
|
||||
"license": "MIT",
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.1.tgz",
|
||||
"integrity": "sha512-Po+sicdV3bbRYXdU29XZaHPZrW7HUYUqU1qCu77GCCEMbahC756YpeyefdIYuPMUg0OdO3gKIUfDISBrkjJL+w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/private-theming": "^5.11.9",
|
||||
"@mui/styled-engine": "^5.11.9",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/private-theming": "^5.12.0",
|
||||
"@mui/styled-engine": "^5.12.0",
|
||||
"@mui/types": "^7.2.4",
|
||||
"@mui/utils": "^5.12.0",
|
||||
"clsx": "^1.2.1",
|
||||
"csstype": "^3.1.1",
|
||||
"csstype": "^3.1.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3181,8 +3192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/types": {
|
||||
"version": "7.2.3",
|
||||
"license": "MIT",
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz",
|
||||
"integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
},
|
||||
@@ -3193,10 +3205,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/utils": {
|
||||
"version": "5.11.9",
|
||||
"license": "MIT",
|
||||
"version": "5.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.0.tgz",
|
||||
"integrity": "sha512-RmQwgzF72p7Yr4+AAUO6j1v2uzt6wr7SWXn68KBsnfVpdOHyclCzH2lr/Xu6YOw9su4JRtdAIYfJFXsS6Cjkmw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react-is": "^16.7.1 || ^17.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
@@ -3215,7 +3228,33 @@
|
||||
},
|
||||
"node_modules/@mui/utils/node_modules/react-is": {
|
||||
"version": "18.2.0",
|
||||
"license": "MIT"
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
|
||||
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
|
||||
},
|
||||
"node_modules/@mui/x-data-grid": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.2.1.tgz",
|
||||
"integrity": "sha512-vFobFeFJslc4JRXark5SUFIzh/hwpyB8UENqUE3m0CbqKoRf4so2nkvaiK9TPsVIJbAuF6g1xyS0IhvMU2mzWg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/utils": "^5.11.13",
|
||||
"clsx": "^1.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"reselect": "^4.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mui/material": "^5.4.1",
|
||||
"@mui/system": "^5.4.1",
|
||||
"react": "^17.0.2 || ^18.0.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"version": "5.1.1-v1",
|
||||
@@ -4122,8 +4161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.6",
|
||||
"license": "MIT",
|
||||
"version": "2.11.7",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz",
|
||||
"integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
@@ -5405,10 +5445,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-is": {
|
||||
"version": "17.0.3",
|
||||
"license": "MIT",
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz",
|
||||
"integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
"@types/react": "^17"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-is/node_modules/@types/react": {
|
||||
"version": "17.0.58",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.58.tgz",
|
||||
"integrity": "sha512-c1GzVY97P0fGxwGxhYq989j4XwlcHQoto6wQISOC2v6wm3h0PORRWJFHlkRjfGsiG3y1609WdQ+J+tKxvrEd6A==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
@@ -6991,7 +7042,8 @@
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -7752,8 +7804,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.1",
|
||||
"license": "MIT"
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
},
|
||||
"node_modules/cycle": {
|
||||
"version": "1.0.3",
|
||||
@@ -16691,6 +16744,11 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
|
||||
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/material": "^5.11.10",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@mui/x-data-grid": "^6.2.1",
|
||||
"@nivo/api": "^0.74.1",
|
||||
"@nivo/bar": "^0.80.0",
|
||||
"@nivo/core": "^0.80.0",
|
||||
|
||||
@@ -42,6 +42,7 @@ h2{
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
@@ -129,7 +129,7 @@ if (config && isConfigured && token!==null){
|
||||
<Route path="/users/:UserId" element={<UserInfo />} />
|
||||
<Route path="/libraries" element={<Libraries />} />
|
||||
<Route path="/libraries/:LibraryId" element={<LibraryInfo />} />
|
||||
<Route path="/item/:Id" element={<ItemInfo />} />
|
||||
<Route path="/libraries/item/:Id" element={<ItemInfo />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/activity" element={<Activity />} />
|
||||
<Route path="/testing" element={<Testing />} />
|
||||
|
||||
@@ -1,156 +1,187 @@
|
||||
import React ,{useState} from 'react';
|
||||
import React from 'react';
|
||||
import { Link } from "react-router-dom";
|
||||
// import { useParams } from 'react-router-dom';
|
||||
import { Button, ButtonGroup } from "react-bootstrap";
|
||||
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import AddCircleFillIcon from 'remixicon-react/AddCircleFillIcon';
|
||||
import IndeterminateCircleFillIcon from 'remixicon-react/IndeterminateCircleFillIcon';
|
||||
|
||||
import '../../css/activity/activity-table.css';
|
||||
|
||||
|
||||
function formatTotalWatchTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
|
||||
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
|
||||
let formattedTime='';
|
||||
if(hours)
|
||||
{
|
||||
formattedTime+=`${hours} hours`;
|
||||
}
|
||||
if(minutes)
|
||||
{
|
||||
formattedTime+=` ${minutes} minutes`;
|
||||
}
|
||||
|
||||
function ActivityTable(props) {
|
||||
return formattedTime ;
|
||||
}
|
||||
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
function Row(data) {
|
||||
const { row } = data;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// const classes = useRowStyles();
|
||||
|
||||
const [data, setData] = useState(props.data);
|
||||
|
||||
function handleSort(key) {
|
||||
const direction =
|
||||
sortConfig.key === key && sortConfig.direction === "ascending"
|
||||
? "descending"
|
||||
: "ascending";
|
||||
setSortConfig({ key, direction });
|
||||
}
|
||||
|
||||
function sortData(data, { key, direction }) {
|
||||
if (!key) return data;
|
||||
|
||||
const sortedData = [...data];
|
||||
|
||||
sortedData.sort((a, b) => {
|
||||
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
|
||||
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedData;
|
||||
}
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
};
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
|
||||
const sortedData = sortData(data, sortConfig);
|
||||
|
||||
const indexOfLastUser = currentPage * props.itemCount;
|
||||
const indexOfFirstUser = indexOfLastUser - props.itemCount;
|
||||
const currentData = sortedData.slice(indexOfFirstUser, indexOfLastUser);
|
||||
|
||||
const pageNumbers = [];
|
||||
for (let i = 1; i <= Math.ceil(sortedData.length / props.itemCount); i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
|
||||
const handleCollapse = (itemId) => {
|
||||
setData(data.map(item => {
|
||||
if ((item.NowPlayingItemId+item.EpisodeId) === itemId) {
|
||||
return { ...item, isCollapsed: !item.isCollapsed };
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
}));
|
||||
}
|
||||
function formatTotalWatchTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
|
||||
const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
|
||||
let formattedTime='';
|
||||
if(hours)
|
||||
{
|
||||
formattedTime+=`${hours} hours`;
|
||||
}
|
||||
if(minutes)
|
||||
{
|
||||
formattedTime+=` ${minutes} minutes`;
|
||||
}
|
||||
|
||||
return formattedTime ;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div className='activity-table'>
|
||||
<div className='table-headers'>
|
||||
<div onClick={() => handleSort("UserName")}>User</div>
|
||||
<div onClick={() => handleSort("NowPlayingItemName")}>Title </div>
|
||||
<div onClick={() => handleSort("ActivityDateInserted")}>Date</div>
|
||||
<div onClick={() => handleSort("PlaybackDuration")}>Playback Duration</div>
|
||||
<div onClick={() => handleSort("results")}>Total Plays</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => {if(row.results.length>1){setOpen(!open);}}}
|
||||
>
|
||||
{!open ? <AddCircleFillIcon /> : <IndeterminateCircleFillIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell><Link to={`/users/${row.UserId}`} className='text-decoration-none'>{row.UserName}</Link></TableCell>
|
||||
<TableCell><Link to={`/libraries/item/${row.EpisodeId || row.NowPlayingItemId}`} className='text-decoration-none'>{!row.SeriesName ? row.NowPlayingItemName : row.SeriesName+' - '+ row.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell>{row.Client}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(row.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(row.PlaybackDuration) || '0 minutes'}</TableCell>
|
||||
<TableCell>{row.results.length}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ margin: 1 }}>
|
||||
|
||||
{currentData.map((item) => (
|
||||
|
||||
<div className='table-rows' key={item.NowPlayingItemId+item.EpisodeId} onClick={() => handleCollapse(item.NowPlayingItemId+item.EpisodeId)}>
|
||||
<div className='table-rows-content'>
|
||||
<div><Link to={`/users/${item.UserId}`}>{item.UserName}</Link></div>
|
||||
<div><Link to={`/item/${item.EpisodeId || item.NowPlayingItemId}`}>{!item.SeriesName ? item.NowPlayingItemName : item.SeriesName+' - '+ item.NowPlayingItemName}</Link></div>
|
||||
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(item.ActivityDateInserted))}</div>
|
||||
<div>{formatTotalWatchTime(item.PlaybackDuration) || '0 sec'}</div>
|
||||
<div>{item.results.length+1}</div>
|
||||
</div>
|
||||
<div className={`sub-table ${item.isCollapsed ? 'collapsed' : ''}`}>
|
||||
{item.results.map((sub_item,index) => (
|
||||
|
||||
<div className='table-rows-content bg-grey sub-row' key={sub_item.EpisodeId+index}>
|
||||
<div><Link to={`/users/${sub_item.UserId}`}>{sub_item.UserName}</Link></div>
|
||||
<div><Link to={`/item/${sub_item.EpisodeId || sub_item.NowPlayingItemId}`}>{!sub_item.SeriesName ? sub_item.NowPlayingItemName : sub_item.SeriesName+' - '+ sub_item.NowPlayingItemName}</Link></div>
|
||||
<div>{Intl.DateTimeFormat('en-UK', options).format(new Date(sub_item.ActivityDateInserted))}</div>
|
||||
<div></div>
|
||||
<div>1</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{props.itemCount>0 ?
|
||||
|
||||
<div className="pagination">
|
||||
<button className="page-btn" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
|
||||
First
|
||||
</button>
|
||||
|
||||
<button className="page-btn" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="page-number">{`Page ${currentPage} of ${pageNumbers.length}`}</div>
|
||||
|
||||
<button className="page-btn" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === pageNumbers.length}>
|
||||
Next
|
||||
</button>
|
||||
|
||||
<button className="page-btn" onClick={() => setCurrentPage(pageNumbers.length)} disabled={currentPage === pageNumbers.length}>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
:<></>
|
||||
|
||||
}
|
||||
</div>
|
||||
<Table aria-label="sub-activity" className='rounded-2'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Playback Duration</TableCell>
|
||||
<TableCell>Plays</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{row.results.map((resultRow) => (
|
||||
<TableRow key={resultRow.Id}>
|
||||
|
||||
<TableCell><Link to={`/users/${resultRow.UserId}`} className='text-decoration-none'>{resultRow.UserName}</Link></TableCell>
|
||||
<TableCell><Link to={`/libraries/item/${resultRow.EpisodeId || resultRow.NowPlayingItemId}`} className='text-decoration-none'>{!resultRow.SeriesName ? resultRow.NowPlayingItemName : resultRow.SeriesName+' - '+ resultRow.NowPlayingItemName}</Link></TableCell>
|
||||
<TableCell>{resultRow.Client}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(resultRow.ActivityDateInserted))}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(resultRow.PlaybackDuration) || '0 minutes'}</TableCell>
|
||||
<TableCell>1</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
export default ActivityTable;
|
||||
|
||||
export default function ActivityTable(props) {
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
|
||||
if(rowsPerPage!==props.itemCount)
|
||||
{
|
||||
setRowsPerPage(props.itemCount);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
|
||||
const handleNextPageClick = () => {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPageClick = () => {
|
||||
setPage((prevPage) => prevPage - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Playback Duration</TableCell>
|
||||
<TableCell>Plays</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.data
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => (
|
||||
<Row key={row.Id} row={row} />
|
||||
))}
|
||||
{props.data.length===0 ? <tr><td colSpan="7" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Activity Found</td></tr> :''}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<div className='d-flex justify-content-end my-2'>
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={()=>setPage(0)} disabled={page === 0}>
|
||||
First
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),props.data.length)} of ${props.data.length}`}</div>
|
||||
|
||||
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={()=>setPage(Math.ceil(props.data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(props.data.length / rowsPerPage) - 1}>
|
||||
Last
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ function LastWatchedCard(props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<div className="last-card">
|
||||
<Link to={`/item/${props.data.EpisodeId||props.data.Id}`}>
|
||||
<Link to={`/libraries/item/${props.data.EpisodeId||props.data.Id}`}>
|
||||
<div className="last-card-banner">
|
||||
{loaded ? null : <Blurhash hash={props.data.PrimaryImageHash} width={'100%'} height={'100%'}/>}
|
||||
<img
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Navbar() {
|
||||
<BootstrapNavbar.Collapse id="responsive-navbar-nav">
|
||||
<Nav className="ms-auto">
|
||||
{navData.map((item) => {
|
||||
const isActive = ('/'+item.link).toLocaleLowerCase() === location.pathname.toLocaleLowerCase(); // check if the link is the current path
|
||||
const isActive = location.pathname.toLocaleLowerCase().includes(('/'+item.link).toLocaleLowerCase()); // check if the link is the current path
|
||||
return (
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Link } from "react-router-dom";
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import {Row, Col, Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
|
||||
|
||||
import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon";
|
||||
|
||||
import GlobalStats from './item-info/globalStats';
|
||||
import ItemDetails from './item-info/item-details';
|
||||
import "../css/items/item-details.css";
|
||||
|
||||
import MoreItems from "./item-info/more-items";
|
||||
import ItemActivity from "./item-info/item-activity";
|
||||
|
||||
|
||||
import Config from "../../lib/config";
|
||||
import Loading from "./general/loading";
|
||||
@@ -16,6 +24,33 @@ function ItemInfo() {
|
||||
const [data, setData] = useState();
|
||||
const [config, setConfig] = useState();
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('tabOverview');
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
|
||||
function formatFileSize(sizeInBytes) {
|
||||
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
|
||||
if (sizeInMB < 1000) {
|
||||
return `${sizeInMB.toFixed(2)} MB`;
|
||||
} else {
|
||||
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
|
||||
return `${sizeInGB.toFixed(2)} GB`;
|
||||
}
|
||||
}
|
||||
|
||||
function ticksToTimeString(ticks) {
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
|
||||
return timeString;
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -80,15 +115,78 @@ if(refresh)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ItemDetails data={data} hostUrl={config.hostUrl}/>
|
||||
<GlobalStats ItemId={Id}/>
|
||||
{["Series","Season"].includes(data && data.Type)?
|
||||
<MoreItems data={data}/>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
|
||||
<div className="item-detail-container">
|
||||
<Row className="justify-content-center justify-content-md-start">
|
||||
<Col className="col-auto my-4 my-md-0">
|
||||
{data.PrimaryImageHash && !loaded ? <Blurhash hash={data.PrimaryImageHash} width={'200px'} height={'300px'}/> : null}
|
||||
<img
|
||||
className="item-image"
|
||||
src={
|
||||
config.hostUrl +
|
||||
"/Items/" +
|
||||
(data.Type==="Episode"? data.SeriesId : data.Id) +
|
||||
"/Images/Primary?fillWidth=200&quality=90"
|
||||
}
|
||||
alt=""
|
||||
style={{
|
||||
display: loaded ? "block" :"none"
|
||||
}}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col >
|
||||
<div className="item-details">
|
||||
<div className="d-flex">
|
||||
<h1 className="">
|
||||
{data.SeriesId?
|
||||
<Link to={`/libraries/item/${data.SeriesId}`}>{data.SeriesName || data.Name}</Link>
|
||||
:
|
||||
data.SeriesName || data.Name
|
||||
}
|
||||
|
||||
</h1>
|
||||
<Link className="px-2" to={ config.hostUrl+"/web/index.html#!/details?id="+ (data.EpisodeId ||data.Id)} title="Open in Jellyfin" target="_blank"><ExternalLinkFillIcon/></Link>
|
||||
</div>
|
||||
|
||||
<div className="my-3">
|
||||
{data.Type==="Episode"? <p><Link to={`/libraries/item/${data.SeasonId}`} className="fw-bold">{data.SeasonName}</Link> Episode {data.IndexNumber} - {data.Name}</p> : <></> }
|
||||
{data.Type==="Season"? <p>{data.Name}</p> : <></> }
|
||||
{data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {data.FileName}</p> :<></>}
|
||||
{data.Path ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Path: {data.Path}</p> :<></>}
|
||||
{data.RunTimeTicks ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">{data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(data.RunTimeTicks)}</p> :<></>}
|
||||
{data.Size ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Size: {formatFileSize(data.Size)}</p> :<></>}
|
||||
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
|
||||
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
|
||||
<Tab eventKey="tabOverview" className='bg-transparent'>
|
||||
<GlobalStats ItemId={Id}/>
|
||||
{["Series","Season"].includes(data && data.Type)?
|
||||
<MoreItems data={data}/>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
</Tab>
|
||||
<Tab eventKey="tabActivity" className='bg-transparent'>
|
||||
<ItemActivity itemid={Id}/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/pages/components/item-info/item-activity.js
Normal file
67
src/pages/components/item-info/item-activity.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import TvLineIcon from "remixicon-react/TvLineIcon";
|
||||
import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
|
||||
import ActivityTable from "../activity/activity-table";
|
||||
|
||||
function ItemActivity(props) {
|
||||
const [data, setData] = useState();
|
||||
const token = localStorage.getItem('token');
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const itemData = await axios.post(`/api/getItemHistory`, {
|
||||
itemid: props.itemid,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
setData(itemData.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [data, props.LibraryId,token]);
|
||||
|
||||
|
||||
if (!data) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Activity">
|
||||
<div className="Heading">
|
||||
<h1>Item Activity</h1>
|
||||
<div className="pagination-range">
|
||||
<div className="header">Items</div>
|
||||
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Activity">
|
||||
<ActivityTable data={data} itemCount={itemCount}/>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemActivity;
|
||||
@@ -1,103 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import { Row, Col } from "react-bootstrap";
|
||||
|
||||
import ExternalLinkFillIcon from "remixicon-react/ExternalLinkFillIcon";
|
||||
|
||||
|
||||
import "../../css/items/item-details.css";
|
||||
|
||||
|
||||
|
||||
function ItemDetails(props) {
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
|
||||
function formatFileSize(sizeInBytes) {
|
||||
const sizeInMB = sizeInBytes / 1048576; // 1 MB = 1048576 bytes
|
||||
if (sizeInMB < 1000) {
|
||||
return `${sizeInMB.toFixed(2)} MB`;
|
||||
} else {
|
||||
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
|
||||
return `${sizeInGB.toFixed(2)} GB`;
|
||||
}
|
||||
}
|
||||
|
||||
function ticksToTimeString(ticks) {
|
||||
// Convert ticks to seconds
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
|
||||
// Calculate hours, minutes, and remaining seconds
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
// Format the time string as hh:MM:ss
|
||||
const timeString = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
|
||||
return timeString;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="item-detail-container">
|
||||
<Row className="justify-content-center justify-content-md-start">
|
||||
<Col className="col-auto my-4 my-md-0">
|
||||
{props.data.PrimaryImageHash && !loaded ? <Blurhash hash={props.data.PrimaryImageHash} width={'200px'} height={'300px'}/> : null}
|
||||
<img
|
||||
className="item-image"
|
||||
src={
|
||||
props.hostUrl +
|
||||
"/Items/" +
|
||||
(props.data.Type==="Episode"? props.data.SeriesId : props.data.Id) +
|
||||
"/Images/Primary?fillWidth=200&quality=90"
|
||||
}
|
||||
alt=""
|
||||
style={{
|
||||
display: loaded ? "block" :"none"
|
||||
}}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col >
|
||||
<div className="item-details">
|
||||
<div className="d-flex">
|
||||
<h1 className="">
|
||||
{props.data.SeriesId?
|
||||
<Link to={`/item/${props.data.SeriesId}`}>{props.data.SeriesName || props.data.Name}</Link>
|
||||
:
|
||||
props.data.SeriesName || props.data.Name
|
||||
}
|
||||
|
||||
</h1>
|
||||
<Link className="px-2" to={ props.hostUrl+"/web/index.html#!/details?id="+ (props.data.EpisodeId ||props.data.Id)} title="Open in Jellyfin" target="_blank"><ExternalLinkFillIcon/></Link>
|
||||
</div>
|
||||
|
||||
<div className="my-3">
|
||||
{props.data.Type==="Episode"? <p><Link to={`/item/${props.data.SeasonId}`} className="fw-bold">{props.data.SeasonName}</Link> Episode {props.data.IndexNumber} - {props.data.Name}</p> : <></> }
|
||||
{props.data.Type==="Season"? <p>{props.data.Name}</p> : <></> }
|
||||
{props.data.FileName ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Name: {props.data.FileName}</p> :<></>}
|
||||
{props.data.Path ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Path: {props.data.Path}</p> :<></>}
|
||||
{props.data.RunTimeTicks ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">{props.data.Type==="Series"?"Average Runtime" : "Runtime"}: {ticksToTimeString(props.data.RunTimeTicks)}</p> :<></>}
|
||||
{props.data.Size ? <p style={{color:"lightgrey"}} className="fst-italic fs-6">File Size: {formatFileSize(props.data.Size)}</p> :<></>}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemDetails;
|
||||
@@ -13,7 +13,7 @@ function MoreItemCards(props) {
|
||||
const [fallback, setFallback] = useState(false);
|
||||
return (
|
||||
<div className={props.data.Type==="Episode" ? "last-card episode" : "last-card"}>
|
||||
<Link to={`/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
|
||||
<Link to={`/libraries/item/${ (props.data.Type==="Episode" ? props.data.EpisodeId : props.data.Id) }`}>
|
||||
<div className={props.data.Type==="Episode" ? "last-card-banner episode" : "last-card-banner"}>
|
||||
{props.data.ImageBlurHashes && !loaded ? <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/> : null}
|
||||
|
||||
|
||||
@@ -1,23 +1,98 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import TvLineIcon from "remixicon-react/TvLineIcon";
|
||||
import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
|
||||
import LibraryDetails from './library/library-details';
|
||||
|
||||
// import LibraryDetails from './library/library-details';
|
||||
import Loading from './general/loading';
|
||||
import LibraryGlobalStats from './library/library-stats';
|
||||
import LibraryLastWatched from './library/last-watched';
|
||||
import RecentlyPlayed from './library/recently-added';
|
||||
import LibraryActivity from './library/library-activity';
|
||||
|
||||
import { Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function LibraryInfo() {
|
||||
const { LibraryId } = useParams();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('tabOverview');
|
||||
const [data, setData] = useState();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
console.log('getdata');
|
||||
const libraryrData = await axios.post(`/stats/getLibraryDetails`, {
|
||||
libraryid: LibraryId,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
setData(libraryrData.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [LibraryId,token]);
|
||||
|
||||
if(!data)
|
||||
{
|
||||
return <Loading/>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LibraryDetails LibraryId={LibraryId}/>
|
||||
<LibraryGlobalStats LibraryId={LibraryId}/>
|
||||
<RecentlyPlayed LibraryId={LibraryId}/>
|
||||
<LibraryLastWatched LibraryId={LibraryId}/>
|
||||
|
||||
|
||||
<div className="user-detail-container">
|
||||
<div className="user-image-container">
|
||||
{data.CollectionType==="tvshows" ?
|
||||
|
||||
<TvLineIcon size={'100%'}/>
|
||||
:
|
||||
<FilmLineIcon size={'100%'}/>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="user-name">{data.Name}</p>
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
|
||||
<Button onClick={() => setActiveTab('tabItems')} active={activeTab==='tabItems'} variant='outline-primary' type='button'>Media</Button>
|
||||
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
|
||||
<Tab eventKey="tabOverview" className='bg-transparent'>
|
||||
<LibraryGlobalStats LibraryId={LibraryId}/>
|
||||
<RecentlyPlayed LibraryId={LibraryId}/>
|
||||
<LibraryLastWatched LibraryId={LibraryId}/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabActivity" className='bg-transparent'>
|
||||
<LibraryActivity LibraryId={LibraryId}/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabItems" className='bg-transparent'>
|
||||
<LibraryActivity LibraryId={LibraryId}/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ function RecentlyAddedCard(props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<div className="last-card">
|
||||
<Link to={`/item/${props.data.Id}`}>
|
||||
<Link to={`/libraries/item/${props.data.Id}`}>
|
||||
<div className="last-card-banner">
|
||||
{loaded ? null : <Blurhash hash={props.data.ImageBlurHashes.Primary[props.data.ImageTags.Primary]} width={'100%'} height={'100%'}/>}
|
||||
<img
|
||||
|
||||
@@ -3,17 +3,18 @@ import axios from "axios";
|
||||
import TvLineIcon from "remixicon-react/TvLineIcon";
|
||||
import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
|
||||
// import "../../css/users/user-details.css";
|
||||
import ActivityTable from "../activity/activity-table";
|
||||
|
||||
function LibraryDetails(props) {
|
||||
function LibraryActivity(props) {
|
||||
const [data, setData] = useState();
|
||||
const token = localStorage.getItem('token');
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const libraryrData = await axios.post(`/stats/getLibraryDetails`, {
|
||||
const libraryrData = await axios.post(`/api/getLibraryHistory`, {
|
||||
libraryid: props.LibraryId,
|
||||
}, {
|
||||
headers: {
|
||||
@@ -41,18 +42,26 @@ function LibraryDetails(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-detail-container">
|
||||
<div className="user-image-container">
|
||||
{data.CollectionType==="tvshows" ?
|
||||
|
||||
<TvLineIcon size={'100%'}/>
|
||||
:
|
||||
<FilmLineIcon size={'100%'}/>
|
||||
}
|
||||
<div className="Activity">
|
||||
<div className="Heading">
|
||||
<h1>Library Activity</h1>
|
||||
<div className="pagination-range">
|
||||
<div className="header">Items</div>
|
||||
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="user-name">{data.Name}</p>
|
||||
</div>
|
||||
<div className="Activity">
|
||||
<ActivityTable data={data} itemCount={itemCount}/>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LibraryDetails;
|
||||
export default LibraryActivity;
|
||||
@@ -1,18 +1,163 @@
|
||||
import React, { useState,useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { DropdownButton, Dropdown, Button } from 'react-bootstrap';
|
||||
import { DropdownButton, Dropdown,ButtonGroup, Button } from 'react-bootstrap';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
|
||||
|
||||
import Alert from "react-bootstrap/Alert";
|
||||
|
||||
|
||||
|
||||
import "../../css/settings/backups.css";
|
||||
import { Table } from "react-bootstrap";
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
|
||||
function Row(file) {
|
||||
const { data } = file;
|
||||
|
||||
console.log(data);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
|
||||
async function downloadBackup(filename) {
|
||||
const url=`/data/files/${filename}`;
|
||||
axios({
|
||||
url: url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
}).then(response => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreBackup(filename) {
|
||||
const url=`/data/restore/${filename}`;
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
|
||||
})
|
||||
.catch((error) => {
|
||||
BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
const url=`/data/files/${filename}`;
|
||||
axios
|
||||
.delete(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
BackupFiles().setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
|
||||
})
|
||||
.catch((error) => {
|
||||
BackupFiles().setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
function formatFileSize(sizeInBytes) {
|
||||
const sizeInKB = sizeInBytes / 1024; // 1 KB = 1024 bytes
|
||||
if (sizeInKB < 1024) {
|
||||
return `${sizeInKB.toFixed(2)} KB`;
|
||||
} else {
|
||||
const sizeInMB = sizeInKB / 1024; // 1 MB = 1024 KB
|
||||
if (sizeInMB < 1024) {
|
||||
return `${sizeInMB.toFixed(2)} MB`;
|
||||
} else {
|
||||
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
|
||||
if (sizeInGB < 1024) {
|
||||
return `${sizeInGB.toFixed(2)} GB`;
|
||||
} else {
|
||||
const sizeInTB = sizeInGB / 1024; // 1 TB = 1024 GB
|
||||
if (sizeInTB < 1024) {
|
||||
return `${sizeInTB.toFixed(2)} TB`;
|
||||
} else {
|
||||
const sizeInPB = sizeInTB / 1024; // 1 PB = 1024 TB
|
||||
return `${sizeInPB.toFixed(2)} PB`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableCell>{data.name}</TableCell>
|
||||
<TableCell>{Intl.DateTimeFormat('en-UK', options).format(new Date(data.datecreated))}</TableCell>
|
||||
<TableCell>{formatFileSize(data.size)}</TableCell>
|
||||
<TableCell className="d-flex justify-content-center">
|
||||
<DropdownButton title="Actions" variant="outline-primary">
|
||||
|
||||
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(data.name)}>Download</Dropdown.Item>
|
||||
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(data.name)}>Restore</Dropdown.Item>
|
||||
<Dropdown.Divider ></Dropdown.Divider>
|
||||
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(data.name)}>Delete</Dropdown.Item>
|
||||
</DropdownButton>
|
||||
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function BackupFiles() {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [showAlert, setshowAlert] = useState({visible:false,type:'danger',title:'Error',message:''});
|
||||
const token = localStorage.getItem('token');
|
||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||
const [page, setPage] = React.useState(0);
|
||||
|
||||
|
||||
function handleCloseAlert() {
|
||||
setshowAlert({visible:false});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -36,108 +181,15 @@ export default function BackupFiles() {
|
||||
return () => clearInterval(intervalId);
|
||||
}, [files,token]);
|
||||
|
||||
|
||||
|
||||
async function downloadBackup(filename) {
|
||||
const url=`/data/files/${filename}`;
|
||||
axios({
|
||||
url: url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
}).then(response => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreBackup(filename) {
|
||||
const url=`/data/restore/${filename}`;
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
|
||||
})
|
||||
.catch((error) => {
|
||||
setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
const url=`/data/files/${filename}`;
|
||||
axios
|
||||
.delete(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
setshowAlert({visible:true,title:'Success',type:'success',message:response.data});
|
||||
})
|
||||
.catch((error) => {
|
||||
setshowAlert({visible:true,title:'Error',type:'danger',message:error.response.data});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
function formatFileSize(sizeInBytes) {
|
||||
const sizeInKB = sizeInBytes / 1024; // 1 KB = 1024 bytes
|
||||
if (sizeInKB < 1024) {
|
||||
return `${sizeInKB.toFixed(2)} KB`;
|
||||
} else {
|
||||
const sizeInMB = sizeInKB / 1024; // 1 MB = 1024 KB
|
||||
if (sizeInMB < 1024) {
|
||||
return `${sizeInMB.toFixed(2)} MB`;
|
||||
} else {
|
||||
const sizeInGB = sizeInMB / 1024; // 1 GB = 1024 MB
|
||||
if (sizeInGB < 1024) {
|
||||
return `${sizeInGB.toFixed(2)} GB`;
|
||||
} else {
|
||||
const sizeInTB = sizeInGB / 1024; // 1 TB = 1024 GB
|
||||
if (sizeInTB < 1024) {
|
||||
return `${sizeInTB.toFixed(2)} TB`;
|
||||
} else {
|
||||
const sizeInPB = sizeInTB / 1024; // 1 PB = 1024 TB
|
||||
return `${sizeInPB.toFixed(2)} PB`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
const handleNextPageClick = () => {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPageClick = () => {
|
||||
setPage((prevPage) => prevPage - 1);
|
||||
};
|
||||
|
||||
|
||||
function handleCloseAlert() {
|
||||
setshowAlert({visible:false});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -150,36 +202,50 @@ export default function BackupFiles() {
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>Date Created</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files &&
|
||||
files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).map((file, index) => (
|
||||
<tr key={index}>
|
||||
<td>{file.name}</td>
|
||||
<td>{Intl.DateTimeFormat('en-UK', options).format(new Date(file.datecreated))}</td>
|
||||
<td>{formatFileSize(file.size)}</td>
|
||||
<td>
|
||||
<DropdownButton title="Actions" variant="outline-primary">
|
||||
|
||||
<Dropdown.Item as="button" variant="primary" onClick={()=>downloadBackup(file.name)}>Download</Dropdown.Item>
|
||||
<Dropdown.Item as="button" variant="warning" onClick={()=>restoreBackup(file.name)}>Restore</Dropdown.Item>
|
||||
<Dropdown.Divider ></Dropdown.Divider>
|
||||
<Dropdown.Item as="button" variant="danger" onClick={()=>deleteBackup(file.name)}>Delete</Dropdown.Item>
|
||||
</DropdownButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{files.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"gray"}}>No Backups Found</td></tr> :''}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<TableContainer className='rounded-2 overflow-visible'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>File Name</TableCell>
|
||||
<TableCell>Date Created</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{files && files.sort((a, b) =>new Date(b.datecreated) - new Date(a.datecreated)).slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((file,index) => (
|
||||
<Row key={index} data={file} />
|
||||
))}
|
||||
{files.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}} className='py-2'>No Backups Found</td></tr> :''}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<div className='d-flex justify-content-end my-2'>
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={()=>setPage(0)} disabled={page === 0}>
|
||||
First
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),files.length)} of ${files.length}`}</div>
|
||||
|
||||
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={()=>setPage(Math.ceil(files.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(files.length / rowsPerPage) - 1}>
|
||||
Last
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,116 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
|
||||
import Config from "../../lib/config";
|
||||
import {Row, Col, Tabs, Tab, Button, ButtonGroup } from 'react-bootstrap';
|
||||
|
||||
import GlobalStats from './user-info/globalStats';
|
||||
import UserDetails from './user-info/user-details';
|
||||
import LastPlayed from './user-info/lastplayed';
|
||||
import UserActivity from './user-info/user-activity';
|
||||
import "../css/users/user-details.css";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function UserInfo() {
|
||||
const { UserId } = useParams();
|
||||
const [data, setData] = useState();
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [config, setConfig] = useState();
|
||||
const [activeTab, setActiveTab] = useState('tabOverview');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const newConfig = await Config();
|
||||
setConfig(newConfig);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if(config){
|
||||
try {
|
||||
const userData = await axios.post(`/stats/getUserDetails`, {
|
||||
userid: UserId,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
setData(userData.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
fetchData();
|
||||
|
||||
if (!config) {
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [config, UserId]);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImgError(true);
|
||||
};
|
||||
|
||||
if (!data || !config) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserDetails UserId={UserId}/>
|
||||
<GlobalStats UserId={UserId}/>
|
||||
<LastPlayed UserId={UserId}/>
|
||||
<div className="user-detail-container">
|
||||
<div className="user-image-container">
|
||||
{imgError ? (
|
||||
<AccountCircleFillIcon size={"100%"} />
|
||||
) : (
|
||||
<img
|
||||
className="user-image"
|
||||
src={
|
||||
config.hostUrl +
|
||||
"/Users/" +
|
||||
data.Id +
|
||||
"/Images/Primary?fillHeight=100&fillWidth=100&quality=90"
|
||||
}
|
||||
onError={handleImageError}
|
||||
alt=""
|
||||
></img>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="user-name">{data.Name}</p>
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => setActiveTab('tabOverview')} active={activeTab==='tabOverview'} variant='outline-primary' type='button'>Overview</Button>
|
||||
<Button onClick={() => setActiveTab('tabActivity')} active={activeTab==='tabActivity'} variant='outline-primary' type='button'>Activity</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Tabs defaultActiveKey="tabOverview" activeKey={activeTab} variant='pills'>
|
||||
<Tab eventKey="tabOverview" className='bg-transparent'>
|
||||
<GlobalStats UserId={UserId}/>
|
||||
<LastPlayed UserId={UserId}/>
|
||||
</Tab>
|
||||
<Tab eventKey="tabActivity" className='bg-transparent'>
|
||||
<UserActivity UserId={UserId}/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/pages/components/user-info/user-activity.js
Normal file
65
src/pages/components/user-info/user-activity.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import TvLineIcon from "remixicon-react/TvLineIcon";
|
||||
import FilmLineIcon from "remixicon-react/FilmLineIcon";
|
||||
|
||||
import ActivityTable from "../activity/activity-table";
|
||||
|
||||
function UserActivity(props) {
|
||||
const [data, setData] = useState();
|
||||
const token = localStorage.getItem('token');
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const itemData = await axios.post(`/api/getUserHistory`, {
|
||||
userid: props.UserId,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
setData(itemData.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [ props.LibraryId,token]);
|
||||
|
||||
|
||||
if (!data) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Activity">
|
||||
<div className="Heading">
|
||||
<h1>User Activity</h1>
|
||||
<div className="pagination-range">
|
||||
<div className="header">Items</div>
|
||||
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value);}}>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Activity">
|
||||
<ActivityTable data={data} itemCount={itemCount}/>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserActivity;
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
|
||||
import Config from "../../../lib/config";
|
||||
import "../../css/users/user-details.css";
|
||||
|
||||
function UserDetails(props) {
|
||||
const [data, setData] = useState();
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [config, setConfig] = useState();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const newConfig = await Config();
|
||||
setConfig(newConfig);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
if(config){
|
||||
try {
|
||||
const userData = await axios.post(`/stats/getUserDetails`, {
|
||||
userid: props.UserId,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
setData(userData.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000 * 5);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [data,config, props.UserId]);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImgError(true);
|
||||
};
|
||||
|
||||
if (!data || !config) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-detail-container">
|
||||
<div className="user-image-container">
|
||||
{imgError ? (
|
||||
<AccountCircleFillIcon size={"100%"} />
|
||||
) : (
|
||||
<img
|
||||
className="user-image"
|
||||
src={
|
||||
config.hostUrl +
|
||||
"/Users/" +
|
||||
data.Id +
|
||||
"/Images/Primary?fillHeight=100&fillWidth=100&quality=90"
|
||||
}
|
||||
onError={handleImageError}
|
||||
alt=""
|
||||
></img>
|
||||
)}
|
||||
</div>
|
||||
<p className="user-name">{data.Name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDetails;
|
||||
@@ -1,99 +1,91 @@
|
||||
div a
|
||||
{
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table-rows:hover
|
||||
{
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.table-rows-content div:hover a
|
||||
{
|
||||
color: #00A4DC;
|
||||
}
|
||||
|
||||
.activity-table
|
||||
{
|
||||
background-color: rgba(100,100, 100, 0.2);
|
||||
color: white;
|
||||
color: white !important;
|
||||
|
||||
}
|
||||
|
||||
.table-rows-content{
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.table-headers div {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-bottom: 1px solid transparent;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-headers div:hover {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.table-headers, .table-rows-content
|
||||
td,th, td>button
|
||||
{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* border-bottom: 1px solid rgba(255, 255, 255, 0.05); */
|
||||
color: white !important;
|
||||
background-color: rgba(100,100, 100, 0.2);
|
||||
|
||||
}
|
||||
|
||||
.table-headers div,
|
||||
.table-rows-content div
|
||||
th
|
||||
{
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: white !important;
|
||||
background-color: rgba(200, 200, 200, 0.2);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
td > a
|
||||
{
|
||||
color: white;
|
||||
}
|
||||
|
||||
td:hover > a
|
||||
{
|
||||
color: #00A4DC;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
margin-inline: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: #4a4a4a;
|
||||
outline: unset;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-headers div:last-child,
|
||||
.table-rows-content div:last-child
|
||||
{
|
||||
border-right: none;
|
||||
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
overflow: hidden;
|
||||
max-height: 0; /* set the height to 0 to collapse the div */
|
||||
opacity:0;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
transition: all 0.3s ease;
|
||||
opacity: 100;
|
||||
max-height: min-content;
|
||||
}
|
||||
|
||||
.sub-row{
|
||||
color: darkgray;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sub-row a{
|
||||
color: darkgray;
|
||||
}
|
||||
|
||||
.sub-row a:hover{
|
||||
color: #00A4DC;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-row:last-child
|
||||
|
||||
.pagination-range .items
|
||||
{
|
||||
margin-bottom: 50px;
|
||||
background-color: rgb(255, 255, 255, 0.1);
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.bg-grey
|
||||
.pagination-range .header
|
||||
{
|
||||
background-color: rgb(100, 100, 100,0.2);
|
||||
padding-inline: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.pagination-range
|
||||
{
|
||||
width: 130px;
|
||||
height: 35px;
|
||||
color: white;
|
||||
display: flex;
|
||||
background-color: rgb(100, 100, 100,0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 1.2em;
|
||||
align-self: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination-range select
|
||||
{
|
||||
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(255, 255, 255, 0.1);
|
||||
color:white;
|
||||
font-size: 1em;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.page-btn
|
||||
{
|
||||
background-color: rgb(90 45 165) !important;
|
||||
border-color: rgb(90 45 165) !important;
|
||||
}
|
||||
@@ -20,3 +20,12 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-details div a{
|
||||
text-decoration: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.item-details div a:hover{
|
||||
color: #00A4DC !important;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
.episode{
|
||||
width: 220px !important;
|
||||
height: 128px !important;
|
||||
@@ -75,19 +76,22 @@
|
||||
}
|
||||
|
||||
.last-item-details {
|
||||
|
||||
|
||||
width: 90%;
|
||||
/* height: 30%; */
|
||||
position: relative;
|
||||
/* padding-top: 10px; */
|
||||
margin: 10px;
|
||||
|
||||
/* background-color: #f71b1b; */
|
||||
}
|
||||
|
||||
.last-item-details a{
|
||||
text-decoration: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.last-item-details a:hover{
|
||||
color: #00A4DC !important;
|
||||
}
|
||||
|
||||
|
||||
.last-item-name {
|
||||
/* width: 185px; */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
margin: 0px;
|
||||
height: calc(100vh - 100px);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
/* z-index: 9999; */
|
||||
background-color: #1e1c22;
|
||||
transition: opacity 800ms ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading::before
|
||||
{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.component-loading {
|
||||
|
||||
height: inherit;
|
||||
width: inherit;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading__spinner {
|
||||
width: 50px;
|
||||
|
||||
@@ -173,4 +173,14 @@ margin-bottom: 100%;
|
||||
.card-ip {
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
.card-text >a{
|
||||
text-decoration: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.card-text a:hover{
|
||||
color: #00A4DC !important;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ th:hover{
|
||||
th{
|
||||
border-bottom: none !important;
|
||||
cursor: default !important;
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
/* background-color: rgba(0, 0, 0, 0.8) !important; */
|
||||
}
|
||||
|
||||
.backup-file-download
|
||||
|
||||
@@ -112,4 +112,13 @@ input[type=number] {
|
||||
{
|
||||
padding-inline: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-items div a{
|
||||
text-decoration: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.stat-items div a:hover{
|
||||
color: #00A4DC !important;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
padding-right: 20px;
|
||||
margin-right: 20px;
|
||||
|
||||
|
||||
}
|
||||
@@ -1,83 +1,3 @@
|
||||
.Users
|
||||
{
|
||||
color: white;
|
||||
/* padding-right: 20px; */
|
||||
padding-bottom: 20px;
|
||||
/* margin-top: 10px; */
|
||||
}
|
||||
|
||||
.user-activity-table {
|
||||
border-collapse: collapse;
|
||||
border-radius: 5px;
|
||||
/* margin: 25px 0; */
|
||||
font-size: 0.9em;
|
||||
font-family: sans-serif;
|
||||
/* min-width: 400px; */
|
||||
/* box-shadow: 0 0 20px rgba(255, 255, 255, 0.15); */
|
||||
background-color: rgba(100,100, 100, 0.2);
|
||||
color: white;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
td
|
||||
{
|
||||
padding: 15px 15px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 576px) {
|
||||
td[data-cell]::before {
|
||||
content: attr(data-cell)": ";
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
color: #a8a8a8;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
td a{
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
td:hover a{
|
||||
|
||||
color: rgb(0, 164, 219);
|
||||
}
|
||||
|
||||
|
||||
|
||||
th {
|
||||
padding: 15px 15px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid transparent !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
th:hover {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: rgba(100, 100, 100, 0.1);
|
||||
}
|
||||
|
||||
tr:nth-child(odd) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* tbody tr:last-of-type {
|
||||
border-bottom: 2px solid #009879;
|
||||
} */
|
||||
|
||||
|
||||
.card-user-image
|
||||
{
|
||||
border-radius: 50%;
|
||||
@@ -87,98 +7,3 @@ tr:nth-child(odd) {
|
||||
|
||||
}
|
||||
|
||||
tbody tr:hover
|
||||
{
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
|
||||
td:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-inline: 5px;
|
||||
background-color: rgb(90 45 165);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btn:enabled:hover {
|
||||
background-color: rgb(66, 35, 114);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
margin-inline: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.pagination-range
|
||||
{
|
||||
width: 130px;
|
||||
height: 35px;
|
||||
color: white;
|
||||
display: flex;
|
||||
background-color: rgb(100, 100, 100,0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 1.2em;
|
||||
align-self: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination-range select
|
||||
{
|
||||
|
||||
height: 35px;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: rgb(255, 255, 255, 0.1);
|
||||
color:white;
|
||||
font-size: 1em;
|
||||
|
||||
|
||||
}
|
||||
.pagination-range .header
|
||||
{
|
||||
padding-inline: 10px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
|
||||
select option {
|
||||
background-color: #4a4a4a;
|
||||
outline: unset;
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
}
|
||||
|
||||
.pagination-range .items
|
||||
{
|
||||
background-color: rgb(255, 255, 255, 0.1);
|
||||
padding-inline: 10px;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import CryptoJS from 'crypto-js';
|
||||
import "./css/setup.css";
|
||||
// import LibrarySync from "./components/settings/librarySync";
|
||||
|
||||
// import Loading from './components/loading';
|
||||
import Loading from './components/general/loading';
|
||||
|
||||
function Login() {
|
||||
const [config, setConfig] = useState(null);
|
||||
@@ -76,6 +76,11 @@ function Login() {
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
if(!config || config.token)
|
||||
{
|
||||
return <Loading/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="form-box">
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from "axios";
|
||||
import Config from "../lib/config";
|
||||
import CryptoJS from 'crypto-js';
|
||||
import "./css/setup.css";
|
||||
import Loading from "./components/general/loading";
|
||||
// import LibrarySync from "./components/settings/librarySync";
|
||||
|
||||
// import Loading from './components/loading';
|
||||
@@ -76,6 +77,11 @@ function Signup() {
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
if(!config)
|
||||
{
|
||||
return <Loading/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="form-box">
|
||||
|
||||
@@ -3,39 +3,23 @@ import axios from "axios";
|
||||
import Config from "../lib/config";
|
||||
import { Link } from 'react-router-dom';
|
||||
import AccountCircleFillIcon from "remixicon-react/AccountCircleFillIcon";
|
||||
import { DropdownButton, Dropdown,ButtonGroup, Button } from 'react-bootstrap';
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
import "./css/users/users.css";
|
||||
|
||||
import Loading from "./components/general/loading";
|
||||
|
||||
function Users() {
|
||||
const [data, setData] = useState();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
function handleSort(key) {
|
||||
const direction =
|
||||
sortConfig.key === key && sortConfig.direction === "ascending"
|
||||
? "descending"
|
||||
: "ascending";
|
||||
setSortConfig({ key, direction });
|
||||
}
|
||||
|
||||
function sortData(data, { key, direction }) {
|
||||
if (!key) return data;
|
||||
|
||||
const sortedData = [...data];
|
||||
|
||||
sortedData.sort((a, b) => {
|
||||
if (a[key] < b[key]) return direction === "ascending" ? -1 : 1;
|
||||
if (a[key] > b[key]) return direction === "ascending" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedData;
|
||||
}
|
||||
function Row(row) {
|
||||
const { data } = row;
|
||||
|
||||
function formatTotalWatchTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
|
||||
@@ -75,6 +59,61 @@ function Users() {
|
||||
|
||||
|
||||
|
||||
const options = {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableCell>
|
||||
{data.PrimaryImageTag ? (
|
||||
<img
|
||||
className="card-user-image"
|
||||
src={
|
||||
row.hostUrl +
|
||||
"/Users/" +
|
||||
data.UserId +
|
||||
"/Images/Primary?quality=10"
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<AccountCircleFillIcon color="#fff" size={30} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell><Link to={`/users/${data.UserId}`} className="text-decoration-none">{data.UserName}</Link></TableCell>
|
||||
<TableCell>{data.LastWatched || 'never'}</TableCell>
|
||||
<TableCell>{data.LastClient || 'n/a'}</TableCell>
|
||||
<TableCell>{data.TotalPlays}</TableCell>
|
||||
<TableCell>{formatTotalWatchTime(data.TotalWatchTime) || 0}</TableCell>
|
||||
<TableCell>{data.LastSeen ? formatLastSeenTime(data.LastSeen) : 'never'}</TableCell>
|
||||
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Users() {
|
||||
const [data, setData] = useState();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||
const [page, setPage] = React.useState(0);
|
||||
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemCount,setItemCount] = useState(10);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
@@ -94,7 +133,7 @@ function Users() {
|
||||
axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
@@ -109,9 +148,7 @@ function Users() {
|
||||
|
||||
|
||||
|
||||
if (!data && config) {
|
||||
fetchData();
|
||||
}
|
||||
fetchData();
|
||||
|
||||
if (!config) {
|
||||
fetchConfig();
|
||||
@@ -119,22 +156,21 @@ function Users() {
|
||||
|
||||
const intervalId = setInterval(fetchData, 60000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [data, config]);
|
||||
}, [config]);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const sortedData = sortData(data, sortConfig);
|
||||
|
||||
const indexOfLastUser = currentPage * itemCount;
|
||||
const indexOfFirstUser = indexOfLastUser - itemCount;
|
||||
const currentUsers = sortedData.slice(indexOfFirstUser, indexOfLastUser);
|
||||
const handleNextPageClick = () => {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPageClick = () => {
|
||||
setPage((prevPage) => prevPage - 1);
|
||||
};
|
||||
|
||||
|
||||
const pageNumbers = [];
|
||||
for (let i = 1; i <= Math.ceil(sortedData.length / itemCount); i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
@@ -143,7 +179,7 @@ function Users() {
|
||||
<h1 >All Users</h1>
|
||||
<div className="pagination-range">
|
||||
<div className="header">Items</div>
|
||||
<select value={itemCount} onChange={(event) => {setItemCount(event.target.value); setCurrentPage(1);}}>
|
||||
<select value={itemCount} onChange={(event) => {setRowsPerPage(event.target.value); setPage(0); setItemCount(event.target.value);}}>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
@@ -152,62 +188,54 @@ function Users() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="user-activity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="d-none d-md-table-cell" ></th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("UserName")}>User</th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("LastWatched")}>Last Watched</th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("LastClient")}>Last Client</th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("TotalPlays")}>Total Plays</th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("TotalWatchTime")}>Total Watch Time</th>
|
||||
<th className="d-none d-md-table-cell" onClick={() => handleSort("LastSeen")}>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentUsers.map((item) => (
|
||||
<tr key={item.UserId} >
|
||||
<td className="d-block d-md-table-cell">
|
||||
{item.PrimaryImageTag ? (
|
||||
<img
|
||||
className="card-user-image"
|
||||
src={
|
||||
config.hostUrl +
|
||||
"/Users/" +
|
||||
item.UserId +
|
||||
"/Images/Primary?quality=10"
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<AccountCircleFillIcon color="#fff" size={30} />
|
||||
)}
|
||||
</td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"User"}> <Link to={`/users/${item.UserId}`}>{item.UserName}</Link></td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"Last Watched"}>{item.LastWatched || 'never'}</td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"Last Client"}>{item.LastClient || 'n/a'}</td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"Total Plays"}>{item.TotalPlays}</td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"Total Watch Time"}>{formatTotalWatchTime(item.TotalWatchTime) || 0}</td>
|
||||
<td className="d-block d-md-table-cell py-2" data-cell={"Last Seen"}>{item.LastSeen ? formatLastSeenTime(item.LastSeen) : 'never'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pagination">
|
||||
<button className="page-btn" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
|
||||
First
|
||||
</button>
|
||||
<button className="page-btn" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
|
||||
Previous
|
||||
</button>
|
||||
<div className="page-number">{`Page ${currentPage} of ${pageNumbers.length}`}</div>
|
||||
<button className="page-btn" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === pageNumbers.length}>
|
||||
Next
|
||||
</button>
|
||||
<button className="page-btn" onClick={() => setCurrentPage(pageNumbers.length)} disabled={currentPage === pageNumbers.length}>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
<TableContainer className='rounded-2'>
|
||||
<Table aria-label="collapsible table" >
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Last Watched</TableCell>
|
||||
<TableCell>Last Client</TableCell>
|
||||
<TableCell>Plays</TableCell>
|
||||
<TableCell>Watch Time</TableCell>
|
||||
<TableCell>Last Seen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data && data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => (
|
||||
<Row key={row.id} data={row} hostUrl={config.hostUrl}/>
|
||||
))}
|
||||
{data.length===0 ? <tr><td colSpan="5" style={{ textAlign: "center", fontStyle: "italic" ,color:"grey"}}>No Backups Found</td></tr> :''}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<div className='d-flex justify-content-end my-2'>
|
||||
<ButtonGroup className="pagination-buttons">
|
||||
<Button className="page-btn" onClick={()=>setPage(0)} disabled={page === 0}>
|
||||
First
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={handlePreviousPageClick} disabled={page === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="page-number d-flex align-items-center justify-content-center">{`${page *rowsPerPage + 1}-${Math.min((page * rowsPerPage+ 1 ) + (rowsPerPage - 1),data.length)} of ${data.length}`}</div>
|
||||
|
||||
<Button className="page-btn" onClick={handleNextPageClick} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button className="page-btn" onClick={()=>setPage(Math.ceil(data.length / rowsPerPage) - 1)} disabled={page >= Math.ceil(data.length / rowsPerPage) - 1}>
|
||||
Last
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user