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

@@ -95,6 +95,7 @@
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"jest": "^28.1.3", "jest": "^28.1.3",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"sass": "^1.56.1",
"typescript": "^4.7.4" "typescript": "^4.7.4"
}, },
"resolutions": { "resolutions": {

View File

@@ -1,6 +1,8 @@
{ {
"actions": { "actions": {
"save": "Save" "save": "Save",
"cancel": "Cancel",
"ok": "Okay"
}, },
"tip": "Tip: ", "tip": "Tip: ",
"time": { "time": {

View File

@@ -3,6 +3,7 @@
"name": "Dash.", "name": "Dash.",
"description": "A module for displaying the graphs of your running Dash. instance.", "description": "A module for displaying the graphs of your running Dash. instance.",
"settings": { "settings": {
"title": "Settings for Dash. integration",
"cpuMultiView": { "cpuMultiView": {
"label": "CPU Multi-Core View" "label": "CPU Multi-Core View"
}, },
@@ -18,6 +19,10 @@
"url": { "url": {
"label": "Dash. URL" "label": "Dash. URL"
} }
},
"remove": {
"title": "Remove Dash. integration",
"confirm": "Are you sure, that you want to remove the Dash. integration?"
} }
}, },
"card": { "card": {

View File

@@ -3,6 +3,7 @@
"name": "Date", "name": "Date",
"description": "Show the current time and date in a card", "description": "Show the current time and date in a card",
"settings": { "settings": {
"title": "Settings for date integration",
"display24HourFormat": { "display24HourFormat": {
"label": "Display full time (24-hour)" "label": "Display full time (24-hour)"
} }

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 { NextLink } from '@mantine/next';
import { createStyles } from '@mantine/styles'; import { createStyles } from '@mantine/styles';
import { IconDots } from '@tabler/icons';
import { ServiceType } from '../../../../types/service'; import { ServiceType } from '../../../../types/service';
import { useCardStyles } from '../../../layout/useCardStyles'; import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore'; import { useEditModeStore } from '../../Views/useEditModeStore';
@@ -32,11 +33,7 @@ export const ServiceTile = ({ className, service }: ServiceTileProps) => {
return ( return (
<Card className={cx(className, cardClass)} withBorder radius="lg" shadow="md"> <Card className={cx(className, cardClass)} withBorder radius="lg" shadow="md">
{isEditMode && {/* TODO: add service menu */}
{
/*<AppShelfMenu service={service} />*/
}}{' '}
{/* TODO: change to serviceMenu */}
{!service.url || isEditMode ? ( {!service.url || isEditMode ? (
<UnstyledButton <UnstyledButton
className={classes.button} 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 { 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 { CalendarTile } from './calendar';
import { ClockTile } from './clock'; import { ClockTile } from './clock';
import { DashDotTile } from './dash-dot'; import { DashDotTile } from './dash-dot';*/
import { WeatherTile } from './weather';*/
type TileDefinitionProps = { type TileDefinitionProps = {
[key in keyof IntegrationsType | 'service']: { [key in keyof IntegrationsType | 'service']: {
@@ -25,14 +27,14 @@ export const Tiles: TileDefinitionProps = {
maxHeight: 12, maxHeight: 12,
}, },
bitTorrent: { bitTorrent: {
component: CalendarTile, component: EmptyTile, //CalendarTile,
minWidth: 4, minWidth: 4,
maxWidth: 12, maxWidth: 12,
minHeight: 5, minHeight: 5,
maxHeight: 12, maxHeight: 12,
}, },
calendar: { calendar: {
component: CalendarTile, component: EmptyTile, //CalendarTile,
minWidth: 4, minWidth: 4,
maxWidth: 12, maxWidth: 12,
minHeight: 5, minHeight: 5,
@@ -46,21 +48,21 @@ export const Tiles: TileDefinitionProps = {
maxHeight: 12, maxHeight: 12,
}, },
dashDot: { dashDot: {
component: DashDotTile, component: EmptyTile, //DashDotTile,
minWidth: 4, minWidth: 4,
maxWidth: 9, maxWidth: 9,
minHeight: 5, minHeight: 5,
maxHeight: 14, maxHeight: 14,
}, },
torrentNetworkTraffic: { torrentNetworkTraffic: {
component: CalendarTile, component: EmptyTile, //CalendarTile,
minWidth: 4, minWidth: 4,
maxWidth: 12, maxWidth: 12,
minHeight: 5, minHeight: 5,
maxHeight: 12, maxHeight: 12,
}, },
useNet: { useNet: {
component: CalendarTile, component: EmptyTile, //CalendarTile,
minWidth: 4, minWidth: 4,
maxWidth: 12, maxWidth: 12,
minHeight: 5, minHeight: 5,

View File

@@ -1,24 +1,29 @@
import { Group, Stack } from '@mantine/core'; import { Group, Stack } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useConfigContext } from '../../../config/provider'; import { useConfigContext } from '../../../config/provider';
import { ServiceTile } from '../Tiles/Service/Service'; import { CategoryType } from '../../../types/category';
import { WrapperType } from '../../../types/wrapper';
import { DashboardCategory } from '../Wrappers/Category/Category';
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar'; import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
export const DashboardView = () => { export const DashboardView = () => {
const wrappers = useWrapperItems(); const wrappers = useWrapperItems();
const clockModule = useConfigContext().config?.integrations.clock;
return ( return (
<Group align="top" h="100%"> <Group align="top" h="100%">
{/*<DashboardSidebar location="left" />*/} <DashboardSidebar location="left" />
<Stack mx={-10} style={{ flexGrow: 1 }}> <Stack mx={-10} style={{ flexGrow: 1 }}>
{wrappers.map( {wrappers.map((item) =>
(item) => item.type === 'category' ? (
item.type === 'category' <DashboardCategory key={item.id} category={item as unknown as CategoryType} />
? 'category' //<DashboardCategory key={item.id} category={item as unknown as CategoryType} /> ) : (
: 'wrapper' //<DashboardWrapper key={item.id} wrapper={item as WrapperType} /> <DashboardWrapper key={item.id} wrapper={item as WrapperType} />
)
)} )}
</Stack> </Stack>
{/*<DashboardSidebar location="right" />*/} <DashboardSidebar location="right" />
</Group> </Group>
); );
}; };

View File

@@ -0,0 +1,45 @@
import { ActionIcon, Button, Text, Tooltip } from '@mantine/core';
import { IconEdit, IconEditOff } from '@tabler/icons';
import { useScreenLargerThan } from '../../../tools/hooks/useScreenLargerThan';
import { useEditModeStore } from './useEditModeStore';
export const ViewToggleButton = () => {
const screenLargerThanMd = useScreenLargerThan('md');
const { enabled: isEditMode, toggleEditMode } = useEditModeStore();
return (
<Tooltip
label={
<Text align="center">
In edit mode, you can adjust
<br />
the size and position of your tiles.
</Text>
}
>
{screenLargerThanMd ? (
<Button
variant={isEditMode ? 'filled' : 'default'}
h={44}
w={180}
leftIcon={isEditMode ? <IconEditOff /> : <IconEdit />}
onClick={() => toggleEditMode()}
color={isEditMode ? 'red' : undefined}
radius="md"
>
<Text>{isEditMode ? 'Exit Edit Mode' : 'Enter Edit Mode'}</Text>
</Button>
) : (
<ActionIcon
onClick={() => toggleEditMode()}
variant="default"
radius="md"
size="xl"
color="blue"
>
{isEditMode ? <IconEditOff /> : <IconEdit />}
</ActionIcon>
)}
</Tooltip>
);
};

View File

@@ -0,0 +1,66 @@
import { Card, Group, Title } from '@mantine/core';
import { CategoryType } from '../../../../types/category';
import { HomarrCardWrapper } from '../../Tiles/HomarrCardWrapper';
import { Tiles } from '../../Tiles/tilesDefinitions';
import { GridstackTileWrapper } from '../../Tiles/TileWrapper';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { useGridstack } from '../gridstack/use-gridstack';
import { CategoryEditMenu } from './CategoryEditMenu';
interface DashboardCategoryProps {
category: CategoryType;
}
export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
const { refs, items, integrations } = useGridstack('category', category.id);
const isEditMode = useEditModeStore((x) => x.enabled);
return (
<HomarrCardWrapper pt={10} mx={10}>
<Group position="apart" align="center">
<Title order={3}>{category.name}</Title>
{isEditMode ? <CategoryEditMenu category={category} /> : null}
</Group>
<div
className="grid-stack grid-stack-category"
style={{ transitionDuration: '0s' }}
data-category={category.id}
ref={refs.wrapper}
>
{items?.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
</GridstackTileWrapper>
);
})}
{Object.entries(integrations).map(([k, v]) => {
const { component: TileComponent, ...tile } = Tiles[k as keyof typeof Tiles];
return (
<GridstackTileWrapper
id={k}
type="module"
key={k}
itemRef={refs.items.current[k]}
{...tile}
{...v.shape.location}
{...v.shape.size}
>
<TileComponent className="grid-stack-item-content" module={v} />
</GridstackTileWrapper>
);
})}
</div>
</HomarrCardWrapper>
);
};

View File

@@ -0,0 +1,51 @@
import { ActionIcon, Menu } from '@mantine/core';
import {
IconDots,
IconTransitionTop,
IconTransitionBottom,
IconRowInsertTop,
IconRowInsertBottom,
IconEdit,
} from '@tabler/icons';
import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category';
import { useCategoryActions } from './useCategoryActions';
interface CategoryEditMenuProps {
category: CategoryType;
}
export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
const { name: configName } = useConfigContext();
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit } =
useCategoryActions(configName, category);
return (
<Menu withinPortal>
<Menu.Target>
<ActionIcon>
<IconDots />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item icon={<IconEdit size={20} />} onClick={edit}>
Edit
</Menu.Item>
<Menu.Label>Change positon</Menu.Label>
<Menu.Item icon={<IconTransitionTop size={20} />} onClick={moveCategoryUp}>
Move up
</Menu.Item>
<Menu.Item icon={<IconTransitionBottom size={20} />} onClick={moveCategoryDown}>
Move down
</Menu.Item>
<Menu.Label>Add category</Menu.Label>
<Menu.Item icon={<IconRowInsertTop size={20} />} onClick={addCategoryAbove}>
Add category above
</Menu.Item>
<Menu.Item icon={<IconRowInsertBottom size={20} />} onClick={addCategoryBelow}>
Add category below
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -0,0 +1,50 @@
import { Button, Group, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { ContextModalProps } from '@mantine/modals';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { CategoryType } from '../../../../types/category';
export interface CategoryEditModalInnerProps {
category: CategoryType;
onSuccess: (category: CategoryType) => Promise<void>;
}
export const CategoryEditModal = ({
context,
innerProps,
id,
}: ContextModalProps<CategoryEditModalInnerProps>) => {
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const form = useForm<FormType>({
initialValues: {
name: innerProps.category.name,
},
validate: {
name: (val: string) => (!val || val.trim().length === 0 ? 'Name is required' : null),
},
});
const handleSubmit = async (values: FormType) => {
innerProps.onSuccess({ ...innerProps.category, name: values.name });
context.closeModal(id);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput data-autoFocus {...form.getInputProps('name')} label="Name of category" />
<Group mt="md" grow>
<Button onClick={() => context.closeModal(id)} variant="light" color="gray">
Cancel
</Button>
<Button type="submit">Save</Button>
</Group>
</form>
);
};
type FormType = {
name: string;
};

View File

@@ -0,0 +1,186 @@
import { v4 as uuidv4 } from 'uuid';
import { useConfigStore } from '../../../../config/store';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { CategoryType } from '../../../../types/category';
import { WrapperType } from '../../../../types/wrapper';
import { CategoryEditModalInnerProps } from './CategoryEditModal';
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
const updateConfig = useConfigStore((x) => x.updateConfig);
// creates a new category above the current
const addCategoryAbove = () => {
const abovePosition = category.position - 1;
openContextModalGeneric<CategoryEditModalInnerProps>({
modal: 'categoryEditModal',
innerProps: {
category: {
id: uuidv4(),
name: 'New category',
position: abovePosition + 1,
},
onSuccess: async (category) => {
if (!configName) return;
const newWrapper: WrapperType = {
id: uuidv4(),
position: abovePosition + 2,
};
// Adding category and wrapper and moving other items down
updateConfig(configName, (previous) => {
const aboveWrappers = previous.wrappers.filter((x) => x.position <= abovePosition);
const aboveCategories = previous.categories.filter((x) => x.position <= abovePosition);
const belowWrappers = previous.wrappers.filter((x) => x.position > abovePosition);
const belowCategories = previous.categories.filter((x) => x.position > abovePosition);
return {
...previous,
categories: [
...aboveCategories,
category,
// Move categories below down
...belowCategories.map((x) => ({ ...x, position: x.position + 2 })),
],
wrappers: [
...aboveWrappers,
newWrapper,
// Move wrappers below down
...belowWrappers.map((x) => ({ ...x, position: x.position + 2 })),
],
};
});
},
},
});
};
// creates a new category below the current
const addCategoryBelow = () => {
const belowPosition = category.position + 1;
openContextModalGeneric<CategoryEditModalInnerProps>({
modal: 'categoryEditModal',
innerProps: {
category: {
id: uuidv4(),
name: 'New category',
position: belowPosition + 1,
},
onSuccess: async (category) => {
if (!configName) return;
const newWrapper: WrapperType = {
id: uuidv4(),
position: belowPosition,
};
// Adding category and wrapper and moving other items down
updateConfig(configName, (previous) => {
const aboveWrappers = previous.wrappers.filter((x) => x.position < belowPosition);
const aboveCategories = previous.categories.filter((x) => x.position < belowPosition);
const belowWrappers = previous.wrappers.filter((x) => x.position >= belowPosition);
const belowCategories = previous.categories.filter((x) => x.position >= belowPosition);
return {
...previous,
categories: [
...aboveCategories,
category,
// Move categories below down
...belowCategories.map((x) => ({ ...x, position: x.position + 2 })),
],
wrappers: [
...aboveWrappers,
newWrapper,
// Move wrappers below down
...belowWrappers.map((x) => ({ ...x, position: x.position + 2 })),
],
};
});
},
},
});
};
const moveCategoryUp = () => {
if (!configName) return;
updateConfig(configName, (previous) => {
const currentItem = previous.categories.find((x) => x.id === category.id);
if (!currentItem) return previous;
const upperItem = previous.categories.find((x) => x.position === currentItem.position - 2);
if (!upperItem) return previous;
currentItem.position -= 2;
upperItem.position += 2;
return {
...previous,
categories: [
...previous.categories.filter((c) => ![currentItem.id, upperItem.id].includes(c.id)),
{ ...upperItem },
{ ...currentItem },
],
};
});
};
const moveCategoryDown = () => {
if (!configName) return;
updateConfig(configName, (previous) => {
const currentItem = previous.categories.find((x) => x.id === category.id);
if (!currentItem) return previous;
const belowItem = previous.categories.find((x) => x.position === currentItem.position + 2);
if (!belowItem) return previous;
currentItem.position += 2;
belowItem.position -= 2;
return {
...previous,
categories: [
...previous.categories.filter((c) => ![currentItem.id, belowItem.id].includes(c.id)),
{ ...currentItem },
{ ...belowItem },
],
};
});
};
const edit = async () => {
openContextModalGeneric<CategoryEditModalInnerProps>({
modal: 'categoryEditModal',
innerProps: {
category,
onSuccess: async (category) => {
if (!configName) return;
await updateConfig(configName, (prev) => {
const currentCategory = prev.categories.find((c) => c.id === category.id);
if (!currentCategory) return prev;
return {
...prev,
categories: [...prev.categories.filter((c) => c.id !== category.id), { ...category }],
};
});
},
},
});
};
return {
addCategoryAbove,
addCategoryBelow,
moveCategoryUp,
moveCategoryDown,
edit,
};
};

View File

@@ -0,0 +1,56 @@
import { WrapperType } from '../../../../types/wrapper';
import { Tiles } from '../../Tiles/tilesDefinitions';
import { GridstackTileWrapper } from '../../Tiles/TileWrapper';
import { useGridstack } from '../gridstack/use-gridstack';
interface DashboardWrapperProps {
wrapper: WrapperType;
}
export const DashboardWrapper = ({ wrapper }: DashboardWrapperProps) => {
const { refs, items, integrations } = useGridstack('wrapper', wrapper.id);
return (
<div
className="grid-stack grid-stack-wrapper"
style={{ transitionDuration: '0s' }}
data-wrapper={wrapper.id}
ref={refs.wrapper}
>
{items?.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
</GridstackTileWrapper>
);
})}
{Object.entries(integrations).map(([k, v]) => {
console.log(k);
const { component: TileComponent, ...tile } = Tiles[k as keyof typeof Tiles];
return (
<GridstackTileWrapper
id={k}
type="module"
key={k}
itemRef={refs.items.current[k]}
{...tile}
{...v.shape.location}
{...v.shape.size}
>
<TileComponent className="grid-stack-item-content" module={v} />
</GridstackTileWrapper>
);
})}
</div>
);
};

View File

@@ -9,6 +9,7 @@ import { useCardStyles } from '../useCardStyles';
import { SettingsMenu } from './SettingsMenu'; import { SettingsMenu } from './SettingsMenu';
import { ToolsMenu } from './ToolsMenu'; import { ToolsMenu } from './ToolsMenu';
import { AddElementAction } from './Actions/AddElementAction/AddElementAction'; import { AddElementAction } from './Actions/AddElementAction/AddElementAction';
import { ViewToggleButton } from '../../Dashboard/Views/ViewToggleButton';
export const HeaderHeight = 64; export const HeaderHeight = 64;
@@ -27,6 +28,7 @@ export function Header(props: any) {
<AddItemShelfButton /> <AddItemShelfButton />
<AddElementAction /> <AddElementAction />
<ToolsMenu /> <ToolsMenu />
<ViewToggleButton />
<SettingsMenu /> <SettingsMenu />
</Group> </Group>
</Group> </Group>

View File

@@ -9,12 +9,17 @@ import { appWithTranslation } from 'next-i18next';
import { AppProps } from 'next/app'; import { AppProps } from 'next/app';
import Head from 'next/head'; import Head from 'next/head';
import { useState } from 'react'; import { useState } from 'react';
import { IntegrationsEditModal } from '../components/Dashboard/Tiles/IntegrationsEditModal';
import { IntegrationRemoveModal } from '../components/Dashboard/Tiles/IntegrationRemoveModal';
import { EditServiceModal } from '../components/Dashboard/Modals/EditService/EditServiceModal'; import { EditServiceModal } from '../components/Dashboard/Modals/EditService/EditServiceModal';
import { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal'; import { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal';
import { ConfigProvider } from '../config/provider'; import { ConfigProvider } from '../config/provider';
import { ColorTheme } from '../tools/color'; import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient'; import { queryClient } from '../tools/queryClient';
import { theme } from '../tools/theme'; import { theme } from '../tools/theme';
import { IntegrationChangePositionModal } from '../components/Dashboard/Tiles/IntegrationChangePositionModal';
import '../styles/global.scss';
import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal';
function App(this: any, props: AppProps & { colorScheme: ColorScheme }) { function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
const { Component, pageProps } = props; const { Component, pageProps } = props;
@@ -76,15 +81,22 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
> >
<ConfigProvider>
<NotificationsProvider limit={4} position="bottom-left"> <NotificationsProvider limit={4} position="bottom-left">
<ModalsProvider <ModalsProvider
modals={{ editService: EditServiceModal, selectElement: SelectElementModal }} modals={{
editService: EditServiceModal,
selectElement: SelectElementModal,
integrationOptions: IntegrationsEditModal,
integrationRemove: IntegrationRemoveModal,
integrationChangePosition: IntegrationChangePositionModal,
categoryEditModal: CategoryEditModal,
}}
> >
<ConfigProvider>
<Component {...pageProps} /> <Component {...pageProps} />
</ConfigProvider>
</ModalsProvider> </ModalsProvider>
</NotificationsProvider> </NotificationsProvider>
</ConfigProvider>
</MantineProvider> </MantineProvider>
</ColorTheme.Provider> </ColorTheme.Provider>
</ColorSchemeProvider> </ColorSchemeProvider>

56
src/styles/global.scss Normal file
View File

@@ -0,0 +1,56 @@
@import 'fily-publish-gridstack/dist/gridstack.min.css';
.grid-stack-placeholder > .placeholder-content {
background-color: rgb(248, 249, 250) !important;
border-radius: 5px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
@media (prefers-color-scheme: dark) {
.grid-stack-placeholder > .placeholder-content {
background-color: rgba(255, 255, 255, 0.05) !important;
}
}
@for $i from 1 to 13 {
.grid-stack>.grid-stack-item[gs-w="#{$i}"] { width: $i * 64px }
.grid-stack>.grid-stack-item[gs-min-w="#{$i}"] { min-width: $i * 64px }
.grid-stack>.grid-stack-item[gs-max-w="#{$i}"] { max-width: $i * 64px }
}
@for $i from 1 to 13 {
.grid-stack>.grid-stack-item[gs-h="#{$i}"] { height: $i * 64px; }
.grid-stack>.grid-stack-item[gs-min-h="#{$i}"] { min-height: $i * 64px; }
.grid-stack>.grid-stack-item[gs-max-h="#{$i}"] { max-height: $i * 64px; }
}
.grid-stack>.grid-stack-item>.grid-stack-item-content,
.grid-stack>.grid-stack-item>.placeholder-content {
inset: 10px;
}
.grid-stack>.grid-stack-item>.ui-resizable-se {
bottom: 10px;
right: 10px;
}
.grid-stack>.grid-stack-item {
min-width: 64px;
}
@for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-x="#{$i}"] { left: $i * 64px }
}
@for $i from 1 to 96 {
.grid-stack>.grid-stack-item[gs-y="#{$i}"] { top: $i * 64px }
}
.grid-stack > .grid-stack-item > .grid-stack-item-content {
overflow-y: hidden;
}
.grid-stack.grid-stack-animate {
transition: none;
}

26
src/tools/bytesHelper.ts Normal file
View File

@@ -0,0 +1,26 @@
export const bytes = {
toPerSecondString: (bytes?: number) => {
if (!bytes) return '-';
for (let i = 0; i < 4; i++) {
if (bytes >= 1000 && i !== 3) {
bytes /= 1000;
continue;
}
return `${bytes.toFixed(1)} ${perSecondUnits[i]}`;
}
},
toString: (bytes: number) => {
for (let i = 0; i < 4; i++) {
if (bytes >= 1024 && i !== 3) {
bytes /= 1024;
continue;
}
return `${bytes.toFixed(1)} ${units[i]}`;
}
},
};
const perSecondUnits = ['b/s', 'Kb/s', 'Mb/s', 'Gb/s'];
const units = ['B', 'KiB', 'MiB', 'GiB'];

View File

@@ -0,0 +1,8 @@
import { MantineSize, useMantineTheme } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
export const useScreenLargerThan = (size: MantineSize | number) => {
const { breakpoints } = useMantineTheme();
const pixelCount = typeof size === 'string' ? breakpoints[size] : size;
return useMediaQuery(`(min-width: ${pixelCount}px)`);
};

View File

@@ -0,0 +1,8 @@
import { MantineSize, useMantineTheme } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
export const useScreenSmallerThan = (size: MantineSize | number) => {
const { breakpoints } = useMantineTheme();
const pixelCount = typeof size === 'string' ? breakpoints[size] : size;
return useMediaQuery(`(max-width: ${pixelCount}px)`);
};

3
src/tools/percentage.ts Normal file
View File

@@ -0,0 +1,3 @@
export const percentage = (partialValue: number, totalValue: number) => {
return ((100 * partialValue) / totalValue).toFixed(1);
};

View File

@@ -31,13 +31,14 @@ export interface WeatherIntegrationType extends TileBaseType {
export interface DashDotIntegrationType extends TileBaseType { export interface DashDotIntegrationType extends TileBaseType {
properties: { properties: {
graphs: DashDotIntegrationGraphType[]; graphs: DashDotGraphType[];
isStorageMultiView: boolean;
isCpuMultiView: boolean;
isCompactView: boolean; isCompactView: boolean;
url: string; url: string;
}; };
} }
type DashDotIntegrationGraphType = { name: DashDotGraphType; isMultiView?: boolean };
export type DashDotGraphType = 'cpu' | 'storage' | 'ram' | 'network' | 'gpu'; export type DashDotGraphType = 'cpu' | 'storage' | 'ram' | 'network' | 'gpu';
export interface BitTorrentIntegrationType extends TileBaseType { export interface BitTorrentIntegrationType extends TileBaseType {

1966
yarn.lock

File diff suppressed because it is too large Load Diff