mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-09 15:05:48 +01:00
✨ Add gridstack dashboard layout
This commit is contained in:
@@ -95,6 +95,7 @@
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"jest": "^28.1.3",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.56.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"actions": {
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"ok": "Okay"
|
||||
},
|
||||
"tip": "Tip: ",
|
||||
"time": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Dash.",
|
||||
"description": "A module for displaying the graphs of your running Dash. instance.",
|
||||
"settings": {
|
||||
"title": "Settings for Dash. integration",
|
||||
"cpuMultiView": {
|
||||
"label": "CPU Multi-Core View"
|
||||
},
|
||||
@@ -18,6 +19,10 @@
|
||||
"url": {
|
||||
"label": "Dash. URL"
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove Dash. integration",
|
||||
"confirm": "Are you sure, that you want to remove the Dash. integration?"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Date",
|
||||
"description": "Show the current time and date in a card",
|
||||
"settings": {
|
||||
"title": "Settings for date integration",
|
||||
"display24HourFormat": {
|
||||
"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 { createStyles } from '@mantine/styles';
|
||||
import { IconDots } from '@tabler/icons';
|
||||
import { ServiceType } from '../../../../types/service';
|
||||
import { useCardStyles } from '../../../layout/useCardStyles';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
@@ -32,11 +33,7 @@ export const ServiceTile = ({ className, service }: ServiceTileProps) => {
|
||||
|
||||
return (
|
||||
<Card className={cx(className, cardClass)} withBorder radius="lg" shadow="md">
|
||||
{isEditMode &&
|
||||
{
|
||||
/*<AppShelfMenu service={service} />*/
|
||||
}}{' '}
|
||||
{/* TODO: change to serviceMenu */}
|
||||
{/* TODO: add service menu */}
|
||||
{!service.url || isEditMode ? (
|
||||
<UnstyledButton
|
||||
className={classes.button}
|
||||
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 { ServiceTile } from './Service/Service';
|
||||
import { ClockTile } from './Clock/ClockTile';
|
||||
import { EmptyTile } from './EmptyTile';
|
||||
import { ServiceTile } from './Service/ServiceTile';
|
||||
import { WeatherTile } from './Weather/WeatherTile';
|
||||
/*import { CalendarTile } from './calendar';
|
||||
import { ClockTile } from './clock';
|
||||
import { DashDotTile } from './dash-dot';
|
||||
import { WeatherTile } from './weather';*/
|
||||
import { DashDotTile } from './dash-dot';*/
|
||||
|
||||
type TileDefinitionProps = {
|
||||
[key in keyof IntegrationsType | 'service']: {
|
||||
@@ -25,14 +27,14 @@ export const Tiles: TileDefinitionProps = {
|
||||
maxHeight: 12,
|
||||
},
|
||||
bitTorrent: {
|
||||
component: CalendarTile,
|
||||
component: EmptyTile, //CalendarTile,
|
||||
minWidth: 4,
|
||||
maxWidth: 12,
|
||||
minHeight: 5,
|
||||
maxHeight: 12,
|
||||
},
|
||||
calendar: {
|
||||
component: CalendarTile,
|
||||
component: EmptyTile, //CalendarTile,
|
||||
minWidth: 4,
|
||||
maxWidth: 12,
|
||||
minHeight: 5,
|
||||
@@ -46,21 +48,21 @@ export const Tiles: TileDefinitionProps = {
|
||||
maxHeight: 12,
|
||||
},
|
||||
dashDot: {
|
||||
component: DashDotTile,
|
||||
component: EmptyTile, //DashDotTile,
|
||||
minWidth: 4,
|
||||
maxWidth: 9,
|
||||
minHeight: 5,
|
||||
maxHeight: 14,
|
||||
},
|
||||
torrentNetworkTraffic: {
|
||||
component: CalendarTile,
|
||||
component: EmptyTile, //CalendarTile,
|
||||
minWidth: 4,
|
||||
maxWidth: 12,
|
||||
minHeight: 5,
|
||||
maxHeight: 12,
|
||||
},
|
||||
useNet: {
|
||||
component: CalendarTile,
|
||||
component: EmptyTile, //CalendarTile,
|
||||
minWidth: 4,
|
||||
maxWidth: 12,
|
||||
minHeight: 5,
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
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 { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
||||
|
||||
export const DashboardView = () => {
|
||||
const wrappers = useWrapperItems();
|
||||
const clockModule = useConfigContext().config?.integrations.clock;
|
||||
|
||||
return (
|
||||
<Group align="top" h="100%">
|
||||
{/*<DashboardSidebar location="left" />*/}
|
||||
<DashboardSidebar location="left" />
|
||||
<Stack mx={-10} style={{ flexGrow: 1 }}>
|
||||
{wrappers.map(
|
||||
(item) =>
|
||||
item.type === 'category'
|
||||
? 'category' //<DashboardCategory key={item.id} category={item as unknown as CategoryType} />
|
||||
: 'wrapper' //<DashboardWrapper key={item.id} wrapper={item as WrapperType} />
|
||||
{wrappers.map((item) =>
|
||||
item.type === 'category' ? (
|
||||
<DashboardCategory key={item.id} category={item as unknown as CategoryType} />
|
||||
) : (
|
||||
<DashboardWrapper key={item.id} wrapper={item as WrapperType} />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
{/*<DashboardSidebar location="right" />*/}
|
||||
<DashboardSidebar location="right" />
|
||||
</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 { ToolsMenu } from './ToolsMenu';
|
||||
import { AddElementAction } from './Actions/AddElementAction/AddElementAction';
|
||||
import { ViewToggleButton } from '../../Dashboard/Views/ViewToggleButton';
|
||||
|
||||
export const HeaderHeight = 64;
|
||||
|
||||
@@ -27,6 +28,7 @@ export function Header(props: any) {
|
||||
<AddItemShelfButton />
|
||||
<AddElementAction />
|
||||
<ToolsMenu />
|
||||
<ViewToggleButton />
|
||||
<SettingsMenu />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -9,12 +9,17 @@ import { appWithTranslation } from 'next-i18next';
|
||||
import { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
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 { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal';
|
||||
import { ConfigProvider } from '../config/provider';
|
||||
import { ColorTheme } from '../tools/color';
|
||||
import { queryClient } from '../tools/queryClient';
|
||||
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 }) {
|
||||
const { Component, pageProps } = props;
|
||||
@@ -76,15 +81,22 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ModalsProvider
|
||||
modals={{ editService: EditServiceModal, selectElement: SelectElementModal }}
|
||||
>
|
||||
<ConfigProvider>
|
||||
<ConfigProvider>
|
||||
<NotificationsProvider limit={4} position="bottom-left">
|
||||
<ModalsProvider
|
||||
modals={{
|
||||
editService: EditServiceModal,
|
||||
selectElement: SelectElementModal,
|
||||
integrationOptions: IntegrationsEditModal,
|
||||
integrationRemove: IntegrationRemoveModal,
|
||||
integrationChangePosition: IntegrationChangePositionModal,
|
||||
categoryEditModal: CategoryEditModal,
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</ConfigProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</ConfigProvider>
|
||||
</MantineProvider>
|
||||
</ColorTheme.Provider>
|
||||
</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 {
|
||||
properties: {
|
||||
graphs: DashDotIntegrationGraphType[];
|
||||
graphs: DashDotGraphType[];
|
||||
isStorageMultiView: boolean;
|
||||
isCpuMultiView: boolean;
|
||||
isCompactView: boolean;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type DashDotIntegrationGraphType = { name: DashDotGraphType; isMultiView?: boolean };
|
||||
export type DashDotGraphType = 'cpu' | 'storage' | 'ram' | 'network' | 'gpu';
|
||||
|
||||
export interface BitTorrentIntegrationType extends TileBaseType {
|
||||
|
||||
Reference in New Issue
Block a user