Merge branch 'dev' into feature/dashdot-consistency-changes

This commit is contained in:
Thomas Camlong
2023-02-14 10:53:46 +09:00
committed by GitHub
157 changed files with 3054 additions and 1772 deletions

View File

@@ -33,9 +33,10 @@ export const AppTile = ({ className, app }: AppTileProps) => {
justify="space-around"
align="center"
style={{ height: '100%', width: '100%' }}
className="dashboard-tile-app"
>
<Box hidden={false}>
<Title order={5} size="md" ta="center" lineClamp={1} className={classes.appName}>
<Title order={5} size="md" ta="center" lineClamp={1} className={cx(classes.appName, 'dashboard-tile-app-title')}>
{app.name}
</Title>
</Box>

View File

@@ -18,7 +18,7 @@ export const HomarrCardWrapper = ({ ...props }: HomarrCardWrapperProps) => {
return (
<Card
{...restProps}
className={cx(restProps.className, cardClass)}
className={cx(restProps.className, cardClass, 'dashboard-gs-generic-item')}
withBorder
style={{ cursor: isEditMode ? 'move' : 'default' }}
radius="lg"

View File

@@ -1,4 +1,4 @@
import create from 'zustand';
import { create } from 'zustand';
interface EditModeState {
enabled: boolean;

View File

@@ -16,7 +16,7 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
const { refs, apps, widgets } = useGridstack('category', category.id);
const isEditMode = useEditModeStore((x) => x.enabled);
const { config } = useConfigContext();
const { classes: cardClasses } = useCardStyles(true);
const { classes: cardClasses, cx } = useCardStyles(true);
const categoryList = config?.categories.map((x) => x.name) ?? [];
const [toggledCategories, setToggledCategories] = useLocalStorage({
@@ -28,7 +28,7 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
return (
<Accordion
classNames={{
item: cardClasses.card,
item: cx(cardClasses.card, 'dashboard-gs-category'),
}}
mx={10}
chevronPosition="left"

View File

@@ -1,5 +1,5 @@
import { useMantineTheme } from '@mantine/core';
import create from 'zustand';
import { create } from 'zustand';
import { useConfigContext } from '../../../../config/provider';
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';

View File

@@ -1,6 +1,6 @@
import { Accordion, Grid, Group, Stack, Text } from '@mantine/core';
import { IconBrush, IconChartCandle, IconDragDrop, IconLayout } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { Accordion, Checkbox, Grid, Group, Stack, Switch, Text } from '@mantine/core';
import { IconBrush, IconChartCandle, IconCode, IconDragDrop, IconLayout } from '@tabler/icons';
import { i18n, useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { GridstackConfiguration } from './Layout/GridstackConfiguration';
import { LayoutSelector } from './Layout/LayoutSelector';
@@ -55,7 +55,7 @@ const getItems = () => {
'settings/customization/general',
'settings/customization/color-selector',
]);
return [
const items = [
{
id: 'layout',
image: <IconLayout />,
@@ -114,4 +114,26 @@ const getItems = () => {
),
},
];
if (process.env.NODE_ENV === 'development') {
items.push({
id: 'dev',
image: <IconCode />,
label: 'Developer options',
description: 'Options to help when developing',
content: (
<Stack>
<Checkbox
label="Use debug language"
defaultChecked={i18n?.language === 'cimode'}
description="This will show the translation keys instead of the actual translations"
onChange={(e) =>
// Change to CI mode language
i18n?.changeLanguage(e.target.checked ? 'cimode' : 'en')
}
/>
</Stack>
),
});
}
return items;
};

View File

@@ -1,24 +1,13 @@
import {
Box,
createStyles,
Group,
Loader,
ScrollArea,
Stack,
Text,
useMantineTheme,
} from '@mantine/core';
import { Box, createStyles, Group, Loader, Stack, Text, useMantineTheme } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import { ChangeEvent, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
const CodeEditor = dynamic(
() => import('@uiw/react-textarea-code-editor').then((mod) => mod.default),
{ ssr: false }
);
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism.css';
export const CustomCssChanger = () => {
const { t } = useTranslation('settings/customization/page-appearance');
@@ -53,22 +42,20 @@ export const CustomCssChanger = () => {
<Stack spacing={4} mt="xl">
<Text>{t('customCSS.label')}</Text>
<Text color="dimmed" size="xs">
{t('customCSS.description')}
{t('customCSS.description')}
</Text>
<div className={classes.codeEditorRoot}>
<ScrollArea style={{ height: codeEditorHeight }}>
<CodeEditor
className={classes.codeEditor}
placeholder={t('customCSS.placeholder')}
value={nonDebouncedCustomCSS}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
setNonDebouncedCustomCSS(event.target.value.trim())
}
language="css"
data-color-mode={colorScheme}
minHeight={codeEditorHeight}
/>
</ScrollArea>
<Editor
value={nonDebouncedCustomCSS}
onValueChange={(code) => setNonDebouncedCustomCSS(code)}
highlight={(code) => highlight(code, languages.extend('css', {}), 'css')}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: codeEditorHeight,
}}
/>
{codeIsDirty && (
<Box className={classes.codeEditorFooter}>
<Group p="xs" spacing="xs">

View File

@@ -19,6 +19,7 @@ export default function Layout({ children }: any) {
minHeight: 'calc(100vh - var(--mantine-header-height))',
},
}}
className="dashboard-app-shell"
>
<Head />
<Background />

View File

@@ -17,12 +17,14 @@ export function Logo({ size = 'md', withoutText = false }: LogoProps) {
width={size === 'md' ? 50 : 12}
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo-color.svg'}
alt="Homarr Logo"
className="dashboard-header-logo-image"
/>
{withoutText ? null : (
<Text
size={size === 'md' ? 22 : 10}
weight="bold"
variant="gradient"
className="dashboard-header-logo-text"
gradient={primaryGradient}
>
{config?.settings.customization.pageTitle || 'Homarr'}

View File

@@ -13,7 +13,7 @@ export const HeaderHeight = 64;
export function Header(props: any) {
const { classes } = useStyles();
const { classes: cardClasses } = useCardStyles(false);
const { classes: cardClasses, cx } = useCardStyles(false);
const { attributes } = usePackageAttributesStore();
const { isLoading, error, data } = useQuery({
@@ -27,12 +27,12 @@ export function Header(props: any) {
data?.tag_name > `v${attributes.packageVersion}` ? data?.tag_name : undefined;
return (
<MantineHeader height="auto" className={cardClasses.card}>
<MantineHeader height="auto" className={cx(cardClasses.card, 'dashboard-header')}>
<Group p="xs" noWrap grow>
<Box className={classes.hide}>
<Box className={cx(classes.hide, 'dashboard-header-logo-root')}>
<Logo />
</Box>
<Group position="right" style={{ maxWidth: 'none' }} noWrap>
<Group className="dashboard-header-group-right" position="right" style={{ maxWidth: 'none' }} noWrap>
<Search />
<ToggleEditModeAction />
<DockerMenuButton />

View File

@@ -57,7 +57,7 @@ export function Search() {
const { config } = useConfigContext();
const [searchQuery, setSearchQuery] = useState('');
const [debounced] = useDebouncedValue(searchQuery, 250);
const { classes: cardClasses } = useCardStyles(true);
const { classes: cardClasses, cx } = useCardStyles(true);
const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
@@ -216,7 +216,8 @@ export function Search() {
}
}}
classNames={{
input: cardClasses.card,
input: cx(cardClasses.card, 'dashboard-header-search-input'),
root: 'dashboard-header-search-root',
}}
radius="lg"
size="md"

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import create from 'zustand';
import { create } from 'zustand';
import { ConfigType } from '../types/config';
export const useConfigStore = create<UseConfigStoreType>((set, get) => ({

View File

@@ -2,19 +2,31 @@ import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
// eslint-disable-next-line consistent-return
export function middleware(req: NextRequest, ev: NextFetchEvent) {
const isCorrectPassword = req.cookies.get('password')?.value === process.env.PASSWORD;
const { cookies } = req;
// Don't even bother with the middleware if there is no defined password
if (!process.env.PASSWORD) return NextResponse.next();
const url = req.nextUrl.clone();
const skipURL =
url.pathname &&
(url.pathname.includes('login') ||
url.pathname === '/api/configs/tryPassword' ||
(url.pathname.includes('/_next/') && !url.pathname.includes('/pages/')) ||
url.pathname === '/favicon.ico' ||
url.pathname === '/404' ||
url.pathname === '/migrate' ||
url.pathname.includes('pages/_app'));
if (!skipURL && !isCorrectPassword && process.env.PASSWORD) {
const passwordCookie = cookies.get('password')?.value;
const isCorrectPassword = passwordCookie?.toString() === process.env.PASSWORD;
// Skip the middleware if the URL is 'login', 'api/configs/tryPassword', '_next/*', 'favicon.ico', '404', 'migrate' or 'pages/_app'
const skippedUrls = [
'/login',
'/api/configs/tryPassword',
'/_next/',
'/favicon.ico',
'/404',
'/migrate',
'/pages/_app',
];
if (skippedUrls.some((skippedUrl) => url.pathname.startsWith(skippedUrl))) {
return NextResponse.next();
}
// If the password is not correct, redirect to the login page
if (!isCorrectPassword && process.env.PASSWORD) {
url.pathname = '/login';
return NextResponse.rewrite(url);
}
return NextResponse.next();
}

View File

@@ -26,9 +26,9 @@ import {
ServerSidePackageAttributesType,
} from '../tools/server/getPackageVersion';
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
import 'video.js/dist/video-js.css';
import '../styles/global.scss';
import '@uiw/react-textarea-code-editor/dist.css';
function App(
this: any,

View File

@@ -1,4 +1,4 @@
import create from 'zustand';
import { create } from 'zustand';
import { ServerSidePackageAttributesType } from '../../server/getPackageVersion';

View File

@@ -75,6 +75,20 @@ export const languages: Language[] = [
translatedName: 'LOLCAT',
emoji: '🐱',
},
// Norwegian
{
shortName: 'no',
originalName: 'Norsk',
translatedName: 'Norwegian',
emoji: '🇳🇴',
},
// Slovak
{
shortName: 'sk',
originalName: 'Slovenčina',
translatedName: 'Slovak',
emoji: '🇸🇰',
},
{
shortName: 'nl',
originalName: 'Nederlands',

View File

@@ -33,6 +33,7 @@ export const dashboardNamespaces = [
'modules/dashdot',
'modules/overseerr',
'modules/common-media-cards',
'modules/video-stream',
];
export const loginNamespaces = ['authentication/login'];

View File

@@ -0,0 +1,274 @@
import {
Avatar,
Box,
Card,
Group,
Stack,
Title,
Text,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { useElementSize, useListState } from '@mantine/hooks';
import { linearGradientDef } from '@nivo/core';
import { Serie, Datum, ResponsiveLine } from '@nivo/line';
import { IconDownload, IconUpload } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { useConfigContext } from '../../config/provider';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { useColorTheme } from '../../tools/color';
import { humanFileSize } from '../../tools/humanFileSize';
import {
NormalizedDownloadQueueResponse,
TorrentTotalDownload,
} from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import definition, { ITorrentNetworkTraffic } from './TorrentNetworkTrafficTile';
interface TorrentNetworkTrafficTileProps {
widget: ITorrentNetworkTraffic;
}
export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) {
const { config } = useConfigContext();
const { ref: refRoot, height: heightRoot } = useElementSize();
const { ref: refTitle, height: heightTitle } = useElementSize();
const { ref: refFooter, height: heightFooter } = useElementSize();
const { primaryColor, secondaryColor } = useColorTheme();
const { t } = useTranslation(`modules/${definition.id}`);
const [clientDataHistory, setClientDataHistory] = useListState<NormalizedDownloadQueueResponse>();
const { data, dataUpdatedAt } = useGetDownloadClientsQueue();
useEffect(() => {
if (data) {
setClientDataHistory.append(data);
}
if (clientDataHistory.length < 30) {
return;
}
setClientDataHistory.remove(0);
}, [dataUpdatedAt]);
if (!data) {
return null;
}
const recoredAppsOverTime = clientDataHistory.flatMap((x) => x.apps.map((app) => app));
// removing duplicates the "naive" way: https://stackoverflow.com/a/9229821/15257712
const uniqueRecordedAppsOverTime = recoredAppsOverTime
.map((x) => x.appId)
.filter((item, position) => recoredAppsOverTime.map((y) => y.appId).indexOf(item) === position);
const lineChartData: Serie[] = uniqueRecordedAppsOverTime.flatMap((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const series: Serie[] = [
{
id: `download_${appId}`,
data: records.map((record, index) => ({
x: index,
y: record.totalDownload,
})),
},
];
if (records.some((x) => x.type === 'torrent')) {
const torrentRecords = records.map((record, index): Datum | null => {
if (record.type !== 'torrent') {
return null;
}
return {
x: index,
y: record.totalUpload,
};
});
const filteredRecords = torrentRecords.filter((x) => x !== null) as Datum[];
series.push({
id: `upload_${appId}`,
data: filteredRecords,
});
}
return series;
});
const totalDownload = uniqueRecordedAppsOverTime
.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const lastRecord = records.at(-1);
return lastRecord?.totalDownload ?? 0;
})
.reduce((acc, n) => acc + n, 0);
const totalUpload = uniqueRecordedAppsOverTime
.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId && x.type === 'torrent');
const lastRecord = records.at(-1) as TorrentTotalDownload;
return lastRecord?.totalUpload ?? 0;
})
.reduce((acc, n) => acc + n, 0);
const graphHeight = heightRoot - heightFooter - heightTitle;
const { colors } = useMantineTheme();
return (
<Stack ref={refRoot} style={{ height: '100%' }}>
<Group ref={refTitle}>
<IconDownload />
<Title order={4}>{t('card.lineChart.title')}</Title>
</Group>
<Box
style={{
height: graphHeight,
width: '100%',
position: 'relative',
}}
>
<Box style={{ height: '100%', width: '100%', position: 'absolute' }}>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
const { points } = slice;
const recordsFromPoints = uniqueRecordedAppsOverTime.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const point = points.find((x) => x.id.includes(appId));
const pointIndex = Number(point?.data.x) ?? 0;
const color = point?.serieColor;
return {
record: records[pointIndex],
color,
};
});
return (
<Card p="xs" radius="md" withBorder>
<Card.Section p="xs">
<Stack spacing="xs">
{recordsFromPoints.map((entry, index) => {
const app = config?.apps.find((x) => x.id === entry.record.appId);
if (!app) {
return null;
}
return (
<Group key={`download-client-tooltip-${index}`}>
<AppAvatar iconUrl={app.appearance.iconUrl} />
<Stack spacing={0}>
<Text size="sm">{app.name}</Text>
<Group>
<Group spacing="xs">
<IconDownload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalDownload, false)}
</Text>
</Group>
{entry.record.type === 'torrent' && (
<Group spacing="xs">
<IconUpload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalUpload, false)}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
);
})}
</Stack>
</Card.Section>
</Card>
);
}}
data={lineChartData}
curve="monotoneX"
yFormat=" >-.2f"
axisLeft={null}
axisBottom={null}
axisRight={null}
enablePoints={false}
enableGridX={false}
enableGridY={false}
enableArea
defs={[
linearGradientDef('gradientA', [
{ offset: 0, color: 'inherit' },
{ offset: 100, color: 'inherit', opacity: 0 },
]),
]}
colors={lineChartData.flatMap((data) =>
data.id.toString().startsWith('upload_')
? colors[secondaryColor][5]
: colors[primaryColor][5]
)}
fill={[{ match: '*', id: 'gradientA' }]}
margin={{ bottom: 5 }}
animate={false}
/>
</Box>
</Box>
<Group position="apart" ref={refFooter}>
<Group>
<Group spacing="xs">
<IconDownload color={colors[primaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalDownload, false)}
</Text>
</Group>
<Group spacing="xs">
<IconUpload color={colors[secondaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalUpload, false)}
</Text>
</Group>
</Group>
<Avatar.Group>
{uniqueRecordedAppsOverTime.map((appId, index) => {
const app = config?.apps.find((x) => x.id === appId);
if (!app) {
return null;
}
return (
<Tooltip
label={app.name}
key={`download-client-app-tooltip-${index}`}
withArrow
withinPortal
>
<AppAvatar iconUrl={app.appearance.iconUrl} />
</Tooltip>
);
})}
</Avatar.Group>
</Group>
</Stack>
);
}
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
const { colors, colorScheme } = useMantineTheme();
return (
<Avatar
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"
radius="xl"
p={4}
/>
);
};

View File

@@ -1,31 +1,13 @@
import {
Avatar,
Box,
Card,
Group,
Stack,
Text,
Title,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { useElementSize, useListState } from '@mantine/hooks';
import { linearGradientDef } from '@nivo/core';
import { Datum, ResponsiveLine, Serie } from '@nivo/line';
import { IconArrowsUpDown, IconDownload, IconUpload } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { useConfigContext } from '../../config/provider';
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
import { useColorTheme } from '../../tools/color';
import { humanFileSize } from '../../tools/humanFileSize';
import {
NormalizedDownloadQueueResponse,
TorrentTotalDownload,
} from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
import { IconArrowsUpDown } from '@tabler/icons';
import dynamic from 'next/dynamic';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
const torrentNetworkTrafficTile = dynamic(() => import('./Tile'), {
ssr: false,
});
const definition = defineWidget({
id: 'dlspeed',
icon: IconArrowsUpDown,
@@ -37,257 +19,9 @@ const definition = defineWidget({
maxWidth: 12,
maxHeight: 6,
},
component: TorrentNetworkTrafficTile,
component: torrentNetworkTrafficTile,
});
export type ITorrentNetworkTraffic = IWidget<(typeof definition)['id'], typeof definition>;
interface TorrentNetworkTrafficTileProps {
widget: ITorrentNetworkTraffic;
}
function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTrafficTileProps) {
const { config } = useConfigContext();
const { ref: refRoot, height: heightRoot } = useElementSize();
const { ref: refTitle, height: heightTitle } = useElementSize();
const { ref: refFooter, height: heightFooter } = useElementSize();
const { primaryColor, secondaryColor } = useColorTheme();
const { t } = useTranslation(`modules/${definition.id}`);
const [clientDataHistory, setClientDataHistory] = useListState<NormalizedDownloadQueueResponse>();
const { data, dataUpdatedAt } = useGetDownloadClientsQueue();
useEffect(() => {
if (data) {
setClientDataHistory.append(data);
}
if (clientDataHistory.length < 30) {
return;
}
setClientDataHistory.remove(0);
}, [dataUpdatedAt]);
if (!data) {
return null;
}
const recoredAppsOverTime = clientDataHistory.flatMap((x) => x.apps.map((app) => app));
// removing duplicates the "naive" way: https://stackoverflow.com/a/9229821/15257712
const uniqueRecordedAppsOverTime = recoredAppsOverTime
.map((x) => x.appId)
.filter((item, position) => recoredAppsOverTime.map((y) => y.appId).indexOf(item) === position);
const lineChartData: Serie[] = uniqueRecordedAppsOverTime.flatMap((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const series: Serie[] = [
{
id: `download_${appId}`,
data: records.map((record, index) => ({
x: index,
y: record.totalDownload,
})),
},
];
if (records.some((x) => x.type === 'torrent')) {
const torrentRecords = records.map((record, index): Datum | null => {
if (record.type !== 'torrent') {
return null;
}
return {
x: index,
y: record.totalUpload,
};
});
const filteredRecords = torrentRecords.filter((x) => x !== null) as Datum[];
series.push({
id: `upload_${appId}`,
data: filteredRecords,
});
}
return series;
});
const totalDownload = uniqueRecordedAppsOverTime
.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const lastRecord = records.at(-1);
return lastRecord?.totalDownload ?? 0;
})
.reduce((acc, n) => acc + n, 0);
const totalUpload = uniqueRecordedAppsOverTime
.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId && x.type === 'torrent');
const lastRecord = records.at(-1) as TorrentTotalDownload;
return lastRecord?.totalUpload ?? 0;
})
.reduce((acc, n) => acc + n, 0);
const graphHeight = heightRoot - heightFooter - heightTitle;
const { colors } = useMantineTheme();
return (
<Stack ref={refRoot} style={{ height: '100%' }}>
<Group ref={refTitle}>
<IconDownload />
<Title order={4}>{t('card.lineChart.title')}</Title>
</Group>
<Box
style={{
height: graphHeight,
width: '100%',
position: 'relative',
}}
>
<Box style={{ height: '100%', width: '100%', position: 'absolute' }}>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
const { points } = slice;
const recordsFromPoints = uniqueRecordedAppsOverTime.map((appId) => {
const records = recoredAppsOverTime.filter((x) => x.appId === appId);
const point = points.find((x) => x.id.includes(appId));
const pointIndex = Number(point?.data.x) ?? 0;
const color = point?.serieColor;
return {
record: records[pointIndex],
color,
};
});
return (
<Card p="xs" radius="md" withBorder>
<Card.Section p="xs">
<Stack spacing="xs">
{recordsFromPoints.map((entry, index) => {
const app = config?.apps.find((x) => x.id === entry.record.appId);
if (!app) {
return null;
}
return (
<Group key={`download-client-tooltip-${index}`}>
<AppAvatar iconUrl={app.appearance.iconUrl} />
<Stack spacing={0}>
<Text size="sm">{app.name}</Text>
<Group>
<Group spacing="xs">
<IconDownload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalDownload, false)}
</Text>
</Group>
{entry.record.type === 'torrent' && (
<Group spacing="xs">
<IconUpload opacity={0.6} size={14} />
<Text size="xs" color="dimmed">
{humanFileSize(entry.record.totalUpload, false)}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
);
})}
</Stack>
</Card.Section>
</Card>
);
}}
data={lineChartData}
curve="monotoneX"
yFormat=" >-.2f"
axisLeft={null}
axisBottom={null}
axisRight={null}
enablePoints={false}
enableGridX={false}
enableGridY={false}
enableArea
defs={[
linearGradientDef('gradientA', [
{ offset: 0, color: 'inherit' },
{ offset: 100, color: 'inherit', opacity: 0 },
]),
]}
colors={lineChartData.flatMap((data) =>
data.id.toString().startsWith('upload_')
? colors[secondaryColor][5]
: colors[primaryColor][5]
)}
fill={[{ match: '*', id: 'gradientA' }]}
margin={{ bottom: 5 }}
animate={false}
/>
</Box>
</Box>
<Group position="apart" ref={refFooter}>
<Group>
<Group spacing="xs">
<IconDownload color={colors[primaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalDownload, false)}
</Text>
</Group>
<Group spacing="xs">
<IconUpload color={colors[secondaryColor][5]} opacity={0.6} size={18} />
<Text color="dimmed" size="sm">
{humanFileSize(totalUpload, false)}
</Text>
</Group>
</Group>
<Avatar.Group>
{uniqueRecordedAppsOverTime.map((appId, index) => {
const app = config?.apps.find((x) => x.id === appId);
if (!app) {
return null;
}
return (
<Tooltip
label={app.name}
key={`download-client-app-tooltip-${index}`}
withArrow
withinPortal
>
<AppAvatar iconUrl={app.appearance.iconUrl} />
</Tooltip>
);
})}
</Avatar.Group>
</Group>
</Stack>
);
}
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
const { colors, colorScheme } = useMantineTheme();
return (
<Avatar
src={iconUrl}
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
size="sm"
radius="xl"
p={4}
/>
);
};
export default definition;

View File

@@ -5,6 +5,7 @@ import usenet from './useNet/UseNetTile';
import weather from './weather/WeatherTile';
import torrent from './torrent/TorrentTile';
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
import videoStream from './video/VideoStreamTile';
export default {
calendar,
@@ -14,4 +15,5 @@ export default {
'torrents-status': torrent,
dlspeed: torrentNetworkTraffic,
date,
'video-stream': videoStream,
};

View File

@@ -0,0 +1,67 @@
import { createStyles, LoadingOverlay } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
interface VideoFeedProps {
source: string;
muted: boolean;
autoPlay: boolean;
controls: boolean;
}
const VideoFeed = ({ source, controls, autoPlay, muted }: VideoFeedProps) => {
const videoRef = useRef(null);
const [player, setPlayer] = useState<ReturnType<typeof videojs>>();
const { classes, cx } = useStyles();
useEffect(() => {
// make sure Video.js player is only initialized once
if (player) {
return;
}
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
setPlayer(videojs(videoElement, { autoplay: autoPlay, muted, controls }, () => {}));
}, [videoRef]);
useEffect(
() => () => {
if (!player) {
return;
}
if (player.isDisposed()) {
return;
}
player.dispose();
},
[player]
);
return (
<>
<LoadingOverlay visible={player === undefined} />
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video className={cx('video-js', classes.video)} ref={videoRef}>
<source src={source} type="video/mp4" />
</video>
</>
);
};
const useStyles = createStyles(({ radius }) => ({
video: {
height: '100%',
borderRadius: radius.md,
overflow: 'hidden',
},
}));
export default VideoFeed;

View File

@@ -0,0 +1,70 @@
import { Center, Group, Stack, Title } from '@mantine/core';
import { IconDeviceCctv, IconHeartBroken } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
const VideoFeed = dynamic(() => import('./VideoFeed'), { ssr: false });
const definition = defineWidget({
id: 'video-stream',
icon: IconDeviceCctv,
options: {
FeedUrl: {
type: 'text',
defaultValue: '',
},
autoPlay: {
type: 'switch',
defaultValue: true,
},
muted: {
type: 'switch',
defaultValue: true,
},
controls: {
type: 'switch',
defaultValue: false,
},
},
gridstack: {
minWidth: 3,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
component: VideoStreamWidget,
});
export type VideoStreamWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface VideoStreamWidgetProps {
widget: VideoStreamWidget;
}
function VideoStreamWidget({ widget }: VideoStreamWidgetProps) {
const { t } = useTranslation('modules/video-stream');
if (!widget.properties.FeedUrl) {
return (
<Center h="100%">
<Stack align="center">
<IconHeartBroken />
<Title order={4}>{t('errors.invalidStream')}</Title>
</Stack>
</Center>
);
}
return (
<Group position="center" w="100%" h="100%">
<VideoFeed
source={widget?.properties.FeedUrl}
muted={widget?.properties.muted}
autoPlay={widget?.properties.autoPlay}
controls={widget?.properties.controls}
/>
</Group>
);
}
export default definition;