Custom column counts for gridstack #613 #660

This commit is contained in:
Manuel
2023-02-05 17:16:03 +01:00
committed by GitHub
parent 5296ce88d2
commit 2539e8cec1
37 changed files with 2064 additions and 745 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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