mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 06:55:51 +01:00
✨ Add gridstack dashboard layout
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Save"
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"ok": "Okay"
|
||||||
},
|
},
|
||||||
"tip": "Tip: ",
|
"tip": "Tip: ",
|
||||||
"time": {
|
"time": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/components/Dashboard/Tiles/Clock/ClockTile.tsx
Normal file
70
src/components/Dashboard/Tiles/Clock/ClockTile.tsx
Normal 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();
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }[];
|
||||||
|
}
|
||||||
73
src/components/Dashboard/Tiles/DashDot/DashDotGraph.tsx
Normal file
73
src/components/Dashboard/Tiles/DashDot/DashDotGraph.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
157
src/components/Dashboard/Tiles/DashDot/DashDotTile.tsx
Normal file
157
src/components/Dashboard/Tiles/DashDot/DashDotTile.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
8
src/components/Dashboard/Tiles/DashDot/types.ts
Normal file
8
src/components/Dashboard/Tiles/DashDot/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { DashDotGraphType } from '../../../../types/integration';
|
||||||
|
|
||||||
|
export interface DashDotGraph {
|
||||||
|
id: DashDotGraphType;
|
||||||
|
name: string;
|
||||||
|
twoSpan: boolean;
|
||||||
|
isMultiView: boolean | undefined;
|
||||||
|
}
|
||||||
6
src/components/Dashboard/Tiles/EmptyTile.tsx
Normal file
6
src/components/Dashboard/Tiles/EmptyTile.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { HomarrCardWrapper } from './HomarrCardWrapper';
|
||||||
|
import { BaseTileProps } from './type';
|
||||||
|
|
||||||
|
export const EmptyTile = ({ className }: BaseTileProps) => {
|
||||||
|
return <HomarrCardWrapper className={className}>Empty</HomarrCardWrapper>;
|
||||||
|
};
|
||||||
15
src/components/Dashboard/Tiles/HomarrCardWrapper.tsx
Normal file
15
src/components/Dashboard/Tiles/HomarrCardWrapper.tsx
Normal 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)} />;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
35
src/components/Dashboard/Tiles/IntegrationRemoveModal.tsx
Normal file
35
src/components/Dashboard/Tiles/IntegrationRemoveModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
124
src/components/Dashboard/Tiles/IntegrationsEditModal.tsx
Normal file
124
src/components/Dashboard/Tiles/IntegrationsEditModal.tsx
Normal 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'],
|
||||||
|
]);
|
||||||
6
src/components/Dashboard/Tiles/Service/ServiceIcon.tsx
Normal file
6
src/components/Dashboard/Tiles/Service/ServiceIcon.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
interface ServiceIconProps {
|
||||||
|
size: '100%' | number;
|
||||||
|
service: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServiceIcon = ({ size }: ServiceIconProps) => null;
|
||||||
@@ -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}
|
||||||
73
src/components/Dashboard/Tiles/Weather/WeatherIcon.tsx
Normal file
73
src/components/Dashboard/Tiles/Weather/WeatherIcon.tsx
Normal 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',
|
||||||
|
};
|
||||||
87
src/components/Dashboard/Tiles/Weather/WeatherTile.tsx
Normal file
87
src/components/Dashboard/Tiles/Weather/WeatherTile.tsx
Normal 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`;
|
||||||
|
};
|
||||||
41
src/components/Dashboard/Tiles/Weather/types.ts
Normal file
41
src/components/Dashboard/Tiles/Weather/types.ts
Normal 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;
|
||||||
|
}
|
||||||
53
src/components/Dashboard/Tiles/Weather/useWeatherForCity.ts
Normal file
53
src/components/Dashboard/Tiles/Weather/useWeatherForCity.ts
Normal 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¤t_weather=true&timezone=Europe%2FLondon`
|
||||||
|
);
|
||||||
|
return (await res.json()) as WeatherResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Coordinates = { latitude: number; longitude: number };
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
45
src/components/Dashboard/Views/ViewToggleButton.tsx
Normal file
45
src/components/Dashboard/Views/ViewToggleButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/components/Dashboard/Wrappers/Category/Category.tsx
Normal file
66
src/components/Dashboard/Wrappers/Category/Category.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
56
src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx
Normal file
56
src/components/Dashboard/Wrappers/Wrapper/Wrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
<NotificationsProvider limit={4} position="bottom-left">
|
<ConfigProvider>
|
||||||
<ModalsProvider
|
<NotificationsProvider limit={4} position="bottom-left">
|
||||||
modals={{ editService: EditServiceModal, selectElement: SelectElementModal }}
|
<ModalsProvider
|
||||||
>
|
modals={{
|
||||||
<ConfigProvider>
|
editService: EditServiceModal,
|
||||||
|
selectElement: SelectElementModal,
|
||||||
|
integrationOptions: IntegrationsEditModal,
|
||||||
|
integrationRemove: IntegrationRemoveModal,
|
||||||
|
integrationChangePosition: IntegrationChangePositionModal,
|
||||||
|
categoryEditModal: CategoryEditModal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<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
56
src/styles/global.scss
Normal 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
26
src/tools/bytesHelper.ts
Normal 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'];
|
||||||
8
src/tools/hooks/useScreenLargerThan.ts
Normal file
8
src/tools/hooks/useScreenLargerThan.ts
Normal 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)`);
|
||||||
|
};
|
||||||
8
src/tools/hooks/useScreenSmallerThan.ts
Normal file
8
src/tools/hooks/useScreenSmallerThan.ts
Normal 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
3
src/tools/percentage.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const percentage = (partialValue: number, totalValue: number) => {
|
||||||
|
return ((100 * partialValue) / totalValue).toFixed(1);
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user