mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 09:25:47 +01:00
117
src/components/Settings/Customization/CustomizationAccordeon.tsx
Normal file
117
src/components/Settings/Customization/CustomizationAccordeon.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Accordion, Grid, Group, Stack, Text } from '@mantine/core';
|
||||
import { IconBrush, IconChartCandle, IconDragDrop, IconLayout } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { GridstackConfiguration } from './Layout/GridstackConfiguration';
|
||||
import { LayoutSelector } from './Layout/LayoutSelector';
|
||||
import { BackgroundChanger } from './Meta/BackgroundChanger';
|
||||
import { FaviconChanger } from './Meta/FaviconChanger';
|
||||
import { LogoImageChanger } from './Meta/LogoImageChanger';
|
||||
import { BrowserTabTitle } from './Meta/MetaTitleChanger';
|
||||
import { DashboardTitleChanger } from './Meta/PageTitleChanger';
|
||||
import { ColorSelector } from './Theme/ColorSelector';
|
||||
import { CustomCssChanger } from './Theme/CustomCssChanger';
|
||||
import { DashboardTilesOpacitySelector } from './Theme/OpacitySelector';
|
||||
import { ShadeSelector } from './Theme/ShadeSelector';
|
||||
|
||||
export const CustomizationSettingsAccordeon = () => {
|
||||
const items = getItems().map((item) => (
|
||||
<Accordion.Item value={item.id} key={item.label}>
|
||||
<Accordion.Control>
|
||||
<AccordionLabel {...item} />
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Text size="sm">{item.content}</Text>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
));
|
||||
return (
|
||||
<Accordion variant="contained" chevronPosition="right">
|
||||
{items}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
interface AccordionLabelProps {
|
||||
label: string;
|
||||
image: ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const AccordionLabel = ({ label, image, description }: AccordionLabelProps) => (
|
||||
<Group noWrap>
|
||||
{image}
|
||||
<div>
|
||||
<Text>{label}</Text>
|
||||
<Text size="sm" color="dimmed" weight={400}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const getItems = () => {
|
||||
const { t } = useTranslation([
|
||||
'settings/customization/general',
|
||||
'settings/customization/color-selector',
|
||||
]);
|
||||
return [
|
||||
{
|
||||
id: 'layout',
|
||||
image: <IconLayout />,
|
||||
label: t('accordeon.layout.name'),
|
||||
description: t('accordeon.layout.description'),
|
||||
content: <LayoutSelector />,
|
||||
},
|
||||
{
|
||||
id: 'gridstack',
|
||||
image: <IconDragDrop />,
|
||||
label: t('accordeon.gridstack.name'),
|
||||
description: t('accordeon.gridstack.description'),
|
||||
content: <GridstackConfiguration />,
|
||||
},
|
||||
{
|
||||
id: 'page_metadata',
|
||||
image: <IconChartCandle />,
|
||||
label: t('accordeon.pageMetadata.name'),
|
||||
description: t('accordeon.pageMetadata.description'),
|
||||
content: (
|
||||
<>
|
||||
<DashboardTitleChanger />
|
||||
<BrowserTabTitle />
|
||||
<LogoImageChanger />
|
||||
<FaviconChanger />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'appereance',
|
||||
image: <IconBrush />,
|
||||
label: t('accordeon.appereance.name'),
|
||||
description: t('accordeon.appereance.description'),
|
||||
content: (
|
||||
<>
|
||||
<BackgroundChanger />
|
||||
|
||||
<Stack spacing="xs" my="md">
|
||||
<Text>{t('settings/customization/color-selector:colors')}</Text>
|
||||
<Grid>
|
||||
<Grid.Col sm={12} md={6}>
|
||||
<ColorSelector type="primary" defaultValue="red" />
|
||||
</Grid.Col>
|
||||
<Grid.Col sm={12} md={6}>
|
||||
<ColorSelector type="secondary" defaultValue="orange" />
|
||||
</Grid.Col>
|
||||
<Grid.Col sm={12} md={6}>
|
||||
<ShadeSelector />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<DashboardTilesOpacitySelector />
|
||||
<CustomCssChanger />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,46 +1,19 @@
|
||||
import { ScrollArea, Stack } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import { useConfigStore } from '../../../config/store';
|
||||
import { LayoutSelector } from './Layout/LayoutSelector';
|
||||
import { BackgroundChanger } from './Meta/BackgroundChanger';
|
||||
import { FaviconChanger } from './Meta/FaviconChanger';
|
||||
import { LogoImageChanger } from './Meta/LogoImageChanger';
|
||||
import { MetaTitleChanger } from './Meta/MetaTitleChanger';
|
||||
import { PageTitleChanger } from './Meta/PageTitleChanger';
|
||||
import { ColorSelector } from './Theme/ColorSelector';
|
||||
import { CustomCssChanger } from './Theme/CustomCssChanger';
|
||||
import { OpacitySelector } from './Theme/OpacitySelector';
|
||||
import { ShadeSelector } from './Theme/ShadeSelector';
|
||||
import { CustomizationSettingsAccordeon } from './CustomizationAccordeon';
|
||||
|
||||
export default function CustomizationSettings() {
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const { t } = useTranslation('common');
|
||||
const { height, width } = useViewportSize();
|
||||
|
||||
const { updateConfig } = useConfigStore();
|
||||
const { height } = useViewportSize();
|
||||
const { t } = useTranslation('settings/customization/general');
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ height: height - 100 }} offsetScrollbars>
|
||||
<Stack mt="xs" mb="md" spacing="xs">
|
||||
<LayoutSelector defaultLayout={config?.settings.customization.layout} />
|
||||
<PageTitleChanger defaultValue={config?.settings.customization.pageTitle} />
|
||||
<MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} />
|
||||
<LogoImageChanger defaultValue={config?.settings.customization.logoImageUrl} />
|
||||
<FaviconChanger defaultValue={config?.settings.customization.faviconUrl} />
|
||||
<BackgroundChanger defaultValue={config?.settings.customization.backgroundImageUrl} />
|
||||
<CustomCssChanger defaultValue={config?.settings.customization.customCss} />
|
||||
<ColorSelector
|
||||
type="primary"
|
||||
defaultValue={config?.settings.customization.colors.primary}
|
||||
/>
|
||||
<ColorSelector
|
||||
type="secondary"
|
||||
defaultValue={config?.settings.customization.colors.secondary}
|
||||
/>
|
||||
<ShadeSelector defaultValue={config?.settings.customization.colors.shade} />
|
||||
<OpacitySelector defaultValue={config?.settings.customization.appOpacity} />
|
||||
<Text color="dimmed">
|
||||
{t('text')}
|
||||
</Text>
|
||||
<CustomizationSettingsAccordeon />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Alert, Button, Grid, Input, LoadingOverlay, Slider } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconCheck, IconReload } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';
|
||||
import { sleep } from '../../../../tools/client/time';
|
||||
import { GridstackSettingsType } from '../../../../types/settings';
|
||||
|
||||
export const GridstackConfiguration = () => {
|
||||
const { t } = useTranslation(['settings/customization/gridstack', 'common']);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
if (!config || !configName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValue = config.settings.customization?.gridstack ?? {
|
||||
columnCountSmall: 3,
|
||||
columnCountMedium: 6,
|
||||
columnCountLarge: 12,
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: initialValue,
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: GridstackSettingsType) => {
|
||||
setIsSaving(true);
|
||||
|
||||
await sleep(250);
|
||||
await updateConfig(
|
||||
configName,
|
||||
(previousConfig) => ({
|
||||
...previousConfig,
|
||||
settings: {
|
||||
...previousConfig.settings,
|
||||
customization: {
|
||||
...previousConfig.settings.customization,
|
||||
gridstack: values,
|
||||
},
|
||||
},
|
||||
}),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
form.resetDirty();
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)} style={{ position: 'relative' }}>
|
||||
<LoadingOverlay overlayBlur={2} visible={isSaving} radius="md" />
|
||||
<Input.Wrapper
|
||||
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.small') })}
|
||||
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.medium })}
|
||||
mb="md"
|
||||
>
|
||||
<Slider min={1} max={8} mt="xs" {...form.getInputProps('columnCountSmall')} />
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper
|
||||
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.medium') })}
|
||||
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.large })}
|
||||
mb="md"
|
||||
>
|
||||
<Slider min={3} max={16} mt="xs" {...form.getInputProps('columnCountMedium')} />
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper
|
||||
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.large') })}
|
||||
description={t('columnsCount.descriptionExceedsPreset', {
|
||||
pixels: GridstackBreakpoints.large,
|
||||
})}
|
||||
>
|
||||
<Slider min={5} max={20} mt="xs" {...form.getInputProps('columnCountLarge')} />
|
||||
</Input.Wrapper>
|
||||
{form.isDirty() && (
|
||||
<Alert variant="light" color="yellow" title="Unsaved changes" my="md">
|
||||
{t('unsavedChanges')}
|
||||
</Alert>
|
||||
)}
|
||||
<Grid mt="md">
|
||||
<Grid.Col md={6} xs={12}>
|
||||
<Button variant="light" leftIcon={<IconCheck size={18} />} type="submit" fullWidth>
|
||||
{t('applyChanges')}
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
<Grid.Col md={6} xs={12}>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconReload size={18} />}
|
||||
onClick={() =>
|
||||
form.setValues({
|
||||
columnCountSmall: 3,
|
||||
columnCountMedium: 6,
|
||||
columnCountLarge: 12,
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
{t('defaultValues')}
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,39 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Indicator,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { createDummyArray } from '../../../../tools/client/arrays';
|
||||
import { CustomizationSettingsType } from '../../../../types/settings';
|
||||
import { Logo } from '../../../layout/Logo';
|
||||
|
||||
interface LayoutSelectorProps {
|
||||
defaultLayout: CustomizationSettingsType['layout'] | undefined;
|
||||
}
|
||||
|
||||
// TODO: add translations
|
||||
export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
|
||||
export const LayoutSelector = () => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { name: configName } = useConfigContext();
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true);
|
||||
const [rightSidebar, setRightSidebar] = useState(defaultLayout?.enabledRightSidebar ?? true);
|
||||
const [docker, setDocker] = useState(defaultLayout?.enabledDocker ?? false);
|
||||
const [ping, setPing] = useState(defaultLayout?.enabledPing ?? false);
|
||||
const [searchBar, setSearchBar] = useState(defaultLayout?.enabledSearchbar ?? false);
|
||||
const layoutSettings = config?.settings.customization.layout;
|
||||
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
const [leftSidebar, setLeftSidebar] = useState(layoutSettings?.enabledLeftSidebar ?? true);
|
||||
const [rightSidebar, setRightSidebar] = useState(layoutSettings?.enabledRightSidebar ?? true);
|
||||
const [docker, setDocker] = useState(layoutSettings?.enabledDocker ?? false);
|
||||
const [ping, setPing] = useState(layoutSettings?.enabledPing ?? false);
|
||||
const [searchBar, setSearchBar] = useState(layoutSettings?.enabledSearchbar ?? false);
|
||||
const { t } = useTranslation('settings/common');
|
||||
|
||||
if (!configName) return null;
|
||||
if (!configName || !config) return null;
|
||||
|
||||
const handleChange = (
|
||||
key: keyof CustomizationSettingsType['layout'],
|
||||
@@ -68,99 +64,140 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const enabledPing = layoutSettings?.enabledPing ?? false;
|
||||
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Title order={6}>{t('layout.title')}</Title>
|
||||
|
||||
<Paper px="xs" py={4} withBorder>
|
||||
<Group position="apart">
|
||||
<Logo size="xs" />
|
||||
<Group spacing={5}>
|
||||
{searchBar ? (
|
||||
<Paper
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: colorScheme === 'dark' ? colors.gray[8] : colors.gray[1],
|
||||
}}
|
||||
p={2}
|
||||
w={60}
|
||||
/>
|
||||
) : null}
|
||||
{docker ? <ActionIcon size={10} disabled /> : null}
|
||||
<>
|
||||
<Stack spacing={0} mb="md">
|
||||
<Title order={6}>{t('layout.preview.title')}</Title>
|
||||
<Text color="dimmed" size="xs">
|
||||
{t('layout.preview.subtitle')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack spacing="xs">
|
||||
<Paper px="xs" py={4} withBorder>
|
||||
<Group position="apart">
|
||||
<Logo size="xs" />
|
||||
<Group spacing={5}>
|
||||
{searchBar && <PlaceholderElement width={60} height={10} />}
|
||||
{docker && <PlaceholderElement width={10} height={10} />}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Group align="stretch">
|
||||
{leftSidebar && (
|
||||
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex align="center" justify="center" direction="column">
|
||||
<Text align="center">{t('layout.sidebar')}</Text>
|
||||
<Text color="dimmed" size="xs" align="center">
|
||||
Only for
|
||||
<br />
|
||||
apps &<br />
|
||||
integrations
|
||||
</Text>
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper className={classes.primaryWrapper} p="xs" withBorder>
|
||||
<Text align="center">{t('layout.main')}</Text>
|
||||
<Text color="dimmed" size="xs" align="center">
|
||||
{t('layout.cannotturnoff')}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
{rightSidebar && (
|
||||
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex align="center" justify="center" direction="column">
|
||||
<Text align="center">{t('layout.sidebar')}</Text>
|
||||
<Text color="dimmed" size="xs" align="center">
|
||||
Only for
|
||||
<br />
|
||||
apps &<br />
|
||||
integrations
|
||||
</Text>
|
||||
<Flex gap={6}>
|
||||
{leftSidebar && (
|
||||
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex gap={5} wrap="wrap">
|
||||
{createDummyArray(5).map((item, index) => (
|
||||
<PlaceholderElement
|
||||
height={index % 4 === 0 ? 60 + 5 : 30}
|
||||
width={30}
|
||||
key={`example-item-right-sidebard-${index}`}
|
||||
index={index}
|
||||
hasPing={enabledPing}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper className={classes.primaryWrapper} p="xs" withBorder>
|
||||
<Flex gap={5} wrap="wrap">
|
||||
{createDummyArray(10).map((item, index) => (
|
||||
<PlaceholderElement
|
||||
height={30}
|
||||
width={index % 5 === 0 ? 60 : 30}
|
||||
key={`example-item-main-${index}`}
|
||||
index={index}
|
||||
hasPing={enabledPing}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack spacing="xs">
|
||||
<Checkbox
|
||||
label={t('layout.enablelsidebar')}
|
||||
description={t('layout.enablelsidebardesc')}
|
||||
checked={leftSidebar}
|
||||
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enablersidebar')}
|
||||
description={t('layout.enablersidebardesc')}
|
||||
checked={rightSidebar}
|
||||
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enablesearchbar')}
|
||||
checked={searchBar}
|
||||
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enabledocker')}
|
||||
checked={docker}
|
||||
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enableping')}
|
||||
checked={ping}
|
||||
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
|
||||
/>
|
||||
{rightSidebar && (
|
||||
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
|
||||
<Flex gap={5} align="start" wrap="wrap">
|
||||
{createDummyArray(5).map((item, index) => (
|
||||
<PlaceholderElement
|
||||
height={30}
|
||||
width={index % 4 === 0 ? 60 + 5 : 30}
|
||||
key={`example-item-right-sidebard-${index}`}
|
||||
index={index}
|
||||
hasPing={enabledPing}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Divider label={t('layout.divider')} labelPosition="center" mt="md" mb="xs" />
|
||||
<Stack spacing="xs">
|
||||
<Checkbox
|
||||
label={t('layout.enablelsidebar')}
|
||||
description={t('layout.enablelsidebardesc')}
|
||||
checked={leftSidebar}
|
||||
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enablersidebar')}
|
||||
description={t('layout.enablersidebardesc')}
|
||||
checked={rightSidebar}
|
||||
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enablesearchbar')}
|
||||
checked={searchBar}
|
||||
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enabledocker')}
|
||||
checked={docker}
|
||||
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('layout.enableping')}
|
||||
checked={ping}
|
||||
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseElement = ({ height, width }: { height: number; width: number }) => (
|
||||
<Paper
|
||||
sx={(theme) => ({
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1],
|
||||
})}
|
||||
h={height}
|
||||
p={2}
|
||||
w={width}
|
||||
/>
|
||||
);
|
||||
|
||||
const PlaceholderElement = (props: any) => {
|
||||
const { height, width, hasPing, index } = props;
|
||||
|
||||
if (hasPing) {
|
||||
return (
|
||||
<Indicator
|
||||
position="bottom-end"
|
||||
size={5}
|
||||
offset={10}
|
||||
color={index % 4 === 0 ? 'red' : 'green'}
|
||||
>
|
||||
<BaseElement width={width} height={height} />
|
||||
</Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
return <BaseElement width={width} height={height} />;
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
primaryWrapper: {
|
||||
flexGrow: 2,
|
||||
|
||||
@@ -4,15 +4,13 @@ import { ChangeEventHandler, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface BackgroundChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
|
||||
export const BackgroundChanger = ({ defaultValue }: BackgroundChangerProps) => {
|
||||
export const BackgroundChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [backgroundImageUrl, setBackgroundImageUrl] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [backgroundImageUrl, setBackgroundImageUrl] = useState(
|
||||
config?.settings.customization.backgroundImageUrl
|
||||
);
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
|
||||
@@ -4,21 +4,19 @@ import { ChangeEventHandler, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface FaviconChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
|
||||
export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
|
||||
export const FaviconChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [faviconUrl, setFaviconUrl] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [faviconUrl, setFaviconUrl] = useState(
|
||||
config?.settings.customization.faviconUrl ?? '/imgs/favicon/favicon-squared.png'
|
||||
);
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const faviconUrl = value.trim().length === 0 ? undefined : value;
|
||||
const faviconUrl = value.trim();
|
||||
setFaviconUrl(faviconUrl);
|
||||
updateConfig(configName, (prev) => ({
|
||||
...prev,
|
||||
@@ -35,6 +33,7 @@ export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
label={t('favicon.label')}
|
||||
description={t('favicon.description')}
|
||||
placeholder="/imgs/favicon/favicon.svg"
|
||||
value={faviconUrl}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -4,21 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface LogoImageChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
|
||||
export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
|
||||
export const LogoImageChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [logoImageSrc, setLogoImageSrc] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [logoImageSrc, setLogoImageSrc] = useState(config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png');
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const logoImageSrc = value.trim().length === 0 ? undefined : value;
|
||||
const logoImageSrc = value.trim();
|
||||
setLogoImageSrc(logoImageSrc);
|
||||
updateConfig(configName, (prev) => ({
|
||||
...prev,
|
||||
@@ -35,9 +31,11 @@ export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
label={t('logo.label')}
|
||||
description={t('logo.description')}
|
||||
placeholder="/imgs/logo/logo.png"
|
||||
value={logoImageSrc}
|
||||
onChange={handleChange}
|
||||
mb="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface MetaTitleChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
|
||||
// TODO: change to pageTitle
|
||||
export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
|
||||
export const BrowserTabTitle = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [metaTitle, setMetaTitle] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [metaTitle, setMetaTitle] = useState(config?.settings.customization.metaTitle ?? '');
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const metaTitle = value.trim().length === 0 ? undefined : value;
|
||||
const metaTitle = value.trim();
|
||||
setMetaTitle(metaTitle);
|
||||
updateConfig(configName, (prev) => ({
|
||||
...prev,
|
||||
@@ -36,9 +31,11 @@ export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
label={t('metaTitle.label')}
|
||||
description={t('metaTitle.description')}
|
||||
placeholder="homarr - the best dashboard"
|
||||
value={metaTitle}
|
||||
onChange={handleChange}
|
||||
mb="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface PageTitleChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
|
||||
// TODO: change to dashboard title
|
||||
export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
|
||||
export const DashboardTitleChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [pageTitle, setPageTitle] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [pageTitle, setPageTitle] = useState(config?.settings.customization.pageTitle ?? '');
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const pageTitle = value.trim().length === 0 ? undefined : value;
|
||||
const pageTitle = value.trim();
|
||||
setPageTitle(pageTitle);
|
||||
updateConfig(configName, (prev) => ({
|
||||
...prev,
|
||||
@@ -36,9 +31,11 @@ export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
|
||||
return (
|
||||
<TextInput
|
||||
label={t('pageTitle.label')}
|
||||
description={t('pageTitle.description')}
|
||||
placeholder="homarr"
|
||||
value={pageTitle}
|
||||
onChange={handleChange}
|
||||
mb="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ColorSelector({ type, defaultValue }: ColorControlProps) {
|
||||
const [color, setColor] = useState(defaultValue);
|
||||
const [popoverOpened, popover] = useDisclosure(false);
|
||||
const { setPrimaryColor, setSecondaryColor } = useColorTheme();
|
||||
const { name: configName } = useConfigContext();
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
@@ -1,44 +1,105 @@
|
||||
import { Textarea } from '@mantine/core';
|
||||
import {
|
||||
Box,
|
||||
createStyles,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ChangeEventHandler, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface CustomCssChangerProps {
|
||||
defaultValue: string | undefined;
|
||||
}
|
||||
const CodeEditor = dynamic(
|
||||
() => import('@uiw/react-textarea-code-editor').then((mod) => mod.default),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export const CustomCssChanger = ({ defaultValue }: CustomCssChangerProps) => {
|
||||
export const CustomCssChanger = () => {
|
||||
const { t } = useTranslation('settings/customization/page-appearance');
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const { name: configName } = useConfigContext();
|
||||
const [customCss, setCustomCss] = useState(defaultValue);
|
||||
const { colorScheme, colors } = useMantineTheme();
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [nonDebouncedCustomCSS, setNonDebouncedCustomCSS] = useState(
|
||||
config?.settings.customization.customCss ?? ''
|
||||
);
|
||||
const [debouncedCustomCSS] = useDebouncedValue(nonDebouncedCustomCSS, 696);
|
||||
const { classes } = useStyles();
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (ev) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const customCss = value.trim().length === 0 ? undefined : value;
|
||||
setCustomCss(customCss);
|
||||
useEffect(() => {
|
||||
updateConfig(configName, (prev) => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
customization: {
|
||||
...prev.settings.customization,
|
||||
customCss,
|
||||
customCss: debouncedCustomCSS,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
}, [debouncedCustomCSS]);
|
||||
|
||||
const codeIsDirty = nonDebouncedCustomCSS !== debouncedCustomCSS;
|
||||
const codeEditorHeight = codeIsDirty ? 250 - 42 : 250;
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
minRows={5}
|
||||
label={t('customCSS.label')}
|
||||
placeholder={t('customCSS.placeholder')}
|
||||
value={customCss}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Stack spacing={4} mt="xl">
|
||||
<Text>{t('customCSS.label')}</Text>
|
||||
<Text color="dimmed" size="xs">
|
||||
{t('customCSS.description')}
|
||||
</Text>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
<ScrollArea style={{ height: codeEditorHeight }}>
|
||||
<CodeEditor
|
||||
className={classes.codeEditor}
|
||||
placeholder={t('customCSS.placeholder')}
|
||||
value={nonDebouncedCustomCSS}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setNonDebouncedCustomCSS(event.target.value.trim())
|
||||
}
|
||||
language="css"
|
||||
data-color-mode={colorScheme}
|
||||
minHeight={codeEditorHeight}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{codeIsDirty && (
|
||||
<Box className={classes.codeEditorFooter}>
|
||||
<Group p="xs" spacing="xs">
|
||||
<Loader color={colors.gray[0]} size={18} />
|
||||
<Text>{t('customCSS.applying')}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ colors, colorScheme, radius }) => ({
|
||||
codeEditorFooter: {
|
||||
borderBottomLeftRadius: radius.sm,
|
||||
borderBottomRightRadius: radius.sm,
|
||||
backgroundColor: colorScheme === 'dark' ? colors.dark[7] : undefined,
|
||||
},
|
||||
codeEditorRoot: {
|
||||
borderColor: colorScheme === 'dark' ? colors.dark[4] : colors.gray[4],
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
codeEditor: {
|
||||
backgroundColor: colorScheme === 'dark' ? colors.dark[6] : 'white',
|
||||
fontSize: 12,
|
||||
|
||||
'& ::placeholder': {
|
||||
color: colorScheme === 'dark' ? colors.dark[3] : colors.gray[5],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,15 +4,11 @@ import { useState } from 'react';
|
||||
import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
|
||||
interface OpacitySelectorProps {
|
||||
defaultValue: number | undefined;
|
||||
}
|
||||
|
||||
export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
|
||||
const [opacity, setOpacity] = useState(defaultValue || 100);
|
||||
export function DashboardTilesOpacitySelector() {
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [opacity, setOpacity] = useState(config?.settings.customization.appOpacity || 100);
|
||||
const { t } = useTranslation('settings/customization/opacity-selector');
|
||||
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
@@ -15,17 +15,13 @@ import { useConfigContext } from '../../../../config/provider';
|
||||
import { useConfigStore } from '../../../../config/store';
|
||||
import { useColorTheme } from '../../../../tools/color';
|
||||
|
||||
interface ShadeSelectorProps {
|
||||
defaultValue: MantineTheme['primaryShade'] | undefined;
|
||||
}
|
||||
|
||||
export function ShadeSelector({ defaultValue }: ShadeSelectorProps) {
|
||||
export function ShadeSelector() {
|
||||
const { t } = useTranslation('settings/customization/shade-selector');
|
||||
const [shade, setShade] = useState(defaultValue);
|
||||
const { config, name: configName } = useConfigContext();
|
||||
const [shade, setShade] = useState(config?.settings.customization.colors.shade);
|
||||
const [popoverOpened, popover] = useDisclosure(false);
|
||||
const { primaryColor, setPrimaryShade } = useColorTheme();
|
||||
|
||||
const { name: configName } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
Reference in New Issue
Block a user