From cfe36ee2477aa481350defdefe512c5864e084d0 Mon Sep 17 00:00:00 2001 From: GrimJu Date: Sat, 11 Jan 2025 03:42:12 +0100 Subject: [PATCH] Prototype implementation of activity timeline feature --- ...90_create_function_fs_get_user_activity.js | 70 +++ backend/routes/api.js | 25 + package-lock.json | 469 +++++++----------- package.json | 1 + public/locales/en-UK/translation.json | 8 +- src/lib/navdata.jsx | 9 +- src/pages/activity_time_line.jsx | 195 ++++++++ .../activity-timeline-item.jsx | 76 +++ .../activity-timeline/activity-timeline.jsx | 80 +++ .../components/activity-timeline/helpers.jsx | 56 +++ src/pages/css/timeline/activity-timeline.css | 25 + src/routes.jsx | 6 + 12 files changed, 733 insertions(+), 287 deletions(-) create mode 100644 backend/migrations/090_create_function_fs_get_user_activity.js create mode 100644 src/pages/activity_time_line.jsx create mode 100644 src/pages/components/activity-timeline/activity-timeline-item.jsx create mode 100644 src/pages/components/activity-timeline/activity-timeline.jsx create mode 100644 src/pages/components/activity-timeline/helpers.jsx create mode 100644 src/pages/css/timeline/activity-timeline.css diff --git a/backend/migrations/090_create_function_fs_get_user_activity.js b/backend/migrations/090_create_function_fs_get_user_activity.js new file mode 100644 index 0000000..7df42a6 --- /dev/null +++ b/backend/migrations/090_create_function_fs_get_user_activity.js @@ -0,0 +1,70 @@ +exports.up = function(knex) { + return knex.schema.raw(` + CREATE OR REPLACE FUNCTION fs_get_user_activity( + user_id text, + library_ids text[] + ) + RETURNS TABLE ( + "UserName" text, + "Title" text, + "EpisodeCount" bigint, + "FirstActivityDate" timestamptz, + "LastActivityDate" timestamptz, + "TotalPlaybackDuration" bigint, + "SeasonName" text, + "MediaType" text, + "NowPlayingItemId" text + ) AS $$ + BEGIN + RETURN QUERY + SELECT + jp."UserName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" + ELSE jp."NowPlayingItemName" + END AS "Title", + COUNT(DISTINCT jp."EpisodeId") AS "EpisodeCount", + MIN(jp."ActivityDateInserted") AS "FirstActivityDate", + MAX(jp."ActivityDateInserted") AS "LastActivityDate", + SUM(jp."PlaybackDuration")::bigint AS "TotalPlaybackDuration", + ls."Name" AS "SeasonName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN 'Show' + ELSE 'Movie' + END AS "MediaType", + jp."NowPlayingItemId" + FROM + public.jf_playback_activity AS jp + JOIN + public.jf_library_items AS jli ON jp."NowPlayingItemId" = jli."Id" + JOIN + public.jf_libraries AS jl ON jli."ParentId" = jl."Id" + LEFT JOIN + public.jf_library_seasons AS ls ON jp."SeasonId" = ls."Id" + WHERE + jp."UserId" = user_id + AND jl."Id" = ANY(library_ids) + GROUP BY + jp."UserName", + CASE + WHEN jp."SeriesName" IS NOT NULL THEN jp."SeriesName" + ELSE jp."NowPlayingItemName" + END, + jp."SeriesName", + jp."SeasonId", + ls."Name", + jp."NowPlayingItemId" + HAVING + NOT (MAX(jl."Name") = 'Shows' AND ls."Name" IS NULL) + ORDER BY + MAX(jp."ActivityDateInserted") DESC; + END; + $$ LANGUAGE plpgsql; +`); +}; + +exports.down = function(knex) { + return knex.schema.raw(` + DROP FUNCTION IF EXISTS fs_get_user_activity; + `); +}; \ No newline at end of file diff --git a/backend/routes/api.js b/backend/routes/api.js index e0da1e5..eacaf04 100644 --- a/backend/routes/api.js +++ b/backend/routes/api.js @@ -1432,6 +1432,31 @@ router.post("/deletePlaybackActivity", async (req, res) => { } }); +router.post("/getActivityTimeLine", async (req, res) => { + try { + const { userId, libraries } = req.body; + + if (libraries === undefined || !Array.isArray(libraries)) { + res.status(400); + res.send("A list of IDs is required. EG: [1,2,3]"); + return; + } + + if (userId === undefined) { + res.status(400); + res.send("A userId is required."); + return; + } + + const {rows} = await db.query(`SELECT * FROM fs_get_user_activity($1, $2);`, [userId, libraries]); + res.send(rows); + } catch (error) { + console.log(error); + res.status(503); + res.send(error); + } +}); + // Handle other routes router.use((req, res) => { res.status(404).send({ error: "Not Found" }); diff --git a/package-lock.json b/package-lock.json index f0f494f..2d341df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", + "@mui/lab": "^6.0.0-beta.22", "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.3", "@mui/x-date-pickers": "^7.23.3", @@ -3257,6 +3258,44 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -4151,10 +4190,43 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz", + "integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.0.tgz", - "integrity": "sha512-/d8NwSuC3rMwCjswmGB3oXC4sdDuhIUJ8inVQAxGrADJhf0eq/kmy+foFKvpYhHl2siOZR+MLdFttw6/Bzqtqg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.1.tgz", + "integrity": "sha512-2OmnEyoHpj5//dJJpMuxOeLItCCHdf99pjMFfUFdBteCunAK9jW+PwEo4mtdGcLs7P+IgZ+85ypd52eY4AigoQ==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" @@ -4185,23 +4257,19 @@ } } }, - "node_modules/@mui/material": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.0.tgz", - "integrity": "sha512-qhlTFyRMxfoVPxUtA5e8IvqxP0dWo2Ij7cvot7Orag+etUlZH+3UwD8gZGt+3irOoy7Ms3UNBflYjwEikUXtAQ==", + "node_modules/@mui/lab": { + "version": "6.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.22.tgz", + "integrity": "sha512-9nwUfBj+UzoQJOCbqV+JcCSJ74T+gGWrM1FMlXzkahtYUcMN+5Zmh2ArlttW3zv2dZyCzp7K5askcnKF0WzFQg==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.3.0", - "@mui/system": "^6.3.0", - "@mui/types": "^7.2.20", - "@mui/utils": "^6.3.0", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", + "@mui/base": "5.0.0-beta.68", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^19.0.0", - "react-transition-group": "^4.4.5" + "prop-types": "^15.8.1" }, "engines": { "node": ">=14.0.0" @@ -4213,7 +4281,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.3.0", + "@mui/material": "^6.3.1", + "@mui/material-pigment-css": "^6.3.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4233,13 +4302,69 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.0.tgz", - "integrity": "sha512-tdS8jvqMokltNTXg6ioRCCbVdDmZUJZa/T9VtTnX2Lwww3FTgCakst9tWLZSxm1fEE9Xp0m7onZJmgeUmWQYVw==", + "node_modules/@mui/material": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.1.tgz", + "integrity": "sha512-ynG9ayhxgCsHJ/dtDcT1v78/r2GwQyP3E0hPz3GdPRl0uFJz/uUTtI5KFYwadXmbC+Uv3bfB8laZ6+Cpzh03gA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.3.0", + "@mui/core-downloads-tracker": "^6.3.1", + "@mui/system": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.3.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.1.tgz", + "integrity": "sha512-g0u7hIUkmXmmrmmf5gdDYv9zdAig0KoxhIQn1JN8IVqApzf/AyRhH3uDGx5mSvs8+a1zb4+0W6LC260SyTTtdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.1", "prop-types": "^15.8.1" }, "engines": { @@ -4259,10 +4384,11 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.0.tgz", - "integrity": "sha512-iWA6eyiPkO07AlHxRUvI7dwVRSc+84zV54kLmjUms67GJeOWVuXlu8ZO+UhCnwJxHacghxnabsMEqet5PYQmHg==", + "node_modules/@mui/styled-engine": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.1.tgz", + "integrity": "sha512-/7CC0d2fIeiUxN5kCCwYu4AWUDd9cCTxWCyo0v/Rnv6s8uk6hWgJC3VLZBoDENBHf/KjqDZuYJ2CR+7hD6QYww==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", "@emotion/cache": "^11.13.5", @@ -4292,16 +4418,17 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.0.tgz", - "integrity": "sha512-L+8hUHMNlfReKSqcnVslFrVhoNfz/jw7Fe9NfDE85R3KarvZ4O3MU9daF/lZeqEAvnYxEilkkTfDwQ7qCgJdFg==", + "node_modules/@mui/system": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.1.tgz", + "integrity": "sha512-AwqQ3EAIT2np85ki+N15fF0lFXX1iFPqenCzVOSl3QXKy2eifZeGd9dGtt7pGMoFw5dzW4dRGGzRpLAq9rkl7A==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.3.0", - "@mui/styled-engine": "^6.3.0", - "@mui/types": "^7.2.20", - "@mui/utils": "^6.3.0", + "@mui/private-theming": "^6.3.1", + "@mui/styled-engine": "^6.3.1", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.3.1", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -4331,13 +4458,28 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", + "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", + "@mui/types": "^7.2.21", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -4360,150 +4502,11 @@ } } }, - "node_modules/@mui/material/node_modules/react-is": { + "node_modules/@mui/utils/node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, - "node_modules/@mui/private-theming": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", - "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.14", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.14.tgz", - "integrity": "sha512-auXLXzUaCSSOLqJXmsAaq7P96VPRXg2Rrz6OHNV7lr+kB8lobUF+/N84Vd9C4G/wvCXYPs5TYuuGBRhcGbiBGg==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.14", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "clsx": "^2.1.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/types": { - "version": "7.2.20", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", - "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", - "prop-types": "^15.8.1", - "react-is": "^18.2.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" }, "node_modules/@mui/x-data-grid": { "version": "7.23.3", @@ -4541,40 +4544,6 @@ } } }, - "node_modules/@mui/x-data-grid/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-data-grid/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@mui/x-date-pickers": { "version": "7.23.3", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz", @@ -4640,40 +4609,6 @@ } } }, - "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-date-pickers/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@mui/x-internals": { "version": "7.23.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz", @@ -4693,40 +4628,6 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@mui/x-internals/node_modules/@mui/utils": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", - "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.20", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/x-internals/node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" - }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index af1b69c..3b74515 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.0", + "@mui/lab": "^6.0.0-beta.22", "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.23.3", "@mui/x-date-pickers": "^7.23.3", diff --git a/public/locales/en-UK/translation.json b/public/locales/en-UK/translation.json index eddda06..5b87ec1 100644 --- a/public/locales/en-UK/translation.json +++ b/public/locales/en-UK/translation.json @@ -8,7 +8,8 @@ "STATISTICS": "Statistics", "SETTINGS": "Settings", "ABOUT": "About", - "LOGOUT": "Logout" + "LOGOUT": "Logout", + "TIMELINE": "Timeline" }, "HOME_PAGE": { "SESSIONS": "Sessions", @@ -230,6 +231,9 @@ "GITHUB": "Github", "Backup": "Backup Jellystat" }, + "TIMELINE_PAGE": { + "TIMELINE": "Timeline" + }, "SEARCH": "Search", "TOTAL": "Total", "LAST": "Last", @@ -305,4 +309,4 @@ "POSTCODE": "Postcode", "X_ROWS_SELECTED": "{ROWS} Rows Selected", "SUBTITLES": "Subtitles" -} +} \ No newline at end of file diff --git a/src/lib/navdata.jsx b/src/lib/navdata.jsx index 52deecb..a3c5d43 100644 --- a/src/lib/navdata.jsx +++ b/src/lib/navdata.jsx @@ -7,13 +7,14 @@ import SettingsFillIcon from 'remixicon-react/SettingsFillIcon'; import GalleryFillIcon from 'remixicon-react/GalleryFillIcon'; import UserFillIcon from 'remixicon-react/UserFillIcon'; import InformationFillIcon from 'remixicon-react/InformationFillIcon'; +import TimeLineIcon from 'remixicon-react/TimeLineIcon'; import { Trans } from 'react-i18next'; export const navData = [ { id: 0, - icon: , + icon: , text: , link: "" }, @@ -35,6 +36,12 @@ export const navData = [ text: , link: "activity" }, + { + id: 8, + icon: , + text: , + link: "timeline" + }, { id: 5, icon: , diff --git a/src/pages/activity_time_line.jsx b/src/pages/activity_time_line.jsx new file mode 100644 index 0000000..11e6db8 --- /dev/null +++ b/src/pages/activity_time_line.jsx @@ -0,0 +1,195 @@ +import "./css/stats.css"; + +import { Trans } from "react-i18next"; +import ActivityTimelineComponent from "./components/activity-timeline/activity-timeline"; +import { useEffect, useState } from "react"; + +import axios from "../lib/axios_instance.jsx"; +import Config from "../lib/config.jsx"; +import "./css/timeline/activity-timeline.css"; +import Loading from "./components/general/loading"; +import { Button, FormSelect, Modal } from "react-bootstrap"; +import LibraryFilterModal from "./components/library/library-filter-modal"; + +function ActivityTimeline() { + const [users, setUsers] = useState(); + const [selectedUser, setSelectedUser] = useState( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedUser") ?? "" + ); + const [libraries, setLibraries] = useState(); + const [config, setConfig] = useState(null); + const [showLibraryFilters, setShowLibraryFilters] = useState(false); + const [selectedLibraries, setSelectedLibraries] = useState( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") != + undefined + ? JSON.parse( + localStorage.getItem("PREF_ACTIVITY_TIMELINE_selectedLibraries") + ) + : [] + ); + + const handleLibraryFilter = (selectedOptions) => { + setSelectedLibraries(selectedOptions); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify(selectedOptions) + ); + }; + const handleUserSelection = (selectedUser) => { + console.log(selectedUser); + + setSelectedUser(selectedUser); + localStorage.setItem("PREF_ACTIVITY_TIMELINE_selectedUser", selectedUser); + }; + + const toggleSelectAll = () => { + if (selectedLibraries.length > 0) { + setSelectedLibraries([]); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify([]) + ); + } else { + setSelectedLibraries(libraries.map((library) => library.Id)); + localStorage.setItem( + "PREF_ACTIVITY_TIMELINE_selectedLibraries", + JSON.stringify(libraries.map((library) => library.Id)) + ); + } + }; + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + if (error.code === "ERR_NETWORK") { + console.log(error); + } + } + }; + + if (!config) { + fetchConfig(); + } + }, [config]); + + useEffect(() => { + if (config) { + const url = `/stats/getAllUserActivity`; + axios + .get(url, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }) + .then((users) => { + setUsers(users.data); + }) + .catch((error) => { + console.log(error); + }); + } + }, [config]); + + useEffect(() => { + if (config) { + const url = `/stats/getLibraryMetadata`; + axios + .get(url, { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + }) + .then((libraries) => { + setLibraries(libraries.data); + }) + .catch((error) => { + console.log(error); + }); + } + }, [config]); + + return users?.length > 0 && libraries?.length > 0 ? ( +
+
+

+ +

+
+
+
+
+ +
+ handleUserSelection(e.target.value)} + value={selectedUser} + className="w-md-75 rounded-0 rounded-end" + > + {users.map((user) => ( + + ))} + +
+
+
+ + + setShowLibraryFilters(false)} + > + + + + + + + + + + + +
+
+
+
+ {selectedUser && selectedLibraries?.length > 0 && ( + + )} +
+
+ ) : ( + + ); +} + +export default ActivityTimeline; diff --git a/src/pages/components/activity-timeline/activity-timeline-item.jsx b/src/pages/components/activity-timeline/activity-timeline-item.jsx new file mode 100644 index 0000000..21bf9c6 --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline-item.jsx @@ -0,0 +1,76 @@ +/* eslint-disable react/prop-types */ +import { useState } from "react"; + +import TimelineItem from "@mui/lab/TimelineItem"; +import TimelineSeparator from "@mui/lab/TimelineSeparator"; +import TimelineConnector from "@mui/lab/TimelineConnector"; +import TimelineContent from "@mui/lab/TimelineContent"; +import Typography from "@mui/material/Typography"; +import Card from "react-bootstrap/Card"; +import baseUrl from "../../../lib/baseurl"; + +import "../../css/timeline/activity-timeline.css"; + +import moment from "moment"; +import TvLineIcon from "remixicon-react/TvLineIcon.js"; +import FilmLineIcon from "remixicon-react/FilmLineIcon.js"; +import { MEDIA_TYPES } from "./helpers"; + +function formatEntryDates(entry) { + const { FirstActivityDate, LastActivityDate, MediaType } = entry; + const startDate = moment(FirstActivityDate); + const endDate = moment(LastActivityDate); + + if (startDate.isSame(endDate, "day") || MediaType === MEDIA_TYPES.Movies) { + return startDate.format("L"); + } else { + return `${startDate.format("L")} - ${endDate.format("L")}`; + } +} +const DefaultImage = (props) => { + const { MediaType } = props; + return ( +
+ {MediaType === MEDIA_TYPES.Shows ? SeriesIcon : MovieIcon} +
+ ); +}; +const SeriesIcon = ; +const MovieIcon = ; + +export default function ActivityTimelineItem(entry) { + const { Title, SeasonName, NowPlayingItemId } = entry; + const [useDefaultImage, setUseDefaultImage] = useState(false); + return ( + + + +
+ {!useDefaultImage ? ( + setUseDefaultImage(true)} + /> + ) : ( + + )} +
+ +
+ + + {Title} + + {SeasonName && {SeasonName}} + {formatEntryDates(entry)} + +
+ ); +} diff --git a/src/pages/components/activity-timeline/activity-timeline.jsx b/src/pages/components/activity-timeline/activity-timeline.jsx new file mode 100644 index 0000000..7e9712c --- /dev/null +++ b/src/pages/components/activity-timeline/activity-timeline.jsx @@ -0,0 +1,80 @@ +/* eslint-disable react/prop-types */ +import { useEffect, useState } from "react"; +import axios from "../../../lib/axios_instance"; + +import Timeline from "@mui/lab/Timeline"; + +import "../../css/timeline/activity-timeline.css"; + +import Config from "../../../lib/config.jsx"; +import Loading from "../../../pages/components/general/loading.jsx"; + +import ActivityTimelineItem from "./activity-timeline-item.jsx"; +import { groupAdjacentSeasons } from "./helpers.jsx"; + +export default function ActivityTimelineComponent(props) { + const { userId, libraries } = props; + + const [timelineEntries, setTimelineEntries] = useState(); + const [config, setConfig] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const newConfig = await Config.getConfig(); + setConfig(newConfig); + } catch (error) { + if (error.code === "ERR_NETWORK") { + console.log(error); + } + } + }; + + const fetchLibraries = () => { + if (config) { + const url = `/api/getActivityTimeLine`; + axios + .post( + url, + { userId: userId, libraries: libraries }, + { + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + } + ) + .then((timelineEntries) => { + const groupedAdjacentSeasons = groupAdjacentSeasons([ + ...timelineEntries.data, + ]); + setTimelineEntries(groupedAdjacentSeasons); + }) + .catch((error) => { + console.log(error); + }); + } + }; + + if (!config) { + fetchConfig(); + } + + fetchLibraries(); + }, [userId, libraries, config]); + + return timelineEntries?.length > 0 ? ( +
+ + {timelineEntries.map((entry) => ( + + ))} + +
+ ) : ( + + ); +} diff --git a/src/pages/components/activity-timeline/helpers.jsx b/src/pages/components/activity-timeline/helpers.jsx new file mode 100644 index 0000000..99c20a6 --- /dev/null +++ b/src/pages/components/activity-timeline/helpers.jsx @@ -0,0 +1,56 @@ +export const MEDIA_TYPES = { + Movies: "Movie", + Shows: "Show", +}; + +/** + * groups subsequent seasons of shows into single entries with a combined label and timeframe + * @param {*} timelineEntries List of entries as returned by /api/getActivityTimeLine + * @returns Same list of entries, seasons of the same show that follow each other will be merged into one entry + */ +export function groupAdjacentSeasons(timelineEntries) { + return timelineEntries + .reverse() + .map((entry, index, entryArray) => { + if (entry?.MediaType === MEDIA_TYPES.Shows) { + let potentialNextSeasonIndex = index + 1; + //if the next entry is another season of the same show, merge them + if (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) { + let highestSeasonName = entry.SeasonName; + let lastSeasonInSession; + //merge all further seasons as well + while (entry.Title === entryArray[potentialNextSeasonIndex]?.Title) { + const potentialNextSeason = entryArray[potentialNextSeasonIndex]; + if (entry.Title === potentialNextSeason?.Title) { + lastSeasonInSession = potentialNextSeason; + //remove season from list after usage + entryArray[potentialNextSeasonIndex] = undefined; + + //hack: in my db the seasons weren't always sorted correctly. + if ( + highestSeasonName?.localeCompare( + lastSeasonInSession.SeasonName + ) === -1 + ) { + highestSeasonName = lastSeasonInSession.SeasonName; + } + } else { + //all subsequent seasons have been merged into one entry and were removed from the list + break; + } + potentialNextSeasonIndex++; + } + const newSeasonName = `${entry.SeasonName} - ${highestSeasonName}`; + const newLastActivityDate = lastSeasonInSession.LastActivityDate; + return { + ...entry, + SeasonName: newSeasonName, + LastActivityDate: newLastActivityDate, + }; + } + } + return entry; + }) + .filter((entry) => !!entry) + .reverse(); +} diff --git a/src/pages/css/timeline/activity-timeline.css b/src/pages/css/timeline/activity-timeline.css new file mode 100644 index 0000000..fa4851d --- /dev/null +++ b/src/pages/css/timeline/activity-timeline.css @@ -0,0 +1,25 @@ +@import "../variables.module.css"; + +.Heading { + justify-content: space-between; +} +.activity-card { + display: flex; + width: 10rem; + .activity-card-img { + border-radius: var(--bs-border-radius-lg) !important; + object-fit: cover; + background-color: black; + background-repeat: no-repeat; + background-size: cover; + transition: all 0.2s ease-in-out; + } +} + +.MuiTimelineItem-root { + height: 20rem; +} + +.MuiTimelineContent-root { + align-self: center; +} diff --git a/src/routes.jsx b/src/routes.jsx index 8fb7873..5e7788c 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -11,6 +11,7 @@ import About from "./pages/about"; import TestingRoutes from "./pages/testing"; import Activity from "./pages/activity"; import Statistics from "./pages/statistics"; +import ActivityTimeline from "./pages/activity_time_line"; const routes = [ { @@ -58,6 +59,11 @@ const routes = [ element: , exact: true, }, + { + path: "/timeline", + element: , + exact: true, + }, { path: "/about", element: ,