🔀 Merge branch 'dev' into feature/add-basic-authentication
This commit is contained in:
@@ -205,7 +205,7 @@ export const EditAppModal = ({
|
||||
<NetworkTab form={form} />
|
||||
<AppearanceTab
|
||||
form={form}
|
||||
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
|
||||
disallowAppNamePropagation={() => setAllowAppNamePropagation(false)}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
/>
|
||||
<IntegrationTab form={form} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flex, Select, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { Flex, NumberInput, Select, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -9,13 +9,13 @@ import { IconSelector } from '../../../../../IconSelector/IconSelector';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
disallowAppNameProgagation: () => void;
|
||||
disallowAppNamePropagation: () => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const AppearanceTab = ({
|
||||
form,
|
||||
disallowAppNameProgagation,
|
||||
disallowAppNamePropagation,
|
||||
allowAppNamePropagation,
|
||||
}: AppearanceTabProps) => {
|
||||
const iconSelectorRef = useRef();
|
||||
@@ -46,7 +46,7 @@ export const AppearanceTab = ({
|
||||
defaultValue={form.values.appearance.iconUrl}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.iconUrl', value);
|
||||
disallowAppNameProgagation();
|
||||
disallowAppNamePropagation();
|
||||
}}
|
||||
value={form.values.appearance.iconUrl}
|
||||
ref={iconSelectorRef}
|
||||
@@ -66,26 +66,41 @@ export const AppearanceTab = ({
|
||||
}}
|
||||
/>
|
||||
{form.values.appearance.appNameStatus === 'normal' && (
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
data={[
|
||||
{ value: 'column', label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
},
|
||||
{
|
||||
value: 'column-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.bottom') as string,
|
||||
},
|
||||
{ value: 'row', label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.positionAppName', value);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
data={[
|
||||
{
|
||||
value: 'column',
|
||||
label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
},
|
||||
{
|
||||
value: 'column-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.bottom') as string,
|
||||
},
|
||||
{
|
||||
value: 'row',
|
||||
label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.positionAppName', value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('appearance.lineClampAppName.label')}
|
||||
description={t('appearance.lineClampAppName.description')}
|
||||
min={0}
|
||||
{...form.getInputProps('appearance.lineClampAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.lineClampAppName', value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -95,6 +95,7 @@ export const AvailableElementTypes = ({
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
appNameStatus: 'normal',
|
||||
positionAppName: 'column',
|
||||
lineClampAppName: 1,
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
const { data, isFetching, isError, error, isActive } = usePing(app);
|
||||
const tooltipLabel = useTooltipLabel({ isFetching, isError, data, errorMessage: error?.message });
|
||||
const isOnline = isError ? false : data?.state === 'online';
|
||||
|
||||
const pulse = usePingPulse({ isOnline, settings: userWithSettings?.settings });
|
||||
|
||||
if (!isActive) return null;
|
||||
@@ -32,7 +33,7 @@ export const AppPing = ({ app }: AppPingProps) => {
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: replaceDotWithIcon ? 5 : 20,
|
||||
bottom: replaceDotWithIcon ? 0 : 20,
|
||||
right: replaceDotWithIcon ? 8 : 20,
|
||||
zIndex: 2,
|
||||
}}
|
||||
@@ -110,6 +111,16 @@ const usePing = (app: AppType) => {
|
||||
{
|
||||
retry: false,
|
||||
enabled: isActive,
|
||||
refetchOnWindowFocus: false,
|
||||
retryDelay(failureCount, error) {
|
||||
// TODO: Add logic to retry on timeout
|
||||
return 3000;
|
||||
},
|
||||
// 5 minutes of cache
|
||||
cacheTime: 1000 * 60 * 5,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retryOnMount: true,
|
||||
|
||||
select: (data) => {
|
||||
const isOk = isStatusOk(app, data.status);
|
||||
if (isOk)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Box, Flex, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { Affix, Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { createStyles, useMantineTheme } from '@mantine/styles';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppType } from '../../../../types/app';
|
||||
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||
import { HomarrCardWrapper } from '../HomarrCardWrapper';
|
||||
import { BaseTileProps } from '../type';
|
||||
@@ -17,89 +16,77 @@ interface AppTileProps extends BaseTileProps {
|
||||
|
||||
export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
const { cx, classes } = useStyles();
|
||||
|
||||
const { colorScheme } = useMantineTheme();
|
||||
|
||||
const tooltipContent = [
|
||||
app.appearance.appNameStatus === "hover" ? app.name : undefined,
|
||||
app.behaviour.tooltipDescription
|
||||
].filter( e => e ).join( ': ' );
|
||||
app.appearance.appNameStatus === 'hover' ? app.name : undefined,
|
||||
app.behaviour.tooltipDescription,
|
||||
]
|
||||
.filter((e) => e)
|
||||
.join(': ');
|
||||
|
||||
const {
|
||||
classes: { card: cardClass },
|
||||
} = useCardStyles(false);
|
||||
const isRow = app.appearance.positionAppName.includes('row');
|
||||
|
||||
function Inner() {
|
||||
return (
|
||||
<Tooltip.Floating
|
||||
label={tooltipContent}
|
||||
position="right-start"
|
||||
c={ colorScheme === 'light' ? "black" : "dark.0" }
|
||||
color={ colorScheme === 'light' ? "gray.2" : "dark.4" }
|
||||
c={colorScheme === 'light' ? 'black' : 'dark.0'}
|
||||
color={colorScheme === 'light' ? 'gray.2' : 'dark.4'}
|
||||
multiline
|
||||
disabled={tooltipContent === ''}
|
||||
styles={{ tooltip: { '&': { maxWidth: 300, }, }, }}
|
||||
disabled={!tooltipContent}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
m={0}
|
||||
p={0}
|
||||
justify="space-around"
|
||||
align="center"
|
||||
<Box
|
||||
className={`${classes.base} ${cx(classes.appContent, 'dashboard-tile-app')}`}
|
||||
h="100%"
|
||||
w="100%"
|
||||
className="dashboard-tile-app"
|
||||
direction={app.appearance.positionAppName ?? 'column'}
|
||||
sx={{
|
||||
flexFlow: app.appearance.positionAppName ?? 'column',
|
||||
}}
|
||||
>
|
||||
<Box w="100%" hidden={["hover", "hidden"].includes(app.appearance.appNameStatus)}>
|
||||
{app.appearance.appNameStatus === 'normal' && (
|
||||
<Text
|
||||
w="100%"
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
fw={700}
|
||||
size="md"
|
||||
ta="center"
|
||||
weight={700}
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
lineClamp={2}
|
||||
sx={{
|
||||
flex: isRow ? '1' : undefined,
|
||||
}}
|
||||
lineClamp={app.appearance.lineClampAppName}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
w="100%"
|
||||
h="100%"
|
||||
display="flex"
|
||||
sx={{
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '1 1 auto',
|
||||
flexWrap: 'wrap',
|
||||
)}
|
||||
<motion.img
|
||||
className={cx('dashboard-tile-app-image')}
|
||||
src={app.appearance.iconUrl}
|
||||
height="85%"
|
||||
width="85%"
|
||||
alt={app.name}
|
||||
whileHover={{ scale: 0.9 }}
|
||||
initial={{ scale: 0.8 }}
|
||||
style={{
|
||||
maxHeight: '90%',
|
||||
maxWidth: '90%',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
objectFit: 'contain',
|
||||
width: isRow ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<motion.img
|
||||
className={classes.image}
|
||||
height="85%"
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={app.appearance.iconUrl}
|
||||
alt={app.name}
|
||||
whileHover={{
|
||||
scale: 1.2,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip.Floating>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomarrCardWrapper className={className}>
|
||||
<HomarrCardWrapper className={className} p={10}>
|
||||
<AppMenu app={app} />
|
||||
{!app.url || isEditMode ? (
|
||||
<UnstyledButton
|
||||
className={classes.button}
|
||||
className={`${classes.button} ${classes.base}`}
|
||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||
>
|
||||
<Inner />
|
||||
@@ -110,7 +97,7 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
component={Link}
|
||||
href={app.behaviour.externalUrl.length > 0 ? app.behaviour.externalUrl : app.url}
|
||||
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
||||
className={cx(classes.button)}
|
||||
className={`${classes.button} ${classes.base}`}
|
||||
>
|
||||
<Inner />
|
||||
</UnstyledButton>
|
||||
@@ -121,9 +108,15 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
image: {
|
||||
maxHeight: '90%',
|
||||
maxWidth: '90%',
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
appContent: {
|
||||
gap: 0,
|
||||
overflow: 'visible',
|
||||
flexGrow: 5,
|
||||
},
|
||||
appName: {
|
||||
wordBreak: 'break-word',
|
||||
@@ -131,9 +124,6 @@ const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
button: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconLayoutKanban, IconPencil, IconSettings, IconTrash } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useColorTheme } from '../../../tools/color';
|
||||
import { useEditModeStore } from '../Views/useEditModeStore';
|
||||
|
||||
interface GenericTileMenuProps {
|
||||
@@ -11,12 +12,14 @@ interface GenericTileMenuProps {
|
||||
displayEdit: boolean;
|
||||
}
|
||||
|
||||
export const GenericTileMenu = ({
|
||||
handleClickEdit,
|
||||
handleClickChangePosition,
|
||||
handleClickDelete,
|
||||
displayEdit,
|
||||
}: GenericTileMenuProps) => {
|
||||
export const GenericTileMenu = (
|
||||
{
|
||||
handleClickEdit,
|
||||
handleClickChangePosition,
|
||||
handleClickDelete,
|
||||
displayEdit,
|
||||
}: GenericTileMenuProps
|
||||
) => {
|
||||
const { t } = useTranslation('common');
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
@@ -28,13 +31,13 @@ export const GenericTileMenu = ({
|
||||
<Menu withinPortal withArrow position="right">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
style={{ zIndex: 1 }}
|
||||
size="md"
|
||||
radius="md"
|
||||
variant="light"
|
||||
pos="absolute"
|
||||
top={8}
|
||||
right={8}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<IconSettings />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -19,13 +19,13 @@ import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons-react'
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { InfoCard } from '../../../InfoCard/InfoCard';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { mapObject } from '../../../../tools/client/objects';
|
||||
import Widgets from '../../../../widgets';
|
||||
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
|
||||
import { IWidget } from '../../../../widgets/widgets';
|
||||
import { InfoCard } from '../../../InfoCard/InfoCard';
|
||||
import { DraggableList } from './Inputs/DraggableList';
|
||||
import { LocationSelection } from './Inputs/LocationSelection';
|
||||
import { StaticDraggableList } from './Inputs/StaticDraggableList';
|
||||
@@ -138,6 +138,8 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
const info = option.info ?? false;
|
||||
const link = option.infoLink ?? undefined;
|
||||
|
||||
if (option.hide) return null;
|
||||
|
||||
switch (option.type) {
|
||||
case 'switch':
|
||||
return (
|
||||
@@ -148,15 +150,17 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
|
||||
{...option.inputProps}
|
||||
/>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<TextInput
|
||||
value={value as string}
|
||||
@@ -169,8 +173,10 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<MultiSelect
|
||||
data={option.data}
|
||||
@@ -183,15 +189,26 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
</Stack>
|
||||
);
|
||||
case 'select':
|
||||
const items = typeof option.data === 'function' ? option.data() : option.data;
|
||||
const data = items.map((dataType) => {
|
||||
return !dataType.label
|
||||
? {
|
||||
value: dataType.value,
|
||||
label: t(`descriptor.settings.${key}.data.${dataType.value}`),
|
||||
}
|
||||
: dataType;
|
||||
});
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<Select
|
||||
defaultValue={option.defaultValue}
|
||||
data={option.data}
|
||||
data={data}
|
||||
value={value as string}
|
||||
onChange={(v) => handleChange(key, v ?? option.defaultValue)}
|
||||
withinPortal
|
||||
@@ -203,8 +220,10 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<NumberInput
|
||||
value={value as number}
|
||||
@@ -217,8 +236,10 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<Slider
|
||||
label={value}
|
||||
@@ -270,7 +291,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
<Stack spacing="xs">
|
||||
<Group align="center" spacing="sm">
|
||||
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<StaticDraggableList
|
||||
value={typedVal}
|
||||
@@ -298,8 +319,10 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Group align="center" spacing="sm">
|
||||
<Text size="0.875rem" weight="500">{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
<Text size="0.875rem" weight="500">
|
||||
{t(`descriptor.settings.${key}.label`)}
|
||||
</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<MultiSelect
|
||||
data={value.map((name: any) => ({ value: name, label: name }))}
|
||||
@@ -324,7 +347,7 @@ const WidgetOptionTypeSwitch: FC<{
|
||||
<Stack spacing="xs">
|
||||
<Group align="center" spacing="sm">
|
||||
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link}/>}
|
||||
{info && <InfoCard message={t(`descriptor.settings.${key}.info`)} link={link} />}
|
||||
</Group>
|
||||
<DraggableList
|
||||
items={Array.from(value).map((v: any) => ({
|
||||
|
||||
@@ -35,6 +35,13 @@ export const ConfigProvider = ({
|
||||
const { configs } = useConfigStore((s) => ({ configs: s.configs }), shallow);
|
||||
|
||||
const currentConfig = configs.find((c) => c.value.configProperties.name === configName)?.value;
|
||||
const { setPrimaryColor, setSecondaryColor, setPrimaryShade } = useColorTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setPrimaryColor(currentConfig?.settings.customization.colors.primary || 'red');
|
||||
setSecondaryColor(currentConfig?.settings.customization.colors.secondary || 'orange');
|
||||
setPrimaryShade(currentConfig?.settings.customization.colors.shade || 6);
|
||||
}, [currentConfig]);
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider
|
||||
|
||||
1
src/images/undraw_bug_fixing_oc-7-a.svg
Normal file
1
src/images/undraw_bug_fixing_oc-7-a.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
1
src/images/undraw_page_not_found_re_e9o6.svg
Normal file
1
src/images/undraw_page_not_found_re_e9o6.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
@@ -4,21 +4,14 @@ import {
|
||||
IconCheck,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import Dockerode from 'dockerode';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RouterInputs, api } from '~/utils/api';
|
||||
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
|
||||
import { AppType } from '../../types/app';
|
||||
|
||||
export interface ContainerActionBarProps {
|
||||
selected: Dockerode.ContainerInfo[];
|
||||
reload: () => void;
|
||||
@@ -121,7 +114,7 @@ const useDockerActionMutation = () => {
|
||||
{ action, id: container.Id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
notifications.update({
|
||||
id: container.Id,
|
||||
title: containerName,
|
||||
message: `${t(`actions.${action}.end`)} ${containerName}`,
|
||||
|
||||
@@ -1,105 +1,54 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Text,
|
||||
Title,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { Button, Center, Stack, Text, Title, createStyles } from '@mantine/core';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import pageNotFoundImage from '~/images/undraw_page_not_found_re_e9o6.svg';
|
||||
import { pageNotFoundNamespaces } from '~/tools/server/translation-namespaces';
|
||||
|
||||
import { getServerSideTranslations } from '../tools/server/getServerSideTranslations';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
paddingTop: 80,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
|
||||
inner: {
|
||||
position: 'relative',
|
||||
},
|
||||
|
||||
image: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
zIndex: 0,
|
||||
opacity: 0.75,
|
||||
},
|
||||
|
||||
content: {
|
||||
paddingTop: 220,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
paddingTop: 120,
|
||||
},
|
||||
},
|
||||
|
||||
title: {
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
textAlign: 'center',
|
||||
fontWeight: 900,
|
||||
fontSize: 38,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
fontSize: 32,
|
||||
},
|
||||
},
|
||||
|
||||
description: {
|
||||
maxWidth: 540,
|
||||
margin: 'auto',
|
||||
marginTop: theme.spacing.xl,
|
||||
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
|
||||
},
|
||||
}));
|
||||
|
||||
function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
|
||||
<path
|
||||
fill={theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0]}
|
||||
d="M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Custom404() {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { t } = useTranslation('layout/errors/not-found');
|
||||
|
||||
return (
|
||||
<Container className={classes.root}>
|
||||
<div className={classes.inner}>
|
||||
<Illustration className={classes.image} />
|
||||
<div className={classes.content}>
|
||||
<Title className={classes.title}>Config not found</Title>
|
||||
<Text color="dimmed" size="lg" align="center" className={classes.description}>
|
||||
The config you are trying to access does not exist. Please check the URL and try again.
|
||||
</Text>
|
||||
<Group position="center">
|
||||
<Link href="/">
|
||||
<Button size="md">Take me back to home page</Button>
|
||||
</Link>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<Center h="100dvh" w="100dvw">
|
||||
<Head>
|
||||
<title>Page not found • Homarr</title>
|
||||
</Head>
|
||||
<Stack maw={500} p="xl">
|
||||
<Image className={classes.image} src={pageNotFoundImage} width={200} height={200} alt="" />
|
||||
<Title>{t('title')}</Title>
|
||||
<Text>{t('text')}</Text>
|
||||
|
||||
<Button component={Link} variant="light" href="/">
|
||||
{t('button')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStaticProps({ req, res, locale }: GetServerSidePropsContext) {
|
||||
const translations = await getServerSideTranslations(['common'], locale, undefined, undefined);
|
||||
const translations = await getServerSideTranslations(
|
||||
[...pageNotFoundNamespaces, 'common'],
|
||||
locale,
|
||||
req,
|
||||
res
|
||||
);
|
||||
return {
|
||||
props: {
|
||||
...translations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
image: {
|
||||
margin: '0 auto',
|
||||
display: 'blcok',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import Consola from 'consola';
|
||||
import { getCookie, setCookie } from 'cookies-next';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import moment from 'moment-timezone';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import { Session } from 'next-auth';
|
||||
import { SessionProvider, getSession } from 'next-auth/react';
|
||||
@@ -16,6 +17,7 @@ import { CommonHead } from '~/components/layout/Meta/CommonHead';
|
||||
import { env } from '~/env.js';
|
||||
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
|
||||
import { modals } from '~/modals';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import { ConfigType } from '~/types/config';
|
||||
import { api } from '~/utils/api';
|
||||
import { colorSchemeParser } from '~/validations/user';
|
||||
@@ -44,9 +46,15 @@ function App(
|
||||
secondaryColor?: MantineTheme['primaryColor'];
|
||||
primaryShade?: MantineTheme['primaryShade'];
|
||||
session: Session;
|
||||
configName?: string;
|
||||
locale: string;
|
||||
}>
|
||||
) {
|
||||
const { Component, pageProps } = props;
|
||||
// TODO: make mapping from our locales to moment locales
|
||||
const language = getLanguageByCode(pageProps.locale);
|
||||
require('moment/locale/' + language.momentLocale);
|
||||
moment.locale(language.momentLocale);
|
||||
|
||||
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>(
|
||||
props.pageProps.primaryColor ?? 'red'
|
||||
@@ -151,6 +159,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
...getActiveColorScheme(session, ctx),
|
||||
packageAttributes: getServiceSidePackageAttributes(),
|
||||
session,
|
||||
locale: ctx.locale ?? 'en',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
83
src/pages/_error.tsx
Normal file
83
src/pages/_error.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Accordion,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceDesktop, IconInfoCircle, IconServer } from '@tabler/icons-react';
|
||||
import { NextPageContext } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import imageBugFixing from '~/images/undraw_bug_fixing_oc-7-a.svg';
|
||||
|
||||
function Error({ statusCode }: { statusCode: number }) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
const getColor = (color: string) => theme.colors[color][theme.colorScheme === 'dark' ? 5 : 7];
|
||||
return (
|
||||
<Center className={classes.root} h="100dvh" maw={400}>
|
||||
<Head>
|
||||
<title>An error occurred • Homarr</title>
|
||||
</Head>
|
||||
<Stack>
|
||||
<Image className={classes.image} src={imageBugFixing} alt="bug illustration" />
|
||||
<Title>An unexpected error has occurred</Title>
|
||||
<Text>
|
||||
This page has crashed unexpectedly. We're sorry for the inconvenience. Please try again or
|
||||
contact an administrator
|
||||
</Text>
|
||||
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="detailed">
|
||||
<Accordion.Control icon={<IconInfoCircle color={getColor('red')} size="1rem" />}>
|
||||
Detailed error information
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="xs">
|
||||
<Group position="apart">
|
||||
<Text fw="bold">Type</Text>
|
||||
<Text>
|
||||
{statusCode ? (
|
||||
<Group spacing="xs">
|
||||
<IconServer size="1rem" />
|
||||
<Text>Server side</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group spacing="xs">
|
||||
<IconDeviceDesktop size="1rem" />
|
||||
<Text>Client side</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
Error.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { statusCode };
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
root: {
|
||||
margin: '0 auto',
|
||||
},
|
||||
image: {
|
||||
maxWidth: 400,
|
||||
maxHeight: 200,
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
export default Error;
|
||||
@@ -3,7 +3,7 @@ import { boardRouter } from './routers/board';
|
||||
import { calendarRouter } from './routers/calendar';
|
||||
import { configRouter } from './routers/config';
|
||||
import { dashDotRouter } from './routers/dash-dot';
|
||||
import { dnsHoleRouter } from './routers/dns-hole';
|
||||
import { dnsHoleRouter } from './routers/dns-hole/router';
|
||||
import { downloadRouter } from './routers/download';
|
||||
import { iconRouter } from './routers/icon';
|
||||
import { inviteRouter } from './routers/invite';
|
||||
@@ -17,6 +17,8 @@ import { weatherRouter } from './routers/weather';
|
||||
import { dockerRouter } from './routers/docker/router';
|
||||
import { usenetRouter } from './routers/usenet/router';
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
import { timezoneRouter } from './routers/timezone';
|
||||
import { notebookRouter } from './routers/notebook';
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -27,21 +29,23 @@ export const rootRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
rss: rssRouter,
|
||||
user: userRouter,
|
||||
calendar: calendarRouter,
|
||||
config: configRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconRouter,
|
||||
dashDot: dashDotRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
docker: dockerRouter,
|
||||
download: downloadRouter,
|
||||
icon: iconRouter,
|
||||
mediaRequest: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
overseerr: overseerrRouter,
|
||||
timezone: timezoneRouter,
|
||||
usenet: usenetRouter,
|
||||
calendar: calendarRouter,
|
||||
weather: weatherRouter,
|
||||
invites: inviteRouter,
|
||||
boards: boardRouter,
|
||||
password: passwordRouter
|
||||
password: passwordRouter,
|
||||
notebook: notebookRouter
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -23,11 +23,11 @@ export const appRouter = createTRPCRouter({
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
cause: input,
|
||||
message: `App ${input} was not found`,
|
||||
message: `App ${input.id} was not found`,
|
||||
});
|
||||
}
|
||||
const res = await axios
|
||||
.get(app.url, { httpsAgent: agent, timeout: 2000 })
|
||||
.get(app.url, { httpsAgent: agent, timeout: 10000 })
|
||||
.then((response) => ({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PiHoleClient } from '~/tools/server/sdk/pihole/piHole';
|
||||
import { ConfigAppType } from '~/types/app';
|
||||
import { AdStatistics } from '~/widgets/dnshole/type';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
control: publicProcedure
|
||||
@@ -135,8 +135,14 @@ const collectAdGuardSummary = async (app: ConfigAppType) => {
|
||||
const status = await adGuard.getStatus();
|
||||
const countFilteredDomains = await adGuard.getCountFilteringDomains();
|
||||
|
||||
const blockedQueriesToday = stats.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday = stats.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
const blockedQueriesToday =
|
||||
stats.time_units === 'days'
|
||||
? stats.blocked_filtering[stats.blocked_filtering.length - 1]
|
||||
: stats.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday =
|
||||
stats.time_units === 'days'
|
||||
? stats.dns_queries[stats.dns_queries.length - 1]
|
||||
: stats.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
|
||||
return {
|
||||
domainsBeingBlocked: countFilteredDomains,
|
||||
@@ -65,7 +65,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
status: item.status,
|
||||
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
||||
posterPath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${genericItem.posterPath}`,
|
||||
href: `${appUrl}/movie/${item.media.tmdbId}`,
|
||||
href: `${appUrl}/${item.type}/${item.media.tmdbId}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
37
src/server/api/routers/notebook.ts
Normal file
37
src/server/api/routers/notebook.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { BackendConfigType } from '~/types/config';
|
||||
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const notebookRouter = createTRPCRouter({
|
||||
update: publicProcedure
|
||||
.input(z.object({ widgetId: z.string(), content: z.string(), configName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const widget = config.widgets.find((widget) => widget.id === input.widgetId) as
|
||||
| INotebookWidget
|
||||
| undefined;
|
||||
|
||||
if (!widget) {
|
||||
return new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Specified widget was not found',
|
||||
});
|
||||
}
|
||||
|
||||
widget.properties.content = input.content;
|
||||
|
||||
const newConfig: BackendConfigType = {
|
||||
...config,
|
||||
widgets: [...config.widgets.filter((w) => w.id !== widget.id), widget],
|
||||
};
|
||||
|
||||
const targetPath = path.join('data/configs', `${input.configName}.json`);
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
});
|
||||
17
src/server/api/routers/timezone.ts
Normal file
17
src/server/api/routers/timezone.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { find } from 'geo-tz'
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const timezoneRouter = createTRPCRouter({
|
||||
at: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
longitude: z.number(),
|
||||
latitude: z.number(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return find(input.latitude,input.longitude)[0];
|
||||
}),
|
||||
})
|
||||
@@ -16,3 +16,7 @@ export const formatNumber = (n: number, decimalPlaces: number) => {
|
||||
}
|
||||
return n.toFixed(decimalPlaces);
|
||||
};
|
||||
|
||||
export const formatPercentage = (n: number, decimalPlaces: number) => {
|
||||
return `${(n * 100).toFixed(decimalPlaces)}%`;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export const getFrontendConfig = async (name: string): Promise<ConfigType> => {
|
||||
let config = getConfig(name);
|
||||
let shouldMigrateConfig = false;
|
||||
|
||||
config = migrateAppConfigs(config);
|
||||
|
||||
const anyWeatherWidgetWithStringLocation = config.widgets.some(
|
||||
(widget) => widget.type === 'weather' && typeof widget.properties.location === 'string'
|
||||
);
|
||||
@@ -129,3 +131,18 @@ const migratePiholeIntegrationField = (config: BackendConfigType) => {
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const migrateAppConfigs = (config: BackendConfigType) => {
|
||||
return {
|
||||
...config,
|
||||
apps: config.apps.map((app) => ({
|
||||
...app,
|
||||
appearance: {
|
||||
...app.appearance,
|
||||
appNameStatus: app.appearance.appNameStatus?? 'normal',
|
||||
positionAppName: app.appearance.positionAppName?? 'column',
|
||||
lineClampAppName: app.appearance.lineClampAppName?? 1,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export class Language {
|
||||
export type Language = {
|
||||
shortName: string;
|
||||
originalName: string;
|
||||
translatedName: string;
|
||||
@@ -10,14 +10,8 @@ export class Language {
|
||||
*/
|
||||
country?: string;
|
||||
|
||||
constructor(shortName: string, originalName: string, translatedName: string, emoji: string, country: string) {
|
||||
this.shortName = shortName;
|
||||
this.originalName = originalName;
|
||||
this.translatedName = translatedName;
|
||||
this.emoji = emoji;
|
||||
this.country = country;
|
||||
}
|
||||
}
|
||||
momentLocale: string;
|
||||
};
|
||||
|
||||
export const languages: Language[] = [
|
||||
{
|
||||
@@ -25,14 +19,16 @@ export const languages: Language[] = [
|
||||
originalName: 'Deutsch',
|
||||
translatedName: 'German',
|
||||
emoji: '🇩🇪',
|
||||
country: 'DE'
|
||||
country: 'DE',
|
||||
momentLocale: 'de',
|
||||
},
|
||||
{
|
||||
shortName: 'en',
|
||||
originalName: 'English',
|
||||
translatedName: 'English',
|
||||
emoji: '🇬🇧',
|
||||
country: 'GB'
|
||||
country: 'GB',
|
||||
momentLocale: 'en-gb',
|
||||
},
|
||||
// Danish
|
||||
{
|
||||
@@ -40,7 +36,8 @@ export const languages: Language[] = [
|
||||
originalName: 'Dansk',
|
||||
translatedName: 'Danish',
|
||||
emoji: '🇩🇰',
|
||||
country: 'DK'
|
||||
country: 'DK',
|
||||
momentLocale: 'da',
|
||||
},
|
||||
// Hebrew
|
||||
{
|
||||
@@ -48,48 +45,55 @@ export const languages: Language[] = [
|
||||
originalName: 'עברית',
|
||||
translatedName: 'Hebrew',
|
||||
emoji: '🇮🇱',
|
||||
country: 'IL'
|
||||
country: 'IL',
|
||||
momentLocale: 'he',
|
||||
},
|
||||
{
|
||||
shortName: 'es',
|
||||
originalName: 'Español',
|
||||
translatedName: 'Spanish',
|
||||
emoji: '🇪🇸',
|
||||
country: 'ES'
|
||||
country: 'ES',
|
||||
momentLocale: 'es',
|
||||
},
|
||||
{
|
||||
shortName: 'fr',
|
||||
originalName: 'Français',
|
||||
translatedName: 'French',
|
||||
emoji: '🇫🇷',
|
||||
country: 'FR'
|
||||
country: 'FR',
|
||||
momentLocale: 'fr',
|
||||
},
|
||||
{
|
||||
shortName: 'it',
|
||||
originalName: 'Italiano',
|
||||
translatedName: 'Italian',
|
||||
emoji: '🇮🇹',
|
||||
country: 'IT'
|
||||
country: 'IT',
|
||||
momentLocale: 'it',
|
||||
},
|
||||
{
|
||||
shortName: 'ja',
|
||||
originalName: '日本語',
|
||||
translatedName: 'Japanese',
|
||||
emoji: '🇯🇵',
|
||||
country: 'JP'
|
||||
country: 'JP',
|
||||
momentLocale: 'jp'
|
||||
},
|
||||
{
|
||||
shortName: 'ko',
|
||||
originalName: '한국어',
|
||||
translatedName: 'Korean',
|
||||
emoji: '🇰🇷',
|
||||
country: 'KR'
|
||||
country: 'KR',
|
||||
momentLocale: 'ko'
|
||||
},
|
||||
{
|
||||
shortName: 'lol',
|
||||
originalName: 'LOLCAT',
|
||||
translatedName: 'LOLCAT',
|
||||
emoji: '🐱',
|
||||
momentLocale: 'en-gb',
|
||||
},
|
||||
// Norwegian
|
||||
{
|
||||
@@ -97,7 +101,8 @@ export const languages: Language[] = [
|
||||
originalName: 'Norsk',
|
||||
translatedName: 'Norwegian',
|
||||
emoji: '🇳🇴',
|
||||
country: 'NO'
|
||||
country: 'NO',
|
||||
momentLocale: 'nb',
|
||||
},
|
||||
// Slovak
|
||||
{
|
||||
@@ -105,57 +110,64 @@ export const languages: Language[] = [
|
||||
originalName: 'Slovenčina',
|
||||
translatedName: 'Slovak',
|
||||
emoji: '🇸🇰',
|
||||
country: 'SK'
|
||||
country: 'SK',
|
||||
momentLocale: 'sk',
|
||||
},
|
||||
{
|
||||
shortName: 'nl',
|
||||
originalName: 'Nederlands',
|
||||
translatedName: 'Dutch',
|
||||
emoji: '🇳🇱',
|
||||
country: 'NL'
|
||||
country: 'NL',
|
||||
momentLocale: 'nl',
|
||||
},
|
||||
{
|
||||
shortName: 'pl',
|
||||
originalName: 'Polski',
|
||||
translatedName: 'Polish',
|
||||
emoji: '🇵🇱',
|
||||
country: 'PL'
|
||||
country: 'PL',
|
||||
momentLocale: 'pl',
|
||||
},
|
||||
{
|
||||
shortName: 'pt',
|
||||
originalName: 'Português',
|
||||
translatedName: 'Portuguese',
|
||||
emoji: '🇵🇹',
|
||||
country: 'PT'
|
||||
country: 'PT',
|
||||
momentLocale: 'pt',
|
||||
},
|
||||
{
|
||||
shortName: 'ru',
|
||||
originalName: 'Русский',
|
||||
translatedName: 'Russian',
|
||||
emoji: '🇷🇺',
|
||||
country: 'RU'
|
||||
country: 'RU',
|
||||
momentLocale: 'ru',
|
||||
},
|
||||
{
|
||||
momentLocale: 'si',
|
||||
shortName: 'sl',
|
||||
originalName: 'Slovenščina',
|
||||
translatedName: 'Slovenian',
|
||||
emoji: '🇸🇮',
|
||||
country: 'SI'
|
||||
},
|
||||
|
||||
{
|
||||
shortName: 'sv',
|
||||
originalName: 'Svenska',
|
||||
translatedName: 'Swedish',
|
||||
emoji: '🇸🇪',
|
||||
country: 'SE'
|
||||
country: 'SE',
|
||||
momentLocale: 'sv',
|
||||
},
|
||||
{
|
||||
shortName: 'uk',
|
||||
originalName: 'Українська',
|
||||
translatedName: 'Ukrainian',
|
||||
emoji: '🇺🇦',
|
||||
country: 'UA'
|
||||
country: 'UA',
|
||||
momentLocale: 'uk',
|
||||
},
|
||||
// Vietnamese
|
||||
{
|
||||
@@ -163,43 +175,48 @@ export const languages: Language[] = [
|
||||
originalName: 'Tiếng Việt',
|
||||
translatedName: 'Vietnamese',
|
||||
emoji: '🇻🇳',
|
||||
country: 'VN'
|
||||
country: 'VN',
|
||||
momentLocale: 'vi',
|
||||
},
|
||||
{
|
||||
shortName: 'zh',
|
||||
originalName: '中文',
|
||||
translatedName: 'Chinese',
|
||||
emoji: '🇨🇳',
|
||||
country: 'CN'
|
||||
country: 'CN',
|
||||
momentLocale: 'cn'
|
||||
},
|
||||
{
|
||||
shortName: 'el',
|
||||
originalName: 'Ελληνικά',
|
||||
translatedName: 'Greek',
|
||||
emoji: '🇬🇷',
|
||||
country: 'GR'
|
||||
country: 'GR',
|
||||
momentLocale: 'el',
|
||||
shortName: 'gr'
|
||||
},
|
||||
{
|
||||
shortName: 'tr',
|
||||
originalName: 'Türkçe',
|
||||
translatedName: 'Turkish',
|
||||
emoji: '🇹🇷',
|
||||
country: 'TR'
|
||||
country: 'TR',
|
||||
momentLocale: 'tr',
|
||||
},
|
||||
{
|
||||
shortName: 'lv',
|
||||
originalName: 'Latvian',
|
||||
translatedName: 'Latvian',
|
||||
emoji: '🇱🇻',
|
||||
country: 'LV'
|
||||
country: 'LV',
|
||||
momentLocale: 'lv',
|
||||
},
|
||||
// Croatian
|
||||
{
|
||||
shortName: 'hr',
|
||||
originalName: 'Hrvatski',
|
||||
translatedName: 'Croatian',
|
||||
emoji: '🇭🇷',
|
||||
country: 'HR'
|
||||
country: 'HR',
|
||||
momentLocale: 'hr',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const adGuardApiStatsResponseSchema = z.object({
|
||||
time_units: z.enum(['hours']),
|
||||
time_units: z.enum(['hours', 'days']),
|
||||
top_queried_domains: z.array(z.record(z.string(), z.number())),
|
||||
top_clients: z.array(z.record(z.string(), z.number())),
|
||||
top_blocked_domains: z.array(z.record(z.string(), z.number())),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { trimStringEnding } from '../../../shared/strings';
|
||||
import {
|
||||
adGuardApiFilteringStatusSchema,
|
||||
@@ -77,19 +78,4 @@ export class AdGuard {
|
||||
}
|
||||
}
|
||||
|
||||
export type AdGuardStatsType = {
|
||||
time_units: string;
|
||||
top_queried_domains: { [key: string]: number }[];
|
||||
top_clients: { [key: string]: number }[];
|
||||
top_blocked_domains: { [key: string]: number }[];
|
||||
dns_queries: number[];
|
||||
blocked_filtering: number[];
|
||||
replaced_safebrowsing: number[];
|
||||
replaced_parental: number[];
|
||||
num_dns_queries: number;
|
||||
num_blocked_filtering: number;
|
||||
num_replaced_safebrowsing: number;
|
||||
num_replaced_safesearch: number;
|
||||
num_replaced_parental: number;
|
||||
avg_processing_time: number;
|
||||
};
|
||||
export type AdGuardStatsType = z.infer<typeof adGuardApiStatsResponseSchema>;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const boardNamespaces = [
|
||||
'modules/dns-hole-summary',
|
||||
'modules/dns-hole-controls',
|
||||
'modules/bookmark',
|
||||
'modules/notebook',
|
||||
'widgets/error-boundary',
|
||||
'widgets/draggable-list',
|
||||
'widgets/location',
|
||||
@@ -45,3 +46,6 @@ export const manageNamespaces = [
|
||||
'manage/users/invites',
|
||||
'manage/users/create',
|
||||
];
|
||||
export const loginNamespaces = ['authentication/login'];
|
||||
|
||||
export const pageNotFoundNamespaces = ['layout/errors/not-found'];
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AppAppearanceType {
|
||||
iconUrl: string;
|
||||
appNameStatus: "normal"|"hover"|"hidden";
|
||||
positionAppName: Property.FlexDirection;
|
||||
lineClampAppName: number;
|
||||
}
|
||||
|
||||
export type IntegrationType =
|
||||
|
||||
@@ -33,6 +33,7 @@ const getTrpcConfiguration = () => ({
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
maxURLLength: 2000,
|
||||
}),
|
||||
],
|
||||
queryClient,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Stack, Text, Title } from '@mantine/core';
|
||||
import { Stack, Text, createStyles } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconClock } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import moment from 'moment-timezone';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useSetSafeInterval } from '../../hooks/useSetSafeInterval';
|
||||
import { defineWidget } from '../helper';
|
||||
@@ -16,6 +19,39 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
dateFormat: {
|
||||
type: 'select',
|
||||
defaultValue: 'dddd, MMMM D',
|
||||
data: () => [
|
||||
{ value: 'hide' },
|
||||
{ value: 'dddd, MMMM D', label: moment().format('dddd, MMMM D') },
|
||||
{ value: 'dddd, D MMMM', label: moment().format('dddd, D MMMM') },
|
||||
{ value: 'MMM D', label: moment().format('MMM D') },
|
||||
{ value: 'D MMM', label: moment().format('D MMM') },
|
||||
{ value: 'DD/MM/YYYY', label: moment().format('DD/MM/YYYY') },
|
||||
{ value: 'MM/DD/YYYY', label: moment().format('MM/DD/YYYY') },
|
||||
{ value: 'DD/MM', label: moment().format('DD/MM') },
|
||||
{ value: 'MM/DD', label: moment().format('MM/DD') },
|
||||
],
|
||||
},
|
||||
enableTimezone: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
timezoneLocation: {
|
||||
type: 'location',
|
||||
defaultValue: {
|
||||
name: 'Paris',
|
||||
latitude: 48.85341,
|
||||
longitude: 2.3488,
|
||||
},
|
||||
},
|
||||
titleState: {
|
||||
type: 'select',
|
||||
defaultValue: 'both',
|
||||
data: [{ value: 'both' }, { value: 'city' }, { value: 'none' }],
|
||||
info: true,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 1,
|
||||
@@ -33,52 +69,102 @@ interface DateTileProps {
|
||||
}
|
||||
|
||||
function DateTile({ widget }: DateTileProps) {
|
||||
const date = useDateState();
|
||||
const date = useDateState(
|
||||
widget.properties.enableTimezone ? widget.properties.timezoneLocation : undefined
|
||||
);
|
||||
const formatString = widget.properties.display24HourFormat ? 'HH:mm' : 'h:mm A';
|
||||
const { width, ref } = useElementSize();
|
||||
const { ref, width } = useElementSize();
|
||||
const { cx, classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Stack ref={ref} spacing="xs" justify="space-around" align="center" style={{ height: '100%' }}>
|
||||
<Title>{dayjs(date).format(formatString)}</Title>
|
||||
{width > 200 && <Text size="lg">{dayjs(date).format('dddd, MMMM D')}</Text>}
|
||||
<Stack ref={ref} className={cx(classes.wrapper, 'dashboard-tile-clock-wrapper')}>
|
||||
{widget.properties.enableTimezone && widget.properties.titleState !== 'none' && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-city')}
|
||||
>
|
||||
{widget.properties.timezoneLocation.name}
|
||||
{widget.properties.titleState === 'both' && moment(date).format(' (z)')}
|
||||
</Text>
|
||||
)}
|
||||
<Text className={cx(classes.clock, 'dashboard-tile-clock-hour')}>
|
||||
{moment(date).format(formatString)}
|
||||
</Text>
|
||||
{!widget.properties.dateFormat.includes('hide') && (
|
||||
<Text
|
||||
size={width < 150 ? 'sm' : 'lg'}
|
||||
pt="0.2rem"
|
||||
className={cx(classes.extras, 'dashboard-tile-clock-date')}
|
||||
>
|
||||
{moment(date).format(widget.properties.dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(()=>({
|
||||
wrapper:{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
gap: 0,
|
||||
},
|
||||
clock:{
|
||||
lineHeight: '1',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 700,
|
||||
fontSize: '2.125rem',
|
||||
},
|
||||
extras:{
|
||||
lineHeight: '1',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* State which updates when the minute is changing
|
||||
* @returns current date updated every new minute
|
||||
*/
|
||||
const useDateState = () => {
|
||||
const [date, setDate] = useState(new Date());
|
||||
const useDateState = (location?: { latitude: number; longitude: number }) => {
|
||||
//Gets a timezone from user input location. If location is undefined, then it means it's a local timezone so keep undefined
|
||||
const { data: timezone } = api.timezone.at.useQuery(location!, {
|
||||
enabled: location !== undefined,
|
||||
});
|
||||
const { locale } = useRouter();
|
||||
const [date, setDate] = useState(getNewDate(timezone));
|
||||
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());
|
||||
const language = getLanguageByCode(locale ?? 'en');
|
||||
moment.locale(language.momentLocale);
|
||||
setDate(getNewDate(timezone));
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setDate(getNewDate(timezone));
|
||||
// Starts interval which update the date every minute
|
||||
setSafeInterval(() => {
|
||||
setDate(getNewDate(timezone));
|
||||
}, 1000 * 60);
|
||||
//1 minute - current seconds and milliseconds count
|
||||
},
|
||||
1000 * 60 - (1000 * moment().seconds() + moment().milliseconds())
|
||||
);
|
||||
|
||||
return () => timeoutRef.current && clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
}, [timezone, locale]);
|
||||
|
||||
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();
|
||||
//Returns a local date if no inputs or returns date from input zone
|
||||
const getNewDate = (timezone?: string) => {
|
||||
if (timezone) {
|
||||
return moment().tz(timezone);
|
||||
}
|
||||
return moment();
|
||||
};
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Card, Center, Container, Stack, Text } from '@mantine/core';
|
||||
import { Box, Card, Center, Container, Flex, Text } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import {
|
||||
IconAd,
|
||||
IconBarrierBlock,
|
||||
IconPercentage,
|
||||
IconSearch,
|
||||
IconWorldWww,
|
||||
TablerIconsProps,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
import { formatNumber } from '../../tools/client/math';
|
||||
import { formatNumber, formatPercentage } from '../../tools/client/math';
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const availableLayouts = ['grid', 'row', 'column'] as const;
|
||||
type AvailableLayout = (typeof availableLayouts)[number];
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dns-hole-summary',
|
||||
icon: IconAd,
|
||||
@@ -23,10 +28,15 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
layout: {
|
||||
type: 'select',
|
||||
defaultValue: 'grid' as AvailableLayout,
|
||||
data: availableLayouts.map((x) => ({ value: x })),
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
minHeight: 1,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
@@ -40,7 +50,6 @@ interface DnsHoleSummaryWidgetProps {
|
||||
}
|
||||
|
||||
function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
const { t } = useTranslation('modules/dns-hole-summary');
|
||||
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
|
||||
|
||||
if (isInitialLoading || !data) {
|
||||
@@ -48,139 +57,47 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
display="grid"
|
||||
h="100%"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr',
|
||||
marginLeft: -20,
|
||||
marginRight: -20,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(240, 82, 60, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(240, 82, 60, 0.2)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconBarrierBlock size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.adsBlockedToday, 0)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.queriesBlockedToday')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 165, 20, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 165, 20, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconPercentage size={30} />
|
||||
<Text align="center">{(data.adsBlockedTodayPercentage * 100).toFixed(2)}%</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 175, 218, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 175, 218, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconSearch size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.dnsQueriesToday, 3)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.queriesToday')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 176, 96, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 176, 96, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconWorldWww size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.domainsBeingBlocked, 0)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.domainsOnAdlist')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Container h="100%" p={0} style={constructContainerStyle(widget.properties.layout)}>
|
||||
{stats.map((item) => (
|
||||
<StatCard item={item} usePiHoleColors={widget.properties.usePiHoleColors} data={data} />
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: IconBarrierBlock,
|
||||
value: (x) => formatNumber(x.adsBlockedToday, 2),
|
||||
label: 'card.metrics.queriesBlockedToday',
|
||||
color: 'rgba(240, 82, 60, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconPercentage,
|
||||
value: (x) => formatPercentage(x.adsBlockedTodayPercentage, 2),
|
||||
color: 'rgba(255, 165, 20, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
value: (x) => formatNumber(x.dnsQueriesToday, 2),
|
||||
label: 'card.metrics.queriesToday',
|
||||
color: 'rgba(0, 175, 218, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconWorldWww,
|
||||
value: (x) => formatNumber(x.domainsBeingBlocked, 2),
|
||||
label: 'card.metrics.domainsOnAdlist',
|
||||
color: 'rgba(0, 176, 96, 0.4)',
|
||||
},
|
||||
] satisfies StatItem[];
|
||||
|
||||
type StatItem = {
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
value: (x: RouterOutputs['dnsHole']['summary']) => string;
|
||||
label?: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const useDnsHoleSummeryQuery = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
@@ -194,4 +111,71 @@ export const useDnsHoleSummeryQuery = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type StatCardProps = {
|
||||
item: StatItem;
|
||||
data: RouterOutputs['dnsHole']['summary'];
|
||||
usePiHoleColors: boolean;
|
||||
};
|
||||
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||
const { t } = useTranslation('modules/dns-hole-summary');
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isLong = width > height + 20;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
m="0.4rem"
|
||||
p="0.2rem"
|
||||
bg={usePiHoleColors ? item.color : 'rgba(96, 96, 96, 0.1)'}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="space-evenly"
|
||||
direction={isLong ? 'row' : 'column'}
|
||||
>
|
||||
<item.icon size={30} style={{ margin: '0 10' }} />
|
||||
<Flex
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{
|
||||
flex: isLong ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text align="center" lh={1.2} size="md" weight="bold">
|
||||
{item.value(data)}
|
||||
</Text>
|
||||
{item.label && (
|
||||
<Text align="center" lh={1.2} size="0.75rem">
|
||||
{t<string>(item.label)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const constructContainerStyle = (flexLayout: (typeof availableLayouts)[number]) => {
|
||||
if (flexLayout === 'grid') {
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: flexLayout,
|
||||
};
|
||||
};
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -14,6 +14,7 @@ import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
import weather from './weather/WeatherTile';
|
||||
import notebook from './notebook/NotebookWidgetTile';
|
||||
|
||||
export default {
|
||||
calendar,
|
||||
@@ -32,4 +33,5 @@ export default {
|
||||
'dns-hole-summary': dnsHoleSummary,
|
||||
'dns-hole-controls': dnsHoleControls,
|
||||
bookmark,
|
||||
notebook,
|
||||
};
|
||||
|
||||
163
src/widgets/notebook/NotebookEditor.tsx
Normal file
163
src/widgets/notebook/NotebookEditor.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ActionIcon, createStyles, rem } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Link, RichTextEditor } from '@mantine/tiptap';
|
||||
import { IconArrowUp, IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { BubbleMenu, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { INotebookWidget } from './NotebookWidgetTile';
|
||||
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
});
|
||||
|
||||
export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
const [content, setContent] = useState(widget.properties.content);
|
||||
|
||||
const { enabled } = useEditModeStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { primaryColor } = useColorTheme();
|
||||
|
||||
const { mutateAsync } = api.notebook.update.useMutation();
|
||||
|
||||
const [debouncedContent] = useDebouncedValue(content, 500);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Link],
|
||||
content,
|
||||
editable: false,
|
||||
onUpdate: (e) => {
|
||||
setContent(e.editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditToggle = (previous: boolean) => {
|
||||
const current = !previous;
|
||||
if (!editor) return current;
|
||||
editor.setEditable(current);
|
||||
|
||||
updateConfig(
|
||||
configName!,
|
||||
(previous) => {
|
||||
const currentWidget = previous.widgets.find((x) => x.id === widget.id);
|
||||
currentWidget!.properties.content = debouncedContent;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
widgets: [
|
||||
...previous.widgets.filter((iterationWidget) => iterationWidget.id !== widget.id),
|
||||
currentWidget!,
|
||||
],
|
||||
};
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
void mutateAsync({
|
||||
configName: configName!,
|
||||
content: debouncedContent,
|
||||
widgetId: widget.id,
|
||||
});
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
if (!config || !configName) return <WidgetLoading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<ActionIcon
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
top={7}
|
||||
right={7}
|
||||
pos="absolute"
|
||||
color={primaryColor}
|
||||
variant="light"
|
||||
size={30}
|
||||
radius={'md'}
|
||||
onClick={() => setIsEditing(handleEditToggle)}
|
||||
>
|
||||
{isEditing ? <IconEditOff size={20} /> : <IconEdit size={20} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
editor={editor}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
'& .ProseMirror': {
|
||||
padding: '0 !important',
|
||||
},
|
||||
border: 'none',
|
||||
},
|
||||
toolbar: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
paddingTop: 0,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display: isEditing && widget.properties.showToolbar === true ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Strikethrough />
|
||||
<RichTextEditor.ClearFormatting />
|
||||
<RichTextEditor.Code />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.H1 />
|
||||
<RichTextEditor.H2 />
|
||||
<RichTextEditor.H3 />
|
||||
<RichTextEditor.H4 />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Blockquote />
|
||||
<RichTextEditor.Hr />
|
||||
<RichTextEditor.BulletList />
|
||||
<RichTextEditor.OrderedList />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Link />
|
||||
<RichTextEditor.Unlink />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</RichTextEditor.Toolbar>
|
||||
{editor && (
|
||||
<BubbleMenu editor={editor}>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Link />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
<RichTextEditor.Content />
|
||||
</RichTextEditor>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal file
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IconNotes } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const Editor = dynamic(() => import('./NotebookEditor').then((module) => module.Editor), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'notebook',
|
||||
icon: IconNotes,
|
||||
options: {
|
||||
showToolbar: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
hide: true,
|
||||
defaultValue: `<h2>Welcome to <strong>Homarr's</strong> notebook widget</h2><p>The <code>notebook</code> widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users. It is based on <a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev/">Tiptap.dev</a> and supports all of its features:</p><ul><li><p>General text formatting: <strong>bold</strong>, <em>italic</em>, underline, <s>strike-through</s></p></li><li><p>Headings (h1-h6)</p></li><li><p>Sub and super scripts (<sup /> and <sub /> tags)</p></li><li><p>Ordered and bullet lists</p></li><li><p>Text align </p></li></ul><h3>Widget options</h3><p>This widget has two options :</p><ul><li><p>Show toolbar : Shows the toolbar when the widget is in the local edit mode.</p></li></ul>`,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
component: NotebookWidget,
|
||||
});
|
||||
|
||||
export default definition;
|
||||
|
||||
export type INotebookWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface NotebookWidgetProps {
|
||||
widget: INotebookWidget;
|
||||
}
|
||||
|
||||
function NotebookWidget(props: NotebookWidgetProps) {
|
||||
return <Editor widget={props.widget} />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure, useElementSize } from '@mantine/hooks';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
||||
|
||||
import { calculateETA } from '../../tools/client/calculateEta';
|
||||
import { humanFileSize } from '../../tools/humanFileSize';
|
||||
@@ -32,19 +34,20 @@ import { AppType } from '../../types/app';
|
||||
interface TorrentQueueItemProps {
|
||||
torrent: NormalizedTorrent;
|
||||
app?: AppType;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) => {
|
||||
export const BitTorrrentQueueItem = ({ torrent, width, app }: TorrentQueueItemProps) => {
|
||||
const [popoverOpened, { open: openPopover, close: closePopover }] = useDisclosure(false);
|
||||
const theme = useMantineTheme();
|
||||
const { width } = useElementSize();
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation('modules/torrents-status');
|
||||
|
||||
const downloadSpeed = torrent.downloadSpeed / 1024 / 1024;
|
||||
const uploadSpeed = torrent.uploadSpeed / 1024 / 1024;
|
||||
const size = torrent.totalSelected;
|
||||
return (
|
||||
<tr key={torrent.id}>
|
||||
<tr key={torrent.id} className={classes.transparentBackground}>
|
||||
<td>
|
||||
<Popover opened={popoverOpened} radius="md" shadow="md" width={350} withinPortal>
|
||||
<Popover.Dropdown>
|
||||
@@ -74,25 +77,33 @@ export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) =>
|
||||
</Popover>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs">{humanFileSize(size, false)}</Text>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{humanFileSize(size, false)}
|
||||
</Text>
|
||||
</td>
|
||||
{theme.fn.largerThan('xs') && (
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text size="xs">{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{downloadSpeed > 0 ? `${downloadSpeed.toFixed(1)} Mb/s` : '-'}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
{theme.fn.largerThan('xs') && (
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text size="xs">{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}</Text>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{uploadSpeed > 0 ? `${uploadSpeed.toFixed(1)} Mb/s` : '-'}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
{theme.fn.largerThan('xs') && (
|
||||
{width > MIN_WIDTH_MOBILE && (
|
||||
<td>
|
||||
<Text size="xs">{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}</Text>
|
||||
<Text className={classes.noTextBreak} size="xs">
|
||||
{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}
|
||||
</Text>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Text>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Text className={classes.noTextBreak}>{(torrent.progress * 100).toFixed(1)}%</Text>
|
||||
<Progress
|
||||
radius="lg"
|
||||
color={torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'}
|
||||
@@ -104,7 +115,7 @@ export const BitTorrrentQueueItem = ({ torrent, app }: TorrentQueueItemProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => {
|
||||
const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
|
||||
const { t } = useTranslation('modules/torrents-status');
|
||||
const { colors } = useMantineTheme();
|
||||
|
||||
@@ -219,3 +230,12 @@ const TorrentQueuePopover = ({ torrent, app }: TorrentQueueItemProps) => {
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
noTextBreak: {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
transparentBackground: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
|
||||
import {
|
||||
Badge,
|
||||
Center,
|
||||
@@ -17,11 +17,12 @@ import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useCardStyles } from '~/components/layout/Common/useCardStyles';
|
||||
|
||||
import { MIN_WIDTH_MOBILE } from '../../constants/constants';
|
||||
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
||||
import { NormalizedDownloadQueueResponse } from '../../types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||
import { AppIntegrationType } from '../../types/app';
|
||||
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { BitTorrrentQueueItem } from './TorrentQueueItem';
|
||||
@@ -70,6 +71,7 @@ interface TorrentTileProps {
|
||||
function TorrentTile({ widget }: TorrentTileProps) {
|
||||
const { t } = useTranslation('modules/torrents-status');
|
||||
const { width, ref } = useElementSize();
|
||||
const { classes } = useCardStyles(true);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -154,11 +156,11 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTorrents.map((torrent, index) => (
|
||||
<BitTorrrentQueueItem key={index} torrent={torrent} app={undefined} />
|
||||
<BitTorrrentQueueItem key={index} torrent={torrent} width={width} app={undefined} />
|
||||
))}
|
||||
|
||||
{filteredTorrents.length !== torrents.length && (
|
||||
<tr>
|
||||
<tr className={classes.card}>
|
||||
<td colSpan={width > MIN_WIDTH_MOBILE ? 6 : 3}>
|
||||
<Flex gap="xs" align="center" justify="center">
|
||||
<IconInfoCircle opacity={0.7} size={18} />
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
IconSun,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
|
||||
interface WeatherIconProps {
|
||||
code: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,16 +23,17 @@ interface WeatherIconProps {
|
||||
* @param code weather code from api
|
||||
* @returns weather tile component
|
||||
*/
|
||||
export const WeatherIcon = ({ code }: WeatherIconProps) => {
|
||||
export const WeatherIcon = ({ code, size=50 }: WeatherIconProps) => {
|
||||
const { t } = useTranslation('modules/weather');
|
||||
|
||||
const { width, ref } = useElementSize();
|
||||
|
||||
const { icon: Icon, name } =
|
||||
weatherDefinitions.find((wd) => wd.codes.includes(code)) ?? unknownWeather;
|
||||
|
||||
return (
|
||||
<Tooltip withinPortal withArrow label={t(`card.weatherDescriptions.${name}`)}>
|
||||
<Box>
|
||||
<Icon style={{ float: 'left' }} size={50} />
|
||||
<Icon style={{ float: 'left' }} size={size} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Center, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { Center, Flex, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconArrowDownRight, IconArrowUpRight, IconCloudRain } from '@tabler/icons-react';
|
||||
import {
|
||||
IconArrowDownRight,
|
||||
IconArrowUpRight,
|
||||
IconCloudRain,
|
||||
IconCurrentLocation,
|
||||
IconMapPin,
|
||||
} from '@tabler/icons-react';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
@@ -15,6 +21,10 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
displayCityName: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
location: {
|
||||
type: 'location',
|
||||
defaultValue: {
|
||||
@@ -75,21 +85,27 @@ function WeatherTile({ widget }: WeatherTileProps) {
|
||||
// TODO: add widgetWrapper that is generic and uses the definition
|
||||
return (
|
||||
<Stack
|
||||
ref={ref}
|
||||
spacing="xs"
|
||||
justify="space-around"
|
||||
align="center"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
justify="space-around"
|
||||
ref={ref}
|
||||
spacing={0}
|
||||
align="center"
|
||||
>
|
||||
<Group align="center" position="center" spacing="xs">
|
||||
<WeatherIcon code={weather.current_weather.weathercode} />
|
||||
<Title>
|
||||
<Flex
|
||||
align="center"
|
||||
gap={width < 120 ? '0.25rem' : 'xs'}
|
||||
justify={'center'}
|
||||
direction={width < 200 ? 'column' : 'row'}
|
||||
>
|
||||
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
|
||||
<Title size={'h2'}>
|
||||
{getPerferedUnit(
|
||||
weather.current_weather.temperature,
|
||||
widget.properties.displayInFahrenheit
|
||||
)}
|
||||
</Title>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{width > 200 && (
|
||||
<Group noWrap spacing="xs">
|
||||
<IconArrowUpRight />
|
||||
@@ -104,6 +120,13 @@ function WeatherTile({ widget }: WeatherTileProps) {
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{widget.properties.displayCityName && (
|
||||
<Group noWrap spacing={5} align="center">
|
||||
<IconMapPin height={15} width={15} />
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{widget.properties.location.name}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,18 +42,20 @@ export type IWidgetOptionValue = (
|
||||
| IDraggableEditableListInputValue<any>
|
||||
| IMultipleTextInputOptionValue
|
||||
| ILocationOptionValue
|
||||
) & ICommonWidgetOptions;
|
||||
) &
|
||||
ICommonWidgetOptions;
|
||||
|
||||
// Interface for data type
|
||||
interface DataType {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ICommonWidgetOptions {
|
||||
interface ICommonWidgetOptions {
|
||||
info?: boolean;
|
||||
hide?: boolean;
|
||||
infoLink?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// will show a multi-select with specified data
|
||||
export type IMultiSelectOptionValue = {
|
||||
@@ -67,7 +69,7 @@ export type IMultiSelectOptionValue = {
|
||||
export type ISelectOptionValue = {
|
||||
type: 'select';
|
||||
defaultValue: string;
|
||||
data: DataType[];
|
||||
data: DataType[] | (() => DataType[]);
|
||||
inputProps?: Partial<SelectProps>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user