mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 23:45:48 +01:00
🎨 Moved integrations in widgets directory
This commit is contained in:
@@ -3,7 +3,7 @@ import { closeModal, ContextModalProps } from '@mantine/modals';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { IntegrationsType } from '../../../../types/integration';
|
||||
import { IntegrationChangePositionModalInnerProps } from '../../Tiles/Integrations/IntegrationsMenu';
|
||||
import { WidgetChangePositionModalInnerProps } from '../../Tiles/Widgets/WidgetsMenu';
|
||||
import { Tiles } from '../../Tiles/tilesDefinitions';
|
||||
import { ChangePositionModal } from './ChangePositionModal';
|
||||
|
||||
@@ -11,7 +11,7 @@ export const ChangeIntegrationPositionModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<IntegrationChangePositionModalInnerProps>) => {
|
||||
}: ContextModalProps<WidgetChangePositionModalInnerProps>) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Box, Indicator, IndicatorProps, Popover } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { MediaList } from './MediaList';
|
||||
import { MediasType } from './type';
|
||||
|
||||
interface CalendarDayProps {
|
||||
date: Date;
|
||||
medias: MediasType;
|
||||
}
|
||||
|
||||
export const CalendarDay = ({ date, medias }: CalendarDayProps) => {
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
if (medias.totalCount === 0) {
|
||||
return <div>{date.getDate()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom"
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transition="pop"
|
||||
onClose={close}
|
||||
opened={opened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Box onClick={open}>
|
||||
<DayIndicator color={'red'} position="bottom-start" medias={medias.books}>
|
||||
<DayIndicator color={'yellow'} position="top-start" medias={medias.movies}>
|
||||
<DayIndicator color={'blue'} position="top-end" medias={medias.tvShows}>
|
||||
<DayIndicator color={'green'} position="bottom-end" medias={medias.musics}>
|
||||
<div>{date.getDate()}</div>
|
||||
</DayIndicator>
|
||||
</DayIndicator>
|
||||
</DayIndicator>
|
||||
</DayIndicator>
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MediaList medias={medias} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface DayIndicatorProps {
|
||||
color: string;
|
||||
medias: any[];
|
||||
children: JSX.Element;
|
||||
position: IndicatorProps['position'];
|
||||
}
|
||||
|
||||
const DayIndicator = ({ color, medias, children, position }: DayIndicatorProps) => {
|
||||
if (medias.length === 0) return children;
|
||||
|
||||
return (
|
||||
<Indicator size={10} withBorder offset={5} color={color} position={position}>
|
||||
{children}
|
||||
</Indicator>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createStyles, MantineThemeColors, useMantineTheme } from '@mantine/core';
|
||||
import { Calendar } from '@mantine/dates';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useColorTheme } from '../../../../tools/color';
|
||||
import { isToday } from '../../../../tools/isToday';
|
||||
import { CalendarIntegrationType } from '../../../../types/integration';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { BaseTileProps } from '../type';
|
||||
import { CalendarDay } from './CalendarDay';
|
||||
import { MediasType } from './type';
|
||||
|
||||
interface CalendarTileProps extends BaseTileProps {
|
||||
module: CalendarIntegrationType | undefined;
|
||||
}
|
||||
|
||||
export const CalendarTile = ({ className, module }: CalendarTileProps) => {
|
||||
const { secondaryColor } = useColorTheme();
|
||||
const { name: configName } = useConfigContext();
|
||||
const { classes, cx } = useStyles(secondaryColor);
|
||||
const { colorScheme, colors } = useMantineTheme();
|
||||
const [month, setMonth] = useState(new Date());
|
||||
|
||||
const { data: medias } = useQuery({
|
||||
queryKey: ['calendar/medias', { month: month.getMonth(), year: month.getFullYear() }],
|
||||
queryFn: async () =>
|
||||
(await (
|
||||
await fetch(
|
||||
`/api/modules/calendar?year=${month.getFullYear()}&month=${
|
||||
month.getMonth() + 1
|
||||
}&configName=${configName}`
|
||||
)
|
||||
).json()) as MediasType,
|
||||
});
|
||||
|
||||
if (!module) return <></>;
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className} p={6}>
|
||||
<Calendar
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
size="xs"
|
||||
fullWidth
|
||||
onChange={() => {}}
|
||||
firstDayOfWeek={module.properties?.isWeekStartingAtSunday ? 'sunday' : 'monday'}
|
||||
dayStyle={(date) => ({
|
||||
margin: 1,
|
||||
backgroundColor: isToday(date)
|
||||
? colorScheme === 'dark'
|
||||
? colors.dark[5]
|
||||
: colors.gray[0]
|
||||
: undefined,
|
||||
})}
|
||||
styles={{
|
||||
calendarHeader: {
|
||||
marginRight: 40,
|
||||
marginLeft: 40,
|
||||
},
|
||||
}}
|
||||
allowLevelChange={false}
|
||||
dayClassName={(_, modifiers) => cx({ [classes.weekend]: modifiers.weekend })}
|
||||
renderDay={(date) => (
|
||||
<CalendarDay date={date} medias={getReleasedMediasForDate(medias, date)} />
|
||||
)}
|
||||
/>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme, secondaryColor: keyof MantineThemeColors) => ({
|
||||
weekend: {
|
||||
color: `${secondaryColor} !important`,
|
||||
},
|
||||
}));
|
||||
|
||||
const getReleasedMediasForDate = (medias: MediasType | undefined, date: Date): MediasType => {
|
||||
const books =
|
||||
medias?.books.filter((b) => new Date(b.releaseDate).toDateString() === date.toDateString()) ??
|
||||
[];
|
||||
const movies =
|
||||
medias?.movies.filter((m) => new Date(m.inCinemas).toDateString() === date.toDateString()) ??
|
||||
[];
|
||||
const musics =
|
||||
medias?.musics.filter((m) => new Date(m.releaseDate).toDateString() === date.toDateString()) ??
|
||||
[];
|
||||
const tvShows =
|
||||
medias?.tvShows.filter(
|
||||
(tv) => new Date(tv.airDateUtc).toDateString() === date.toDateString()
|
||||
) ?? [];
|
||||
const totalCount = medias ? books.length + movies.length + musics.length + tvShows.length : 0;
|
||||
|
||||
return {
|
||||
books,
|
||||
movies,
|
||||
musics,
|
||||
tvShows,
|
||||
totalCount,
|
||||
};
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createStyles, Divider, ScrollArea } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
LidarrMediaDisplay,
|
||||
RadarrMediaDisplay,
|
||||
ReadarrMediaDisplay,
|
||||
SonarrMediaDisplay,
|
||||
} from '../../../../modules/common';
|
||||
import { MediasType } from './type';
|
||||
|
||||
interface MediaListProps {
|
||||
medias: MediasType;
|
||||
}
|
||||
|
||||
export const MediaList = ({ medias }: MediaListProps) => {
|
||||
const { classes } = useStyles();
|
||||
const lastMediaType = getLastMediaType(medias);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
offsetScrollbars
|
||||
scrollbarSize={5}
|
||||
pt={5}
|
||||
className={classes.scrollArea}
|
||||
styles={{
|
||||
viewport: {
|
||||
maxHeight: 450,
|
||||
minHeight: 210,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{mapMedias(medias.tvShows, SonarrMediaDisplay, lastMediaType === 'tv-show')}
|
||||
{mapMedias(medias.movies, RadarrMediaDisplay, lastMediaType === 'movie')}
|
||||
{mapMedias(medias.musics, LidarrMediaDisplay, lastMediaType === 'music')}
|
||||
{mapMedias(medias.books, ReadarrMediaDisplay, lastMediaType === 'book')}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const mapMedias = (
|
||||
medias: any[],
|
||||
MediaComponent: (props: { media: any }) => JSX.Element | null,
|
||||
containsLastItem: boolean
|
||||
) => {
|
||||
return medias.map((media, index) => (
|
||||
<React.Fragment>
|
||||
<MediaComponent media={media} />
|
||||
{containsLastItem && index === medias.length - 1 ? null : <MediaDivider />}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
const MediaDivider = () => <Divider variant="dashed" size="sm" my="xl" />;
|
||||
|
||||
const getLastMediaType = (medias: MediasType) => {
|
||||
if (medias.books.length >= 1) return 'book';
|
||||
if (medias.musics.length >= 1) return 'music';
|
||||
if (medias.movies.length >= 1) return 'movie';
|
||||
return 'tv-show';
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
scrollArea: {
|
||||
width: 400,
|
||||
},
|
||||
}));
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface MediasType {
|
||||
tvShows: any[]; // Sonarr
|
||||
movies: any[]; // Radarr
|
||||
musics: any[]; // Lidarr
|
||||
books: any[]; // Readarr
|
||||
totalCount: number;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Center, Stack, Text, Title } from '@mantine/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSetSafeInterval } from '../../../../tools/hooks/useSetSafeInterval';
|
||||
import { ClockIntegrationType } from '../../../../types/integration';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { IntegrationsMenu } from '../Integrations/IntegrationsMenu';
|
||||
import { BaseTileProps } from '../type';
|
||||
|
||||
interface ClockTileProps extends BaseTileProps {
|
||||
module: ClockIntegrationType | undefined;
|
||||
}
|
||||
|
||||
export const ClockTile = ({ className, module }: ClockTileProps) => {
|
||||
const date = useDateState();
|
||||
const formatString = module?.properties.is24HoursFormat ? 'HH:mm' : 'h:mm A';
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<IntegrationsMenu<'clock'>
|
||||
integration="clock"
|
||||
module={module}
|
||||
options={module?.properties}
|
||||
labels={{ is24HoursFormat: 'descriptor.settings.display24HourFormat.label' }}
|
||||
/>
|
||||
<Center style={{ height: '100%' }}>
|
||||
<Stack spacing="xs">
|
||||
<Title>{dayjs(date).format(formatString)}</Title>
|
||||
<Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* State which updates when the minute is changing
|
||||
* @returns current date updated every new minute
|
||||
*/
|
||||
const useDateState = () => {
|
||||
const [date, setDate] = useState(new Date());
|
||||
const setSafeInterval = useSetSafeInterval();
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(); // reference for initial timeout until first minute change
|
||||
useEffect(() => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDate(new Date());
|
||||
// Starts intervall which update the date every minute
|
||||
setSafeInterval(() => {
|
||||
setDate(new Date());
|
||||
}, 1000 * 60);
|
||||
}, getMsUntilNextMinute());
|
||||
|
||||
return () => timeoutRef.current && clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
// calculates the amount of milliseconds until next minute starts.
|
||||
const getMsUntilNextMinute = () => {
|
||||
const now = new Date();
|
||||
const nextMinute = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes() + 1
|
||||
);
|
||||
return nextMinute.getTime() - now.getTime();
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Group, Stack, Text } from '@mantine/core';
|
||||
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { bytes } from '../../../../tools/bytesHelper';
|
||||
|
||||
interface DashDotCompactNetworkProps {
|
||||
info: DashDotInfo;
|
||||
}
|
||||
|
||||
export interface DashDotInfo {
|
||||
storage: {
|
||||
layout: { size: number }[];
|
||||
};
|
||||
network: {
|
||||
speedUp: number;
|
||||
speedDown: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DashDotCompactNetwork = ({ info }: DashDotCompactNetworkProps) => {
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
|
||||
const upSpeed = bytes.toPerSecondString(info?.network?.speedUp);
|
||||
const downSpeed = bytes.toPerSecondString(info?.network?.speedDown);
|
||||
|
||||
return (
|
||||
<Group noWrap align="start" position="apart" w="100%" maw="251px">
|
||||
<Text weight={500}>{t('card.graphs.network.label')}</Text>
|
||||
<Stack align="end" spacing={0}>
|
||||
<Group spacing={0}>
|
||||
<Text size="xs" color="dimmed" align="right">
|
||||
{upSpeed}
|
||||
</Text>
|
||||
<IconArrowNarrowUp size={16} stroke={1.5} />
|
||||
</Group>
|
||||
<Group spacing={0}>
|
||||
<Text size="xs" color="dimmed" align="right">
|
||||
{downSpeed}
|
||||
</Text>
|
||||
<IconArrowNarrowDown size={16} stroke={1.5} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Group, Stack, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { bytes } from '../../../../tools/bytesHelper';
|
||||
import { percentage } from '../../../../tools/percentage';
|
||||
import { DashDotInfo } from './DashDotTile';
|
||||
|
||||
interface DashDotCompactStorageProps {
|
||||
info: DashDotInfo;
|
||||
}
|
||||
|
||||
export const DashDotCompactStorage = ({ info }: DashDotCompactStorageProps) => {
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
const { data: storageLoad } = useDashDotStorage();
|
||||
|
||||
const totalUsed = calculateTotalLayoutSize({
|
||||
layout: storageLoad?.layout ?? [],
|
||||
key: 'load',
|
||||
});
|
||||
const totalSize = calculateTotalLayoutSize({
|
||||
layout: info?.storage?.layout ?? [],
|
||||
key: 'size',
|
||||
});
|
||||
|
||||
return (
|
||||
<Group noWrap align="start" position="apart" w="100%" maw="251px">
|
||||
<Text weight={500}>{t('card.graphs.storage.label')}</Text>
|
||||
<Stack align="end" spacing={0}>
|
||||
<Text color="dimmed" size="xs">
|
||||
{percentage(totalUsed, totalSize)}%
|
||||
</Text>
|
||||
<Text color="dimmed" size="xs">
|
||||
{bytes.toString(totalUsed)} / {bytes.toString(totalSize)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateTotalLayoutSize = <TLayoutItem,>({
|
||||
layout,
|
||||
key,
|
||||
}: CalculateTotalLayoutSizeProps<TLayoutItem>) =>
|
||||
layout.reduce((total, current) => total + (current[key] as number), 0);
|
||||
|
||||
interface CalculateTotalLayoutSizeProps<TLayoutItem> {
|
||||
layout: TLayoutItem[];
|
||||
key: keyof TLayoutItem;
|
||||
}
|
||||
|
||||
const useDashDotStorage = () => {
|
||||
const { name: configName, config } = useConfigContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'dashdot/storage',
|
||||
{
|
||||
configName,
|
||||
url: config?.integrations.dashDot?.properties.url,
|
||||
},
|
||||
],
|
||||
queryFn: () => fetchDashDotStorageLoad(configName),
|
||||
});
|
||||
};
|
||||
|
||||
async function fetchDashDotStorageLoad(configName: string | undefined) {
|
||||
console.log(`storage request: ${configName}`);
|
||||
if (!configName) throw new Error('configName is undefined');
|
||||
return (await (
|
||||
await axios.get('/api/modules/dashdot/storage', { params: { configName } })
|
||||
).data) as DashDotStorageLoad;
|
||||
}
|
||||
|
||||
interface DashDotStorageLoad {
|
||||
layout: { load: number }[];
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { createStyles, Stack, Title, useMantineTheme } from '@mantine/core';
|
||||
import { DashDotGraph as GraphType } from './types';
|
||||
|
||||
interface DashDotGraphProps {
|
||||
graph: GraphType;
|
||||
isCompact: boolean;
|
||||
dashDotUrl: string;
|
||||
}
|
||||
|
||||
export const DashDotGraph = ({ graph, isCompact, dashDotUrl }: DashDotGraphProps) => {
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<Stack
|
||||
className={classes.graphStack}
|
||||
w="100%"
|
||||
maw="251px"
|
||||
style={{
|
||||
width: isCompact ? (graph.twoSpan ? '100%' : 'calc(50% - 10px)') : undefined,
|
||||
}}
|
||||
>
|
||||
<Title className={classes.graphTitle} order={4}>
|
||||
{graph.name}
|
||||
</Title>
|
||||
<iframe
|
||||
className={classes.iframe}
|
||||
key={graph.name}
|
||||
title={graph.name}
|
||||
src={useIframeSrc(dashDotUrl, graph, isCompact)}
|
||||
frameBorder="0"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const useIframeSrc = (dashDotUrl: string, graph: GraphType, isCompact: boolean) => {
|
||||
const { colorScheme, colors, radius } = useMantineTheme();
|
||||
const surface = (colorScheme === 'dark' ? colors.dark[7] : colors.gray[0]).substring(1); // removes # from hex value
|
||||
return (
|
||||
`${dashDotUrl}` +
|
||||
`?singleGraphMode=true` +
|
||||
`&graph=${graph.id}` +
|
||||
`&theme=${colorScheme}` +
|
||||
`&surface=${surface}` +
|
||||
`&gap=${isCompact ? 10 : 5}` +
|
||||
`&innerRadius=${radius.lg}` +
|
||||
`&multiView=${graph.isMultiView}`
|
||||
);
|
||||
};
|
||||
|
||||
export const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
iframe: {
|
||||
flex: '1 0 auto',
|
||||
maxWidth: '100%',
|
||||
height: '140px',
|
||||
borderRadius: theme.radius.lg,
|
||||
},
|
||||
graphTitle: {
|
||||
ref: getRef('graphTitle'),
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
transition: 'opacity .1s ease-in-out',
|
||||
pointerEvents: 'none',
|
||||
marginTop: 10,
|
||||
marginRight: 25,
|
||||
},
|
||||
graphStack: {
|
||||
position: 'relative',
|
||||
[`&:hover .${getRef('graphTitle')}`]: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,140 +0,0 @@
|
||||
import { createStyles, Group, Title } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { DashDotCompactNetwork, DashDotInfo } from './DashDotCompactNetwork';
|
||||
import { BaseTileProps } from '../type';
|
||||
import { DashDotGraph } from './DashDotGraph';
|
||||
import { DashDotIntegrationType } from '../../../../types/integration';
|
||||
import { IntegrationsMenu } from '../Integrations/IntegrationsMenu';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { DashDotCompactStorage } from './DashDotCompactStorage';
|
||||
|
||||
interface DashDotTileProps extends BaseTileProps {
|
||||
module: DashDotIntegrationType | undefined;
|
||||
}
|
||||
|
||||
export const DashDotTile = ({ module, className }: DashDotTileProps) => {
|
||||
const { classes } = useDashDotTileStyles();
|
||||
const { t } = useTranslation('modules/dashdot');
|
||||
|
||||
const dashDotUrl = module?.properties.url;
|
||||
|
||||
const { data: info } = useDashDotInfo();
|
||||
|
||||
const graphs = module?.properties.graphs.map((g) => ({
|
||||
id: g,
|
||||
name: t(`card.graphs.${g === 'ram' ? 'memory' : g}.title`),
|
||||
twoSpan: ['network', 'gpu'].includes(g),
|
||||
isMultiView:
|
||||
(g === 'cpu' && module.properties.isCpuMultiView) ||
|
||||
(g === 'storage' && module.properties.isStorageMultiView),
|
||||
}));
|
||||
|
||||
const heading = (
|
||||
<Title order={3} mb="xs">
|
||||
{t('card.title')}
|
||||
</Title>
|
||||
);
|
||||
|
||||
const menu = (
|
||||
<IntegrationsMenu<'dashDot'>
|
||||
module={module}
|
||||
integration="dashDot"
|
||||
options={module?.properties}
|
||||
labels={{
|
||||
isCpuMultiView: 'descriptor.settings.cpuMultiView.label',
|
||||
isStorageMultiView: 'descriptor.settings.storageMultiView.label',
|
||||
isCompactView: 'descriptor.settings.useCompactView.label',
|
||||
graphs: 'descriptor.settings.graphs.label',
|
||||
url: 'descriptor.settings.url.label',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!dashDotUrl) {
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
{menu}
|
||||
<div>
|
||||
{heading}
|
||||
<p>{t('card.errors.noService')}</p>
|
||||
</div>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const isCompact = module?.properties.isCompactView ?? false;
|
||||
|
||||
const isCompactStorageVisible = graphs?.some((g) => g.id === 'storage' && isCompact);
|
||||
|
||||
const isCompactNetworkVisible = graphs?.some((g) => g.id === 'network' && isCompact);
|
||||
|
||||
const displayedGraphs = graphs?.filter(
|
||||
(g) => !isCompact || !['network', 'storage'].includes(g.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
{menu}
|
||||
{heading}
|
||||
{!info && <p>{t('card.errors.noInformation')}</p>}
|
||||
{info && (
|
||||
<div className={classes.graphsContainer}>
|
||||
<Group position="apart" w="100%">
|
||||
{isCompactStorageVisible && <DashDotCompactStorage info={info} />}
|
||||
{isCompactNetworkVisible && <DashDotCompactNetwork info={info} />}
|
||||
</Group>
|
||||
<Group position="center" w="100%" className={classes.graphsWrapper}>
|
||||
{displayedGraphs?.map((graph) => (
|
||||
<DashDotGraph
|
||||
key={graph.id}
|
||||
graph={graph}
|
||||
dashDotUrl={dashDotUrl}
|
||||
isCompact={isCompact}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const useDashDotInfo = () => {
|
||||
const { name: configName, config } = useConfigContext();
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'dashdot/info',
|
||||
{
|
||||
configName,
|
||||
url: config?.integrations.dashDot?.properties.url,
|
||||
},
|
||||
],
|
||||
queryFn: () => fetchDashDotInfo(configName),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDashDotInfo = async (configName: string | undefined) => {
|
||||
if (!configName) return {} as DashDotInfo;
|
||||
return (await (
|
||||
await axios.get('/api/modules/dashdot/info', { params: { configName } })
|
||||
).data) as DashDotInfo;
|
||||
};
|
||||
|
||||
export const useDashDotTileStyles = createStyles(() => ({
|
||||
graphsContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
rowGap: 10,
|
||||
columnGap: 10,
|
||||
},
|
||||
graphsWrapper: {
|
||||
'& > *:nth-child(odd):last-child': {
|
||||
width: '100% !important',
|
||||
maxWidth: '100% !important',
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashDotGraphType } from '../../../../types/integration';
|
||||
|
||||
export interface DashDotGraph {
|
||||
id: DashDotGraphType;
|
||||
name: string;
|
||||
twoSpan: boolean;
|
||||
isMultiView: boolean | undefined;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export const ServiceMenu = ({ service }: TileMenuProps) => {
|
||||
service,
|
||||
allowServiceNamePropagation: false,
|
||||
},
|
||||
styles: {
|
||||
root: {
|
||||
zIndex: 201,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,6 +29,11 @@ export const ServiceMenu = ({ service }: TileMenuProps) => {
|
||||
innerProps: {
|
||||
service,
|
||||
},
|
||||
styles: {
|
||||
root: {
|
||||
zIndex: 201,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { UsenetQueueList } from './UsenetQueueList';
|
||||
import {
|
||||
useGetUsenetInfo,
|
||||
usePauseUsenetQueue,
|
||||
useResumeUsenetQueue,
|
||||
} from '../../../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../../../tools/humanFileSize';
|
||||
import { ServiceIntegrationType } from '../../../../types/service';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { BaseTileProps } from '../type';
|
||||
import { UsenetHistoryList } from './UsenetHistoryList';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const downloadServiceTypes: ServiceIntegrationType['type'][] = ['sabnzbd', 'nzbGet'];
|
||||
|
||||
interface UseNetTileProps extends BaseTileProps {}
|
||||
|
||||
export const UseNetTile = ({ className }: UseNetTileProps) => {
|
||||
const { t } = useTranslation('modules/usenet');
|
||||
const { config } = useConfigContext();
|
||||
const downloadServices =
|
||||
config?.services.filter(
|
||||
(x) => x.integration && downloadServiceTypes.includes(x.integration.type)
|
||||
) ?? [];
|
||||
|
||||
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
|
||||
const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! });
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedServiceId && downloadServices.length) {
|
||||
setSelectedService(downloadServices[0].id);
|
||||
}
|
||||
}, [downloadServices, selectedServiceId]);
|
||||
|
||||
const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! });
|
||||
const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! });
|
||||
|
||||
if (downloadServices.length === 0) {
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<Stack>
|
||||
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
|
||||
<Group>
|
||||
<Text>{t('card.errors.noDownloadClients.text')}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedServiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ref, width, height } = useElementSize();
|
||||
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<Tabs keepMounted={false} defaultValue="queue">
|
||||
<Tabs.List ref={ref} mb="md" style={{ flex: 1 }} grow>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</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 serviceId={selectedServiceId} />
|
||||
{!data ? null : data.paused ? (
|
||||
<Button uppercase onClick={() => resume()} radius="xl" size="xs" fullWidth mt="sm">
|
||||
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button uppercase onClick={() => pause()} radius="xl" size="xs" fullWidth mt="sm">
|
||||
<IconPlayerPause size={12} style={{ marginRight: 5 }} />{' '}
|
||||
{dayjs.duration(data.eta, 's').format('HH:mm')}
|
||||
</Button>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="history" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<UsenetHistoryList serviceId={selectedServiceId} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Center,
|
||||
Code,
|
||||
Group,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconAlertCircle } from '@tabler/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useGetUsenetHistory } from '../../../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../../../tools/humanFileSize';
|
||||
import { parseDuration } from '../../../../tools/parseDuration';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface UsenetHistoryListProps {
|
||||
serviceId: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ serviceId }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const { t } = useTranslation(['modules/usenet', 'common']);
|
||||
|
||||
const { ref, width, height } = useElementSize();
|
||||
const durationBreakpoint = 400;
|
||||
const { data, isLoading, isError, error } = useGetUsenetHistory({
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
serviceId,
|
||||
});
|
||||
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Group position="center">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
my="lg"
|
||||
title={t('modules/usenet:history.error.title')}
|
||||
color="red"
|
||||
radius="md"
|
||||
>
|
||||
{t('modules/usenet:history.error.message')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
</Code>
|
||||
</Alert>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.items.length <= 0) {
|
||||
return (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title order={3}>{t('modules/usenet:history.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('modules/usenet:history.header.name')}</th>
|
||||
<th style={{ width: 100 }}>{t('modules/usenet:history.header.size')}</th>
|
||||
{durationBreakpoint < width ? (
|
||||
<th style={{ width: 200 }}>{t('modules/usenet:history.header.duration')}</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((history) => (
|
||||
<tr key={history.id}>
|
||||
<td>
|
||||
<Tooltip position="top" label={history.name}>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{history.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(history.size)}</Text>
|
||||
</td>
|
||||
{durationBreakpoint < width ? (
|
||||
<td>
|
||||
<Text size="xs">{parseDuration(history.time, t)}</Text>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
position="center"
|
||||
mt="md"
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,187 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Center,
|
||||
Code,
|
||||
Group,
|
||||
Pagination,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useGetUsenetDownloads } from '../../../../tools/hooks/api';
|
||||
import { humanFileSize } from '../../../../tools/humanFileSize';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface UsenetQueueListProps {
|
||||
serviceId: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ serviceId }) => {
|
||||
const theme = useMantineTheme();
|
||||
const { t } = useTranslation('modules/usenet');
|
||||
const progressbarBreakpoint = theme.breakpoints.xs;
|
||||
const progressBreakpoint = 400;
|
||||
const sizeBreakpoint = 300;
|
||||
const { ref, width, height } = useElementSize();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, isLoading, isError, error } = useGetUsenetDownloads({
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
serviceId,
|
||||
});
|
||||
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
<Skeleton height={40} mt={10} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Group position="center">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
my="lg"
|
||||
title={t('queue.error.title')}
|
||||
color="red"
|
||||
radius="md"
|
||||
>
|
||||
{t('queue.error.message')}
|
||||
<Code mt="sm" block>
|
||||
{(error as AxiosError)?.response?.data as string}
|
||||
</Code>
|
||||
</Alert>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.items.length <= 0) {
|
||||
return (
|
||||
<Center style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title order={3}>{t('queue.empty')}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }} />
|
||||
<th style={{ width: '75%' }}>{t('queue.header.name')}</th>
|
||||
{sizeBreakpoint < width ? (
|
||||
<th style={{ width: 100 }}>{t('queue.header.size')}</th>
|
||||
) : null}
|
||||
<th style={{ width: 60 }}>{t('queue.header.eta')}</th>
|
||||
{progressBreakpoint < width ? (
|
||||
<th style={{ width: progressbarBreakpoint > width ? 100 : 200 }}>
|
||||
{t('queue.header.progress')}
|
||||
</th>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((nzb) => (
|
||||
<tr key={nzb.id}>
|
||||
<td>
|
||||
{nzb.state === 'paused' ? (
|
||||
<Tooltip label="NOT IMPLEMENTED">
|
||||
<ActionIcon color="gray" variant="subtle" radius="xl" size="sm">
|
||||
<IconPlayerPlay size="16" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label="NOT IMPLEMENTED">
|
||||
<ActionIcon color="primary" variant="subtle" radius="xl" size="sm">
|
||||
<IconPlayerPause size="16" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Tooltip position="top" label={nzb.name}>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
size="xs"
|
||||
color={nzb.state === 'paused' ? 'dimmed' : undefined}
|
||||
>
|
||||
{nzb.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
{sizeBreakpoint < width ? (
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(nzb.size)}</Text>
|
||||
</td>
|
||||
) : null}
|
||||
<td>
|
||||
{nzb.eta <= 0 ? (
|
||||
<Text size="xs" color="dimmed">
|
||||
{t('queue.paused')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
|
||||
)}
|
||||
</td>
|
||||
{progressBreakpoint < width ? (
|
||||
<td style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text mr="sm" style={{ whiteSpace: 'nowrap' }}>
|
||||
{nzb.progress.toFixed(1)}%
|
||||
</Text>
|
||||
{width > progressbarBreakpoint ? (
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
|
||||
value={nzb.progress}
|
||||
size="lg"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
size="sm"
|
||||
position="center"
|
||||
mt="md"
|
||||
total={totalPages}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface UsenetQueueItem {
|
||||
name: string;
|
||||
progress: number;
|
||||
/**
|
||||
* Size in bytes
|
||||
*/
|
||||
size: number;
|
||||
id: string;
|
||||
state: 'paused' | 'downloading' | 'queued';
|
||||
eta: number;
|
||||
}
|
||||
export interface UsenetHistoryItem {
|
||||
name: string;
|
||||
/**
|
||||
* Size in bytes
|
||||
*/
|
||||
size: number;
|
||||
id: string;
|
||||
time: number;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Box, Tooltip } from '@mantine/core';
|
||||
import {
|
||||
IconCloud,
|
||||
IconCloudFog,
|
||||
IconCloudRain,
|
||||
IconCloudSnow,
|
||||
IconCloudStorm,
|
||||
IconQuestionMark,
|
||||
IconSnowflake,
|
||||
IconSun,
|
||||
TablerIcon,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
interface WeatherIconProps {
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon which should be displayed when specific code is defined
|
||||
* @param code weather code from api
|
||||
* @returns weather tile component
|
||||
*/
|
||||
export const WeatherIcon = ({ code }: WeatherIconProps) => {
|
||||
const { t } = useTranslation('modules/weather');
|
||||
|
||||
const { icon: Icon, name } =
|
||||
weatherDefinitions.find((wd) => wd.codes.includes(code)) ?? unknownWeather;
|
||||
|
||||
return (
|
||||
<Tooltip withinPortal withArrow label={t(`card.weatherDescriptions.${name}`)}>
|
||||
<Box>
|
||||
<Icon size={50} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type WeatherDefinitionType = { icon: TablerIcon; name: string; codes: number[] };
|
||||
|
||||
// 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
|
||||
const weatherDefinitions: WeatherDefinitionType[] = [
|
||||
{ icon: IconSun, name: 'clear', codes: [0] },
|
||||
{ icon: IconCloud, name: 'mainlyClear', codes: [1, 2, 3] },
|
||||
{ icon: IconCloudFog, name: 'fog', codes: [45, 48] },
|
||||
{ icon: IconCloud, name: 'drizzle', codes: [51, 53, 55] },
|
||||
{ icon: IconSnowflake, name: 'freezingDrizzle', codes: [56, 57] },
|
||||
{ icon: IconCloudRain, name: 'rain', codes: [61, 63, 65] },
|
||||
{ icon: IconCloudRain, name: 'freezingRain', codes: [66, 67] },
|
||||
{ icon: IconCloudSnow, name: 'snowFall', codes: [71, 73, 75] },
|
||||
{ icon: IconCloudSnow, name: 'snowGrains', codes: [77] },
|
||||
{ icon: IconCloudRain, name: 'rainShowers', codes: [80, 81, 82] },
|
||||
{ icon: IconCloudSnow, name: 'snowShowers', codes: [85, 86] },
|
||||
{ icon: IconCloudStorm, name: 'thunderstorm', codes: [95] },
|
||||
{ icon: IconCloudStorm, name: 'thunderstormWithHail', codes: [96, 99] },
|
||||
];
|
||||
|
||||
const unknownWeather: Omit<WeatherDefinitionType, 'codes'> = {
|
||||
icon: IconQuestionMark,
|
||||
name: 'unknown',
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Center, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowDownRight, IconArrowUpRight } from '@tabler/icons';
|
||||
import { WeatherIcon } from './WeatherIcon';
|
||||
import { BaseTileProps } from '../type';
|
||||
import { useWeatherForCity } from './useWeatherForCity';
|
||||
import { WeatherIntegrationType } from '../../../../types/integration';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { IntegrationsMenu } from '../Integrations/IntegrationsMenu';
|
||||
|
||||
interface WeatherTileProps extends BaseTileProps {
|
||||
module: WeatherIntegrationType | undefined;
|
||||
}
|
||||
|
||||
export const WeatherTile = ({ className, module }: WeatherTileProps) => {
|
||||
const {
|
||||
data: weather,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useWeatherForCity(module?.properties.location ?? 'Paris');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<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>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<Center>
|
||||
<Text weight={500}>An error occured</Text>
|
||||
</Center>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<IntegrationsMenu
|
||||
integration="weather"
|
||||
module={module}
|
||||
options={module?.properties}
|
||||
labels={{
|
||||
isFahrenheit: 'descriptor.settings.displayInFahrenheit.label',
|
||||
location: 'descriptor.settings.location.label',
|
||||
}}
|
||||
/>
|
||||
<Center style={{ height: '100%' }}>
|
||||
<Group spacing="md" noWrap align="center">
|
||||
<WeatherIcon code={weather!.current_weather.weathercode} />
|
||||
<Stack p={0} spacing={4}>
|
||||
<Title order={2}>
|
||||
{getPerferedUnit(
|
||||
weather!.current_weather.temperature,
|
||||
module?.properties.isFahrenheit
|
||||
)}
|
||||
</Title>
|
||||
<Group spacing="xs" noWrap>
|
||||
<div>
|
||||
<span>
|
||||
{getPerferedUnit(
|
||||
weather!.daily.temperature_2m_max[0],
|
||||
module?.properties.isFahrenheit
|
||||
)}
|
||||
</span>
|
||||
<IconArrowUpRight size={16} style={{ right: 15 }} />
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{getPerferedUnit(
|
||||
weather!.daily.temperature_2m_min[0],
|
||||
module?.properties.isFahrenheit
|
||||
)}
|
||||
</span>
|
||||
<IconArrowDownRight size={16} />
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Center>
|
||||
</HomarrCardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const getPerferedUnit = (value: number, isFahrenheit = false): string =>
|
||||
isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { WeatherResponse } from './types';
|
||||
|
||||
/**
|
||||
* Requests the weather of the specified city
|
||||
* @param cityName name of the city where the weather should be requested
|
||||
* @returns weather of specified city
|
||||
*/
|
||||
export const useWeatherForCity = (cityName: string) => {
|
||||
const {
|
||||
data: city,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({ queryKey: ['weatherCity', { cityName }], queryFn: () => fetchCity(cityName) });
|
||||
const weatherQuery = useQuery({
|
||||
queryKey: ['weather', { cityName }],
|
||||
queryFn: () => fetchWeather(city?.results[0]),
|
||||
enabled: !!city,
|
||||
refetchInterval: 1000 * 60 * 5, // requests the weather every 5 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
...weatherQuery,
|
||||
isLoading: weatherQuery.isLoading || isLoading,
|
||||
isError: weatherQuery.isError || isError,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests the coordinates of a city
|
||||
* @param cityName name of city
|
||||
* @returns list with all coordinates for citites with specified name
|
||||
*/
|
||||
const fetchCity = async (cityName: string) => {
|
||||
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${cityName}`);
|
||||
return (await res.json()) as { results: Coordinates[] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests the weather of specific coordinates
|
||||
* @param coordinates of the location the weather should be fetched
|
||||
* @returns weather of specified coordinates
|
||||
*/
|
||||
const fetchWeather = async (coordinates?: Coordinates) => {
|
||||
if (!coordinates) return;
|
||||
const { longitude, latitude } = coordinates;
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=Europe%2FLondon`
|
||||
);
|
||||
return (await res.json()) as WeatherResponse;
|
||||
};
|
||||
|
||||
type Coordinates = { latitude: number; longitude: number };
|
||||
@@ -2,11 +2,11 @@ import { Button, Group, MultiSelect, Stack, Switch, TextInput } from '@mantine/c
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import { useConfigStore } from '../../../config/store';
|
||||
import { DashDotGraphType, IntegrationsType } from '../../../types/integration';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { DashDotGraphType, IntegrationsType } from '../../../../types/integration';
|
||||
|
||||
export type IntegrationEditModalInnerProps<
|
||||
export type WidgetEditModalInnerProps<
|
||||
TIntegrationKey extends keyof IntegrationsType = keyof IntegrationsType
|
||||
> = {
|
||||
integration: TIntegrationKey;
|
||||
@@ -14,11 +14,11 @@ export type IntegrationEditModalInnerProps<
|
||||
labels: IntegrationOptionLabels<IntegrationOptions<TIntegrationKey>>;
|
||||
};
|
||||
|
||||
export const IntegrationsEditModal = ({
|
||||
export const WidgetsEditModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<IntegrationEditModalInnerProps>) => {
|
||||
}: ContextModalProps<WidgetEditModalInnerProps>) => {
|
||||
const translationKey = integrationModuleTranslationsMap.get(innerProps.integration);
|
||||
const { t } = useTranslation([translationKey ?? '', 'common']);
|
||||
const [moduleProperties, setModuleProperties] = useState(innerProps.options);
|
||||
@@ -4,38 +4,38 @@ import { openContextModalGeneric } from '../../../../tools/mantineModalManagerEx
|
||||
import { IntegrationsType } from '../../../../types/integration';
|
||||
import { TileBaseType } from '../../../../types/tile';
|
||||
import { GenericTileMenu } from '../GenericTileMenu';
|
||||
import { IntegrationRemoveModalInnerProps } from '../IntegrationRemoveModal';
|
||||
import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal';
|
||||
import {
|
||||
IntegrationEditModalInnerProps,
|
||||
WidgetEditModalInnerProps,
|
||||
integrationModuleTranslationsMap,
|
||||
IntegrationOptionLabels,
|
||||
IntegrationOptions,
|
||||
} from '../IntegrationsEditModal';
|
||||
} from './WidgetsEditModal';
|
||||
|
||||
export type IntegrationChangePositionModalInnerProps = {
|
||||
export type WidgetChangePositionModalInnerProps = {
|
||||
integration: keyof IntegrationsType;
|
||||
module: TileBaseType;
|
||||
};
|
||||
|
||||
interface IntegrationsMenuProps<TIntegrationKey extends keyof IntegrationsType> {
|
||||
interface WidgetsMenuProps<TIntegrationKey extends keyof IntegrationsType> {
|
||||
integration: TIntegrationKey;
|
||||
module: TileBaseType | undefined;
|
||||
options: IntegrationOptions<TIntegrationKey> | undefined;
|
||||
labels: IntegrationOptionLabels<IntegrationOptions<TIntegrationKey>>;
|
||||
}
|
||||
|
||||
export const IntegrationsMenu = <TIntegrationKey extends keyof IntegrationsType>({
|
||||
export const WidgetsMenu = <TIntegrationKey extends keyof IntegrationsType>({
|
||||
integration,
|
||||
options,
|
||||
labels,
|
||||
module,
|
||||
}: IntegrationsMenuProps<TIntegrationKey>) => {
|
||||
}: WidgetsMenuProps<TIntegrationKey>) => {
|
||||
const { t } = useTranslation(integrationModuleTranslationsMap.get(integration));
|
||||
|
||||
if (!module) return null;
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
openContextModalGeneric<IntegrationRemoveModalInnerProps>({
|
||||
openContextModalGeneric<WidgetsRemoveModalInnerProps>({
|
||||
modal: 'integrationRemove',
|
||||
title: <Title order={4}>{t('descriptor.remove.title')}</Title>,
|
||||
innerProps: {
|
||||
@@ -45,7 +45,7 @@ export const IntegrationsMenu = <TIntegrationKey extends keyof IntegrationsType>
|
||||
};
|
||||
|
||||
const handleChangeSizeClick = () => {
|
||||
openContextModalGeneric<IntegrationChangePositionModalInnerProps>({
|
||||
openContextModalGeneric<WidgetChangePositionModalInnerProps>({
|
||||
modal: 'changeIntegrationPositionModal',
|
||||
size: 'xl',
|
||||
title: null,
|
||||
@@ -57,7 +57,7 @@ export const IntegrationsMenu = <TIntegrationKey extends keyof IntegrationsType>
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
openContextModalGeneric<IntegrationEditModalInnerProps<TIntegrationKey>>({
|
||||
openContextModalGeneric<WidgetEditModalInnerProps<TIntegrationKey>>({
|
||||
modal: 'integrationOptions',
|
||||
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
|
||||
innerProps: {
|
||||
@@ -2,18 +2,18 @@ import React from 'react';
|
||||
import { Button, Group, Stack, Text } from '@mantine/core';
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { IntegrationsType } from '../../../types/integration';
|
||||
import { integrationModuleTranslationsMap } from './IntegrationsEditModal';
|
||||
import { IntegrationsType } from '../../../../types/integration';
|
||||
import { integrationModuleTranslationsMap } from './WidgetsEditModal';
|
||||
|
||||
export type IntegrationRemoveModalInnerProps = {
|
||||
export type WidgetsRemoveModalInnerProps = {
|
||||
integration: keyof IntegrationsType;
|
||||
};
|
||||
|
||||
export const IntegrationRemoveModal = ({
|
||||
export const WidgetsRemoveModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<IntegrationRemoveModalInnerProps>) => {
|
||||
}: ContextModalProps<WidgetsRemoveModalInnerProps>) => {
|
||||
const translationKey = integrationModuleTranslationsMap.get(innerProps.integration);
|
||||
const { t } = useTranslation([translationKey ?? '', 'common']);
|
||||
const handleDeletion = () => {
|
||||
@@ -1,15 +1,13 @@
|
||||
import { IntegrationsType } from '../../../types/integration';
|
||||
import { CalendarTile } from './Calendar/CalendarTile';
|
||||
import { ClockTile } from './Clock/ClockTile';
|
||||
import { DashDotTile } from './DashDot/DashDotTile';
|
||||
import { CalendarTile } from '../../../widgets/calendar/CalendarTile';
|
||||
import { ClockTile } from '../../../widgets/clock/ClockTile';
|
||||
import { DashDotTile } from '../../../widgets/dashDot/DashDotTile';
|
||||
import { UseNetTile } from '../../../widgets/useNet/UseNetTile';
|
||||
import { WeatherTile } from '../../../widgets/weather/WeatherTile';
|
||||
import { EmptyTile } from './EmptyTile';
|
||||
import { ServiceTile } from './Service/ServiceTile';
|
||||
import { UseNetTile } from './UseNet/UseNetTile';
|
||||
import { WeatherTile } from './Weather/WeatherTile';
|
||||
/*import { CalendarTile } from './calendar';
|
||||
import { ClockTile } from './clock';
|
||||
import { DashDotTile } from './dash-dot';*/
|
||||
|
||||
// TODO: just remove and use service (later app) directly. For widgets the the definition should contain min/max width/height
|
||||
type TileDefinitionProps = {
|
||||
[key in keyof IntegrationsType | 'service']: {
|
||||
minWidth?: number;
|
||||
|
||||
Reference in New Issue
Block a user