🔥 Remove old and unused components

This commit is contained in:
Manuel Ruwe
2022-12-23 17:17:57 +01:00
parent b23f464140
commit f3b601dc2d
37 changed files with 131 additions and 1945 deletions

View File

@@ -28,5 +28,6 @@ module.exports = {
'@typescript-eslint/no-shadow': 'off', '@typescript-eslint/no-shadow': 'off',
'@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'off',
'linebreak-style': 0
}, },
}; };

View File

@@ -1,15 +1,14 @@
import { Group, Text, useMantineTheme } from '@mantine/core'; import { Group, Text, useMantineTheme } from '@mantine/core';
import { IconX as X, IconCheck as Check, IconX, IconPhoto, IconUpload } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import { setCookie } from 'cookies-next';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications';
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state'; import { useConfigStore } from '../../config/store';
import { Config } from '../../tools/types'; import { Config } from '../../tools/types';
import { migrateToIdConfig } from '../../tools/migrate';
export default function LoadConfigComponent(props: any) { export default function LoadConfigComponent(props: any) {
const { setConfig } = useConfig(); const { updateConfig } = useConfigStore();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { t } = useTranslation('settings/general/config-changer'); const { t } = useTranslation('settings/general/config-changer');
@@ -48,8 +47,7 @@ export default function LoadConfigComponent(props: any) {
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict', sameSite: 'strict',
}); });
const migratedConfig = migrateToIdConfig(newConfig); updateConfig(newConfig.name, (previousConfig) => ({ ...previousConfig, newConfig }));
setConfig(migratedConfig);
}); });
}} }}
accept={['application/json']} accept={['application/json']}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react/no-unknown-property */
import { ReactNode, RefObject } from 'react'; import { ReactNode, RefObject } from 'react';
interface GridstackTileWrapperProps { interface GridstackTileWrapperProps {

View File

@@ -6,10 +6,10 @@ import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
import { CategoryType } from '../../../../types/category'; import { CategoryType } from '../../../../types/category';
export interface CategoryEditModalInnerProps { export type CategoryEditModalInnerProps = {
category: CategoryType; category: CategoryType;
onSuccess: (category: CategoryType) => Promise<void>; onSuccess: (category: CategoryType) => Promise<void>;
} };
export const CategoryEditModal = ({ export const CategoryEditModal = ({
context, context,

View File

@@ -25,6 +25,7 @@ export const DashboardSidebar = ({ location }: DashboardSidebarProps) => {
className="grid-stack grid-stack-sidebar" className="grid-stack grid-stack-sidebar"
style={{ transitionDuration: '0s', height: '100%' }} style={{ transitionDuration: '0s', height: '100%' }}
data-sidebar={location} data-sidebar={location}
// eslint-disable-next-line react/no-unknown-property
gs-min-row={minRow} gs-min-row={minRow}
ref={refs.wrapper} ref={refs.wrapper}
> >

View File

@@ -1,36 +0,0 @@
import { Aside as MantineAside, createStyles } from '@mantine/core';
import Widgets from './Widgets';
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('xs')]: {
display: 'none',
},
},
burger: {
[theme.fn.largerThan('sm')]: {
display: 'none',
},
},
}));
export default function Aside(props: any) {
const { classes, cx } = useStyles();
return (
<MantineAside
pr="md"
hiddenBreakpoint="sm"
hidden
className={cx(classes.hide)}
style={{
border: 'none',
background: 'none',
}}
width={{
base: 'auto',
}}
>
<Widgets />
</MantineAside>
);
}

View File

@@ -1,37 +0,0 @@
import { createStyles, Navbar as MantineNavbar } from '@mantine/core';
import Widgets from './Widgets';
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('xs')]: {
display: 'none',
},
},
burger: {
[theme.fn.largerThan('sm')]: {
display: 'none',
},
},
}));
export default function Navbar() {
const { classes, cx } = useStyles();
return (
<MantineNavbar
pl="md"
hiddenBreakpoint="sm"
hidden
className={cx(classes.hide)}
style={{
border: 'none',
background: 'none',
}}
width={{
base: 'auto',
}}
>
<Widgets />
</MantineNavbar>
);
}

View File

@@ -1,16 +0,0 @@
import { Stack } from '@mantine/core';
import { CalendarModule, DateModule, TotalDownloadsModule, WeatherModule } from '../../modules';
import { DashdotModule } from '../../modules/dashdot';
import { ModuleWrapper } from '../../modules/moduleWrapper';
export default function Widgets(props: any) {
return (
<Stack my={16} style={{ width: 300 }}>
<ModuleWrapper module={CalendarModule} />
<ModuleWrapper module={TotalDownloadsModule} />
<ModuleWrapper module={WeatherModule} />
<ModuleWrapper module={DateModule} />
<ModuleWrapper module={DashdotModule} />
</Stack>
);
}

View File

@@ -58,7 +58,9 @@ export function Search() {
// TODO: ask manuel-rw about overseerr // TODO: ask manuel-rw about overseerr
// Answer: We can simply check if there is a app of the type overseer and display results if there is one. // Answer: We can simply check if there is a app of the type overseer and display results if there is one.
// Overseerr is not use anywhere else, so it makes no sense to add a standalone toggle for displaying results // Overseerr is not use anywhere else, so it makes no sense to add a standalone toggle for displaying results
const isOverseerrEnabled = false; //config?.settings.common.enabledModules.overseerr; const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
);
const overseerrApp = config?.apps.find( const overseerrApp = config?.apps.find(
(app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr' (app) => app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
); );

View File

@@ -1,318 +0,0 @@
/* eslint-disable react/no-children-prop */
import {
Box,
Divider,
Indicator,
Popover,
ScrollArea,
createStyles,
useMantineTheme,
Space,
} from '@mantine/core';
import React, { useEffect, useState } from 'react';
import { Calendar } from '@mantine/dates';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useDisclosure } from '@mantine/hooks';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import {
SonarrMediaDisplay,
RadarrMediaDisplay,
LidarrMediaDisplay,
ReadarrMediaDisplay,
} from '../common';
import { serviceItem } from '../../tools/types';
import { useColorTheme } from '../../tools/color';
export const CalendarModule: IModule = {
title: 'Calendar',
icon: CalendarIcon,
component: CalendarComponent,
options: {
sundaystart: {
name: 'descriptor.settings.sundayStart.label',
value: false,
},
},
id: 'calendar',
};
export default function CalendarComponent(props: any) {
const { config } = useConfig();
const theme = useMantineTheme();
const { secondaryColor } = useColorTheme();
const useStyles = createStyles((theme) => ({
weekend: {
color: `${secondaryColor} !important`,
},
}));
const [sonarrMedias, setSonarrMedias] = useState([] as any);
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
const sonarrServices = config.apps.filter((service) => service.type === 'Sonarr');
const radarrServices = config.apps.filter((service) => service.type === 'Radarr');
const lidarrServices = config.apps.filter((service) => service.type === 'Lidarr');
const readarrServices = config.apps.filter((service) => service.type === 'Readarr');
const today = new Date();
const { classes, cx } = useStyles();
function getMedias(service: serviceItem | undefined, type: string) {
if (!service || !service.apiKey) {
return Promise.resolve({ data: [] });
}
return axios.post(`/api/modules/calendar?type=${type}`, { id: service.id });
}
useEffect(() => {
// Create each Sonarr service and get the medias
const currentSonarrMedias: any[] = [];
Promise.all(
sonarrServices.map((service) =>
getMedias(service, 'sonarr')
.then((res) => {
currentSonarrMedias.push(...res.data);
})
.catch(() => {
currentSonarrMedias.push([]);
})
)
).then(() => {
setSonarrMedias(currentSonarrMedias);
});
const currentRadarrMedias: any[] = [];
Promise.all(
radarrServices.map((service) =>
getMedias(service, 'radarr')
.then((res) => {
currentRadarrMedias.push(...res.data);
})
.catch(() => {
currentRadarrMedias.push([]);
})
)
).then(() => {
setRadarrMedias(currentRadarrMedias);
});
const currentLidarrMedias: any[] = [];
Promise.all(
lidarrServices.map((service) =>
getMedias(service, 'lidarr')
.then((res) => {
currentLidarrMedias.push(...res.data);
})
.catch(() => {
currentLidarrMedias.push([]);
})
)
).then(() => {
setLidarrMedias(currentLidarrMedias);
});
const currentReadarrMedias: any[] = [];
Promise.all(
readarrServices.map((service) =>
getMedias(service, 'readarr')
.then((res) => {
currentReadarrMedias.push(...res.data);
})
.catch(() => {
currentReadarrMedias.push([]);
})
)
).then(() => {
setReadarrMedias(currentReadarrMedias);
});
}, [config.apps]);
const weekStartsAtSunday =
(config?.modules?.[CalendarModule.id]?.options?.sundaystart?.value as boolean) ?? false;
return (
<Calendar
firstDayOfWeek={weekStartsAtSunday ? 'sunday' : 'monday'}
onChange={(day: any) => {}}
dayStyle={(date) =>
date.getDay() === today.getDay() && date.getDate() === today.getDate()
? {
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0],
margin: 1,
}
: {
margin: 1,
}
}
allowLevelChange={false}
dayClassName={(date, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
renderDay={(renderdate) => (
<DayComponent
renderdate={renderdate}
sonarrmedias={sonarrMedias}
radarrmedias={radarrMedias}
lidarrmedias={lidarrMedias}
readarrmedias={readarrMedias}
/>
)}
/>
);
}
function DayComponent(props: any) {
const {
renderdate,
sonarrmedias,
radarrmedias,
lidarrmedias,
readarrmedias,
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
props;
const [opened, { close, open }] = useDisclosure(false);
const day = renderdate.getDate();
const readarrFiltered = readarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.toDateString() === renderdate.toDateString();
});
const lidarrFiltered = lidarrmedias.filter((media: any) => {
const date = new Date(media.releaseDate);
return date.toDateString() === renderdate.toDateString();
});
const sonarrFiltered = sonarrmedias.filter((media: any) => {
const date = new Date(media.airDateUtc);
return date.toDateString() === renderdate.toDateString();
});
const radarrFiltered = radarrmedias.filter((media: any) => {
const date = new Date(media.inCinemas);
return date.toDateString() === renderdate.toDateString();
});
const totalFiltered = [
...readarrFiltered,
...lidarrFiltered,
...sonarrFiltered,
...radarrFiltered,
];
if (totalFiltered.length === 0) {
return <div>{day}</div>;
}
return (
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transition="pop"
onClose={close}
opened={opened}
>
<Popover.Target>
<Box onClick={open}>
{readarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color="red"
children={null}
/>
)}
{radarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
left: 8,
}}
color="yellow"
children={null}
/>
)}
{sonarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
top: 8,
right: 8,
}}
color="blue"
children={null}
/>
)}
{lidarrFiltered.length > 0 && (
<Indicator
size={10}
withBorder
style={{
position: 'absolute',
bottom: 8,
right: 8,
}}
color="green"
children={undefined}
/>
)}
<div>{day}</div>
</Box>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea
offsetScrollbars
scrollbarSize={5}
style={{
height:
totalFiltered.slice(0, 2).length > 1 ? totalFiltered.slice(0, 2).length * 150 : 220,
width: 400,
}}
>
<Space mt={5} />
{sonarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<SonarrMediaDisplay media={media} />
{index < sonarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{radarrFiltered.length > 0 && sonarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{radarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<RadarrMediaDisplay media={media} />
{index < radarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{sonarrFiltered.length > 0 && lidarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{lidarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<LidarrMediaDisplay media={media} />
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
{lidarrFiltered.length > 0 && readarrFiltered.length > 0 && (
<Divider variant="dashed" size="sm" my="xl" />
)}
{readarrFiltered.map((media: any, index: number) => (
<React.Fragment key={index}>
<ReadarrMediaDisplay media={media} />
{index < readarrFiltered.length - 1 && <Divider variant="dashed" size="sm" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -1 +0,0 @@
export { CalendarModule } from './CalendarModule';

View File

@@ -2,9 +2,8 @@ import { Badge, Button, Group, Image, Stack, Text, Title } from '@mantine/core';
import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons'; import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { useConfigContext } from '../../config/provider';
import { useColorTheme } from '../../tools/color'; import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { RequestModal } from '../overseerr/RequestModal'; import { RequestModal } from '../overseerr/RequestModal';
import { Result } from '../overseerr/SearchResult'; import { Result } from '../overseerr/SearchResult';
@@ -27,9 +26,15 @@ export interface IMedia {
export function OverseerrMediaDisplay(props: any) { export function OverseerrMediaDisplay(props: any) {
const { media }: { media: Result } = props; const { media }: { media: Result } = props;
const { config } = useConfig(); const { config } = useConfigContext();
if (!config) {
return null;
}
const service = config.apps.find( const service = config.apps.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr' (service) =>
service.integration.type === 'overseerr' || service.integration.type === 'jellyseerr'
); );
return ( return (
@@ -45,9 +50,9 @@ export function OverseerrMediaDisplay(props: any) {
plexUrl: media.mediaInfo?.plexUrl ?? media.mediaInfo?.mediaUrl, plexUrl: media.mediaInfo?.plexUrl ?? media.mediaInfo?.mediaUrl,
voteAverage: media.voteAverage?.toString(), voteAverage: media.voteAverage?.toString(),
overseerrResult: media, overseerrResult: media,
overseerrId: `${service?.openedUrl ? service?.openedUrl : service?.url}/${ overseerrId: `${
media.mediaType service?.behaviour.externalUrl ? service.behaviour.externalUrl : service?.url
}/${media.id}`, }/${media.mediaType}/${media.id}`,
type: 'overseer', type: 'overseer',
}} }}
/> />
@@ -56,16 +61,21 @@ export function OverseerrMediaDisplay(props: any) {
export function ReadarrMediaDisplay(props: any) { export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props; const { media }: { media: any } = props;
const { config } = useConfig(); const { config } = useConfigContext();
if (!config) {
return null;
}
// Find lidarr in services // Find lidarr in services
const readarr = config.apps.find((service: serviceItem) => service.type === 'Readarr'); const readarr = config.apps.find((service) => service.integration.type === 'readarr');
// Find a poster CoverType // Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover'); const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) { if (!readarr) {
return null; return null;
} }
const baseUrl = readarr.openedUrl const baseUrl = readarr.behaviour.externalUrl
? new URL(readarr.openedUrl).origin ? new URL(readarr.behaviour.externalUrl).origin
: new URL(readarr.url).origin; : new URL(readarr.url).origin;
// Remove '/' from the end of the lidarr url // Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined; const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
@@ -88,15 +98,22 @@ export function ReadarrMediaDisplay(props: any) {
export function LidarrMediaDisplay(props: any) { export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props; const { media }: { media: any } = props;
const { config } = useConfig(); const { config } = useConfigContext();
if (!config) {
return null;
}
// Find lidarr in services // Find lidarr in services
const lidarr = config.apps.find((service: serviceItem) => service.type === 'Lidarr'); const lidarr = config.apps.find((service) => service.integration.type === 'lidarr');
// Find a poster CoverType // Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover'); const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) { if (!lidarr) {
return null; return null;
} }
const baseUrl = lidarr.openedUrl ? new URL(lidarr.openedUrl).origin : new URL(lidarr.url).origin; const baseUrl = lidarr.behaviour.externalUrl
? new URL(lidarr.behaviour.externalUrl).origin
: new URL(lidarr.url).origin;
// Remove '/' from the end of the lidarr url // Remove '/' from the end of the lidarr url
const fullLink = poster ? `${baseUrl}${poster.url}` : undefined; const fullLink = poster ? `${baseUrl}${poster.url}` : undefined;
// Return a movie poster containting the title and the description // Return a movie poster containting the title and the description

View File

@@ -1,275 +0,0 @@
import { createStyles, Stack, Title, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { IconCalendar as CalendarIcon } from '@tabler/icons';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { IModule } from '../ModuleTypes';
const asModule = <T extends IModule>(t: T) => t;
export const DashdotModule = asModule({
title: 'Dash.',
icon: CalendarIcon,
component: DashdotComponent,
options: {
cpuMultiView: {
name: 'descriptor.settings.cpuMultiView.label',
value: false,
},
storageMultiView: {
name: 'descriptor.settings.storageMultiView.label',
value: false,
},
useCompactView: {
name: 'descriptor.settings.useCompactView.label',
value: false,
},
graphs: {
name: 'descriptor.settings.graphs.label',
value: ['CPU', 'RAM', 'Storage', 'Network'],
options: ['CPU', 'RAM', 'Storage', 'Network', 'GPU'],
},
url: {
name: 'descriptor.settings.url.label',
value: '',
},
},
id: 'dashdot',
});
const useStyles = createStyles((theme, _params, getRef) => ({
heading: {
marginTop: 0,
marginBottom: 10,
},
table: {
display: 'table',
},
tableRow: {
display: 'table-row',
},
tableLabel: {
display: 'table-cell',
paddingRight: 10,
},
tableValue: {
display: 'table-cell',
whiteSpace: 'pre-wrap',
paddingBottom: 5,
},
graphsContainer: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
rowGap: 10,
columnGap: 10,
},
iframe: {
flex: '1 0 auto',
maxWidth: '100%',
height: '140px',
borderRadius: theme.radius.lg,
border: 'none',
colorScheme: 'none',
},
graphTitle: {
ref: getRef('graphTitle'),
position: 'absolute',
right: 0,
opacity: 0,
transition: 'opacity .1s ease-in-out',
pointerEvents: 'none',
},
graphStack: {
[`&:hover .${getRef('graphTitle')}`]: {
opacity: 0.5,
},
},
}));
const bpsPrettyPrint = (bits?: number) =>
!bits
? '-'
: bits > 1000 * 1000 * 1000
? `${(bits / 1000 / 1000 / 1000).toFixed(1)} Gb/s`
: bits > 1000 * 1000
? `${(bits / 1000 / 1000).toFixed(1)} Mb/s`
: bits > 1000
? `${(bits / 1000).toFixed(1)} Kb/s`
: `${bits.toFixed(1)} b/s`;
const bytePrettyPrint = (byte: number): string =>
byte > 1024 * 1024 * 1024
? `${(byte / 1024 / 1024 / 1024).toFixed(1)} GiB`
: byte > 1024 * 1024
? `${(byte / 1024 / 1024).toFixed(1)} MiB`
: byte > 1024
? `${(byte / 1024).toFixed(1)} KiB`
: `${byte.toFixed(1)} B`;
const useJson = (targetUrl: string, url: string) => {
const [data, setData] = useState<any | undefined>();
const doRequest = async () => {
try {
const resp = await axios.get('/api/modules/dashdot', { params: { url, base: targetUrl } });
setData(resp.data);
// eslint-disable-next-line no-empty
} catch (e) {}
};
useEffect(() => {
if (targetUrl) {
doRequest();
}
}, [targetUrl]);
return data;
};
export function DashdotComponent() {
const { config } = useConfig();
const theme = useMantineTheme();
const { classes } = useStyles();
const { colorScheme } = useMantineColorScheme();
const dashConfig = config.modules?.[DashdotModule.id].options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService: serviceItem | undefined = config.apps.filter(
(service) => service.type === 'Dash.'
)[0];
const dashdotUrl = dashdotService?.url ?? dashConfig?.url?.value ?? '';
const enabledGraphs = dashConfig?.graphs?.value ?? ['CPU', 'RAM', 'Storage', 'Network'];
const cpuEnabled = enabledGraphs.includes('CPU');
const storageEnabled = enabledGraphs.includes('Storage');
const ramEnabled = enabledGraphs.includes('RAM');
const networkEnabled = enabledGraphs.includes('Network');
const gpuEnabled = enabledGraphs.includes('GPU');
const info = useJson(dashdotUrl, '/info');
const storageLoad = useJson(dashdotUrl, '/load/storage');
const totalUsed =
(storageLoad?.layout as any[])?.reduce((acc, curr) => (curr.load ?? 0) + acc, 0) ?? 0;
const totalSize =
(info?.storage?.layout as any[])?.reduce((acc, curr) => (curr.size ?? 0) + acc, 0) ?? 0;
const { t } = useTranslation('modules/dashdot');
const graphs = [
{
id: 'cpu',
name: t('card.graphs.cpu.title'),
enabled: cpuEnabled,
params: {
multiView: dashConfig?.cpuMultiView?.value ?? false,
},
},
{
id: 'storage',
name: t('card.graphs.storage.title'),
enabled: storageEnabled && !isCompact,
params: {
multiView: dashConfig?.storageMultiView?.value ?? false,
},
},
{
id: 'ram',
name: t('card.graphs.memory.title'),
enabled: ramEnabled,
},
{
id: 'network',
name: t('card.graphs.network.title'),
enabled: networkEnabled,
spanTwo: true,
},
{
id: 'gpu',
name: t('card.graphs.gpu.title'),
enabled: gpuEnabled,
spanTwo: true,
},
].filter((g) => g.enabled);
if (dashdotUrl === '') {
return (
<div>
<h2 className={classes.heading}>{t('card.title')}</h2>
<p>{t('card.errors.noService')}</p>
</div>
);
}
return (
<div>
<h2 className={classes.heading}>{t('card.title')}</h2>
{!info ? (
<p>{t('card.errors.noInformation')}</p>
) : (
<div className={classes.graphsContainer}>
<div className={classes.table}>
{storageEnabled && isCompact && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>{t('card.graphs.storage.label')}</p>
<p className={classes.tableValue}>
{((100 * totalUsed) / (totalSize || 1)).toFixed(1)}%{'\n'}
{bytePrettyPrint(totalUsed)} / {bytePrettyPrint(totalSize)}
</p>
</div>
)}
{networkEnabled && (
<div className={classes.tableRow}>
<p className={classes.tableLabel}>{t('card.graphs.network.label')}</p>
<p className={classes.tableValue}>
{bpsPrettyPrint(info?.network?.speedUp)} {t('card.graphs.network.metrics.upload')}
{'\n'}
{bpsPrettyPrint(info?.network?.speedDown)}
{t('card.graphs.network.metrics.download')}
</p>
</div>
)}
</div>
{graphs.map((graph) => (
<Stack
className={classes.graphStack}
style={
isCompact
? {
width: graph.spanTwo ? '100%' : 'calc(50% - 5px)',
position: 'relative',
}
: undefined
}
>
<Title className={classes.graphTitle} order={4} mt={10} mr={25}>
{graph.name}
</Title>
<iframe
className={classes.iframe}
key={graph.name}
title={graph.name}
src={`${dashdotUrl}?singleGraphMode=true&graph=${graph.id.toLowerCase()}&theme=${colorScheme}&surface=${(colorScheme ===
'dark'
? theme.colors.dark[7]
: theme.colors.gray[0]
).substring(1)}${isCompact ? '&gap=10' : '&gap=5'}&innerRadius=${theme.radius.lg}${
graph.params
? `&${Object.entries(graph.params)
.map(([key, value]) => `${key}=${value.toString()}`)
.join('&')}`
: ''
}`}
allowTransparency
/>
</Stack>
))}
</div>
)}
</div>
);
}

View File

@@ -1 +0,0 @@
export { DashdotModule } from './DashdotModule';

View File

@@ -1,42 +0,0 @@
import { Group, Text, Title } from '@mantine/core';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { IconClock as Clock } from '@tabler/icons';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const DateModule: IModule = {
title: 'Date',
icon: Clock,
component: DateComponent,
options: {
full: {
name: 'descriptor.settings.display24HourFormat.label',
value: true,
},
},
id: 'date',
};
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const isFullTime = config?.modules?.[DateModule.id]?.options?.full?.value ?? true;
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
// Change date on minute change
// Note: Using 10 000ms instead of 1000ms to chill a little :)
useEffect(() => {
setSafeInterval(() => {
setDate(new Date());
}, 1000 * 60);
}, []);
return (
<Group p="sm" spacing="xs">
<Title>{dayjs(date).format(formatString)}</Title>
<Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>
</Group>
);
}

View File

@@ -1 +0,0 @@
export { DateModule } from './DateModule';

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Button, Group } from '@mantine/core'; import { Button, Group } from '@mantine/core';
import { closeModal, openModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications'; import { showNotification, updateNotification } from '@mantine/notifications';
import { import {
IconCheck, IconCheck,
@@ -17,9 +16,7 @@ import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { TFunction } from 'react-i18next'; import { TFunction } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { tryMatchService } from '../../tools/addToHomarr';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions'; import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { useConfig } from '../../tools/state';
import { AppType } from '../../types/app'; import { AppType } from '../../types/app';
let t: TFunction<'modules/docker', undefined>; let t: TFunction<'modules/docker', undefined>;
@@ -71,7 +68,6 @@ export interface ContainerActionBarProps {
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) { export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
t = useTranslation('modules/docker').t; t = useTranslation('modules/docker').t;
const [isLoading, setisLoading] = useState(false); const [isLoading, setisLoading] = useState(false);
const { config, setConfig } = useConfig();
return ( return (
<Group spacing="xs"> <Group spacing="xs">
@@ -182,7 +178,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
externalUrl: '', externalUrl: '',
}, },
area: { area: {
type: 'wrapper', // TODO: Set the wrapper automatically type: 'sidebar', // TODO: Set the wrapper automatically
properties: {
location: 'right',
},
}, },
shape: { shape: {
location: { location: {
@@ -194,6 +193,10 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
width: 1, width: 1,
}, },
}, },
integration: {
type: null,
properties: [],
},
}, },
}, },
}); });

View File

@@ -1,15 +1,15 @@
import { ActionIcon, Drawer, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Drawer, Text, Tooltip } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import Docker from 'dockerode';
import { IconBrandDocker, IconX } from '@tabler/icons';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { IconBrandDocker, IconX } from '@tabler/icons';
import axios from 'axios';
import Docker from 'dockerode';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider';
import { IModule } from '../ModuleTypes';
import ContainerActionBar from './ContainerActionBar'; import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable'; import DockerTable from './DockerTable';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
export const DockerModule: IModule = { export const DockerModule: IModule = {
title: 'Docker', title: 'Docker',
@@ -22,17 +22,18 @@ export default function DockerMenuButton(props: any) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]); const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]); const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfig(); const { config } = useConfigContext();
const moduleEnabled = config.modules?.[DockerModule.id]?.enabled ?? false;
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
const { t } = useTranslation('modules/docker'); const { t } = useTranslation('modules/docker');
useEffect(() => { useEffect(() => {
reload(); reload();
}, [config.modules]); }, [config?.settings]);
function reload() { function reload() {
if (!moduleEnabled) { if (!dockerEnabled) {
return; return;
} }
setTimeout(() => { setTimeout(() => {
@@ -56,8 +57,8 @@ export default function DockerMenuButton(props: any) {
}); });
}, 300); }, 300);
} }
const exists = config.modules?.[DockerModule.id]?.enabled ?? false;
if (!exists) { if (!dockerEnabled) {
return null; return null;
} }
// Check if the user has at least one container // Check if the user has at least one container

View File

@@ -1,9 +1,3 @@
export * from './calendar';
export * from './dashdot';
export * from './date';
export * from './torrents';
export * from './ping'; export * from './ping';
export * from './weather';
export * from './docker'; export * from './docker';
export * from './overseerr'; export * from './overseerr';
export * from './usenet';

View File

@@ -1,239 +0,0 @@
import {
ActionIcon,
Button,
Card,
Group,
Menu,
MultiSelect,
Switch,
TextInput,
useMantineColorScheme,
} from '@mantine/core';
import { IconAdjustments } from '@tabler/icons';
import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfig } from '../tools/state';
import { IModule } from './ModuleTypes';
function getItems(module: IModule) {
const { config, setConfig } = useConfig();
const { t } = useTranslation([`modules/${module.id}`, 'common']);
const items: JSX.Element[] = [];
if (module.options) {
const keys = Object.keys(module.options);
const values = Object.values(module.options);
// Get the value and the name of the option
const types = values.map((v) => typeof v.value);
// Loop over all the types with a for each loop
types.forEach((type, index) => {
const optionName = `${module.id}.${keys[index]}`;
const moduleInConfig = config.modules?.[module.id];
if (type === 'object') {
items.push(
<MultiSelect
label={t(`${module.options?.[keys[index]].name}`)}
data={module.options?.[keys[index]].options ?? []}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string[]) ??
(values[index].value as string[]) ??
[]
}
searchable
onChange={(value) => {
setConfig({
...config,
modules: {
...config.modules,
[module.id]: {
...moduleInConfig,
options: {
...moduleInConfig?.options,
[keys[index]]: {
...moduleInConfig?.options?.[keys[index]],
value,
},
},
},
},
});
}}
/>
);
}
if (type === 'string') {
items.push(
<form
onSubmit={(e) => {
e.preventDefault();
setConfig({
...config,
modules: {
...config.modules,
[module.id]: {
...config.modules[module.id],
options: {
...config.modules[module.id].options,
[keys[index]]: {
...config.modules[module.id].options?.[keys[index]],
value: (e.target as any)[0].value,
},
},
},
},
});
}}
>
<Group noWrap align="end">
<TextInput
key={optionName}
id={optionName}
name={optionName}
label={t(`${values[index].name}`)}
defaultValue={
(moduleInConfig?.options?.[keys[index]]?.value as string) ??
(values[index].value as string) ??
''
}
onChange={(e) => {}}
/>
<Button type="submit">{t('save', { ns: 'common' })}</Button>
</Group>
</form>
);
}
// TODO: Add support for other types
if (type === 'boolean') {
items.push(
<Switch
defaultChecked={
// Set default checked to the value of the option if it exists
(moduleInConfig?.options?.[keys[index]]?.value as boolean) ??
(values[index].value as boolean) ??
false
}
key={keys[index]}
onClick={(e) => {
setConfig({
...config,
modules: {
...config.modules,
[module.id]: {
...config.modules[module.id],
options: {
...config.modules[module.id].options,
[keys[index]]: {
...config.modules[module.id].options?.[keys[index]],
value: e.currentTarget.checked,
},
},
},
},
});
}}
label={t(values[index].name)}
/>
);
}
});
}
return items;
}
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { colorScheme } = useMantineColorScheme();
const { config, setConfig } = useConfig();
const enabledModules = config.modules ?? {};
// Remove 'Module' from enabled modules titles
const isShown = enabledModules[module.id]?.enabled ?? false;
//TODO: fix the hover problem
const [hovering, setHovering] = useState(false);
const { t } = useTranslation('modules');
if (!isShown) {
return null;
}
return (
<Card
{...props}
key={module.id}
hidden={!isShown}
withBorder
radius="lg"
shadow="sm"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<motion.div
onHoverStart={() => {
setHovering(true);
}}
onHoverEnd={() => {
setHovering(false);
}}
>
<ModuleMenu module={module} hovered={hovering} />
<module.component />
</motion.div>
</Card>
);
}
interface ModuleMenuProps {
hovered: boolean;
module: IModule;
}
export function ModuleMenu(props: ModuleMenuProps) {
const { module, hovered } = props;
const items: JSX.Element[] = getItems(module);
const { t } = useTranslation('modules/common');
return (
<>
{module.options && (
<Menu
key={module.id}
withinPortal
width="lg"
shadow="xl"
withArrow
closeOnItemClick={false}
radius="md"
position="left"
>
<Menu.Target>
<motion.div
style={{
position: 'absolute',
top: module.padding?.top ?? 15,
right: module.padding?.right ?? 15,
alignSelf: 'flex-end',
zIndex: 10,
}}
animate={{
opacity: hovered ? 1 : 0,
}}
>
<ActionIcon>
<IconAdjustments />
</ActionIcon>
</motion.div>
</Menu.Target>
<Menu.Dropdown style={{ maxWidth: 300 }}>
<Menu.Label>{t('settings.label')}</Menu.Label>
{items.map((item) => (
<Menu.Item key={item.key}>{item}</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</>
);
}

View File

@@ -1,10 +1,10 @@
import { Indicator, Tooltip } from '@mantine/core'; import { Indicator, Tooltip } from '@mantine/core';
import { IconPlug as Plug } from '@tabler/icons';
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { IconPlug as Plug } from '@tabler/icons';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state'; import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider';
import { IModule } from '../ModuleTypes'; import { IModule } from '../ModuleTypes';
export const PingModule: IModule = { export const PingModule: IModule = {
@@ -16,12 +16,12 @@ export const PingModule: IModule = {
export default function PingComponent(props: any) { export default function PingComponent(props: any) {
type State = 'loading' | 'down' | 'online'; type State = 'loading' | 'down' | 'online';
const { config } = useConfig(); const { config } = useConfigContext();
const { url }: { url: string } = props; const { url }: { url: string } = props;
const [isOnline, setOnline] = useState<State>('loading'); const [isOnline, setOnline] = useState<State>('loading');
const [response, setResponse] = useState(500); const [response, setResponse] = useState(500);
const exists = config.modules?.[PingModule.id]?.enabled ?? false; const exists = config?.settings.customization.layout.enabledPing || false;
const { t } = useTranslation('modules/ping'); const { t } = useTranslation('modules/ping');
@@ -54,7 +54,7 @@ export default function PingComponent(props: any) {
.catch((error) => { .catch((error) => {
statusCheck(error.response); statusCheck(error.response);
}); });
}, [config.modules?.[PingModule.id]?.enabled]); }, [config?.settings.customization.layout.enabledPing]);
if (!exists) { if (!exists) {
return null; return null;
} }

View File

@@ -1,206 +0,0 @@
import {
Table,
Text,
Tooltip,
Title,
Group,
Progress,
Skeleton,
ScrollArea,
Center,
Stack,
useMantineTheme,
} from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { useElementSize } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useTranslation } from 'next-i18next';
import { IModule } from '../ModuleTypes';
import { useConfig } from '../../tools/state';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
import { humanFileSize } from '../../tools/humanFileSize';
export const TorrentsModule: IModule = {
id: 'torrents-status',
title: 'Torrent',
icon: Download,
component: TorrentsComponent,
options: {
hideComplete: {
name: 'descriptor.settings.hideComplete.label',
value: false,
},
},
};
export default function TorrentsComponent() {
const { config } = useConfig();
const downloadServices =
config.apps.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||
service.type === 'Deluge'
) ?? [];
const hideComplete: boolean =
(config?.modules?.[TorrentsModule.id]?.options?.hideComplete?.value as boolean) ?? false;
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
const setSafeInterval = useSetSafeInterval();
const [isLoading, setIsLoading] = useState(true);
const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const { t } = useTranslation(`modules/${TorrentsModule.id}`);
useEffect(() => {
setIsLoading(true);
if (downloadServices.length === 0) return;
const interval = setInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/torrents')
.then((response) => {
setTorrents(response.data);
setIsLoading(false);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
setIsLoading(false);
showNotification({
title: 'Error fetching torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-downloads-module',
color: 'red',
message:
'Please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 5000);
}, []);
if (downloadServices.length === 0) {
return (
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
</Group>
</Stack>
);
}
if (isLoading) {
return (
<>
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
<Skeleton height={40} mt={10} />
</>
);
}
const ths = (
<tr ref={ref}>
<th>{t('card.table.header.name')}</th>
<th>{t('card.table.header.size')}</th>
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
<th>{t('card.table.header.progress')}</th>
</tr>
);
// Convert Seconds to readable format.
function calculateETA(givenSeconds: number) {
// If its superior than one day return > 1 day
if (givenSeconds > 86400) {
return '> 1 day';
}
// Transform the givenSeconds into a readable format. e.g. 1h 2m 3s
const hours = Math.floor(givenSeconds / 3600);
const minutes = Math.floor((givenSeconds % 3600) / 60);
const seconds = Math.floor(givenSeconds % 60);
// Only show hours if it's greater than 0.
const hoursString = hours > 0 ? `${hours}h ` : '';
const minutesString = minutes > 0 ? `${minutes}m ` : '';
const secondsString = seconds > 0 ? `${seconds}s` : '';
return `${hoursString}${minutesString}${secondsString}`;
}
// Loop over qBittorrent torrents merging with deluge torrents
const rows = torrents
.filter((torrent) => !(torrent.progress === 1 && hideComplete))
.map((torrent) => {
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
const size = torrent.totalSelected;
return (
<tr key={torrent.id}>
<td>
<Tooltip position="top" label={torrent.name}>
<Text
style={{
maxWidth: '30vw',
}}
size="xs"
lineClamp={1}
>
{torrent.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(size)}</Text>
</td>
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
</td>
)}
<td>
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
<Progress
radius="lg"
color={
torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'
}
value={torrent.progress * 100}
size="lg"
/>
</td>
</tr>
);
});
return (
<ScrollArea sx={{ height: 300, width: '100%' }}>
{rows.length > 0 ? (
<Table highlightOnHover p="sm">
<thead>{ths}</thead>
<tbody>{rows}</tbody>
</Table>
) : (
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title order={3}>{t('card.table.body.nothingFound')}</Title>
</Center>
)}
</ScrollArea>
);
}

View File

@@ -1,193 +0,0 @@
import { Text, Title, Group, useMantineTheme, Box, Card, ColorSwatch, Stack } from '@mantine/core';
import { IconDownload as Download } from '@tabler/icons';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { linearGradientDef } from '@nivo/core';
import { useTranslation } from 'next-i18next';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { useConfig } from '../../tools/state';
import { humanFileSize } from '../../tools/humanFileSize';
import { IModule } from '../ModuleTypes';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
export const TotalDownloadsModule: IModule = {
title: 'Download Speed',
icon: Download,
component: TotalDownloadsComponent,
id: 'dlspeed',
};
interface torrentHistory {
x: number;
up: number;
down: number;
}
export default function TotalDownloadsComponent() {
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const downloadServices =
config.apps.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||
service.type === 'Deluge'
) ?? [];
const { t } = useTranslation(`modules/${TotalDownloadsModule.id}`);
const [torrentHistory, torrentHistoryHandlers] = useListState<torrentHistory>([]);
const [torrents, setTorrents] = useState<NormalizedTorrent[]>([]);
const totalDownloadSpeed = torrents.reduce((acc, torrent) => acc + torrent.downloadSpeed, 0);
const totalUploadSpeed = torrents.reduce((acc, torrent) => acc + torrent.uploadSpeed, 0);
useEffect(() => {
if (downloadServices.length === 0) return;
const interval = setSafeInterval(() => {
// Send one request with each download service inside
axios
.post('/api/modules/torrents')
.then((response) => {
setTorrents(response.data);
})
.catch((error) => {
setTorrents([]);
// eslint-disable-next-line no-console
console.error('Error while fetching torrents', error.response.data);
showNotification({
title: 'Torrent speed module failed to fetch torrents',
autoClose: 1000,
disallowClose: true,
id: 'fail-torrent-speed-module',
color: 'red',
message:
'Error fetching torrents, please check your config for any potential errors, check the console for more info',
});
clearInterval(interval);
});
}, 1000);
}, [config.apps]);
useEffect(() => {
torrentHistoryHandlers.append({
x: Date.now(),
down: totalDownloadSpeed,
up: totalUploadSpeed,
});
}, [totalDownloadSpeed, totalUploadSpeed]);
if (downloadServices.length === 0) {
return (
<Group>
<Title order={4}>{t('card.errors.noDownloadClients.title')}</Title>
<div>{t('card.errors.noDownloadClients.text')}</div>
</Group>
);
}
const theme = useMantineTheme();
// Load the last 10 values from the history
const history = torrentHistory.slice(-10);
const chartDataUp = history.map((load, i) => ({
x: load.x,
y: load.up,
})) as Datum[];
const chartDataDown = history.map((load, i) => ({
x: load.x,
y: load.down,
})) as Datum[];
return (
<Stack>
<Title order={4}>{t('card.lineChart.title')}</Title>
<Stack>
<Group>
<ColorSwatch size={12} color={theme.colors.green[5]} />
<Text>
{t('card.lineChart.totalDownload', { download: humanFileSize(totalDownloadSpeed) })}
</Text>
</Group>
<Group>
<ColorSwatch size={12} color={theme.colors.blue[5]} />
<Text>
{t('card.lineChart.totalUpload', { upload: humanFileSize(totalUploadSpeed) })}
</Text>
</Group>
</Stack>
<Box
style={{
height: 200,
width: '100%',
}}
>
<ResponsiveLine
isInteractive
enableSlices="x"
sliceTooltip={({ slice }) => {
const Download = slice.points[0].data.y as number;
const Upload = slice.points[1].data.y as number;
// Get the number of seconds since the last update.
const seconds = (Date.now() - (slice.points[0].data.x as number)) / 1000;
// Round to the nearest second.
const roundedSeconds = Math.round(seconds);
return (
<Card p="sm" radius="md" withBorder>
<Text size="md">{t('card.lineChart.timeSpan', { seconds: roundedSeconds })}</Text>
<Card.Section p="sm">
<Stack>
<Group>
<ColorSwatch size={10} color={theme.colors.green[5]} />
<Text size="md">
{t('card.lineChart.download', { download: humanFileSize(Download) })}
</Text>
</Group>
<Group>
<ColorSwatch size={10} color={theme.colors.blue[5]} />
<Text size="md">
{t('card.lineChart.upload', { upload: humanFileSize(Upload) })}
</Text>
</Group>
</Stack>
</Card.Section>
</Card>
);
}}
data={[
{
id: 'downloads',
data: chartDataUp,
},
{
id: 'uploads',
data: chartDataDown,
},
]}
curve="monotoneX"
yFormat=" >-.2f"
axisTop={null}
axisRight={null}
enablePoints={false}
animate={false}
enableGridX={false}
enableGridY={false}
enableArea
defs={[
linearGradientDef('gradientA', [
{ offset: 0, color: 'inherit' },
{ offset: 100, color: 'inherit', opacity: 0 },
]),
]}
fill={[{ match: '*', id: 'gradientA' }]}
colors={[
// Blue
theme.colors.blue[5],
// Green
theme.colors.green[5],
]}
/>
</Box>
</Stack>
);
}

View File

@@ -1,2 +0,0 @@
export { TorrentsModule } from './TorrentsModule';
export { TotalDownloadsModule } from './TotalDownloadsModule';

View File

@@ -1,117 +0,0 @@
import {
Badge,
Button,
Group,
Select,
Stack,
Tabs,
Text,
Title,
useMantineTheme,
} from '@mantine/core';
import { IconDownload, IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
import { FunctionComponent, useEffect, useState } from 'react';
import { useTranslation } from 'next-i18next';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { useElementSize } from '@mantine/hooks';
import { IModule } from '../ModuleTypes';
import { useGetServiceByType } from '../../tools/hooks/useGetServiceByType';
import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { UsenetQueueList } from '../../widgets/useNet/UsenetQueueList';
import { UsenetHistoryList } from '../../widgets/useNet/UsenetHistoryList';
dayjs.extend(duration);
export const UsenetComponent: FunctionComponent = () => {
const downloadServices = useGetServiceByType('Sabnzbd', 'NZBGet');
const { t } = useTranslation('modules/usenet');
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
const { data } = useGetUsenetInfo({ appId: selectedServiceId! });
useEffect(() => {
if (!selectedServiceId && downloadServices.length) {
setSelectedService(downloadServices[0].id);
}
}, [downloadServices, selectedServiceId]);
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedServiceId! });
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedServiceId! });
if (downloadServices.length === 0) {
return (
<Stack>
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
</Group>
</Stack>
);
}
if (!selectedServiceId) {
return null;
}
const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
return (
<Tabs keepMounted={false} defaultValue="queue">
<Tabs.List ref={ref} mb="md" style={{ flex: 1 }}>
<Tabs.Tab value="queue">{t('tabs.queue')}</Tabs.Tab>
<Tabs.Tab value="history">{t('tabs.history')}</Tabs.Tab>
{data && (
<Group position="right" ml="auto">
{width > MIN_WIDTH_MOBILE && (
<>
<Badge>{humanFileSize(data?.speed)}/s</Badge>
<Badge>
{t('info.sizeLeft')}: {humanFileSize(data?.sizeLeft)}
</Badge>
</>
)}
{data.paused ? (
<Button uppercase onClick={() => resume()} radius="xl" size="xs">
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
</Button>
) : (
<Button uppercase onClick={() => pause()} radius="xl" size="xs">
<IconPlayerPause size={12} style={{ marginRight: 5 }} />{' '}
{dayjs.duration(data.eta, 's').format('HH:mm')}
</Button>
)}
</Group>
)}
</Tabs.List>
{downloadServices.length > 1 && (
<Select
value={selectedServiceId}
onChange={setSelectedService}
ml="xs"
data={downloadServices.map((service) => ({ value: service.id, label: service.name }))}
/>
)}
<Tabs.Panel value="queue">
<UsenetQueueList appId={selectedServiceId} />
</Tabs.Panel>
<Tabs.Panel value="history">
<UsenetHistoryList appId={selectedServiceId} />
</Tabs.Panel>
</Tabs>
);
};
export const UsenetModule: IModule = {
id: 'usenet',
title: 'Usenet',
icon: IconDownload,
component: UsenetComponent,
};
export default UsenetComponent;

View File

@@ -1 +0,0 @@
export { UsenetModule } from './UsenetModule';

View File

@@ -1,41 +0,0 @@
// To parse this data:
//
// import { Convert, WeatherResponse } from "./file";
//
// const weatherResponse = Convert.toWeatherResponse(json);
//
// These functions will throw an error if the JSON doesn't
// match the expected interface, even if the JSON is valid.
export interface WeatherResponse {
current_weather: CurrentWeather;
utc_offset_seconds: number;
latitude: number;
elevation: number;
longitude: number;
generationtime_ms: number;
daily_units: DailyUnits;
daily: Daily;
}
export interface CurrentWeather {
winddirection: number;
windspeed: number;
time: string;
weathercode: number;
temperature: number;
}
export interface Daily {
temperature_2m_max: number[];
time: Date[];
temperature_2m_min: number[];
weathercode: number[];
}
export interface DailyUnits {
temperature_2m_max: string;
temperature_2m_min: string;
time: string;
weathercode: string;
}

View File

@@ -1,201 +0,0 @@
import { Group, Space, Title, Tooltip, Skeleton, Stack, Box } from '@mantine/core';
import axios from 'axios';
import { useEffect, useState } from 'react';
import {
IconArrowDownRight as ArrowDownRight,
IconArrowUpRight as ArrowUpRight,
IconCloud as Cloud,
IconCloudFog as CloudFog,
IconCloudRain as CloudRain,
IconCloudSnow as CloudSnow,
IconCloudStorm as CloudStorm,
IconQuestionMark as QuestionMark,
IconSnowflake as Snowflake,
IconSun as Sun,
} from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { IModule } from '../ModuleTypes';
import { WeatherResponse } from './WeatherInterface';
export const WeatherModule: IModule = {
title: 'Weather',
icon: Sun,
component: WeatherComponent,
options: {
freedomunit: {
name: 'descriptor.settings.displayInFahrenheit.label',
value: false,
},
location: {
name: 'descriptor.settings.location.label',
value: 'Paris',
},
},
id: 'weather',
};
// 0 Clear sky
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
// 45, 48 Fog and depositing rime fog
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
// 56, 57 Freezing Drizzle: Light and dense intensity
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
// 66, 67 Freezing Rain: Light and heavy intensity
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
// 77 Snow grains
// 80, 81, 82 Rain showers: Slight, moderate, and violent
// 85, 86Snow showers slight and heavy
// 95 *Thunderstorm: Slight or moderate
// 96, 99 *Thunderstorm with slight and heavy hail
export function WeatherIcon(props: any) {
const { t } = useTranslation('modules/weather');
const { code } = props;
let data: { icon: any; name: string };
switch (code) {
case 0: {
data = { icon: Sun, name: t('card.weatherDescriptions.clear') };
break;
}
case 1:
case 2:
case 3: {
data = { icon: Cloud, name: t('card.weatherDescriptions.mainlyClear') };
break;
}
case 45:
case 48: {
data = { icon: CloudFog, name: t('card.weatherDescriptions.fog') };
break;
}
case 51:
case 53:
case 55: {
data = { icon: Cloud, name: t('card.weatherDescriptions.drizzle') };
break;
}
case 56:
case 57: {
data = {
icon: Snowflake,
name: t('card.weatherDescriptions.freezingDrizzle'),
};
break;
}
case 61:
case 63:
case 65: {
data = { icon: CloudRain, name: t('card.weatherDescriptions.rain') };
break;
}
case 66:
case 67: {
data = { icon: CloudRain, name: t('card.weatherDescriptions.freezingRain') };
break;
}
case 71:
case 73:
case 75: {
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowFall') };
break;
}
case 77: {
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowGrains') };
break;
}
case 80:
case 81:
case 82: {
data = { icon: CloudRain, name: t('card.weatherDescriptions.rainShowers') };
break;
}
case 85:
case 86: {
data = { icon: CloudSnow, name: t('card.weatherDescriptions.snowShowers') };
break;
}
case 95: {
data = { icon: CloudStorm, name: t('card.weatherDescriptions.thunderstorm') };
break;
}
case 96:
case 99: {
data = {
icon: CloudStorm,
name: t('card.weatherDescriptions.thunderstormWithHail'),
};
break;
}
default: {
data = { icon: QuestionMark, name: t('card.weatherDescriptions.unknown') };
}
}
return (
<Tooltip withinPortal withArrow label={data.name}>
<Box>
<data.icon size={50} />
</Box>
</Tooltip>
);
}
export default function WeatherComponent(props: any) {
// Get location from browser
const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse);
const cityInput: string =
(config?.modules?.[WeatherModule.id]?.options?.location?.value as string) ?? 'Paris';
const isFahrenheit: boolean =
(config?.modules?.[WeatherModule.id]?.options?.freedomunit?.value as boolean) ?? false;
useEffect(() => {
axios
.get(`https://geocoding-api.open-meteo.com/v1/search?name=${cityInput}`)
.then((response) => {
// Check if results exists
const { latitude, longitude } = response.data.results
? response.data.results[0]
: { latitude: 0, longitude: 0 };
axios
.get(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon`
)
.then((res) => {
setWeather(res.data);
});
});
}, [cityInput]);
if (!weather.current_weather) {
return (
<>
<Skeleton height={40} width={100} mb="xl" />
<Group noWrap>
<Skeleton height={50} circle />
<Group>
<Skeleton height={25} width={70} mr="lg" />
<Skeleton height={25} width={70} />
</Group>
</Group>
</>
);
}
function usePerferedUnit(value: number): string {
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
}
return (
<Stack p="sm" spacing="xs">
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
<Group spacing={0}>
<WeatherIcon code={weather.current_weather.weathercode} />
<Space mx="sm" />
<span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
<ArrowUpRight size={16} style={{ right: 15 }} />
<Space mx="sm" />
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
<ArrowDownRight size={16} />
</Group>
</Stack>
);
}

View File

@@ -1 +0,0 @@
export { WeatherModule } from './WeatherModule';

View File

@@ -4,20 +4,25 @@ import { GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import path from 'path'; import path from 'path';
import { useEffect } from 'react'; import { useEffect } from 'react';
import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig'; import LoadConfigComponent from '../components/Config/LoadConfig';
import { Dashboard } from '../components/Dashboard/Dashboard';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useConfigContext } from '../config/provider';
import { useConfigStore } from '../config/store';
import { getConfig } from '../tools/getConfig'; import { getConfig } from '../tools/getConfig';
import { useConfig } from '../tools/state';
import { dashboardNamespaces } from '../tools/translation-namespaces'; import { dashboardNamespaces } from '../tools/translation-namespaces';
import { Config } from '../tools/types'; import { ConfigType } from '../types/config';
type ServerSideProps = {
config: ConfigType;
};
export async function getServerSideProps({ export async function getServerSideProps({
req, req,
res, res,
locale, locale,
query, query,
}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> { }: GetServerSidePropsContext): Promise<{ props: ServerSideProps }> {
const configByUrl = query.slug; const configByUrl = query.slug;
const configPath = path.join(process.cwd(), 'data/configs', `${configByUrl}.json`); const configPath = path.join(process.cwd(), 'data/configs', `${configByUrl}.json`);
const configExists = fs.existsSync(configPath); const configExists = fs.existsSync(configPath);
@@ -28,12 +33,36 @@ export async function getServerSideProps({
return { return {
props: { props: {
config: { config: {
name: 'Default config', schemaVersion: '1.0',
configProperties: {
name: 'Default Configuration',
},
apps: [], apps: [],
settings: { settings: {
searchUrl: 'https://www.google.com/search?q=', common: {
searchEngine: {
type: 'google',
properties: {
enabled: true,
openInNewTab: true,
}, },
modules: {}, },
defaultConfig: 'default',
},
customization: {
layout: {
enabledLeftSidebar: false,
enabledRightSidebar: false,
enabledSearchbar: true,
enabledDocker: false,
enabledPing: false,
},
colors: {},
},
},
categories: [],
wrappers: [],
widgets: [],
}, },
}, },
}; };
@@ -46,14 +75,18 @@ export async function getServerSideProps({
} }
export default function HomePage(props: any) { export default function HomePage(props: any) {
const { config: initialConfig }: { config: Config } = props; const { config: initialConfig }: { config: ConfigType } = props;
const { setConfig } = useConfig(); const { name: configName } = useConfigContext();
const { updateConfig } = useConfigStore();
useEffect(() => { useEffect(() => {
setConfig(initialConfig); if (!configName) {
return;
}
updateConfig(configName, () => initialConfig);
}, [initialConfig]); }, [initialConfig]);
return ( return (
<Layout> <Layout>
<AppShelf /> <Dashboard />
<LoadConfigComponent /> <LoadConfigComponent />
</Layout> </Layout>
); );

View File

@@ -1,5 +1,6 @@
import { getCookie, setCookie } from 'cookies-next'; import { getCookie, setCookie } from 'cookies-next';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import { SSRConfig } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import LoadConfigComponent from '../components/Config/LoadConfig'; import LoadConfigComponent from '../components/Config/LoadConfig';
@@ -12,7 +13,9 @@ import { ConfigType } from '../types/config';
type ServerSideProps = { type ServerSideProps = {
config: ConfigType; config: ConfigType;
// eslint-disable-next-line react/no-unused-prop-types
configName: string; configName: string;
// eslint-disable-next-line react/no-unused-prop-types
_nextI18Next: SSRConfig['_nextI18Next']; _nextI18Next: SSRConfig['_nextI18Next'];
}; };

View File

@@ -35,23 +35,3 @@ export function tryMatchService(container: Dockerode.ContainerInfo | undefined)
.toLowerCase()}.png`, .toLowerCase()}.png`,
}; };
} }
export default async function addToHomarr(
container: Dockerode.ContainerInfo,
config: Config,
setConfig: (newconfig: Config) => void
) {
setConfig({
...config,
apps: [
...config.apps,
{
name: container.Names[0].substring(1),
id: container.Id,
type: tryMatchType(container.Image),
url: `localhost:${container.Ports.at(0)?.PublicPort}`,
icon: await MatchIcon(container.Names[0].substring(1)),
},
],
});
}

View File

@@ -6,7 +6,7 @@ export const getFallbackConfig = (name?: string): BackendConfigType => ({
name: name ?? 'default', name: name ?? 'default',
}, },
categories: [], categories: [],
widgets: {}, widgets: [],
apps: [], apps: [],
settings: { settings: {
common: { common: {

View File

@@ -6,18 +6,17 @@ export const getFrontendConfig = (name: string): ConfigType => {
return { return {
...config, ...config,
apps: config.apps.map((s) => ({ apps: config.apps.map((app) => ({
...s, ...app,
integration: s.integration integration: {
? { ...app.integration ?? null,
...s.integration, type: app.integration?.type ?? null,
properties: s.integration?.properties.map((p) => ({ properties: app.integration?.properties.map((property) => ({
...p, ...property,
value: p.type === 'private' ? null : p.value, value: property.type === 'private' ? undefined : property.value,
isDefined: p.value != null, isDefined: property.value != null,
})), })) ?? [],
} },
: null,
})), })),
}; };
}; };

View File

@@ -1,13 +0,0 @@
import { useConfig } from '../state';
import { Config, ServiceType } from '../types';
export const useGetServiceByType = (...serviceTypes: ServiceType[]) => {
const { config } = useConfig();
return getServiceByType(config, ...serviceTypes);
};
export const getServiceByType = (config: Config, ...serviceTypes: ServiceType[]) =>
config.apps.filter((s) => serviceTypes.includes(s.type));
export const getServiceById = (config: Config, id: string) => config.apps.find((s) => s.id === id);

View File

@@ -1,14 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { Config } from './types';
export function migrateToIdConfig(config: Config): Config {
// Set the config and add an ID to all the services that don't have one
const services = config.apps.map((service) => ({
...service,
id: service.id ?? uuidv4(),
}));
return {
...config,
apps: services,
};
}

View File

@@ -1,92 +0,0 @@
// src/context/state.js
import { showNotification } from '@mantine/notifications';
import axios from 'axios';
import { createContext, ReactNode, useContext, useState } from 'react';
import { IconCheck as Check, IconX as X } from '@tabler/icons';
import { Config } from './types';
type configContextType = {
config: Config;
setConfig: (newconfig: Config) => void;
loadConfig: (name: string) => void;
getConfigs: () => Promise<string[]>;
};
const configContext = createContext<configContextType>({
config: {
name: 'default',
apps: [],
settings: {
searchUrl: 'https://google.com/search?q=',
},
modules: {},
},
setConfig: () => {},
loadConfig: async (name: string) => {},
getConfigs: async () => [],
});
export function useConfig() {
const context = useContext(configContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}
type Props = {
children: ReactNode;
};
export function ConfigProvider({ children }: Props) {
const [config, setConfigInternal] = useState<Config>({
name: 'default',
apps: [],
settings: {
searchUrl: 'https://www.google.com/search?q=',
},
modules: {},
});
async function loadConfig(configName: string) {
try {
const response = await axios.get(`/api/configs/${configName}`);
setConfigInternal(JSON.parse(response.data));
showNotification({
title: 'Config',
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: `Loaded config : ${configName}`,
});
} catch (error) {
showNotification({
title: 'Config',
icon: <X />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: `Error loading config : ${configName}`,
});
}
}
function setConfig(newconfig: Config) {
axios.put(`/api/configs/${newconfig.name}`, newconfig);
setConfigInternal(newconfig);
}
async function getConfigs(): Promise<string[]> {
const response = await axios.get('/api/configs');
return response.data;
}
const value = {
config,
setConfig,
loadConfig,
getConfigs,
};
return <configContext.Provider value={value}>{children}</configContext.Provider>;
}