324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import {
|
|
Affix,
|
|
Button,
|
|
Card,
|
|
Container,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
Transition,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
|
import {
|
|
IconArrowLeft,
|
|
IconBrush,
|
|
IconChartCandle,
|
|
IconCheck,
|
|
IconDragDrop,
|
|
IconLayout,
|
|
IconLock,
|
|
IconX,
|
|
TablerIconsProps,
|
|
} from '@tabler/icons-react';
|
|
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
|
import { useTranslation } from 'next-i18next';
|
|
import Head from 'next/head';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/router';
|
|
import { ReactNode } from 'react';
|
|
import { z } from 'zod';
|
|
import { AccessCustomization } from '~/components/Board/Customize/Access/AccessCustomization';
|
|
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 { useBoardLink } from '~/components/layout/Templates/BoardLayout';
|
|
import { MainLayout } from '~/components/layout/Templates/MainLayout';
|
|
import { createTrpcServersideHelpers } from '~/server/api/helper';
|
|
import { getServerAuthSession } from '~/server/auth';
|
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
|
import { firstUpperCase } from '~/tools/shared/strings';
|
|
import { api } from '~/utils/api';
|
|
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
|
import { boardCustomizationSchema } from '~/validations/boards';
|
|
|
|
const notificationId = 'board-customization-notification';
|
|
|
|
export default function CustomizationPage({
|
|
initialConfig,
|
|
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
|
const query = useRouter().query as {
|
|
slug: string;
|
|
};
|
|
const utils = api.useContext();
|
|
const { data: config } = api.config.byName.useQuery(
|
|
{ name: query.slug },
|
|
{
|
|
initialData: initialConfig,
|
|
refetchOnMount: false,
|
|
}
|
|
);
|
|
const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation();
|
|
const { i18nZodResolver } = useI18nZodResolver();
|
|
const { t } = useTranslation('boards/customize');
|
|
const form = useBoardCustomizationForm({
|
|
initialValues: {
|
|
access: {
|
|
allowGuests: config?.settings.access.allowGuests ?? false,
|
|
},
|
|
layout: {
|
|
leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false,
|
|
rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false,
|
|
pingsEnabled: config?.settings.customization.layout.enabledPing ?? false,
|
|
},
|
|
appearance: {
|
|
backgroundSrc: config?.settings.customization.backgroundImageUrl ?? '',
|
|
primaryColor: config?.settings.customization.colors.primary ?? 'red',
|
|
secondaryColor: config?.settings.customization.colors.secondary ?? 'orange',
|
|
shade: (config?.settings.customization.colors.shade as number | undefined) ?? 8,
|
|
opacity: config?.settings.customization.appOpacity ?? 50,
|
|
customCss: config?.settings.customization.customCss ?? '',
|
|
},
|
|
gridstack: {
|
|
sm: config?.settings.customization.gridstack?.columnCountSmall ?? 3,
|
|
md: config?.settings.customization.gridstack?.columnCountMedium ?? 6,
|
|
lg: config?.settings.customization.gridstack?.columnCountLarge ?? 12,
|
|
},
|
|
pageMetadata: {
|
|
pageTitle: config?.settings.customization.pageTitle ?? '',
|
|
metaTitle: config?.settings.customization.metaTitle ?? '',
|
|
logoSrc: config?.settings.customization.logoImageUrl ?? '',
|
|
faviconSrc: config?.settings.customization.faviconUrl ?? '',
|
|
},
|
|
},
|
|
validate: i18nZodResolver(boardCustomizationSchema),
|
|
validateInputOnChange: true,
|
|
validateInputOnBlur: true,
|
|
});
|
|
|
|
const backToBoardHref = useBoardLink(`/board/${query.slug}`);
|
|
|
|
const handleSubmit = async (values: z.infer<typeof boardCustomizationSchema>) => {
|
|
if (isLoading) return;
|
|
showNotification({
|
|
id: notificationId,
|
|
title: t('notifications.pending.title'),
|
|
message: t('notifications.pending.message'),
|
|
loading: true,
|
|
});
|
|
await saveCusomization(
|
|
{
|
|
name: query.slug,
|
|
...values,
|
|
},
|
|
{
|
|
onSettled() {
|
|
void utils.config.byName.invalidate({ name: query.slug });
|
|
},
|
|
onSuccess() {
|
|
updateNotification({
|
|
id: notificationId,
|
|
title: t('notifications.success.title'),
|
|
message: t('notifications.success.message'),
|
|
color: 'green',
|
|
icon: <IconCheck />,
|
|
});
|
|
form.resetDirty();
|
|
},
|
|
onError() {
|
|
updateNotification({
|
|
id: notificationId,
|
|
title: t('notifications.error.title'),
|
|
message: t('notifications.error.message'),
|
|
color: 'red',
|
|
icon: <IconX />,
|
|
});
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
const metaTitle = `${t('metaTitle', {
|
|
name: firstUpperCase(query.slug),
|
|
})} • Homarr`;
|
|
|
|
return (
|
|
<MainLayout
|
|
contentComponents={
|
|
<Button
|
|
component={Link}
|
|
passHref
|
|
color={config?.settings.customization.colors.primary ?? 'red'}
|
|
href={backToBoardHref}
|
|
variant="light"
|
|
leftIcon={<IconArrowLeft size={16} />}
|
|
>
|
|
{t('backToBoard')}
|
|
</Button>
|
|
}
|
|
>
|
|
<Head>
|
|
<title>{metaTitle}</title>
|
|
</Head>
|
|
<Affix position={{ bottom: rem(20), left: rem(20), right: rem(20) }}>
|
|
<Transition transition="slide-up" mounted={form.isDirty()}>
|
|
{(transitionStyles) => (
|
|
<Card
|
|
style={transitionStyles}
|
|
sx={(theme) => ({
|
|
background:
|
|
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[1],
|
|
})}
|
|
shadow="md"
|
|
withBorder
|
|
>
|
|
<Group position="apart" noWrap>
|
|
<Text weight="bold">{t('save.note')}</Text>
|
|
<Group spacing="md">
|
|
<Button
|
|
onClick={() => {
|
|
form.reset();
|
|
}}
|
|
variant="subtle"
|
|
type="button"
|
|
>
|
|
{t('common:cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (!form.isValid()) {
|
|
form.validate();
|
|
return;
|
|
}
|
|
|
|
handleSubmit(form.values);
|
|
}}
|
|
loading={isLoading}
|
|
color="green"
|
|
>
|
|
{t('save.button')}
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Card>
|
|
)}
|
|
</Transition>
|
|
</Affix>
|
|
<Container>
|
|
<Paper p="xl" py="sm" mih="100%" withBorder>
|
|
<Stack>
|
|
<Group position="apart">
|
|
<Title order={2}>
|
|
{t('pageTitle', {
|
|
name: firstUpperCase(query.slug),
|
|
})}
|
|
</Title>
|
|
</Group>
|
|
<BoardCustomizationFormProvider form={form}>
|
|
<Stack spacing="xl">
|
|
<Stack spacing="xs">
|
|
<SectionTitle type="layout" icon={IconLayout} />
|
|
<LayoutCustomization />
|
|
</Stack>
|
|
<Stack spacing="xs">
|
|
<SectionTitle type="access" icon={IconLock} />
|
|
<AccessCustomization />
|
|
</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>
|
|
</Stack>
|
|
</BoardCustomizationFormProvider>
|
|
</Stack>
|
|
</Paper>
|
|
</Container>
|
|
</MainLayout>
|
|
);
|
|
}
|
|
|
|
type SectionTitleProps = {
|
|
type: 'layout' | 'gridstack' | 'pageMetadata' | 'appereance' | 'access';
|
|
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>
|
|
);
|
|
};
|
|
|
|
const routeParamsSchema = z.object({
|
|
slug: z.string(),
|
|
});
|
|
|
|
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale, params }) => {
|
|
const routeParams = routeParamsSchema.safeParse(params);
|
|
if (!routeParams.success) {
|
|
return {
|
|
notFound: true,
|
|
};
|
|
}
|
|
|
|
const session = await getServerAuthSession({ req, res });
|
|
if (!session?.user.isAdmin) {
|
|
return {
|
|
notFound: true,
|
|
};
|
|
}
|
|
|
|
const helpers = await createTrpcServersideHelpers({ req, res });
|
|
|
|
const config = await helpers.config.byName.fetch({ name: routeParams.data.slug });
|
|
|
|
const translations = await getServerSideTranslations(
|
|
[
|
|
'boards/customize',
|
|
'settings/common',
|
|
'settings/customization/general',
|
|
'settings/customization/page-appearance',
|
|
'settings/customization/shade-selector',
|
|
'settings/customization/opacity-selector',
|
|
'settings/customization/gridstack',
|
|
'settings/customization/access',
|
|
],
|
|
locale,
|
|
req,
|
|
res
|
|
);
|
|
|
|
return {
|
|
props: {
|
|
initialConfig: config,
|
|
primaryColor: config.settings.customization.colors.primary,
|
|
secondaryColor: config.settings.customization.colors.secondary,
|
|
primaryShade: config.settings.customization.colors.shade,
|
|
trpcState: helpers.dehydrate(),
|
|
...translations,
|
|
},
|
|
};
|
|
};
|