Add gridstack dashboard layout

This commit is contained in:
Meierschlumpf
2022-12-10 22:14:31 +01:00
parent b7bb1302e4
commit 001890d763
39 changed files with 2822 additions and 918 deletions

View File

@@ -0,0 +1,70 @@
import { Card, 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();
};

View File

@@ -0,0 +1,36 @@
import { Group, Stack, Text } from '@mantine/core';
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { bytes } from '../../../../tools/bytesHelper';
import { DashDotInfo } from './DashDotTile';
interface DashDotCompactNetworkProps {
info: DashDotInfo;
}
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>
);
};

View File

@@ -0,0 +1,72 @@
import { Group, Stack, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { bytes } from '../../../../tools/bytesHelper';
import { percentage } from '../../../../tools/percentage';
import { DashDotInfo } from './DashDotTile';
interface DashDotCompactStorageProps {
info: DashDotInfo;
dashDotUrl: string;
}
export const DashDotCompactStorage = ({ info, dashDotUrl }: DashDotCompactStorageProps) => {
const { t } = useTranslation('modules/dashdot');
const { data: storageLoad } = useQuery({
queryKey: [
'dashdot/storageLoad',
{
dashDotUrl,
},
],
queryFn: () => fetchDashDotStorageLoad(dashDotUrl),
});
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>) => {
return layout.reduce((total, current) => {
return total + (current[key] as number);
}, 0);
};
interface CalculateTotalLayoutSizeProps<TLayoutItem> {
layout: TLayoutItem[];
key: keyof TLayoutItem;
}
const fetchDashDotStorageLoad = async (targetUrl: string) => {
return (await (
await axios.get('/api/modules/dashdot', { params: { url: '/load/storage', base: targetUrl } })
).data) as DashDotStorageLoad;
};
interface DashDotStorageLoad {
layout: { load: number }[];
}

View File

@@ -0,0 +1,73 @@
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,
},
},
}));

View File

@@ -0,0 +1,157 @@
import {
Card,
createStyles,
Flex,
Grid,
Group,
Stack,
Title,
useMantineTheme,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import { DashDotCompactNetwork } from './DashDotCompactNetwork';
import { DashDotCompactStorage } from './DashDotCompactStorage';
import { BaseTileProps } from '../type';
import { DashDotGraph } from './DashDotGraph';
import { DashDotIntegrationType } from '../../../../types/integration';
import { IntegrationsMenu } from '../Integrations/IntegrationsMenu';
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(dashDotUrl);
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 (
<Card className={className} withBorder p="xs">
{menu}
<div>
{heading}
<p>{t('card.errors.noService')}</p>
</div>
</Card>
);
}
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 (
<Card className={className} withBorder p="xs">
{menu}
{heading}
{!info && <p>{t('card.errors.noInformation')}</p>}
{info && (
<div className={classes.graphsContainer}>
<Group position="apart" w="100%">
{isCompactStorageVisible && (
<DashDotCompactStorage dashDotUrl={dashDotUrl} 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>
)}
</Card>
);
};
const useDashDotInfo = (dashDotUrl: string | undefined) => {
return useQuery({
queryKey: [
'dashdot/info',
{
dashDotUrl,
},
],
queryFn: () => fetchDashDotInfo(dashDotUrl),
});
};
const fetchDashDotInfo = async (targetUrl: string | undefined) => {
if (!targetUrl) return {} as DashDotInfo;
return (await (
await axios.get('/api/modules/dashdot', { params: { url: '/info', base: targetUrl } })
).data) as DashDotInfo;
};
export interface DashDotInfo {
storage: {
layout: { size: number }[];
};
network: {
speedUp: number;
speedDown: number;
};
}
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',
},
},
}));

View File

@@ -0,0 +1,8 @@
import { DashDotGraphType } from '../../../../types/integration';
export interface DashDotGraph {
id: DashDotGraphType;
name: string;
twoSpan: boolean;
isMultiView: boolean | undefined;
}

View File

@@ -0,0 +1,6 @@
import { HomarrCardWrapper } from './HomarrCardWrapper';
import { BaseTileProps } from './type';
export const EmptyTile = ({ className }: BaseTileProps) => {
return <HomarrCardWrapper className={className}>Empty</HomarrCardWrapper>;
};

View File

@@ -0,0 +1,15 @@
import { Card, CardProps } from '@mantine/core';
import { ReactNode } from 'react';
import { useCardStyles } from '../../layout/useCardStyles';
interface HomarrCardWrapperProps extends CardProps {
children: ReactNode;
}
export const HomarrCardWrapper = ({ ...props }: HomarrCardWrapperProps) => {
const {
cx,
classes: { card: cardClass },
} = useCardStyles();
return <Card {...props} className={cx(props.className, cardClass)} />;
};

View File

@@ -0,0 +1,171 @@
import React, { ReactNode } from 'react';
import {
Button,
Center,
createStyles,
Grid,
Group,
NumberInput,
Select,
SelectItem,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { ContextModalProps } from '@mantine/modals';
import { useTranslation } from 'next-i18next';
import { ClockIntegrationType, IntegrationsType } from '../../../types/integration';
import { integrationModuleTranslationsMap } from './IntegrationsEditModal';
import { TileBaseType } from '../../../types/tile';
import {
IconArrowsUpDown,
IconCalendarTime,
IconClock,
IconCloudRain,
IconFileDownload,
} from '@tabler/icons';
import { ServiceIcon } from './Service/ServiceIcon';
import { useForm } from '@mantine/form';
import { Tiles } from './tilesDefinitions';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
export type IntegrationChangePositionModalInnerProps = {
integration: keyof IntegrationsType;
module: TileBaseType;
};
export const IntegrationChangePositionModal = ({
context,
id,
innerProps,
}: ContextModalProps<IntegrationChangePositionModalInnerProps>) => {
const translationKey = integrationModuleTranslationsMap.get(innerProps.integration);
const { t } = useTranslation([translationKey ?? '', 'common']);
const { classes } = useStyles();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const form = useForm<FormType>({
initialValues: {
x: innerProps.module.shape.location.x,
y: innerProps.module.shape.location.y,
width: innerProps.module.shape.size.width.toString(),
height: innerProps.module.shape.size.height.toString(),
},
});
if (!configName) return null;
null;
const handleSubmit = (values: FormType) => {
updateConfig(configName, (prev) => {
return {
...prev,
integrations: {
...prev.integrations,
[innerProps.integration]: {
...prev.integrations[innerProps.integration],
shape: {
location: {
x: values.x,
y: values.y,
},
size: {
height: parseInt(values.height),
width: parseInt(values.width),
},
},
},
},
};
});
context.closeModal(id);
};
const widthData = useWidthData(innerProps.integration);
const heightData = useHeightData(innerProps.integration);
return (
<Stack>
<Stack spacing={0}>
<Center>
<div className={classes.icon}>{integrationIcons[innerProps.integration]}</div>
</Center>
<Text align="center" size="sm">
Change position of
</Text>
<Title align="center" order={4}>
{t('descriptor.name')}
</Title>
</Stack>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Grid>
<Grid.Col span={12} xs={6}>
<NumberInput label="X Position" required {...form.getInputProps('x')} />
</Grid.Col>
<Grid.Col span={12} xs={6}>
<NumberInput label="Y Position" required {...form.getInputProps('y')} />
</Grid.Col>
<Grid.Col span={12} xs={6}>
<Select data={widthData} label="Width" required {...form.getInputProps('width')} />
</Grid.Col>
<Grid.Col span={12} xs={6}>
<Select data={heightData} label="Height" required {...form.getInputProps('height')} />
</Grid.Col>
</Grid>
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">
{t('common:actions.cancel')}
</Button>
<Button type="submit">{t('common:actions.save')}</Button>
</Group>
</Stack>
</form>
</Stack>
);
};
type FormType = {
x: number;
y: number;
width: string;
height: string;
};
// TODO: define width of gridstack somewhere (64)
const useWidthData = (integration: keyof IntegrationsType): SelectItem[] => {
const tileDefinitions = Tiles[integration];
const offset = tileDefinitions.minWidth ?? 2;
const length = (tileDefinitions.maxWidth ?? 12) - offset;
return Array.from({ length }, (_, i) => i + offset).map((n) => ({
value: n.toString(),
label: `${64 * n}px`,
}));
};
const useHeightData = (integration: keyof IntegrationsType): SelectItem[] => {
const tileDefinitions = Tiles[integration];
const offset = tileDefinitions.minHeight ?? 2;
const length = (tileDefinitions.maxHeight ?? 12) - offset;
return Array.from({ length }, (_, i) => i + offset).map((n) => ({
value: n.toString(),
label: `${64 * n}px`,
}));
};
const integrationIcons = {
useNet: <IconFileDownload size="100%" />,
bitTorrent: <IconFileDownload size="100%" />,
calendar: <IconCalendarTime size="100%" />,
clock: <IconClock size="100%" />,
weather: <IconCloudRain size="100%" />,
dashDot: <ServiceIcon size="100%" service="dashdot" />,
torrentNetworkTraffic: <IconArrowsUpDown size="100%" />,
};
const useStyles = createStyles(() => ({
icon: {
height: 120,
width: 120,
},
}));

View File

@@ -0,0 +1,35 @@
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';
export type IntegrationRemoveModalInnerProps = {
integration: keyof IntegrationsType;
};
export const IntegrationRemoveModal = ({
context,
id,
innerProps,
}: ContextModalProps<IntegrationRemoveModalInnerProps>) => {
const translationKey = integrationModuleTranslationsMap.get(innerProps.integration);
const { t } = useTranslation([translationKey ?? '', 'common']);
const handleDeletion = () => {
// TODO: remove tile
context.closeModal(id);
};
return (
<Stack>
<Text>{t('descriptor.remove.confirm')}</Text>
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">
{t('common:actions.cancel')}
</Button>
<Button onClick={() => handleDeletion()}>{t('common:actions.ok')}</Button>
</Group>
</Stack>
);
};

View File

@@ -0,0 +1,98 @@
import { ActionIcon, Menu, Title } from '@mantine/core';
import { IconDots, IconLayoutKanban, IconPencil, IconTrash } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { IntegrationsType } from '../../../../types/integration';
import { TileBaseType } from '../../../../types/tile';
import { IntegrationChangePositionModalInnerProps } from '../IntegrationChangePositionModal';
import { IntegrationRemoveModalInnerProps } from '../IntegrationRemoveModal';
import {
IntegrationEditModalInnerProps,
integrationModuleTranslationsMap,
IntegrationOptionLabels,
IntegrationOptions,
} from '../IntegrationsEditModal';
interface IntegrationsMenuProps<TIntegrationKey extends keyof IntegrationsType> {
integration: TIntegrationKey;
module: TileBaseType | undefined;
options: IntegrationOptions<TIntegrationKey> | undefined;
labels: IntegrationOptionLabels<IntegrationOptions<TIntegrationKey>>;
}
export const IntegrationsMenu = <TIntegrationKey extends keyof IntegrationsType>({
integration,
options,
labels,
module,
}: IntegrationsMenuProps<TIntegrationKey>) => {
const { t } = useTranslation(integrationModuleTranslationsMap.get(integration));
if (!module) return null;
const handleDeleteClick = () => {
openContextModalGeneric<IntegrationRemoveModalInnerProps>({
modal: 'integrationRemove',
title: <Title order={4}>{t('descriptor.remove.title')}</Title>,
innerProps: {
integration,
},
});
};
const handleChangeSizeClick = () => {
openContextModalGeneric<IntegrationChangePositionModalInnerProps>({
modal: 'integrationChangePosition',
size: 'xl',
title: null,
innerProps: {
integration,
module,
},
});
};
const handleEditClick = () => {
openContextModalGeneric<IntegrationEditModalInnerProps<TIntegrationKey>>({
modal: 'integrationOptions',
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
innerProps: {
integration,
options,
labels,
},
});
};
return (
<Menu withinPortal>
<Menu.Target>
<ActionIcon pos="absolute" top={4} right={4}>
<IconDots />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={250}>
<Menu.Label>Settings</Menu.Label>
{options && (
<Menu.Item icon={<IconPencil size={16} stroke={1.5} />} onClick={handleEditClick}>
Edit
</Menu.Item>
)}
<Menu.Item
icon={<IconLayoutKanban size={16} stroke={1.5} />}
onClick={handleChangeSizeClick}
>
Change position
</Menu.Item>
<Menu.Label>Danger zone</Menu.Label>
<Menu.Item
color="red"
icon={<IconTrash size={16} stroke={1.5} color="red" />}
onClick={handleDeleteClick}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -0,0 +1,124 @@
import { Button, Group, MultiSelect, Stack, Switch, TextInput } from '@mantine/core';
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';
export type IntegrationEditModalInnerProps<
TIntegrationKey extends keyof IntegrationsType = keyof IntegrationsType
> = {
integration: TIntegrationKey;
options: IntegrationOptions<TIntegrationKey> | undefined;
labels: IntegrationOptionLabels<IntegrationOptions<TIntegrationKey>>;
};
export const IntegrationsEditModal = ({
context,
id,
innerProps,
}: ContextModalProps<IntegrationEditModalInnerProps>) => {
const translationKey = integrationModuleTranslationsMap.get(innerProps.integration);
const { t } = useTranslation([translationKey ?? '', 'common']);
const [moduleProperties, setModuleProperties] = useState(innerProps.options);
const items = Object.entries(moduleProperties ?? {}) as [
keyof typeof innerProps.options,
IntegrationOptionsValueType
][];
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
if (!configName || !innerProps.options) return null;
const handleChange = (key: string, value: IntegrationOptionsValueType) => {
setModuleProperties((prev) => {
let copyOfPrev: any = { ...prev };
copyOfPrev[key] = value;
return copyOfPrev;
});
};
const handleSave = () => {
updateConfig(configName, (prev) => {
return {
...prev,
integrations: {
...prev.integrations,
[innerProps.integration]:
'properties' in (prev.integrations[innerProps.integration] ?? {})
? {
...prev.integrations[innerProps.integration],
properties: moduleProperties,
}
: prev.integrations[innerProps.integration],
},
};
});
context.closeModal(id);
};
return (
<Stack>
{items.map(([key, value]) => (
<>
{typeof value === 'boolean' ? (
<Switch
label={t(innerProps.labels[key as keyof typeof innerProps.labels])}
checked={value}
onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
/>
) : null}
{typeof value === 'string' ? (
<TextInput
label={t(innerProps.labels[key])}
value={value}
onChange={(ev) => handleChange(key, ev.currentTarget.value)}
/>
) : null}
{typeof value === 'object' && Array.isArray(value) ? (
<MultiSelect
data={['cpu', 'gpu', 'ram', 'storage', 'network']}
value={value}
onChange={(v) => handleChange(key, v as DashDotGraphType[])}
/>
) : null}
</>
))}
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">
{t('common:actions.cancel')}
</Button>
<Button onClick={handleSave}>{t('common:actions.save')}</Button>
</Group>
</Stack>
);
};
type PropertiesOf<
TKey extends keyof IntegrationsType,
T extends IntegrationsType[TKey]
> = T extends { properties: unknown } ? T['properties'] : {};
export type IntegrationOptions<TKey extends keyof IntegrationsType> = PropertiesOf<
TKey,
IntegrationsType[TKey]
>;
export type IntegrationOptionLabels<TIntegrationOptions> = {
[key in keyof TIntegrationOptions]: string;
};
type IntegrationOptionsValueType = boolean | string | DashDotGraphType[];
export const integrationModuleTranslationsMap = new Map<keyof IntegrationsType, string>([
['calendar', 'modules/calendar'],
['clock', 'modules/date'],
['weather', 'modules/weather'],
['dashDot', 'modules/dashdot'],
['bitTorrent', 'modules/torrents-status'],
['useNet', 'modules/usenet'],
['torrentNetworkTraffic', 'modules/dlspeed'],
]);

View File

@@ -0,0 +1,6 @@
interface ServiceIconProps {
size: '100%' | number;
service: string;
}
export const ServiceIcon = ({ size }: ServiceIconProps) => null;

View File

@@ -1,6 +1,7 @@
import { Card, Center, Text, UnstyledButton } from '@mantine/core';
import { ActionIcon, Card, Center, Text, UnstyledButton } from '@mantine/core';
import { NextLink } from '@mantine/next';
import { createStyles } from '@mantine/styles';
import { IconDots } from '@tabler/icons';
import { ServiceType } from '../../../../types/service';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore';
@@ -32,11 +33,7 @@ export const ServiceTile = ({ className, service }: ServiceTileProps) => {
return (
<Card className={cx(className, cardClass)} withBorder radius="lg" shadow="md">
{isEditMode &&
{
/*<AppShelfMenu service={service} />*/
}}{' '}
{/* TODO: change to serviceMenu */}
{/* TODO: add service menu */}
{!service.url || isEditMode ? (
<UnstyledButton
className={classes.button}

View File

@@ -0,0 +1,73 @@
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',
};

View File

@@ -0,0 +1,87 @@
import { Card, 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 { useCardStyles } from '../../../layout/useCardStyles';
import { HomarrCardWrapper } from '../HomarrCardWrapper';
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}>
<Center style={{ height: '100%' }}>
<Group spacing="xl" 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="sm">
<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: boolean = false): string => {
return isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
};

View File

@@ -0,0 +1,41 @@
// 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

@@ -0,0 +1,53 @@
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&current_weather=true&timezone=Europe%2FLondon`
);
return (await res.json()) as WeatherResponse;
};
type Coordinates = { latitude: number; longitude: number };

View File

@@ -1,9 +1,11 @@
import { IntegrationsType } from '../../../types/integration';
import { ServiceTile } from './Service/Service';
import { ClockTile } from './Clock/ClockTile';
import { EmptyTile } from './EmptyTile';
import { ServiceTile } from './Service/ServiceTile';
import { WeatherTile } from './Weather/WeatherTile';
/*import { CalendarTile } from './calendar';
import { ClockTile } from './clock';
import { DashDotTile } from './dash-dot';
import { WeatherTile } from './weather';*/
import { DashDotTile } from './dash-dot';*/
type TileDefinitionProps = {
[key in keyof IntegrationsType | 'service']: {
@@ -25,14 +27,14 @@ export const Tiles: TileDefinitionProps = {
maxHeight: 12,
},
bitTorrent: {
component: CalendarTile,
component: EmptyTile, //CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
calendar: {
component: CalendarTile,
component: EmptyTile, //CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
@@ -46,21 +48,21 @@ export const Tiles: TileDefinitionProps = {
maxHeight: 12,
},
dashDot: {
component: DashDotTile,
component: EmptyTile, //DashDotTile,
minWidth: 4,
maxWidth: 9,
minHeight: 5,
maxHeight: 14,
},
torrentNetworkTraffic: {
component: CalendarTile,
component: EmptyTile, //CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
useNet: {
component: CalendarTile,
component: EmptyTile, //CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,