Merge branch 'dev' of https://github.com/ajnart/homarr into widget-full-translation-support
This commit is contained in:
@@ -205,7 +205,7 @@ export const EditAppModal = ({
|
||||
<NetworkTab form={form} />
|
||||
<AppearanceTab
|
||||
form={form}
|
||||
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
|
||||
disallowAppNamePropagation={() => setAllowAppNamePropagation(false)}
|
||||
allowAppNamePropagation={allowAppNamePropagation}
|
||||
/>
|
||||
<IntegrationTab form={form} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flex, Select, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { Flex, NumberInput, Select, Stack, Switch, Tabs } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -9,13 +9,13 @@ import { IconSelector } from '../../../../../IconSelector/IconSelector';
|
||||
|
||||
interface AppearanceTabProps {
|
||||
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
|
||||
disallowAppNameProgagation: () => void;
|
||||
disallowAppNamePropagation: () => void;
|
||||
allowAppNamePropagation: boolean;
|
||||
}
|
||||
|
||||
export const AppearanceTab = ({
|
||||
form,
|
||||
disallowAppNameProgagation,
|
||||
disallowAppNamePropagation,
|
||||
allowAppNamePropagation,
|
||||
}: AppearanceTabProps) => {
|
||||
const iconSelectorRef = useRef();
|
||||
@@ -46,7 +46,7 @@ export const AppearanceTab = ({
|
||||
defaultValue={form.values.appearance.iconUrl}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.iconUrl', value);
|
||||
disallowAppNameProgagation();
|
||||
disallowAppNamePropagation();
|
||||
}}
|
||||
value={form.values.appearance.iconUrl}
|
||||
ref={iconSelectorRef}
|
||||
@@ -66,26 +66,41 @@ export const AppearanceTab = ({
|
||||
}}
|
||||
/>
|
||||
{form.values.appearance.appNameStatus === 'normal' && (
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
data={[
|
||||
{ value: 'column', label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
},
|
||||
{
|
||||
value: 'column-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.bottom') as string,
|
||||
},
|
||||
{ value: 'row', label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.positionAppName', value);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
data={[
|
||||
{
|
||||
value: 'column',
|
||||
label: t('appearance.positionAppName.dropdown.top') as string },
|
||||
{
|
||||
value: 'row-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.right') as string,
|
||||
},
|
||||
{
|
||||
value: 'column-reverse',
|
||||
label: t('appearance.positionAppName.dropdown.bottom') as string,
|
||||
},
|
||||
{
|
||||
value: 'row',
|
||||
label: t('appearance.positionAppName.dropdown.left') as string },
|
||||
]}
|
||||
{...form.getInputProps('appearance.positionAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.positionAppName', value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('appearance.lineClampAppName.label')}
|
||||
description={t('appearance.lineClampAppName.description')}
|
||||
min={0}
|
||||
{...form.getInputProps('appearance.lineClampAppName')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.lineClampAppName', value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -95,6 +95,7 @@ export const AvailableElementTypes = ({
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
appNameStatus: 'normal',
|
||||
positionAppName: 'column',
|
||||
lineClampAppName: 1,
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: true,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
1
src/images/undraw_bug_fixing_oc-7-a.svg
Normal file
1
src/images/undraw_bug_fixing_oc-7-a.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
@@ -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}`,
|
||||
|
||||
@@ -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
83
src/pages/_error.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Accordion,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
createStyles,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceDesktop, IconInfoCircle, IconServer } from '@tabler/icons-react';
|
||||
import { NextPageContext } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import imageBugFixing from '~/images/undraw_bug_fixing_oc-7-a.svg';
|
||||
|
||||
function Error({ statusCode }: { statusCode: number }) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
const getColor = (color: string) => theme.colors[color][theme.colorScheme === 'dark' ? 5 : 7];
|
||||
return (
|
||||
<Center className={classes.root} h="100dvh" maw={400}>
|
||||
<Head>
|
||||
<title>An error occurred • Homarr</title>
|
||||
</Head>
|
||||
<Stack>
|
||||
<Image className={classes.image} src={imageBugFixing} alt="bug illustration" />
|
||||
<Title>An unexpected error has occurred</Title>
|
||||
<Text>
|
||||
This page has crashed unexpectedly. We're sorry for the inconvenience. Please try again or
|
||||
contact an administrator
|
||||
</Text>
|
||||
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="detailed">
|
||||
<Accordion.Control icon={<IconInfoCircle color={getColor('red')} size="1rem" />}>
|
||||
Detailed error information
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="xs">
|
||||
<Group position="apart">
|
||||
<Text fw="bold">Type</Text>
|
||||
<Text>
|
||||
{statusCode ? (
|
||||
<Group spacing="xs">
|
||||
<IconServer size="1rem" />
|
||||
<Text>Server side</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group spacing="xs">
|
||||
<IconDeviceDesktop size="1rem" />
|
||||
<Text>Client side</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
Error.getInitialProps = ({ res, err }: NextPageContext) => {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { statusCode };
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
root: {
|
||||
margin: '0 auto',
|
||||
},
|
||||
image: {
|
||||
maxWidth: 400,
|
||||
maxHeight: 200,
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
},
|
||||
}));
|
||||
|
||||
export default Error;
|
||||
@@ -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
|
||||
|
||||
@@ -23,11 +23,11 @@ export const appRouter = createTRPCRouter({
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
cause: input,
|
||||
message: `App ${input} was not found`,
|
||||
message: `App ${input.id} was not found`,
|
||||
});
|
||||
}
|
||||
const res = await axios
|
||||
.get(app.url, { httpsAgent: agent, timeout: 2000 })
|
||||
.get(app.url, { httpsAgent: agent, timeout: 10000 })
|
||||
.then((response) => ({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PiHoleClient } from '~/tools/server/sdk/pihole/piHole';
|
||||
import { ConfigAppType } from '~/types/app';
|
||||
import { AdStatistics } from '~/widgets/dnshole/type';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { createTRPCRouter, publicProcedure } from '../../trpc';
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
control: publicProcedure
|
||||
@@ -135,8 +135,14 @@ const collectAdGuardSummary = async (app: ConfigAppType) => {
|
||||
const status = await adGuard.getStatus();
|
||||
const countFilteredDomains = await adGuard.getCountFilteringDomains();
|
||||
|
||||
const blockedQueriesToday = stats.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday = stats.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
const blockedQueriesToday =
|
||||
stats.time_units === 'days'
|
||||
? stats.blocked_filtering[stats.blocked_filtering.length - 1]
|
||||
: stats.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday =
|
||||
stats.time_units === 'days'
|
||||
? stats.dns_queries[stats.dns_queries.length - 1]
|
||||
: stats.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
|
||||
return {
|
||||
domainsBeingBlocked: countFilteredDomains,
|
||||
@@ -65,7 +65,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
status: item.status,
|
||||
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
||||
posterPath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${genericItem.posterPath}`,
|
||||
href: `${appUrl}/movie/${item.media.tmdbId}`,
|
||||
href: `${appUrl}/${item.type}/${item.media.tmdbId}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
37
src/server/api/routers/notebook.ts
Normal file
37
src/server/api/routers/notebook.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { BackendConfigType } from '~/types/config';
|
||||
import { INotebookWidget } from '~/widgets/notebook/NotebookWidgetTile';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const notebookRouter = createTRPCRouter({
|
||||
update: publicProcedure
|
||||
.input(z.object({ widgetId: z.string(), content: z.string(), configName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
const widget = config.widgets.find((widget) => widget.id === input.widgetId) as
|
||||
| INotebookWidget
|
||||
| undefined;
|
||||
|
||||
if (!widget) {
|
||||
return new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Specified widget was not found',
|
||||
});
|
||||
}
|
||||
|
||||
widget.properties.content = input.content;
|
||||
|
||||
const newConfig: BackendConfigType = {
|
||||
...config,
|
||||
widgets: [...config.widgets.filter((w) => w.id !== widget.id), widget],
|
||||
};
|
||||
|
||||
const targetPath = path.join('data/configs', `${input.configName}.json`);
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
});
|
||||
@@ -16,3 +16,7 @@ export const formatNumber = (n: number, decimalPlaces: number) => {
|
||||
}
|
||||
return n.toFixed(decimalPlaces);
|
||||
};
|
||||
|
||||
export const formatPercentage = (n: number, decimalPlaces: number) => {
|
||||
return `${(n * 100).toFixed(decimalPlaces)}%`;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export const getFrontendConfig = async (name: string): Promise<ConfigType> => {
|
||||
let config = getConfig(name);
|
||||
let shouldMigrateConfig = false;
|
||||
|
||||
config = migrateAppConfigs(config);
|
||||
|
||||
const anyWeatherWidgetWithStringLocation = config.widgets.some(
|
||||
(widget) => widget.type === 'weather' && typeof widget.properties.location === 'string'
|
||||
);
|
||||
@@ -129,3 +131,18 @@ const migratePiholeIntegrationField = (config: BackendConfigType) => {
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const migrateAppConfigs = (config: BackendConfigType) => {
|
||||
return {
|
||||
...config,
|
||||
apps: config.apps.map((app) => ({
|
||||
...app,
|
||||
appearance: {
|
||||
...app.appearance,
|
||||
appNameStatus: app.appearance.appNameStatus?? 'normal',
|
||||
positionAppName: app.appearance.positionAppName?? 'column',
|
||||
lineClampAppName: app.appearance.lineClampAppName?? 1,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const adGuardApiStatsResponseSchema = z.object({
|
||||
time_units: z.enum(['hours']),
|
||||
time_units: z.enum(['hours', 'days']),
|
||||
top_queried_domains: z.array(z.record(z.string(), z.number())),
|
||||
top_clients: z.array(z.record(z.string(), z.number())),
|
||||
top_blocked_domains: z.array(z.record(z.string(), z.number())),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { trimStringEnding } from '../../../shared/strings';
|
||||
import {
|
||||
adGuardApiFilteringStatusSchema,
|
||||
@@ -77,19 +78,4 @@ export class AdGuard {
|
||||
}
|
||||
}
|
||||
|
||||
export type AdGuardStatsType = {
|
||||
time_units: string;
|
||||
top_queried_domains: { [key: string]: number }[];
|
||||
top_clients: { [key: string]: number }[];
|
||||
top_blocked_domains: { [key: string]: number }[];
|
||||
dns_queries: number[];
|
||||
blocked_filtering: number[];
|
||||
replaced_safebrowsing: number[];
|
||||
replaced_parental: number[];
|
||||
num_dns_queries: number;
|
||||
num_blocked_filtering: number;
|
||||
num_replaced_safebrowsing: number;
|
||||
num_replaced_safesearch: number;
|
||||
num_replaced_parental: number;
|
||||
avg_processing_time: number;
|
||||
};
|
||||
export type AdGuardStatsType = z.infer<typeof adGuardApiStatsResponseSchema>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AppAppearanceType {
|
||||
iconUrl: string;
|
||||
appNameStatus: "normal"|"hover"|"hidden";
|
||||
positionAppName: Property.FlexDirection;
|
||||
lineClampAppName: number;
|
||||
}
|
||||
|
||||
export type IntegrationType =
|
||||
|
||||
@@ -31,6 +31,7 @@ const getTrpcConfiguration = () => ({
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
maxURLLength: 2000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Card, Center, Container, Stack, Text } from '@mantine/core';
|
||||
import { Box, Card, Center, Container, Flex, Text } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import {
|
||||
IconAd,
|
||||
IconBarrierBlock,
|
||||
IconPercentage,
|
||||
IconSearch,
|
||||
IconWorldWww,
|
||||
TablerIconsProps,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { api } from '~/utils/api';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
import { formatNumber } from '../../tools/client/math';
|
||||
import { formatNumber, formatPercentage } from '../../tools/client/math';
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const availableLayouts = ['grid', 'row', 'column'] as const;
|
||||
type AvailableLayout = (typeof availableLayouts)[number];
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dns-hole-summary',
|
||||
icon: IconAd,
|
||||
@@ -23,10 +28,15 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
layout: {
|
||||
type: 'select',
|
||||
defaultValue: 'grid' as AvailableLayout,
|
||||
data: availableLayouts.map((x) => ({ value: x })),
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
minHeight: 1,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
@@ -40,7 +50,6 @@ interface DnsHoleSummaryWidgetProps {
|
||||
}
|
||||
|
||||
function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
const { t } = useTranslation('modules/dns-hole-summary');
|
||||
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
|
||||
|
||||
if (isInitialLoading || !data) {
|
||||
@@ -48,139 +57,47 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
display="grid"
|
||||
h="100%"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr',
|
||||
marginLeft: -20,
|
||||
marginRight: -20,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(240, 82, 60, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(240, 82, 60, 0.2)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconBarrierBlock size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.adsBlockedToday, 0)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.queriesBlockedToday')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 165, 20, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 165, 20, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconPercentage size={30} />
|
||||
<Text align="center">{(data.adsBlockedTodayPercentage * 100).toFixed(2)}%</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 175, 218, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 175, 218, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconSearch size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.dnsQueriesToday, 3)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.queriesToday')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Card
|
||||
m="xs"
|
||||
sx={(theme) => {
|
||||
if (!widget.properties.usePiHoleColors) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (theme.colorScheme === 'dark') {
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 176, 96, 0.4)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(0, 176, 96, 0.4)',
|
||||
};
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" spacing="xs">
|
||||
<IconWorldWww size={30} />
|
||||
<div>
|
||||
<Text align="center">{formatNumber(data.domainsBeingBlocked, 0)}</Text>
|
||||
<Text align="center" lh={1.2} size="sm">
|
||||
{t('card.metrics.domainsOnAdlist')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
<Container h="100%" p={0} style={constructContainerStyle(widget.properties.layout)}>
|
||||
{stats.map((item) => (
|
||||
<StatCard item={item} usePiHoleColors={widget.properties.usePiHoleColors} data={data} />
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: IconBarrierBlock,
|
||||
value: (x) => formatNumber(x.adsBlockedToday, 2),
|
||||
label: 'card.metrics.queriesBlockedToday',
|
||||
color: 'rgba(240, 82, 60, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconPercentage,
|
||||
value: (x) => formatPercentage(x.adsBlockedTodayPercentage, 2),
|
||||
color: 'rgba(255, 165, 20, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
value: (x) => formatNumber(x.dnsQueriesToday, 2),
|
||||
label: 'card.metrics.queriesToday',
|
||||
color: 'rgba(0, 175, 218, 0.4)',
|
||||
},
|
||||
{
|
||||
icon: IconWorldWww,
|
||||
value: (x) => formatNumber(x.domainsBeingBlocked, 2),
|
||||
label: 'card.metrics.domainsOnAdlist',
|
||||
color: 'rgba(0, 176, 96, 0.4)',
|
||||
},
|
||||
] satisfies StatItem[];
|
||||
|
||||
type StatItem = {
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
value: (x: RouterOutputs['dnsHole']['summary']) => string;
|
||||
label?: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const useDnsHoleSummeryQuery = () => {
|
||||
const { name: configName } = useConfigContext();
|
||||
|
||||
@@ -194,4 +111,71 @@ export const useDnsHoleSummeryQuery = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type StatCardProps = {
|
||||
item: StatItem;
|
||||
data: RouterOutputs['dnsHole']['summary'];
|
||||
usePiHoleColors: boolean;
|
||||
};
|
||||
const StatCard = ({ item, data, usePiHoleColors }: StatCardProps) => {
|
||||
const { t } = useTranslation('modules/dns-hole-summary');
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isLong = width > height + 20;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
m="0.4rem"
|
||||
p="0.2rem"
|
||||
bg={usePiHoleColors ? item.color : 'rgba(96, 96, 96, 0.1)'}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
withBorder
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="space-evenly"
|
||||
direction={isLong ? 'row' : 'column'}
|
||||
>
|
||||
<item.icon size={30} style={{ margin: '0 10' }} />
|
||||
<Flex
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{
|
||||
flex: isLong ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text align="center" lh={1.2} size="md" weight="bold">
|
||||
{item.value(data)}
|
||||
</Text>
|
||||
{item.label && (
|
||||
<Text align="center" lh={1.2} size="0.75rem">
|
||||
{t<string>(item.label)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const constructContainerStyle = (flexLayout: (typeof availableLayouts)[number]) => {
|
||||
if (flexLayout === 'grid') {
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: flexLayout,
|
||||
};
|
||||
};
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -14,6 +14,7 @@ import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
import weather from './weather/WeatherTile';
|
||||
import notebook from './notebook/NotebookWidgetTile';
|
||||
|
||||
export default {
|
||||
calendar,
|
||||
@@ -32,4 +33,5 @@ export default {
|
||||
'dns-hole-summary': dnsHoleSummary,
|
||||
'dns-hole-controls': dnsHoleControls,
|
||||
bookmark,
|
||||
notebook,
|
||||
};
|
||||
|
||||
163
src/widgets/notebook/NotebookEditor.tsx
Normal file
163
src/widgets/notebook/NotebookEditor.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ActionIcon, createStyles, rem } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Link, RichTextEditor } from '@mantine/tiptap';
|
||||
import { IconArrowUp, IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { BubbleMenu, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { INotebookWidget } from './NotebookWidgetTile';
|
||||
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
});
|
||||
|
||||
export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
const [content, setContent] = useState(widget.properties.content);
|
||||
|
||||
const { enabled } = useEditModeStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { primaryColor } = useColorTheme();
|
||||
|
||||
const { mutateAsync } = api.notebook.update.useMutation();
|
||||
|
||||
const [debouncedContent] = useDebouncedValue(content, 500);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Link],
|
||||
content,
|
||||
editable: false,
|
||||
onUpdate: (e) => {
|
||||
setContent(e.editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditToggle = (previous: boolean) => {
|
||||
const current = !previous;
|
||||
if (!editor) return current;
|
||||
editor.setEditable(current);
|
||||
|
||||
updateConfig(
|
||||
configName!,
|
||||
(previous) => {
|
||||
const currentWidget = previous.widgets.find((x) => x.id === widget.id);
|
||||
currentWidget!.properties.content = debouncedContent;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
widgets: [
|
||||
...previous.widgets.filter((iterationWidget) => iterationWidget.id !== widget.id),
|
||||
currentWidget!,
|
||||
],
|
||||
};
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
void mutateAsync({
|
||||
configName: configName!,
|
||||
content: debouncedContent,
|
||||
widgetId: widget.id,
|
||||
});
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
if (!config || !configName) return <WidgetLoading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<ActionIcon
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
top={7}
|
||||
right={7}
|
||||
pos="absolute"
|
||||
color={primaryColor}
|
||||
variant="light"
|
||||
size={30}
|
||||
radius={'md'}
|
||||
onClick={() => setIsEditing(handleEditToggle)}
|
||||
>
|
||||
{isEditing ? <IconEditOff size={20} /> : <IconEdit size={20} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
editor={editor}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
'& .ProseMirror': {
|
||||
padding: '0 !important',
|
||||
},
|
||||
border: 'none',
|
||||
},
|
||||
toolbar: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
paddingTop: 0,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display: isEditing && widget.properties.showToolbar === true ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Strikethrough />
|
||||
<RichTextEditor.ClearFormatting />
|
||||
<RichTextEditor.Code />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.H1 />
|
||||
<RichTextEditor.H2 />
|
||||
<RichTextEditor.H3 />
|
||||
<RichTextEditor.H4 />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Blockquote />
|
||||
<RichTextEditor.Hr />
|
||||
<RichTextEditor.BulletList />
|
||||
<RichTextEditor.OrderedList />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Link />
|
||||
<RichTextEditor.Unlink />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</RichTextEditor.Toolbar>
|
||||
{editor && (
|
||||
<BubbleMenu editor={editor}>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Link />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
<RichTextEditor.Content />
|
||||
</RichTextEditor>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal file
45
src/widgets/notebook/NotebookWidgetTile.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IconNotes } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const Editor = dynamic(() => import('./NotebookEditor').then((module) => module.Editor), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'notebook',
|
||||
icon: IconNotes,
|
||||
options: {
|
||||
showToolbar: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
hide: true,
|
||||
defaultValue: `<h2>Welcome to <strong>Homarr's</strong> notebook widget</h2><p>The <code>notebook</code> widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users. It is based on <a target="_blank" rel="noopener noreferrer nofollow" href="https://tiptap.dev/">Tiptap.dev</a> and supports all of its features:</p><ul><li><p>General text formatting: <strong>bold</strong>, <em>italic</em>, underline, <s>strike-through</s></p></li><li><p>Headings (h1-h6)</p></li><li><p>Sub and super scripts (<sup /> and <sub /> tags)</p></li><li><p>Ordered and bullet lists</p></li><li><p>Text align </p></li></ul><h3>Widget options</h3><p>This widget has two options :</p><ul><li><p>Show toolbar : Shows the toolbar when the widget is in the local edit mode.</p></li></ul>`,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
component: NotebookWidget,
|
||||
});
|
||||
|
||||
export default definition;
|
||||
|
||||
export type INotebookWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface NotebookWidgetProps {
|
||||
widget: INotebookWidget;
|
||||
}
|
||||
|
||||
function NotebookWidget(props: NotebookWidgetProps) {
|
||||
return <Editor widget={props.widget} />;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ interface DataType {
|
||||
|
||||
interface ICommonWidgetOptions {
|
||||
info?: boolean;
|
||||
hide?: boolean;
|
||||
infoLink?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user