Merge pull request #6933 from thornbill/item-count-widget
Add item count widget to dashboard
This commit is contained in:
95
src/apps/dashboard/components/widgets/ItemCountsWidget.tsx
Normal file
95
src/apps/dashboard/components/widgets/ItemCountsWidget.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import React from 'react';
|
||||
|
||||
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
|
||||
import MetricCard from 'apps/dashboard/features/metrics/components/MetricCard';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const ItemCountsWidget = () => {
|
||||
const { data: counts } = useItemCounts();
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch',
|
||||
marginTop: 2
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={Movie}
|
||||
metrics={[{
|
||||
label: globalize.translate('Movies'),
|
||||
value: counts?.MovieCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={Tv}
|
||||
metrics={[{
|
||||
label: globalize.translate('Series'),
|
||||
value: counts?.SeriesCount
|
||||
}, {
|
||||
label: globalize.translate('Episodes'),
|
||||
value: counts?.EpisodeCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={MusicNote}
|
||||
metrics={[{
|
||||
label: globalize.translate('Albums'),
|
||||
value: counts?.AlbumCount
|
||||
}, {
|
||||
label: globalize.translate('Songs'),
|
||||
value: counts?.SongCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={MusicVideo}
|
||||
metrics={[{
|
||||
label: globalize.translate('MusicVideos'),
|
||||
value: counts?.MusicVideoCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={Book}
|
||||
metrics={[{
|
||||
label: globalize.translate('Books'),
|
||||
value: counts?.BookCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, lg: 4 }}>
|
||||
<MetricCard
|
||||
Icon={VideoLibrary}
|
||||
metrics={[{
|
||||
label: globalize.translate('Collections'),
|
||||
value: counts?.BoxSetCount
|
||||
}]}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemCountsWidget;
|
||||
@@ -20,13 +20,13 @@
|
||||
</div>
|
||||
|
||||
<div class="dashboardActionsContainer">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh button-submit">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised">
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised button-delete">
|
||||
<span>${Restart}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised">
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised button-delete">
|
||||
<span>${ButtonShutdown}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,6 +36,8 @@
|
||||
<div id="divRunningTasks" class="paperList" style="padding: 1em;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemCounts"></div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
|
||||
@@ -33,6 +33,7 @@ import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import './dashboard.scss';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
|
||||
function showPlaybackInfo(btn, session) {
|
||||
let title;
|
||||
@@ -748,7 +749,7 @@ const DashboardPage = {
|
||||
|
||||
export default function (view) {
|
||||
const serverId = ApiClient.serverId();
|
||||
let unmountPathsWidget;
|
||||
let unmountWidgetFns = [];
|
||||
|
||||
function onRestartRequired(evt, apiClient) {
|
||||
console.debug('onRestartRequired not implemented', evt, apiClient);
|
||||
@@ -824,7 +825,8 @@ export default function (view) {
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
unmountPathsWidget = renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths'));
|
||||
unmountWidgetFns.push(renderComponent(ItemCountsWidget, {}, page.querySelector('#itemCounts')));
|
||||
unmountWidgetFns.push(renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')));
|
||||
|
||||
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
|
||||
@@ -852,7 +854,10 @@ export default function (view) {
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
if (unmountPathsWidget) unmountPathsWidget();
|
||||
unmountWidgetFns.forEach(unmount => {
|
||||
unmount();
|
||||
});
|
||||
unmountWidgetFns = [];
|
||||
|
||||
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
|
||||
|
||||
33
src/apps/dashboard/features/metrics/api/useItemCounts.ts
Normal file
33
src/apps/dashboard/features/metrics/api/useItemCounts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { LibraryApiGetItemCountsRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchItemCounts = async (
|
||||
api: Api,
|
||||
params?: LibraryApiGetItemCountsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getLibraryApi(api)
|
||||
.getItemCounts(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getItemCountsQuery = (
|
||||
api?: Api,
|
||||
params?: LibraryApiGetItemCountsRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ 'ItemCounts', params ],
|
||||
queryFn: ({ signal }) => fetchItemCounts(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useItemCounts = (
|
||||
params?: LibraryApiGetItemCountsRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getItemCountsQuery(api, params));
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
import { toDecimalString } from 'utils/number';
|
||||
|
||||
interface Metric {
|
||||
label: string
|
||||
value?: number
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
metrics: Metric[]
|
||||
Icon: typeof SvgIcon
|
||||
}
|
||||
|
||||
const MetricCard: FC<MetricCardProps> = ({
|
||||
metrics,
|
||||
Icon
|
||||
}) => {
|
||||
const { dateTimeLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
width: '100%',
|
||||
padding: 2,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{metrics.map(({ label, value }) => (
|
||||
<Box key={label}>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='h5'
|
||||
component='div'
|
||||
>
|
||||
{typeof value !== 'undefined' ? (
|
||||
toDecimalString(value, dateTimeLocale)
|
||||
) : (
|
||||
<Skeleton />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Icon fontSize='large' />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
@@ -9,7 +9,7 @@ import CircularProgress, {
|
||||
} from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { toPercent } from 'utils/number';
|
||||
import { toPercentString } from 'utils/number';
|
||||
import { getCurrentDateTimeLocale } from 'lib/globalize';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
@@ -36,7 +36,7 @@ function CircularProgressWithLabel(
|
||||
component='div'
|
||||
color='text.secondary'
|
||||
>
|
||||
{toPercent(props.value / 100, getCurrentDateTimeLocale())}
|
||||
{toPercentString(props.value / 100, getCurrentDateTimeLocale())}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import './emby-progressring.scss';
|
||||
import 'webcomponents.js/webcomponents-lite';
|
||||
import template from './emby-progressring.template.html';
|
||||
import { getCurrentDateTimeLocale } from '../../lib/globalize';
|
||||
import { toPercent } from '../../utils/number.ts';
|
||||
import { toPercentString } from '../../utils/number.ts';
|
||||
|
||||
const EmbyProgressRing = Object.create(HTMLDivElement.prototype);
|
||||
|
||||
@@ -71,7 +71,7 @@ EmbyProgressRing.setProgress = function (progress) {
|
||||
this.querySelector('.animate-75-100-b').style.transform = 'rotate(' + angle + 'deg)';
|
||||
}
|
||||
|
||||
this.querySelector('.progressring-text').innerHTML = toPercent(progress / 100, getCurrentDateTimeLocale());
|
||||
this.querySelector('.progressring-text').innerHTML = toPercentString(progress / 100, getCurrentDateTimeLocale());
|
||||
};
|
||||
|
||||
EmbyProgressRing.attachedCallback = function () {
|
||||
|
||||
@@ -11,11 +11,11 @@ export function useLocale() {
|
||||
const { dateTimeLocale: dateTimeSetting, language } = useUserSettings();
|
||||
const [ dateFnsLocale, setDateFnsLocale ] = useState<Locale>(enUS);
|
||||
|
||||
const locale = useMemo(() => (
|
||||
const locale: string = useMemo(() => (
|
||||
normalizeLocaleName(language || getDefaultLanguage())
|
||||
), [ language ]);
|
||||
|
||||
const dateTimeLocale = useMemo(() => (
|
||||
const dateTimeLocale: string = useMemo(() => (
|
||||
dateTimeSetting ? normalizeLocaleName(dateTimeSetting) : locale
|
||||
), [ dateTimeSetting, locale ]);
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ html {
|
||||
}
|
||||
|
||||
.button-delete {
|
||||
background: rgb(247, 0, 0);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background: #cb272a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
|
||||
@@ -13,13 +13,27 @@ export function randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a number formatted as a string.
|
||||
* @param {number} value The value as a number.
|
||||
* @param {string} locale The locale to use for formatting (i.e. en-us).
|
||||
* @returns {string} The value formatted as a string.
|
||||
*/
|
||||
export function toDecimalString(value: number, locale: string): string {
|
||||
if (toLocaleStringSupportsOptions()) {
|
||||
return value.toLocaleString(locale);
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a number formatted as a perentage.
|
||||
* @param {number} value The value as a number.
|
||||
* @param {string} locale The locale to use for formatting (i.e. en-us).
|
||||
* @returns {string} The value formatted as a percentage.
|
||||
*/
|
||||
export function toPercent(value: number | null | undefined, locale: string): string {
|
||||
export function toPercentString(value: number | null | undefined, locale: string): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user