🚧 Add board customization page

This commit is contained in:
Meier Lukas
2023-07-31 00:02:10 +02:00
parent 4b1f5881e3
commit 99c2bc11cb
8 changed files with 629 additions and 2 deletions

View File

@@ -0,0 +1,208 @@
import {
CheckIcon,
ColorSwatch,
Group,
Input,
Slider,
Stack,
Text,
TextInput,
createStyles,
rem,
useMantineTheme,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { highlight, languages } from 'prismjs';
import Editor from 'react-simple-code-editor';
import { useBoardCustomizationFormContext } from '../form';
export const AppearanceCustomization = () => {
const { t } = useTranslation([
'settings/customization/page-appearance',
'settings/customization/color-selector',
]);
const theme = useMantineTheme();
const form = useBoardCustomizationFormContext();
return (
<Stack spacing="sm">
<TextInput
label={t('background.label')}
placeholder="/imgs/backgrounds/background.png"
{...form.getInputProps('appearance.backgroundSrc')}
/>
<ColorSelector type="primaryColor" />
<ColorSelector type="secondaryColor" />
<ShadeSelector />
<OpacitySlider />
<CustomCssInput />
</Stack>
);
};
type ColorSelectorProps = {
type: 'primaryColor' | 'secondaryColor';
};
const ColorSelector = ({ type }: ColorSelectorProps) => {
const { t } = useTranslation('settings/customization/color-selector');
const theme = useMantineTheme();
const form = useBoardCustomizationFormContext();
const colors = Object.keys(theme.colors).map((color) => ({
swatch: theme.colors[color][6],
color,
}));
return (
<Input.Wrapper label={type}>
<Group>
{colors.map(({ color, swatch }) => (
<ColorSwatch
key={color}
component="button"
type="button"
onClick={() => form.getInputProps(`appearance.${type}`).onChange(color)}
color={swatch}
style={{ cursor: 'pointer' }}
>
{color === form.values.appearance[type] && <CheckIcon width={rem(10)} />}
</ColorSwatch>
))}
</Group>
</Input.Wrapper>
);
};
const ShadeSelector = () => {
const form = useBoardCustomizationFormContext();
const theme = useMantineTheme();
const primaryColor = form.values.appearance.primaryColor;
const primaryShades = theme.colors[primaryColor].map((_, shade) => ({
swatch: theme.colors[primaryColor][shade],
shade,
}));
return (
<Input.Wrapper label="Shade">
<Group>
{primaryShades.map(({ shade, swatch }) => (
<ColorSwatch
key={shade}
component="button"
type="button"
onClick={() => form.getInputProps(`appearance.shade`).onChange(shade)}
color={swatch}
style={{ cursor: 'pointer' }}
>
{shade === form.values.appearance.shade && <CheckIcon width={rem(10)} />}
</ColorSwatch>
))}
</Group>
</Input.Wrapper>
);
};
const OpacitySlider = () => {
const { t } = useTranslation('settings/customization/opacity-selector');
const form = useBoardCustomizationFormContext();
return (
<Input.Wrapper label={t('label')} mb="sm">
<Slider
step={10}
min={10}
marks={opacityMarks}
styles={{ markLabel: { fontSize: 'xx-small' } }}
{...form.getInputProps('appearance.opacity')}
/>
</Input.Wrapper>
);
};
const opacityMarks = [
{ value: 10, label: '10%' },
{ value: 20, label: '20%' },
{ value: 30, label: '30%' },
{ value: 40, label: '40%' },
{ value: 50, label: '50%' },
{ value: 60, label: '60%' },
{ value: 70, label: '70%' },
{ value: 80, label: '80%' },
{ value: 90, label: '90%' },
{ value: 100, label: '100%' },
];
const CustomCssInput = () => {
const { t } = useTranslation('settings/customization/page-appearance');
const { classes } = useStyles();
const form = useBoardCustomizationFormContext();
return (
<Input.Wrapper
label={t('customCSS.label')}
description={t('customCSS.description')}
inputWrapperOrder={['label', 'description', 'input', 'error']}
>
<div className={classes.codeEditorRoot}>
<Editor
{...form.getInputProps('appearance.customCss')}
onValueChange={(code) => form.getInputProps('appearance.customCss').onChange(code)}
highlight={(code) => highlight(code, languages.extend('css', {}), 'css')}
padding={10}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 12,
minHeight: 250,
}}
/>
</div>
</Input.Wrapper>
);
};
const useStyles = createStyles(({ colors, colorScheme, radius }) => ({
codeEditorFooter: {
borderBottomLeftRadius: radius.sm,
borderBottomRightRadius: radius.sm,
backgroundColor: colorScheme === 'dark' ? colors.dark[7] : undefined,
},
codeEditorRoot: {
marginTop: 4,
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],
},
},
}));
/*
<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

@@ -0,0 +1,37 @@
import { Input, Slider } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { GridstackBreakpoints } from '~/constants/gridstack-breakpoints';
import { useBoardCustomizationFormContext } from '../form';
export const GridstackCustomization = () => {
const { t } = useTranslation('settings/customization/gridstack');
const form = useBoardCustomizationFormContext();
return (
<>
<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('gridstack.sm')} />
</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('gridstack.md')} />
</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('gridstack.lg')} />
</Input.Wrapper>
</>
);
};

View File

@@ -0,0 +1,43 @@
import { Button, Checkbox, Grid, Stack } from '@mantine/core';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useBoardCustomizationFormContext } from '../form';
import { LayoutPreview } from './LayoutPreview';
export const LayoutCustomization = () => {
const { t } = useTranslation('settings/common');
const form = useBoardCustomizationFormContext();
return (
<Grid gutter="xl" align="stretch">
<Grid.Col span={12} sm={6}>
<LayoutPreview
showLeftSidebar={form.values.layout.leftSidebarEnabled}
showRightSidebar={form.values.layout.rightSidebarEnabled}
showPings={form.values.layout.pingsEnabled}
/>
</Grid.Col>
<Grid.Col span={12} sm={6}>
<Stack spacing="sm" h="100%" justify="space-between">
<Stack spacing="xs">
<Checkbox
label={t('layout.enablelsidebar')}
description={t('layout.enablelsidebardesc')}
{...form.getInputProps('layout.leftSidebarEnabled', { type: 'checkbox' })}
/>
<Checkbox
label={t('layout.enablersidebar')}
description={t('layout.enablersidebardesc')}
{...form.getInputProps('layout.rightSidebarEnabled', { type: 'checkbox' })}
/>
<Checkbox
label={t('layout.enableping')}
{...form.getInputProps('layout.pingsEnabled', { type: 'checkbox' })}
/>
</Stack>
</Stack>
</Grid.Col>
</Grid>
);
};

View File

@@ -0,0 +1,124 @@
import { Flex, Group, Indicator, Paper, Stack, createStyles } from '@mantine/core';
import { Logo } from '~/components/layout/Logo';
import { createDummyArray } from '~/tools/client/arrays';
type LayoutPreviewProps = {
showLeftSidebar: boolean;
showRightSidebar: boolean;
showPings: boolean;
};
export const LayoutPreview = ({
showLeftSidebar,
showRightSidebar,
showPings,
}: LayoutPreviewProps) => {
const { classes } = useStyles();
return (
<Stack spacing="xs">
<Paper px="xs" py={4} withBorder>
<Group position="apart">
<div style={{ flex: 1 }}>
<Logo size="xs" />
</div>
<BaseElement width={60} height={10} />
<Group style={{ flex: 1 }} position="right">
<BaseElement width={10} height={10} />
</Group>
</Group>
</Paper>
<Flex gap={6}>
{showLeftSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex gap={5} wrap="wrap" align="end" w={65}>
{createDummyArray(5).map((_item, index) => (
<PlaceholderElement
height={index % 4 === 0 ? 60 + 5 : 30}
width={30}
key={`example-item-right-sidebard-${index}`}
index={index}
showPing={showPings}
/>
))}
</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 + 5 : 30}
key={`example-item-main-${index}`}
index={index}
showPing={showPings}
/>
))}
</Flex>
</Paper>
{showRightSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex gap={5} align="start" wrap="wrap" w={65}>
{createDummyArray(5).map((_item, index) => (
<PlaceholderElement
height={30}
width={index % 4 === 0 ? 60 + 5 : 30}
key={`example-item-right-sidebard-${index}`}
index={index}
showPing={showPings}
/>
))}
</Flex>
</Paper>
)}
</Flex>
</Stack>
);
};
const useStyles = createStyles((theme) => ({
primaryWrapper: {
flexGrow: 2,
},
secondaryWrapper: {
flexGrow: 1,
maxWidth: 100,
},
}));
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}
/>
);
type PlaceholderElementProps = {
height: number;
width: number;
showPing: boolean;
index: number;
};
const PlaceholderElement = ({ height, width, showPing, index }: PlaceholderElementProps) => {
if (showPing) {
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} />;
};

View File

@@ -0,0 +1,37 @@
import { Stack, TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useBoardCustomizationFormContext } from '../form';
export const PageMetadataCustomization = () => {
const { t } = useTranslation('settings/customization/page-appearance');
const form = useBoardCustomizationFormContext();
return (
<Stack spacing="sm">
<TextInput
label={t('pageTitle.label')}
description={t('pageTitle.description')}
placeholder="homarr"
{...form.getInputProps('pageMetadata.pageTitle')}
/>
<TextInput
label={t('metaTitle.label')}
description={t('metaTitle.description')}
placeholder="homarr - the best dashboard"
{...form.getInputProps('pageMetadata.metaTitle')}
/>
<TextInput
label={t('logo.label')}
description={t('logo.description')}
placeholder="/imgs/logo/logo.png"
{...form.getInputProps('pageMetadata.logoSrc')}
/>
<TextInput
label={t('favicon.label')}
description={t('favicon.description')}
placeholder="/imgs/favicon/favicon.svg"
{...form.getInputProps('pageMetadata.faviconSrc')}
/>
</Stack>
);
};

View File

@@ -0,0 +1,43 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { createFormContext } from '@mantine/form';
import { z } from 'zod';
const schema = z.object({
layout: z.object({
leftSidebarEnabled: z.boolean(),
rightSidebarEnabled: z.boolean(),
pingsEnabled: z.boolean(),
}),
gridstack: z.object({
sm: z.number().min(1).max(8),
md: z.number().min(3).max(16),
lg: z.number().min(5).max(20),
}),
pageMetadata: z.object({
pageTitle: z.string(),
metaTitle: z.string(),
logoSrc: z.string(),
faviconSrc: z.string(),
}),
appearance: z.object({
backgroundSrc: z.string(),
primaryColor: z.custom<MantineColor>(
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
),
secondaryColor: z.custom<MantineColor>(
(value) => typeof value === 'string' && MANTINE_COLORS.includes(value)
),
shade: z
.number()
.min(0)
.max(DEFAULT_THEME.colors['blue'].length - 1),
opacity: z.number().min(10).max(100),
customCss: z.string(),
}),
});
export const [
BoardCustomizationFormProvider,
useBoardCustomizationFormContext,
useBoardCustomizationForm,
] = createFormContext<z.infer<typeof schema>>();

View File

@@ -0,0 +1,135 @@
import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core';
import {
IconArrowLeft,
IconBrush,
IconChartCandle,
IconDragDrop,
IconLayout,
TablerIconsProps,
} from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ReactNode } from 'react';
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization';
import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization';
import { PageMetadataCustomization } from '~/components/Board/Customize/PageMetadata/PageMetadataCustomization';
import {
BoardCustomizationFormProvider,
useBoardCustomizationForm,
} from '~/components/Board/Customize/form';
import { MainLayout } from '~/components/layout/main';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { dashboardNamespaces } from '~/tools/server/translation-namespaces';
export default function CustomizationPage() {
const query = useRouter().query as { slug: string };
const { t } = useTranslation([
'settings/customization/general',
'settings/customization/color-selector',
]);
const form = useBoardCustomizationForm({
initialValues: {
layout: {
leftSidebarEnabled: false,
rightSidebarEnabled: false,
pingsEnabled: false,
},
appearance: {
backgroundSrc: '',
primaryColor: 'red',
secondaryColor: 'orange',
shade: 8,
opacity: 50,
customCss: '',
},
gridstack: {
sm: 3,
md: 6,
lg: 12,
},
pageMetadata: {
pageTitle: '',
metaTitle: '',
logoSrc: '',
faviconSrc: '',
},
},
});
return (
<MainLayout>
<Container>
<Paper p="xl" py="sm" mih="100%" withBorder>
<Stack>
<Group position="apart">
<Title order={2}>Customization for {query.slug} Board</Title>
<Button
component={Link}
href={`/board/${query.slug}`}
variant="light"
leftIcon={<IconArrowLeft size={16} />}
>
Back to Board
</Button>
</Group>
<BoardCustomizationFormProvider form={form}>
<form>
<Stack spacing="xl">
<Stack spacing="xs">
<SectionTitle type="layout" icon={IconLayout} />
<LayoutCustomization />
</Stack>
<Stack spacing="xs">
<SectionTitle type="gridstack" icon={IconDragDrop} />
<GridstackCustomization />
</Stack>
<Stack spacing="xs">
<SectionTitle type="pageMetadata" icon={IconChartCandle} />
<PageMetadataCustomization />
</Stack>
<Stack spacing="xs">
<SectionTitle type="appereance" icon={IconBrush} />
<AppearanceCustomization />
</Stack>
<Button type="submit">Save changes</Button>
</Stack>
</form>
</BoardCustomizationFormProvider>
</Stack>
</Paper>
</Container>
</MainLayout>
);
}
type SectionTitleProps = {
type: 'layout' | 'gridstack' | 'pageMetadata' | 'appereance';
icon: (props: TablerIconsProps) => ReactNode;
};
const SectionTitle = ({ type, icon: Icon }: SectionTitleProps) => {
const { t } = useTranslation('settings/customization/general');
return (
<Stack spacing={0}>
<Group spacing="xs">
<Icon size={16} />
<Title order={5}>{t(`accordeon.${type}.name`)}</Title>
</Group>
<Text color="dimmed">{t(`accordeon.${type}.description`)}</Text>
</Stack>
);
};
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale }) => {
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
return {
props: {
...translations,
},
};
};

View File

@@ -63,9 +63,9 @@ export default function HomePage({ config: initialConfig }: DashboardServerSideP
useInitConfig(initialConfig); useInitConfig(initialConfig);
return ( return (
<MainLayout> <Layout>
<Dashboard /> <Dashboard />
<LoadConfigComponent /> <LoadConfigComponent />
</MainLayout> </Layout>
); );
} }