Add board customization page

This commit is contained in:
Meier Lukas
2023-07-31 11:15:18 +02:00
parent e448ce4b00
commit 130b51e109
7 changed files with 267 additions and 64 deletions

View File

@@ -1,43 +1,10 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { createFormContext } from '@mantine/form'; import { createFormContext } from '@mantine/form';
import { z } from 'zod'; import { z } from 'zod';
import { boardCustomizationSchema } from '~/validations/dashboards';
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 [ export const [
BoardCustomizationFormProvider, BoardCustomizationFormProvider,
useBoardCustomizationFormContext, useBoardCustomizationFormContext,
useBoardCustomizationForm, useBoardCustomizationForm,
] = createFormContext<z.infer<typeof schema>>(); ] = createFormContext<z.infer<typeof boardCustomizationSchema>>();

View File

@@ -1,10 +1,13 @@
import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core'; import { Button, Container, Group, Paper, Stack, Text, Title } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { import {
IconArrowLeft, IconArrowLeft,
IconBrush, IconBrush,
IconChartCandle, IconChartCandle,
IconCheck,
IconDragDrop, IconDragDrop,
IconLayout, IconLayout,
IconX,
TablerIconsProps, TablerIconsProps,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
@@ -12,6 +15,7 @@ import { useTranslation } from 'next-i18next';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { z } from 'zod';
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization'; import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization'; import { GridstackCustomization } from '~/components/Board/Customize/Gridstack/GridstackCustomization';
import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization'; import { LayoutCustomization } from '~/components/Board/Customize/Layout/LayoutCustomization';
@@ -21,44 +25,91 @@ import {
useBoardCustomizationForm, useBoardCustomizationForm,
} from '~/components/Board/Customize/form'; } from '~/components/Board/Customize/form';
import { MainLayout } from '~/components/layout/main'; import { MainLayout } from '~/components/layout/main';
import { createTrpcServersideHelpers } from '~/server/api/helper';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { dashboardNamespaces } from '~/tools/server/translation-namespaces'; import { dashboardNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { boardCustomizationSchema } from '~/validations/dashboards';
const notificationId = 'board-customization-notification';
export default function CustomizationPage() { export default function CustomizationPage() {
const query = useRouter().query as { slug: string }; const query = useRouter().query as { slug: string };
const { t } = useTranslation([ const utils = api.useContext();
'settings/customization/general', const { data: config } = api.config.byName.useQuery({ name: query.slug });
'settings/customization/color-selector', const { mutateAsync: saveCusomization, isLoading } = api.config.saveCusomization.useMutation();
]); const { i18nZodResolver } = useI18nZodResolver();
const form = useBoardCustomizationForm({ const form = useBoardCustomizationForm({
initialValues: { initialValues: {
layout: { layout: {
leftSidebarEnabled: false, leftSidebarEnabled: config?.settings.customization.layout.enabledLeftSidebar ?? false,
rightSidebarEnabled: false, rightSidebarEnabled: config?.settings.customization.layout.enabledRightSidebar ?? false,
pingsEnabled: false, pingsEnabled: config?.settings.customization.layout.enabledPing ?? false,
}, },
appearance: { appearance: {
backgroundSrc: '', backgroundSrc: config?.settings.customization.backgroundImageUrl ?? '',
primaryColor: 'red', primaryColor: config?.settings.customization.colors.primary ?? 'red',
secondaryColor: 'orange', secondaryColor: config?.settings.customization.colors.secondary ?? 'orange',
shade: 8, shade: (config?.settings.customization.colors.shade as number | undefined) ?? 8,
opacity: 50, opacity: config?.settings.customization.appOpacity ?? 50,
customCss: '', customCss: config?.settings.customization.customCss ?? '',
}, },
gridstack: { gridstack: {
sm: 3, sm: config?.settings.customization.gridstack?.columnCountSmall ?? 3,
md: 6, md: config?.settings.customization.gridstack?.columnCountMedium ?? 6,
lg: 12, lg: config?.settings.customization.gridstack?.columnCountLarge ?? 12,
}, },
pageMetadata: { pageMetadata: {
pageTitle: '', pageTitle: config?.settings.customization.pageTitle ?? '',
metaTitle: '', metaTitle: config?.settings.customization.metaTitle ?? '',
logoSrc: '', logoSrc: config?.settings.customization.logoImageUrl ?? '',
faviconSrc: '', faviconSrc: config?.settings.customization.faviconUrl ?? '',
}, },
}, },
validate: i18nZodResolver(boardCustomizationSchema),
}); });
const handleSubmit = async (values: z.infer<typeof boardCustomizationSchema>) => {
if (isLoading) return;
showNotification({
id: notificationId,
title: 'Saving customization',
message: 'Please wait while we save your customization',
loading: true,
});
await saveCusomization(
{
name: query.slug,
...values,
},
{
onSettled() {
void utils.config.byName.invalidate({ name: query.slug });
},
onSuccess() {
updateNotification({
id: notificationId,
title: 'Customization saved',
message: 'Your customization has been saved',
color: 'green',
icon: <IconCheck />,
});
},
onError() {
updateNotification({
id: notificationId,
title: 'Error',
message: 'Unable to save customization',
color: 'red',
icon: <IconX />,
});
},
}
);
};
return ( return (
<MainLayout> <MainLayout>
<Container> <Container>
@@ -76,7 +127,7 @@ export default function CustomizationPage() {
</Button> </Button>
</Group> </Group>
<BoardCustomizationFormProvider form={form}> <BoardCustomizationFormProvider form={form}>
<form> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack spacing="xl"> <Stack spacing="xl">
<Stack spacing="xs"> <Stack spacing="xs">
<SectionTitle type="layout" icon={IconLayout} /> <SectionTitle type="layout" icon={IconLayout} />
@@ -94,7 +145,9 @@ export default function CustomizationPage() {
<SectionTitle type="appereance" icon={IconBrush} /> <SectionTitle type="appereance" icon={IconBrush} />
<AppearanceCustomization /> <AppearanceCustomization />
</Stack> </Stack>
<Button type="submit">Save changes</Button> <Button type="submit" loading={isLoading}>
Save changes
</Button>
</Stack> </Stack>
</form> </form>
</BoardCustomizationFormProvider> </BoardCustomizationFormProvider>
@@ -124,11 +177,34 @@ const SectionTitle = ({ type, icon: Icon }: SectionTitleProps) => {
); );
}; };
export const getServerSideProps: GetServerSideProps = async ({ req, res, locale }) => { 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 });
helpers.config.byName.prefetch({ name: routeParams.data.slug });
const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res); const translations = await getServerSideTranslations(dashboardNamespaces, locale, req, res);
return { return {
props: { props: {
trpcState: helpers.dehydrate(),
...translations, ...translations,
}, },
}; };

View File

@@ -2,7 +2,13 @@ import { Button, ButtonProps, Text, Title, Tooltip } from '@mantine/core';
import { useHotkeys, useWindowEvent } from '@mantine/hooks'; import { useHotkeys, useWindowEvent } from '@mantine/hooks';
import { openContextModal } from '@mantine/modals'; import { openContextModal } from '@mantine/modals';
import { hideNotification, showNotification } from '@mantine/notifications'; import { hideNotification, showNotification } from '@mantine/notifications';
import { IconApps, IconBrandDocker, IconEditCircle, IconEditCircleOff } from '@tabler/icons-react'; import {
IconApps,
IconBrandDocker,
IconEditCircle,
IconEditCircleOff,
IconSettings,
} from '@tabler/icons-react';
import Consola from 'consola'; import Consola from 'consola';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@@ -75,6 +81,7 @@ export const HeaderActions = () => {
<> <>
<DockerButton /> <DockerButton />
<ToggleEditModeButton /> <ToggleEditModeButton />
<CustomizeBoardButton />
</> </>
); );
}; };
@@ -91,6 +98,18 @@ const DockerButton = () => {
); );
}; };
const CustomizeBoardButton = () => {
const { name } = useConfigContext();
return (
<Tooltip label="Customize board">
<HeaderActionButton component={Link} href={`/board/${name}/customize`}>
<IconSettings size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>
);
};
type SpecificLinkProps = { type SpecificLinkProps = {
component: typeof Link; component: typeof Link;
href: string; href: string;

16
src/server/api/helper.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createServerSideHelpers } from '@trpc/react-query/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { GetServerSidePropsContext } from 'next';
import superjson from 'superjson';
import { rootRouter } from './root';
import { createTRPCContext } from './trpc';
export const createTrpcServersideHelpers = async (
props: Pick<GetServerSidePropsContext, 'req' | 'res'>
) =>
createServerSideHelpers({
router: rootRouter,
ctx: await createTRPCContext(props as CreateNextContextOptions),
transformer: superjson,
});

View File

@@ -1,13 +1,19 @@
import { MantineTheme } from '@mantine/core';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import Consola from 'consola'; import Consola from 'consola';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import { configExists } from '~/tools/config/configExists';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { BackendConfigType, ConfigType } from '~/types/config'; import { BackendConfigType, ConfigType } from '~/types/config';
import { boardCustomizationSchema } from '~/validations/dashboards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '../../../tools/config/getConfig'; import { getConfig } from '../../../tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
export const configRouter = createTRPCRouter({ export const configRouter = createTRPCRouter({
all: publicProcedure.query(async () => { all: publicProcedure.query(async () => {
@@ -17,10 +23,10 @@ export const configRouter = createTRPCRouter({
// Strip the .json extension from the file name // Strip the .json extension from the file name
return files.map((file) => file.replace('.json', '')); return files.map((file) => file.replace('.json', ''));
}), }),
delete: publicProcedure delete: adminProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: configNameSchema,
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -60,10 +66,10 @@ export const configRouter = createTRPCRouter({
message: 'Configuration deleted with success', message: 'Configuration deleted with success',
}; };
}), }),
save: publicProcedure save: adminProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: configNameSchema,
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'), config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
}) })
) )
@@ -124,6 +130,8 @@ export const configRouter = createTRPCRouter({
}, },
})), })),
], ],
// Settings can only be changed in the configuration file
settings: previousConfig.settings,
}; };
newConfig = { newConfig = {
@@ -160,4 +168,59 @@ export const configRouter = createTRPCRouter({
message: 'Configuration saved with success', message: 'Configuration saved with success',
}; };
}), }),
byName: publicProcedure
.input(
z.object({
name: configNameSchema,
})
)
.query(async ({ ctx, input }) => {
if (!configExists(input.name)) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Configuration not found',
});
}
return await getFrontendConfig(input.name);
}),
saveCusomization: adminProcedure
.input(boardCustomizationSchema.and(z.object({ name: configNameSchema })))
.mutation(async ({ input }) => {
const previousConfig = getConfig(input.name);
const newConfig = {
...previousConfig,
settings: {
...previousConfig.settings,
customization: {
...previousConfig.settings.customization,
appOpacity: input.appearance.opacity,
backgroundImageUrl: input.appearance.backgroundSrc,
colors: {
primary: input.appearance.primaryColor,
secondary: input.appearance.secondaryColor,
shade: input.appearance.shade as MantineTheme['primaryShade'],
},
customCss: input.appearance.customCss,
faviconUrl: input.pageMetadata.faviconSrc,
gridstack: {
columnCountSmall: input.gridstack.sm,
columnCountMedium: input.gridstack.md,
columnCountLarge: input.gridstack.lg,
},
layout: {
...previousConfig.settings.customization.layout,
enabledLeftSidebar: input.layout.leftSidebarEnabled,
enabledRightSidebar: input.layout.rightSidebarEnabled,
enabledPing: input.layout.pingsEnabled,
},
logoImageUrl: input.pageMetadata.logoSrc,
metaTitle: input.pageMetadata.metaTitle,
pageTitle: input.pageMetadata.pageTitle,
},
},
} satisfies BackendConfigType;
const targetPath = path.join('data/configs', `${input.name}.json`);
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
}),
}); });

View File

@@ -128,3 +128,30 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* @see https://trpc.io/docs/procedures * @see https://trpc.io/docs/procedures
*/ */
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
/** Reusable middleware that enforces users are logged in before running the procedure. */
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
if (!ctx.session?.user.isAdmin) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
/**
* Admin (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in admins, use this. It verifies
* the session is valid, guarantees `ctx.session.user` is not null and the user is an admin.
*
* @see https://trpc.io/docs/procedures
*/
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);

View File

@@ -1,5 +1,40 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod'; import { z } from 'zod';
export const createDashboardSchemaValidation = z.object({ export const createDashboardSchemaValidation = z.object({
name: z.string().min(2).max(25), name: z.string().min(2).max(25),
}); });
export const boardCustomizationSchema = 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(),
}),
});