Merge branch 'dev' of https://github.com/ajnart/homarr into widget-full-translation-support

This commit is contained in:
Tagaishi
2023-08-13 17:46:18 +02:00
189 changed files with 3599 additions and 2022 deletions

View File

@@ -205,7 +205,7 @@ export const EditAppModal = ({
<NetworkTab form={form} />
<AppearanceTab
form={form}
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
disallowAppNamePropagation={() => setAllowAppNamePropagation(false)}
allowAppNamePropagation={allowAppNamePropagation}
/>
<IntegrationTab form={form} />

View File

@@ -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>

View File

@@ -95,6 +95,7 @@ export const AvailableElementTypes = ({
iconUrl: '/imgs/logo/logo.png',
appNameStatus: 'normal',
positionAppName: 'column',
lineClampAppName: 1,
},
network: {
enabledStatusChecker: true,

View File

@@ -16,32 +16,27 @@ export const AppPing = ({ app }: AppPingProps) => {
const { config } = useConfigContext();
const { data, isFetching, isError, error, isActive } = usePing(app);
const tooltipLabel = useTooltipLabel({isFetching, isError, data, errorMessage: error?.message})
const tooltipLabel = useTooltipLabel({ isFetching, isError, data, errorMessage: error?.message });
const isOnline = isError ? false : data?.state === 'online';
const pulse = usePingPulse({isOnline});
const pulse = usePingPulse({ isOnline });
if (!isActive) return null;
const replaceDotWithIcon =
config?.settings.customization.accessibility?.replacePingDotsWithIcons ?? false;
config?.settings.customization.accessibility?.replacePingDotsWithIcons ?? false;
return (
<motion.div
style={{
position: 'absolute',
bottom: replaceDotWithIcon ? 5 : 20,
bottom: replaceDotWithIcon ? 0 : 20,
right: replaceDotWithIcon ? 8 : 20,
zIndex: 2,
}}
animate={pulse.animate}
transition={pulse.transition}
>
<Tooltip
withinPortal
radius="lg"
label={tooltipLabel}
>
<Tooltip withinPortal radius="lg" label={tooltipLabel}>
{replaceDotWithIcon ? (
<Box>
<AccessibleIndicatorPing isFetching={isFetching} isOnline={isOnline} />
@@ -61,12 +56,9 @@ export const AppPing = ({ app }: AppPingProps) => {
type AccessibleIndicatorPingProps = {
isOnline: boolean;
isFetching: boolean;
}
};
const AccessibleIndicatorPing = ({
isFetching,
isOnline,
}: AccessibleIndicatorPingProps) => {
const AccessibleIndicatorPing = ({ isFetching, isOnline }: AccessibleIndicatorPingProps) => {
if (isOnline) {
return <IconCheck color="green" />;
}
@@ -90,54 +82,68 @@ type TooltipLabelProps = {
isError: boolean;
data: RouterOutputs['app']['ping'] | undefined;
errorMessage: string | undefined;
}
};
const useTooltipLabel = ({isFetching, isError, data, errorMessage}: TooltipLabelProps) => {
const useTooltipLabel = ({ isFetching, isError, data, errorMessage }: TooltipLabelProps) => {
const { t } = useTranslation('modules/ping');
if (isFetching) return t('states.loading');
if (isError) return errorMessage;
if (isError) return errorMessage;
if (data?.state === 'online') return t('states.online', { response: data?.status ?? 'N/A' });
return `${data?.statusText}: ${data?.status} (denied)`;
}
};
const usePing = (app: AppType) => {
const { config, name } = useConfigContext();
const isActive = (config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
false;
const isActive =
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
false;
const queryResult = api.app.ping.useQuery({
id: app.id,
configName: name ?? ''
}, {
retry: false,
enabled: isActive,
select: (data) => {
const isOk = isStatusOk(app, data.status);
if (isOk)
Consola.info(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Accepted)`);
else
Consola.warn(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Refused)`);
return {
status: data.status,
state: isOk ? ('online' as const) : ('down' as const),
statusText: data.statusText,
};
const queryResult = api.app.ping.useQuery(
{
id: app.id,
configName: name ?? '',
},
});
{
retry: false,
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,
enabled: isActive,
select: (data) => {
const isOk = isStatusOk(app, data.status);
if (isOk)
Consola.info(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Accepted)`);
else
Consola.warn(`Ping of app "${app.name}" (${app.url}) returned ${data.status} (Refused)`);
return {
status: data.status,
state: isOk ? ('online' as const) : ('down' as const),
statusText: data.statusText,
};
},
}
);
return {
...queryResult,
isActive
}
}
isActive,
};
};
type PingPulse = {
animate?: TargetAndTransition;
transition?: Transition;
}
};
const usePingPulse = ({isOnline}: {isOnline: boolean}): PingPulse => {
const usePingPulse = ({ isOnline }: { isOnline: boolean }): PingPulse => {
const { config } = useConfigContext();
const disablePulse = config?.settings.customization.accessibility?.disablePingPulse ?? false;
@@ -147,12 +153,12 @@ const usePingPulse = ({isOnline}: {isOnline: boolean}): PingPulse => {
return {
animate: {
scale: isOnline ? [1, 0.7, 1] : 1
scale: isOnline ? [1, 0.7, 1] : 1,
},
transition: {
repeat: Infinity,
duration: 2.5,
ease: 'easeInOut',
}
}
}
duration: 2.5,
ease: 'easeInOut',
},
};
};

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/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={1}
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,
},
}));

View File

@@ -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>

View File

@@ -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 (

View File

@@ -41,7 +41,7 @@ export const ConfigProvider = ({
setPrimaryColor(currentConfig?.settings.customization.colors.primary || 'red');
setSecondaryColor(currentConfig?.settings.customization.colors.secondary || 'orange');
setPrimaryShade(currentConfig?.settings.customization.colors.shade || 6);
}, [configName]);
}, [currentConfig]);
return (
<ConfigContext.Provider

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -130,7 +130,8 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
appearance: {
iconUrl: '/imgs/logo/logo.png',
appNameStatus: 'normal',
positionAppName: 'column'
positionAppName: 'column',
lineClampAppName: 1,
},
network: {
enabledStatusChecker: true,
@@ -188,7 +189,7 @@ const useDockerActionMutation = () => {
{ action, id: container.Id },
{
onSuccess: () => {
notifications.show({
notifications.update({
id: container.Id,
title: containerName,
message: `${t(`actions.${action}.end`)} ${containerName}`,

View File

@@ -109,57 +109,52 @@ function App(
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</Head>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: asyncStoragePersister }}
>
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<ColorTheme.Provider value={colorTheme}>
<MantineProvider
theme={{
...theme,
components: {
Checkbox: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<ColorTheme.Provider value={colorTheme}>
<MantineProvider
theme={{
...theme,
components: {
Checkbox: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
primaryColor,
primaryShade,
colorScheme,
}}
withGlobalStyles
withNormalizeCSS
>
<ConfigProvider {...props.pageProps}>
<Notifications limit={4} position="bottom-left" />
<ModalsProvider
modals={{
editApp: EditAppModal,
selectElement: SelectElementModal,
integrationOptions: WidgetsEditModal,
integrationRemove: WidgetsRemoveModal,
categoryEditModal: CategoryEditModal,
changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeWidgetPositionModal,
}}
>
<Component {...pageProps} />
</ModalsProvider>
</ConfigProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
},
primaryColor,
primaryShade,
colorScheme,
}}
withGlobalStyles
withNormalizeCSS
>
<ConfigProvider {...props.pageProps}>
<Notifications limit={4} position="bottom-left" />
<ModalsProvider
modals={{
editApp: EditAppModal,
selectElement: SelectElementModal,
integrationOptions: WidgetsEditModal,
integrationRemove: WidgetsRemoveModal,
categoryEditModal: CategoryEditModal,
changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeWidgetPositionModal,
}}
>
<Component {...pageProps} />
</ModalsProvider>
</ConfigProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
<ReactQueryDevtools initialIsOpen={false} />
</>
);
}

83
src/pages/_error.tsx Normal file
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;

View File

@@ -4,7 +4,7 @@ import { appRouter } from './routers/app';
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 { dockerRouter } from './routers/docker/router';
import { downloadRouter } from './routers/download';
import { iconRouter } from './routers/icon';
@@ -15,6 +15,7 @@ import { rssRouter } from './routers/rss';
import { timezoneRouter } from './routers/timezone';
import { usenetRouter } from './routers/usenet/router';
import { weatherRouter } from './routers/weather';
import { notebookRouter } from './routers/notebook';
/**
* This is the primary router for your server.
@@ -37,6 +38,7 @@ export const rootRouter = createTRPCRouter({
timezone: timezoneRouter,
usenet: usenetRouter,
weather: weatherRouter,
notebook: notebookRouter
});
// export type definition of API

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,

View File

@@ -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,

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}`,
};
})
);

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');
}),
});

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)}%`;
};

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,
}
}))
}
}

View File

@@ -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())),

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>;

View File

@@ -43,6 +43,7 @@ export const dashboardNamespaces = [
'modules/dns-hole-summary',
'modules/dns-hole-controls',
'modules/bookmark',
'modules/notebook',
'widgets/error-boundary',
'widgets/draggable-list',
'widgets/location',

View File

@@ -36,6 +36,7 @@ interface AppAppearanceType {
iconUrl: string;
appNameStatus: "normal"|"hover"|"hidden";
positionAppName: Property.FlexDirection;
lineClampAppName: number;
}
export type IntegrationType =

View File

@@ -31,6 +31,7 @@ const getTrpcConfiguration = () => ({
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
maxURLLength: 2000,
}),
],
});

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;

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,
};

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>
</>
);
}

View 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 (&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} />;
}

View File

@@ -53,6 +53,7 @@ interface DataType {
interface ICommonWidgetOptions {
info?: boolean;
hide?: boolean;
infoLink?: string;
}