diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
new file mode 100644
index 0000000000..98fe4be579
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
@@ -0,0 +1,67 @@
+import ChevronRight from '@mui/icons-material/ChevronRight';
+import Button from '@mui/material/Button';
+import List from '@mui/material/List';
+import Typography from '@mui/material/Typography';
+import React from 'react';
+
+import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage';
+import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem';
+import globalize from 'lib/globalize';
+
+const ServerPathWidget = () => {
+ const { data: systemStorage } = useSystemStorage();
+
+ return (
+ <>
+ }
+ sx={{
+ marginTop: 1,
+ marginBottom: 1
+ }}
+ // NOTE: We should use a react-router Link component, but components rendered in legacy views lack the
+ // routing context
+ href='#/dashboard/settings'
+ >
+
+ {globalize.translate('HeaderPaths')}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ServerPathWidget;
diff --git a/src/apps/dashboard/controllers/dashboard.html b/src/apps/dashboard/controllers/dashboard.html
index 5c449c5e91..55906b6882 100644
--- a/src/apps/dashboard/controllers/dashboard.html
+++ b/src/apps/dashboard/controllers/dashboard.html
@@ -75,44 +75,7 @@
-
+
diff --git a/src/apps/dashboard/controllers/dashboard.js b/src/apps/dashboard/controllers/dashboard.js
index 2388ac717d..adc062c0b7 100644
--- a/src/apps/dashboard/controllers/dashboard.js
+++ b/src/apps/dashboard/controllers/dashboard.js
@@ -1,30 +1,31 @@
import escapeHtml from 'escape-html';
-import datetime from 'scripts/datetime';
-import Events from 'utils/events.ts';
+import ServerPathWidget from 'apps/dashboard/components/widgets/ServerPathWidget';
+import ActivityLog from 'components/activitylog';
+import alert from 'components/alert';
+import cardBuilder from 'components/cardbuilder/cardBuilder';
+import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
+import confirm from 'components/confirm/confirm';
+import imageLoader from 'components/images/imageLoader';
+import indicators from 'components/indicators/indicators';
import itemHelper from 'components/itemHelper';
-import serverNotifications from 'scripts/serverNotifications';
-import dom from 'scripts/dom';
-import globalize from 'lib/globalize';
-import { formatDistanceToNow } from 'date-fns';
-import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
import loading from 'components/loading/loading';
import playMethodHelper from 'components/playback/playmethodhelper';
-import cardBuilder from 'components/cardbuilder/cardBuilder';
-import imageLoader from 'components/images/imageLoader';
-import ActivityLog from 'components/activitylog';
-import imageHelper from 'utils/image';
-import indicators from 'components/indicators/indicators';
+import { formatDistanceToNow } from 'date-fns';
+import { getSystemInfoQuery } from 'hooks/useSystemInfo';
+import globalize from 'lib/globalize';
+import { ServerConnections } from 'lib/jellyfin-apiclient';
+import datetime from 'scripts/datetime';
+import dom from 'scripts/dom';
+import serverNotifications from 'scripts/serverNotifications';
import taskButton from 'scripts/taskbutton';
import Dashboard from 'utils/dashboard';
-import alert from 'components/alert';
-import confirm from 'components/confirm/confirm';
-import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
-
-import { getSystemInfoQuery } from 'hooks/useSystemInfo';
-import { ServerConnections } from 'lib/jellyfin-apiclient';
+import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
+import Events from 'utils/events.ts';
+import imageHelper from 'utils/image';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient';
+import { renderComponent } from 'utils/reactUtils';
import 'elements/emby-button/emby-button';
import 'elements/emby-itemscontainer/emby-itemscontainer';
@@ -220,12 +221,6 @@ function reloadSystemInfo(view, apiClient) {
.then(systemInfo => {
view.querySelector('#serverName').innerText = systemInfo.ServerName;
view.querySelector('#versionNumber').innerText = systemInfo.Version;
-
- view.querySelector('#cachePath').innerText = systemInfo.CachePath;
- view.querySelector('#logPath').innerText = systemInfo.LogPath;
- view.querySelector('#transcodePath').innerText = systemInfo.TranscodingTempPath;
- view.querySelector('#metadataPath').innerText = systemInfo.InternalMetadataPath;
- view.querySelector('#webPath').innerText = systemInfo.WebPath;
});
}
@@ -827,6 +822,8 @@ export default function (view) {
button: page.querySelector('.btnRefresh')
});
+ renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths'));
+
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
});
diff --git a/src/apps/dashboard/features/storage/api/useSystemStorage.ts b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
new file mode 100644
index 0000000000..12db4a1713
--- /dev/null
+++ b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
@@ -0,0 +1,28 @@
+import type { Api } from '@jellyfin/sdk';
+import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
+import { queryOptions, useQuery } from '@tanstack/react-query';
+import type { AxiosRequestConfig } from 'axios';
+
+import { useApi } from 'hooks/useApi';
+
+const fetchSystemStorage = async (
+ api: Api,
+ options?: AxiosRequestConfig
+) => {
+ const response = await getSystemApi(api)
+ .getSystemStorage(options);
+ return response.data;
+};
+
+const getSystemStorageQuery = (
+ api?: Api
+) => queryOptions({
+ queryKey: [ 'SystemStorage' ],
+ queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
+ enabled: !!api
+});
+
+export const useSystemStorage = () => {
+ const { api } = useApi();
+ return useQuery(getSystemStorageQuery(api));
+};
diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx
new file mode 100644
index 0000000000..5d63b64460
--- /dev/null
+++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx
@@ -0,0 +1,105 @@
+import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client';
+import LinearProgress from '@mui/material/LinearProgress';
+import ListItem from '@mui/material/ListItem';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import Skeleton from '@mui/material/Skeleton';
+import Typography from '@mui/material/Typography';
+import React, { type FC } from 'react';
+
+import globalize from 'lib/globalize';
+import { getReadableSize } from 'utils/file';
+
+import { StorageType } from '../constants/StorageType';
+
+import StorageTypeIcon from './StorageTypeIcon';
+
+interface StorageListItemProps {
+ label: string
+ folder?: FolderStorageDto
+}
+
+const calculateUsed = (folder?: FolderStorageDto) => {
+ if (typeof folder?.UsedSpace === 'undefined') return 0;
+ if (typeof folder.FreeSpace === 'undefined') return 100;
+
+ return folder.UsedSpace / (folder.FreeSpace + folder.UsedSpace) * 100;
+};
+
+const getStatusColor = (percent: number) => {
+ if (percent >= 90) return 'error';
+ if (percent >= 80) return 'warning';
+ return 'success';
+};
+
+const getStorageTypeText = (type?: string | null) => {
+ if (!type) return undefined;
+
+ if (Object.keys(StorageType).includes(type)) {
+ return globalize.translate(`StorageType.${type}`);
+ }
+
+ return type;
+};
+
+const StorageListItem: FC = ({
+ label,
+ folder
+}) => {
+ const usedSpace = (typeof folder?.UsedSpace === 'undefined') ? '?' : getReadableSize(folder.UsedSpace);
+ const totalSpace = (typeof folder?.FreeSpace === 'undefined' || typeof folder.UsedSpace === 'undefined') ?
+ '?' : getReadableSize(folder.FreeSpace + folder.UsedSpace);
+ const usedPercent = calculateUsed(folder);
+ const statusColor = folder ? getStatusColor(usedPercent) : 'primary';
+
+ return (
+
+
+
+
+
+ {label}
+
+ }
+ secondary={
+ <>
+
+ {folder ? folder.Path : (
+
+ )}
+
+
+
+ {`${usedSpace} / ${totalSpace}`}
+
+ >
+ }
+ slots={{
+ secondary: 'div'
+ }}
+ />
+
+ );
+};
+
+export default StorageListItem;
diff --git a/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx
new file mode 100644
index 0000000000..4b62d455ed
--- /dev/null
+++ b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx
@@ -0,0 +1,31 @@
+import Album from '@mui/icons-material/Album';
+import Lan from '@mui/icons-material/Lan';
+import Memory from '@mui/icons-material/Memory';
+import Storage from '@mui/icons-material/Storage';
+import Usb from '@mui/icons-material/Usb';
+import React, { type FC } from 'react';
+
+import { StorageType } from '../constants/StorageType';
+
+interface StorageTypeIconProps {
+ type?: string | null
+}
+
+const StorageTypeIcon: FC = ({
+ type
+}) => {
+ switch (type) {
+ case StorageType.CDRom:
+ return ;
+ case StorageType.Network:
+ return ;
+ case StorageType.Ram:
+ return ;
+ case StorageType.Removable:
+ return ;
+ default:
+ return ;
+ }
+};
+
+export default StorageTypeIcon;
diff --git a/src/apps/dashboard/features/storage/constants/StorageType.ts b/src/apps/dashboard/features/storage/constants/StorageType.ts
new file mode 100644
index 0000000000..2a0a4d83d1
--- /dev/null
+++ b/src/apps/dashboard/features/storage/constants/StorageType.ts
@@ -0,0 +1,13 @@
+/**
+ * Common storage type values returned by the API.
+ * NOTE: This list is not comprehensive as .NET might return any string.
+ */
+export enum StorageType {
+ CDRom = 'CDRom',
+ Fixed = 'Fixed',
+ Network = 'Network',
+ NoRootDirectory = 'NoRootDirectory',
+ Ram = 'Ram',
+ Removable = 'Removable',
+ Unknown = 'Unknown'
+};
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index 5402157b2e..4498cd36eb 100644
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -743,6 +743,7 @@
"LabelHomeScreenSectionValue": "Home screen section {0}",
"LabelHttpsPort": "Local HTTPS port number",
"LabelHttpsPortHelp": "The TCP port number for the HTTPS server.",
+ "LabelImageCache": "Image Cache",
"LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.",
"LabelImageType": "Image type",
"LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite",
@@ -856,6 +857,7 @@
"LabelPreferredDisplayLanguage": "Preferred display language",
"LabelPreferredSubtitleLanguage": "Preferred subtitle language",
"LabelProfileContainer": "Container",
+ "LabelProgramData": "Program Data",
"LabelProtocol": "Protocol",
"LabelPublicHttpPort": "Public HTTP port number",
"LabelPublicHttpPortHelp": "The public port number that should be mapped to the local HTTP port.",
@@ -1545,6 +1547,13 @@
"StoryArc": "Story Arc",
"StopPlayback": "Stop playback",
"StopRecording": "Stop recording",
+ "StorageType.CDRom": "CD-ROM",
+ "StorageType.Fixed": "Fixed",
+ "StorageType.Network": "Network",
+ "StorageType.NoRootDirectory": "No Root Directory",
+ "StorageType.Ram": "RAM",
+ "StorageType.Removable": "Removable",
+ "StorageType.Unknown": "Unknown",
"StreamCountExceedsLimit": "The number of streams exceeds the limit",
"Studio": "Studio",
"Studios": "Studios",
diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts
new file mode 100644
index 0000000000..1f23d4b967
--- /dev/null
+++ b/src/utils/file.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { getReadableSize } from './file';
+
+describe('getReadableSize()', () => {
+ it('should return the correct units', () => {
+ expect(getReadableSize(5)).toBe('5.0 Bytes');
+ expect(getReadableSize(1024)).toBe('1.0 KiB');
+ expect(getReadableSize(1024 * 1024)).toBe('1.0 MiB');
+ expect(getReadableSize(1024 * 1024 * 1024)).toBe('1.0 GiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 PiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 EiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 ZiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 YiB');
+ });
+
+ it('should return the correct precision', () => {
+ expect(getReadableSize(12345, 0)).toBe('12 KiB');
+ expect(getReadableSize(12345, 2)).toBe('12.06 KiB');
+ expect(getReadableSize(12345, 3)).toBe('12.056 KiB');
+ });
+});
diff --git a/src/utils/file.ts b/src/utils/file.ts
index 0019a5068f..b82df87380 100644
--- a/src/utils/file.ts
+++ b/src/utils/file.ts
@@ -28,3 +28,11 @@ export function readFileAsText(file: File): Promise {
reader.readAsText(file);
});
}
+
+/** Gets a human readable string representing a file size in bytes */
+export function getReadableSize(value: number, precision = 1) {
+ let d = Math.log(value) / Math.log(1024) | 0;
+
+ return (value / Math.pow(1024, d)).toFixed(precision)
+ + ' ' + (d ? 'KMGTPEZY'[--d] + 'iB' : 'Bytes');
+}
diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx
index 097f185406..be4e518ec0 100644
--- a/src/utils/reactUtils.tsx
+++ b/src/utils/reactUtils.tsx
@@ -1,36 +1,34 @@
-import { createRoot } from 'react-dom/client';
-import * as React from 'react';
+import { ThemeProvider } from '@mui/material/styles';
import { QueryClientProvider } from '@tanstack/react-query';
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+
import { ApiProvider } from 'hooks/useApi';
import { UserSettingsProvider } from 'hooks/useUserSettings';
import { WebConfigProvider } from 'hooks/useWebConfig';
+import appTheme from 'themes/themes';
import { queryClient } from 'utils/query/queryClient';
-import { useUserTheme } from 'hooks/useUserTheme';
-import { ThemeProvider } from '@mui/material/styles';
-import { getTheme } from 'themes/themes';
-export const attachReactElement = (Element: (props: object) => React.ReactNode, props: object, element: HTMLElement, replace = false) => {
- const domNode = document.createElement('div');
- const root = createRoot(replace ? domNode : element);
- root.render(
-
-
-
- );
-
- if (replace) {
- element.replaceWith(domNode);
- }
+export const renderComponent = (
+ Component: React.FC
,
+ props: P,
+ element: HTMLElement
+) => {
+ createRoot(element)
+ .render(
+
+
+
+ );
};
const RootContext: React.FC = ({ children }) => {
- const { theme } = useUserTheme();
return (
-
+
{children}