diff --git a/package-lock.json b/package-lock.json index 1f1d748f2e..1287735a17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@loadable/component": "5.15.3", "@mui/icons-material": "5.11.16", "@mui/material": "5.13.3", + "@mui/x-data-grid": "6.6.0", "@react-hook/resize-observer": "1.2.6", "@tanstack/react-query": "4.29.12", "@tanstack/react-query-devtools": "4.29.12", @@ -3337,6 +3338,31 @@ "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.6.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.6.0.tgz", + "integrity": "sha512-RCAdQM4D0RWLnFCtuv6pJEFqUtH3WmlTU2Av+p3Ir2Utw03FQzc81oOpOZNv/6SXv+rMa+i42pb6fW0U5Xz0AQ==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@mui/utils": "^5.13.1", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + }, + "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.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -14657,6 +14683,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "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", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -22686,6 +22717,18 @@ } } }, + "@mui/x-data-grid": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.6.0.tgz", + "integrity": "sha512-RCAdQM4D0RWLnFCtuv6pJEFqUtH3WmlTU2Av+p3Ir2Utw03FQzc81oOpOZNv/6SXv+rMa+i42pb6fW0U5Xz0AQ==", + "requires": { + "@babel/runtime": "^7.21.0", + "@mui/utils": "^5.13.1", + "clsx": "^1.2.1", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -31054,6 +31097,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", diff --git a/package.json b/package.json index 0741a67a2b..a138373fab 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@loadable/component": "5.15.3", "@mui/icons-material": "5.11.16", "@mui/material": "5.13.3", + "@mui/x-data-grid": "6.6.0", "@react-hook/resize-observer": "1.2.6", "@tanstack/react-query": "4.29.12", "@tanstack/react-query-devtools": "4.29.12", diff --git a/src/apps/experimental/App.tsx b/src/apps/experimental/App.tsx index 0013ef0430..0c0778b74e 100644 --- a/src/apps/experimental/App.tsx +++ b/src/apps/experimental/App.tsx @@ -36,6 +36,9 @@ const ExperimentalApp = () => { {LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)} + + {/* Redirects for old paths */} + } /> ); diff --git a/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx b/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx index 85db4d1f29..89f18669e9 100644 --- a/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx +++ b/src/apps/experimental/components/AppToolbar/UserMenuButton.tsx @@ -1,6 +1,4 @@ -import Avatar from '@mui/material/Avatar'; import IconButton from '@mui/material/IconButton'; -import { useTheme } from '@mui/material/styles'; import Tooltip from '@mui/material/Tooltip'; import React, { useCallback, useState } from 'react'; @@ -8,10 +6,10 @@ import { useApi } from 'hooks/useApi'; import globalize from 'scripts/globalize'; import AppUserMenu, { ID } from './menus/AppUserMenu'; +import UserAvatar from 'components/UserAvatar'; const UserMenuButton = () => { - const theme = useTheme(); - const { api, user } = useApi(); + const { user } = useApi(); const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState(null); const isUserMenuOpen = Boolean(userMenuAnchorEl); @@ -37,18 +35,7 @@ const UserMenuButton = () => { color='inherit' sx={{ padding: 0 }} > - + diff --git a/src/apps/experimental/components/GridActionsCellLink.tsx b/src/apps/experimental/components/GridActionsCellLink.tsx new file mode 100644 index 0000000000..faf7619c9e --- /dev/null +++ b/src/apps/experimental/components/GridActionsCellLink.tsx @@ -0,0 +1,17 @@ +import React, { type RefAttributes } from 'react'; +import { Link } from 'react-router-dom'; +import { GridActionsCellItem, type GridActionsCellItemProps } from '@mui/x-data-grid'; + +type GridActionsCellLinkProps = { to: string } & GridActionsCellItemProps & RefAttributes; + +/** + * Link component to use in mui's data-grid action column due to a current bug with passing props to custom link components. + * @see https://github.com/mui/mui-x/issues/4654 + */ +const GridActionsCellLink = ({ to, ...props }: GridActionsCellLinkProps) => ( + + + +); + +export default GridActionsCellLink; diff --git a/src/apps/experimental/components/activityTable/LogLevelChip.tsx b/src/apps/experimental/components/activityTable/LogLevelChip.tsx new file mode 100644 index 0000000000..8053f770c1 --- /dev/null +++ b/src/apps/experimental/components/activityTable/LogLevelChip.tsx @@ -0,0 +1,34 @@ +import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; +import Chip from '@mui/material/Chip'; +import React from 'react'; + +import globalize from 'scripts/globalize'; + +const LogLevelChip = ({ level }: { level: LogLevel }) => { + let color: 'info' | 'warning' | 'error' | undefined = undefined; + switch (level) { + case LogLevel.Information: + color = 'info'; + break; + case LogLevel.Warning: + color = 'warning'; + break; + case LogLevel.Error: + case LogLevel.Critical: + color = 'error'; + break; + } + + const levelText = globalize.translate(`LogLevel.${level}`); + + return ( + + ); +}; + +export default LogLevelChip; diff --git a/src/apps/experimental/components/activityTable/OverviewCell.tsx b/src/apps/experimental/components/activityTable/OverviewCell.tsx new file mode 100644 index 0000000000..69702de5f6 --- /dev/null +++ b/src/apps/experimental/components/activityTable/OverviewCell.tsx @@ -0,0 +1,64 @@ +import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; +import Info from '@mui/icons-material/Info'; +import Box from '@mui/material/Box'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import React, { FC, useCallback, useState } from 'react'; + +const OverviewCell: FC = ({ Overview, ShortOverview }) => { + const displayValue = ShortOverview ?? Overview; + const [ open, setOpen ] = useState(false); + + const onTooltipClose = useCallback(() => { + setOpen(false); + }, []); + + const onTooltipOpen = useCallback(() => { + setOpen(true); + }, []); + + if (!displayValue) return null; + + return ( + + + {displayValue} + + {ShortOverview && Overview && ( + + + + + + + + )} + + ); +}; + +export default OverviewCell; diff --git a/src/apps/experimental/components/drawers/dashboard/DevicesDrawerSection.tsx b/src/apps/experimental/components/drawers/dashboard/DevicesDrawerSection.tsx index ae1e7d4b85..cb3cbf33ce 100644 --- a/src/apps/experimental/components/drawers/dashboard/DevicesDrawerSection.tsx +++ b/src/apps/experimental/components/drawers/dashboard/DevicesDrawerSection.tsx @@ -40,7 +40,7 @@ const DevicesDrawerSection = () => { - + diff --git a/src/apps/experimental/routes/asyncRoutes/admin.ts b/src/apps/experimental/routes/asyncRoutes/admin.ts index 72bcc6f32b..7e8c0eca16 100644 --- a/src/apps/experimental/routes/asyncRoutes/admin.ts +++ b/src/apps/experimental/routes/asyncRoutes/admin.ts @@ -1,6 +1,7 @@ -import { AsyncRoute } from '../../../../components/router/AsyncRoute'; +import { AsyncRoute, AsyncRouteType } from 'components/router/AsyncRoute'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ + { path: 'dashboard/activity', page: 'dashboard/activity', type: AsyncRouteType.Experimental }, { path: 'notificationsettings.html', page: 'dashboard/notifications' }, { path: 'usernew.html', page: 'user/usernew' }, { path: 'userprofiles.html', page: 'user/userprofiles' }, diff --git a/src/apps/experimental/routes/dashboard/activity.tsx b/src/apps/experimental/routes/dashboard/activity.tsx new file mode 100644 index 0000000000..f007e104d3 --- /dev/null +++ b/src/apps/experimental/routes/dashboard/activity.tsx @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api'; +import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'; +import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; +import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; +import PermMedia from '@mui/icons-material/PermMedia'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Typography from '@mui/material/Typography'; +import { DataGrid, type GridColDef } from '@mui/x-data-grid'; +import { Link, useSearchParams } from 'react-router-dom'; + +import Page from 'components/Page'; +import UserAvatar from 'components/UserAvatar'; +import { useApi } from 'hooks/useApi'; +import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'scripts/datetime'; +import globalize from 'scripts/globalize'; +import { toBoolean } from 'utils/string'; + +import LogLevelChip from '../../components/activityTable/LogLevelChip'; +import OverviewCell from '../../components/activityTable/OverviewCell'; +import GridActionsCellLink from '../../components/GridActionsCellLink'; + +const DEFAULT_PAGE_SIZE = 25; +const VIEW_PARAM = 'useractivity'; + +const enum ActivityView { + All, + User, + System +} + +const getActivityView = (param: string | null) => { + if (param === null) return ActivityView.All; + if (toBoolean(param)) return ActivityView.User; + return ActivityView.System; +}; + +const getRowId = (row: ActivityLogEntry) => row.Id ?? -1; + +const Activity = () => { + const { api } = useApi(); + const [ searchParams, setSearchParams ] = useSearchParams(); + + const [ activityView, setActivityView ] = useState( + getActivityView(searchParams.get(VIEW_PARAM))); + const [ isLoading, setIsLoading ] = useState(true); + const [ paginationModel, setPaginationModel ] = useState({ + page: 0, + pageSize: DEFAULT_PAGE_SIZE + }); + const [ rowCount, setRowCount ] = useState(0); + const [ rows, setRows ] = useState([]); + const [ users, setUsers ] = useState>({}); + + const userColDef: GridColDef[] = activityView !== ActivityView.System ? [ + { + field: 'User', + headerName: globalize.translate('LabelUser'), + width: 60, + valueGetter: ({ row }) => users[row.UserId]?.Name, + renderCell: ({ row }) => ( + + + + ) + } + ] : []; + + const columns: GridColDef[] = [ + { + field: 'Date', + headerName: globalize.translate('LabelDate'), + width: 90, + type: 'date', + valueGetter: ({ value }) => parseISO8601Date(value), + valueFormatter: ({ value }) => toLocaleDateString(value) + }, + { + field: 'Time', + headerName: globalize.translate('LabelTime'), + width: 100, + type: 'dateTime', + valueGetter: ({ row }) => parseISO8601Date(row.Date), + valueFormatter: ({ value }) => toLocaleTimeString(value) + }, + { + field: 'Severity', + headerName: globalize.translate('LabelLevel'), + width: 110, + renderCell: ({ value }) => ( + value ? ( + + ) : undefined + ) + }, + ...userColDef, + { + field: 'Name', + headerName: globalize.translate('LabelName'), + width: 200 + }, + { + field: 'Overview', + headerName: globalize.translate('LabelOverview'), + width: 200, + valueGetter: ({ row }) => row.ShortOverview ?? row.Overview, + renderCell: ({ row }) => ( + + ) + }, + { + field: 'Type', + headerName: globalize.translate('LabelType'), + width: 120 + }, + { + field: 'actions', + type: 'actions', + getActions: ({ row }) => { + const actions = []; + + if (row.ItemId) { + actions.push( + } + label={globalize.translate('LabelMediaDetails')} + title={globalize.translate('LabelMediaDetails')} + to={`/details?id=${row.ItemId}`} + /> + ); + } + + return actions; + } + } + ]; + + const onViewChange = useCallback((_e, newView: ActivityView | null) => { + if (newView !== null) { + setActivityView(newView); + } + }, []); + + useEffect(() => { + if (api) { + const fetchUsers = async () => { + const { data } = await getUserApi(api).getUsers(); + const usersById: Record = {}; + data.forEach(user => { + if (user.Id) { + usersById[user.Id] = user; + } + }); + + setUsers(usersById); + }; + + fetchUsers() + .catch(err => { + console.error('[activity] failed to fetch users', err); + }); + } + }, [ api ]); + + useEffect(() => { + if (api) { + const fetchActivity = async () => { + const params: { + startIndex: number, + limit: number, + hasUserId?: boolean + } = { + startIndex: paginationModel.page * paginationModel.pageSize, + limit: paginationModel.pageSize + }; + if (activityView !== ActivityView.All) { + params.hasUserId = activityView === ActivityView.User; + } + + const { data } = await getActivityLogApi(api) + .getLogEntries(params); + + setRowCount(data.TotalRecordCount ?? 0); + setRows(data.Items ?? []); + setIsLoading(false); + }; + + setIsLoading(true); + fetchActivity() + .catch(err => { + console.error('[activity] failed to fetch activity log entries', err); + }); + } + }, [ activityView, api, paginationModel ]); + + useEffect(() => { + const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM)); + if (currentViewParam !== activityView) { + if (activityView === ActivityView.All) { + searchParams.delete(VIEW_PARAM); + } else { + searchParams.set(VIEW_PARAM, `${activityView === ActivityView.User}`); + } + setSearchParams(searchParams); + } + }, [ activityView, searchParams, setSearchParams ]); + + return ( + +
+ + + + {globalize.translate('HeaderActivity')} + + + + + {globalize.translate('All')} + + + {globalize.translate('LabelUser')} + + + {globalize.translate('LabelSystem')} + + + + +
+
+ ); +}; + +export default Activity; diff --git a/src/apps/experimental/routes/legacyRoutes/admin.ts b/src/apps/experimental/routes/legacyRoutes/admin.ts index e2037ddb11..b53f86ce87 100644 --- a/src/apps/experimental/routes/legacyRoutes/admin.ts +++ b/src/apps/experimental/routes/legacyRoutes/admin.ts @@ -163,12 +163,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'dashboard/scheduledtasks/scheduledtasks', view: 'dashboard/scheduledtasks/scheduledtasks.html' } - }, { - path: 'serveractivity.html', - pageProps: { - controller: 'dashboard/serveractivity', - view: 'dashboard/serveractivity.html' - } }, { path: 'apikeys.html', pageProps: { diff --git a/src/apps/experimental/theme.ts b/src/apps/experimental/theme.ts index 7def6bd4b7..d3f84366be 100644 --- a/src/apps/experimental/theme.ts +++ b/src/apps/experimental/theme.ts @@ -21,6 +21,15 @@ const theme = createTheme({ fontFamily: '"Noto Sans", sans-serif', button: { textTransform: 'none' + }, + h1: { + fontSize: '1.8rem' + }, + h2: { + fontSize: '1.5rem' + }, + h3: { + fontSize: '1.17rem' } }, components: { diff --git a/src/apps/stable/App.tsx b/src/apps/stable/App.tsx index bbaec1b922..73c73f5881 100644 --- a/src/apps/stable/App.tsx +++ b/src/apps/stable/App.tsx @@ -51,6 +51,9 @@ const StableApp = () => ( {/* Suppress warnings for unhandled routes */} + + {/* Redirects for old paths */} + } /> ); diff --git a/src/apps/stable/routes/legacyRoutes/admin.ts b/src/apps/stable/routes/legacyRoutes/admin.ts index e2037ddb11..92aaef8041 100644 --- a/src/apps/stable/routes/legacyRoutes/admin.ts +++ b/src/apps/stable/routes/legacyRoutes/admin.ts @@ -164,7 +164,7 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ view: 'dashboard/scheduledtasks/scheduledtasks.html' } }, { - path: 'serveractivity.html', + path: 'dashboard/activity', pageProps: { controller: 'dashboard/serveractivity', view: 'dashboard/serveractivity.html' diff --git a/src/components/UserAvatar.tsx b/src/components/UserAvatar.tsx new file mode 100644 index 0000000000..3a2790e835 --- /dev/null +++ b/src/components/UserAvatar.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; +import Avatar from '@mui/material/Avatar'; +import { useTheme } from '@mui/material/styles'; + +import { useApi } from 'hooks/useApi'; + +interface UserAvatarProps { + user?: UserDto +} + +const UserAvatar: FC = ({ user }) => { + const { api } = useApi(); + const theme = useTheme(); + + return user ? ( + + ) : null; +}; + +export default UserAvatar; diff --git a/src/controllers/dashboard/dashboard.html b/src/controllers/dashboard/dashboard.html index 7fd60f7695..a76cf11971 100644 --- a/src/controllers/dashboard/dashboard.html +++ b/src/controllers/dashboard/dashboard.html @@ -44,7 +44,7 @@
- +

${HeaderActivity}

@@ -61,7 +61,7 @@
- +

${Alerts}

diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index ae59829aa5..c49a8336d3 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -466,7 +466,7 @@ function createToolsMenuList(pluginItems) { }); links.push({ name: globalize.translate('HeaderActivity'), - href: '#/serveractivity.html', + href: '#/dashboard/activity', pageIds: ['serverActivityPage'], icon: 'assessment' }); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 4bb461d211..1a3fdc47ab 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -603,6 +603,7 @@ "LabelCustomDeviceDisplayNameHelp": "Supply a custom display name or leave empty to use the name reported by the device.", "LabelCustomRating": "Custom rating", "LabelDashboardTheme": "Server Dashboard theme", + "LabelDate": "Date", "LabelDateAdded": "Date added", "LabelDateAddedBehavior": "Date added behavior for new content", "LabelDateAddedBehaviorHelp": "If a metadata value is present, it will always be used before either of these options.", @@ -725,11 +726,13 @@ "LabelKodiMetadataUserHelp": "Save watch data to NFO files for other applications to use.", "LabelLanguage": "Language", "LabelLanNetworks": "LAN networks", + "LabelLevel": "Level", "LabelLibraryPageSize": "Library page size", "LabelLibraryPageSizeHelp": "Set the amount of items to show on a library page. Set to 0 in order to disable paging.", "LabelMaxDaysForNextUp": "Max days in 'Next Up'", "LabelMaxDaysForNextUpHelp": "Set the maximum amount of days a show should stay in the 'Next Up' list without watching it.", "LabelMaxVideoResolution": "Maximum Allowed Video Transcoding Resolution", + "LabelMediaDetails": "Media details", "LabelLineup": "Lineup", "LabelLocalCustomCss": "Custom CSS code for styling which applies to this client only. You may want to disable server custom CSS code.", "LabelLocalHttpServerPortNumber": "Local HTTP port number", @@ -931,6 +934,7 @@ "LabelSyncPlaySettingsSpeedToSyncHelp": "Sync correction method that consist in speeding up the playback. Sync Correction must be enabled.", "LabelSyncPlaySettingsSkipToSync": "Enable SkipToSync", "LabelSyncPlaySettingsSkipToSyncHelp": "Sync correction method that consist in seeking to the estimated position. Sync Correction must be enabled.", + "LabelSystem": "System", "LabelTag": "Tag", "LabelTagline": "Tagline", "LabelTextBackgroundColor": "Text background color", @@ -1009,6 +1013,13 @@ "LiveBroadcasts": "Live broadcasts", "LiveTV": "Live TV", "Localization": "Localization", + "LogLevel.Trace": "Trace", + "LogLevel.Debug": "Debug", + "LogLevel.Information": "Information", + "LogLevel.Warning": "Warning", + "LogLevel.Error": "Error", + "LogLevel.Critical": "Critical", + "LogLevel.None": "None", "Logo": "Logo", "Lyricist": "Lyricist", "ManageLibrary": "Manage library", diff --git a/webpack.common.js b/webpack.common.js index c4b67af589..59d32fb952 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -40,7 +40,8 @@ const config = { '@mui/private-theming': '@mui/private-theming/legacy', '@mui/styled-engine': '@mui/styled-engine/legacy', '@mui/system': '@mui/system/legacy', - '@mui/utils': '@mui/utils/legacy' + '@mui/utils': '@mui/utils/legacy', + '@mui/x-data-grid': '@mui/x-data-grid/legacy' } }, plugins: [