🔀 Merge branch 'dev' into feature/add-basic-authentication

This commit is contained in:
Manuel committed 2023-08-13 15:12:20 +02:00
commit b249c0205f
202 files changed
+3334 -1502

No files matched your search

@@ -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)
+53 -63
View File
@@ -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) => ({
+7
View File
@@ -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
View File
@@ -0,0 +1 @@
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1022.7" height="785.81" viewBox="0 0 1022.7 785.81"><defs><linearGradient id="af83dc26-9572-4816-b7a1-1af4f72ff554-94" x1="678.2" y1="821.79" x2="678.2" y2="493.4" gradientTransform="translate(-20.24 29.65) rotate(-2.31)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="gray" stop-opacity="0.25"/><stop offset="0.54" stop-color="gray" stop-opacity="0.12"/><stop offset="1" stop-color="gray" stop-opacity="0.1"/></linearGradient></defs><title>bug fixing</title><ellipse cx="468.63" cy="660.88" rx="425" ry="33" fill="#fa5252" opacity="0.1"/><g opacity="0.1"><path d="M933.7,529.93c-2.54-7.71-12.84-11.26-23-7.92a24.76,24.76,0,0,0-4.23,1.83c-.65-.18-1.32-.34-2-.46a22.42,22.42,0,0,0,.63-6.79A24.6,24.6,0,0,0,916,495.12a24.63,24.63,0,0,0,10.86-21.47,23.76,23.76,0,0,0,8.23-9.32c4.9-9.7,2.87-20.6-4.54-24.35s-17.4,1.08-22.3,10.78a23.69,23.69,0,0,0-2.63,12.15,24.63,24.63,0,0,0-10.86,21.47,24.63,24.63,0,0,0-10.86,21.47A24.64,24.64,0,0,0,873,527.33a23.76,23.76,0,0,0-8.23,9.32,25.46,25.46,0,0,0-2.08,5.74,21.18,21.18,0,0,0-4.44-4.73,25.38,25.38,0,0,0-1-4.5c-3.34-10.17-12.3-16.37-20-13.83s-11.26,12.83-7.92,23a23.07,23.07,0,0,0,7.56,11.08,25.38,25.38,0,0,0,1,4.5c1.94,5.9,5.77,10.46,10.15,12.75a23.23,23.23,0,0,0-.83,3.9,25.29,25.29,0,0,0-7.54,14.91,25.27,25.27,0,0,0-7.54,14.92,24.63,24.63,0,0,0-5,6.81c-4.91,9.7-2.88,20.61,4.53,24.35s17.4-1.07,22.31-10.78a24.62,24.62,0,0,0,2.5-8.09,25.29,25.29,0,0,0,7.54-14.91,25.27,25.27,0,0,0,7.54-14.92A25.21,25.21,0,0,0,879,571.94,25.21,25.21,0,0,0,886.56,557a24.11,24.11,0,0,0,3.39-4,23.46,23.46,0,0,0,12.27-.77,24.76,24.76,0,0,0,4.23-1.83,23.2,23.2,0,0,0,13.42-.48C930,546.61,936.23,537.65,933.7,529.93Z" transform="translate(-88.65 -57.09)" fill="#3f3d56"/><g opacity="0.1"><path d="M915.77,499.53a21.5,21.5,0,0,1-3.44,2.75,23,23,0,0,1-1.11,8.43,26.18,26.18,0,0,0,2.1-3.43A24.64,24.64,0,0,0,915.77,499.53Z" transform="translate(-88.65 -57.09)"/><path d="M926.63,478.06a21.5,21.5,0,0,1-3.44,2.75,23,23,0,0,1-1.11,8.43,25.65,25.65,0,0,0,2.1-3.44A24.85,24.85,0,0,0,926.63,478.06Z" transform="translate(-88.65 -57.09)"/><path d="M902,470.07a21.91,21.91,0,0,1,.17-4.41,24.93,24.93,0,0,0-4.78,6.57,25.63,25.63,0,0,0-1.52,3.73A22.75,22.75,0,0,1,902,470.07Z" transform="translate(-88.65 -57.09)"/><path d="M833.07,600a23.59,23.59,0,0,1,2.95-3.38,22.92,22.92,0,0,1,.87-4,25.09,25.09,0,0,0-2.29,3.71A25.59,25.59,0,0,0,833.07,600Z" transform="translate(-88.65 -57.09)"/><path d="M840.61,585.1a23.59,23.59,0,0,1,2.95-3.38,23,23,0,0,1,.83-3.9l0,0a25.68,25.68,0,0,0-2.2,3.59A26.76,26.76,0,0,0,840.61,585.1Z" transform="translate(-88.65 -57.09)"/><path d="M904.9,521a20.82,20.82,0,0,1-3.43,2.74,22.45,22.45,0,0,1-.63,6.79,18.75,18.75,0,0,1,2,.46,24.76,24.76,0,0,1,4.23-1.83c10.18-3.34,20.47.21,23,7.92a11.68,11.68,0,0,1,.32,6c3.35-3.94,4.74-8.74,3.3-13.14-2.54-7.71-12.84-11.26-23-7.92a24.76,24.76,0,0,0-4.23,1.83c-.65-.18-1.32-.34-2-.46C904.65,522.59,904.79,521.8,904.9,521Z" transform="translate(-88.65 -57.09)"/><path d="M829.35,607.49a25.09,25.09,0,0,0-2.29,3.71,26.56,26.56,0,0,0-1.54,3.74,24.48,24.48,0,0,1,3-3.4A22.92,22.92,0,0,1,829.35,607.49Z" transform="translate(-88.65 -57.09)"/><path d="M891.09,491.54a22,22,0,0,1,.17-4.41,24.93,24.93,0,0,0-4.78,6.57,25.63,25.63,0,0,0-1.52,3.73A22.75,22.75,0,0,1,891.09,491.54Z" transform="translate(-88.65 -57.09)"/><path d="M875.4,579.1a24.29,24.29,0,0,1-1,4.38,26.67,26.67,0,0,0,2.09-3.44,25.49,25.49,0,0,0,1.63-4A24.23,24.23,0,0,1,875.4,579.1Z" transform="translate(-88.65 -57.09)"/><path d="M860.32,608.92a24.15,24.15,0,0,1-1,4.38,25.65,25.65,0,0,0,2.1-3.44,26.47,26.47,0,0,0,1.63-4A23.57,23.57,0,0,1,860.32,608.92Z" transform="translate(-88.65 -57.09)"/><path d="M867.86,594a24.11,24.11,0,0,1-1,4.38A26.67,26.67,0,0,0,869,595a25.49,25.49,0,0,0,1.63-4A24.23,24.23,0,0,1,867.86,594Z" transform="translate(-88.65 -57.09)"/><path d="M882.94,564.19a24,24,0,0,1-1,4.37,25.89,25.89,0,0,0,2.09-3.43,25.48,25.48,0,0,0,1.64-4.07A23.22,23.22,0,0,1,882.94,564.19Z" transform="translate(-88.65 -57.09)"/><path d="M833.57,526.48c7.72-2.53,16.68,3.66,20,13.83a25.68,25.68,0,0,1,1,4.51,21.18,21.18,0,0,1,4.44,4.73,25.53,25.53,0,0,1,2.08-5.75c.35-.68.73-1.33,1.12-2a21.07,21.07,0,0,0-4-4.18,25.38,25.38,0,0,0-1-4.5c-3.34-10.17-12.3-16.37-20-13.83-4.4,1.44-7.44,5.41-8.62,10.43A11.69,11.69,0,0,1,833.57,526.48Z" transform="translate(-88.65 -57.09)"/><path d="M869.37,534.48a21.85,21.85,0,0,1,.18-4.4,24.73,24.73,0,0,0-4.79,6.57,25.07,25.07,0,0,0-1.52,3.72A23,23,0,0,1,869.37,534.48Z" transform="translate(-88.65 -57.09)"/><path d="M880.23,513a21.85,21.85,0,0,1,.18-4.4,24.52,24.52,0,0,0-4.79,6.56,25.63,25.63,0,0,0-1.52,3.73A22.75,22.75,0,0,1,880.23,513Z" transform="translate(-88.65 -57.09)"/><path d="M852.78,623.83a24.25,24.25,0,0,1-1,4.39,25.73,25.73,0,0,0,3.73-7.49A23.57,23.57,0,0,1,852.78,623.83Z" transform="translate(-88.65 -57.09)"/></g><path d="M948.52,430.71a23.68,23.68,0,0,0,8.23-9.32c4.91-9.7,2.88-20.61Line truncated

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1 @@
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="860.13137" height="571.14799" viewBox="0 0 860.13137 571.14799" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M605.66974,324.95306c-7.66934-12.68446-16.7572-26.22768-30.98954-30.36953-16.482-4.7965-33.4132,4.73193-47.77473,14.13453a1392.15692,1392.15692,0,0,0-123.89338,91.28311l.04331.49238q46.22556-3.1878,92.451-6.37554c22.26532-1.53546,45.29557-3.2827,64.97195-13.8156,7.46652-3.99683,14.74475-9.33579,23.20555-9.70782,10.51175-.46217,19.67733,6.87923,26.8802,14.54931,42.60731,45.371,54.937,114.75409,102.73817,154.61591A1516.99453,1516.99453,0,0,0,605.66974,324.95306Z" transform="translate(-169.93432 -164.42601)" fill="#f2f2f2"/><path d="M867.57068,709.78146c-4.71167-5.94958-6.6369-7.343-11.28457-13.34761q-56.7644-73.41638-106.70791-151.79237-33.92354-53.23-64.48275-108.50439-14.54864-26.2781-28.29961-52.96872-10.67044-20.6952-20.8646-41.63793c-1.94358-3.98782-3.8321-7.99393-5.71122-12.00922-4.42788-9.44232-8.77341-18.93047-13.43943-28.24449-5.31686-10.61572-11.789-21.74485-21.55259-28.877a29.40493,29.40493,0,0,0-15.31855-5.89458c-7.948-.51336-15.28184,2.76855-22.17568,6.35295-50.43859,26.301-97.65922,59.27589-140.3696,96.79771A730.77816,730.77816,0,0,0,303.32241,496.24719c-1.008,1.43927-3.39164.06417-2.37419-1.38422q6.00933-8.49818,12.25681-16.81288A734.817,734.817,0,0,1,500.80465,303.06436q18.24824-11.82581,37.18269-22.54245c6.36206-3.60275,12.75188-7.15967,19.25136-10.49653,6.37146-3.27274,13.13683-6.21547,20.41563-6.32547,24.7701-.385,37.59539,27.66695,46.40506,46.54248q4.15283,8.9106,8.40636,17.76626,16.0748,33.62106,33.38729,66.628,10.68453,20.379,21.83683,40.51955,34.7071,62.71816,73.77854,122.897c34.5059,53.1429,68.73651,100.08874,108.04585,149.78472C870.59617,709.21309,868.662,711.17491,867.57068,709.78146Z" transform="translate(-169.93432 -164.42601)" fill="#e4e4e4"/><path d="M414.91613,355.804c-1.43911-1.60428-2.86927-3.20856-4.31777-4.81284-11.42244-12.63259-23.6788-25.11847-39.3644-32.36067a57.11025,57.11025,0,0,0-23.92679-5.54622c-8.56213.02753-16.93178,2.27348-24.84306,5.41792-3.74034,1.49427-7.39831,3.1902-11.00078,4.99614-4.11634,2.07182-8.15927,4.28118-12.1834,6.50883q-11.33112,6.27044-22.36816,13.09089-21.9606,13.57221-42.54566,29.21623-10.67111,8.11311-20.90174,16.75788-9.51557,8.03054-18.64618,16.492c-1.30169,1.20091-3.24527-.74255-1.94358-1.94347,1.60428-1.49428,3.22691-2.97938,4.84955-4.44613q6.87547-6.21546,13.9712-12.19257,12.93921-10.91827,26.54851-20.99312,21.16293-15.67614,43.78288-29.22541,11.30361-6.76545,22.91829-12.96259c2.33794-1.24675,4.70318-2.466,7.09572-3.6211a113.11578,113.11578,0,0,1,16.86777-6.86632,60.0063,60.0063,0,0,1,25.476-2.50265,66.32706,66.32706,0,0,1,23.50512,8.1314c15.40091,8.60812,27.34573,21.919,38.97,34.90915C418.03337,355.17141,416.09875,357.12405,414.91613,355.804Z" transform="translate(-169.93432 -164.42601)" fill="#e4e4e4"/><path d="M730.47659,486.71092l36.90462-13.498,18.32327-6.70183c5.96758-2.18267,11.92082-4.66747,18.08988-6.23036a28.53871,28.53871,0,0,1,16.37356.20862,37.73753,37.73753,0,0,1,12.771,7.91666,103.63965,103.63965,0,0,1,10.47487,11.18643c3.98932,4.79426,7.91971,9.63877,11.86772,14.46706q24.44136,29.89094,48.56307,60.04134,24.12117,30.14991,47.91981,60.556,23.85681,30.48041,47.38548,61.21573,2.88229,3.76518,5.75966,7.53415c1.0598,1.38809,3.44949.01962,2.37472-1.38808Q983.582,650.9742,959.54931,620.184q-24.09177-30.86383-48.51647-61.46586-24.42421-30.60141-49.17853-60.93743-6.16706-7.55761-12.35445-15.09858c-3.47953-4.24073-6.91983-8.52718-10.73628-12.47427-7.00539-7.24516-15.75772-13.64794-26.23437-13.82166-6.15972-.10214-12.121,1.85248-17.844,3.92287-6.16968,2.232-12.32455,4.50571-18.48633,6.75941l-37.16269,13.59243-9.29067,3.3981c-1.64875.603-.93651,3.2619.73111,2.652Z" transform="translate(-169.93432 -164.42601)" fill="#e4e4e4"/><path d="M366.37741,334.52609c-18.75411-9.63866-42.77137-7.75087-60.00508,4.29119a855.84708,855.84708,0,0,1,97.37056,22.72581C390.4603,353.75916,380.07013,341.5635,366.37741,334.52609Z" transform="translate(-169.93432 -164.42601)" fill="#f2f2f2"/><path d="M306.18775,338.7841l-3.61042,2.93462c1.22123-1.02713,2.4908-1.99013,3.795-2.90144C306.31073,338.80665,306.24935,338.79473,306.18775,338.7841Z" transform="translate(-169.93432 -164.42601)" fill="#f2f2f2"/><path d="M831.54929,486.84576c-3.6328-4.42207-7.56046-9.05222-12.99421-10.84836l-5.07308.20008A575.436,575.436,0,0,0,966.74929,651.418Q899.14929,569.13192,831.54929,486.84576Z" transform="translate(-169.93432 -164.42601)" fill="#f2f2f2"/><path d="M516.08388,450.36652A37.4811,37.4811,0,0,0,531.015,471.32518c2.82017,1.92011,6.15681,3.76209,7.12158,7.03463a8.37858,8.37858,0,0,1-.87362,6.1499,24.88351,24.88351,0,0,1-3.86126,5.04137l-.13667.512c-6.99843-4.14731-13.65641-9.3934-17.52227-16.55115s-4.40553-16.53895.34116-23.14544" transform="translate(-169.93432 -164.42601)" fill="#f2f2f2"/><path d="M749.08388,653.36652A37.4811,37.4811,0,0,0,764.015,674.32518c2.82017,1.92011,6.15681,3.76209,7Line truncated

After

Width:  |  Height:  |  Size: 23 KiB

+1 -8
View File
@@ -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}`,
+36 -87
View File
@@ -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',
},
}));
+9
View File
@@ -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
View 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;
+9 -5
View File
@@ -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
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
View 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
View 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];
}),
})
+4
View File
@@ -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)}%`;
};
+17
View File
@@ -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,
}
}))
}
}
+52 -35
View File
@@ -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())),
+2 -16
View File
@@ -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'];
+1
View File
@@ -36,6 +36,7 @@ interface AppAppearanceType {
iconUrl: string;
appNameStatus: "normal"|"hover"|"hidden";
positionAppName: Property.FlexDirection;
lineClampAppName: number;
}
export type IntegrationType =
+1
View File
@@ -33,6 +33,7 @@ const getTrpcConfiguration = () => ({
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
maxURLLength: 2000,
}),
],
queryClient,
+114 -28
View File
@@ -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;
+118 -134
View File
@@ -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;
+2
View File
@@ -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
View 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>
</>
);
}
@@ -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 (&lt;sup /&gt; and &lt;sub /&gt; tags)</p></li><li><p>Ordered and bullet lists</p></li><li><p>Text align&nbsp;</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} />;
}
+32 -12
View File
@@ -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',
},
}));
+6 -4
View File
@@ -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} />
+6 -3
View File
@@ -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>
);
+33 -10
View File
@@ -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>
);
}
+7 -5
View File
@@ -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>;
};