Add user settings, improve color scheme

This commit is contained in:
Meier Lukas
2023-07-29 11:19:40 +02:00
parent d8562e2990
commit ed76afbce8
8 changed files with 129 additions and 31 deletions

View File

@@ -52,6 +52,7 @@ model User {
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
settings UserSettings?
} }
model VerificationToken { model VerificationToken {
@@ -67,3 +68,18 @@ model RegistrationToken {
token String @unique token String @unique
expires DateTime expires DateTime
} }
model UserSettings {
id String @id @default(cuid())
userId String
colorScheme String @default("environment") // environment, light, dark
language String @default("en")
searchTemplate String @default("https://google.com/search?q=%s")
openSearchInNewTab Boolean @default(true)
disablePingPulse Boolean @default(false)
replacePingWithIcons Boolean @default(false)
useDebugLanguage Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
}

View File

@@ -35,7 +35,6 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<ColorSchemeSwitch /> <ColorSchemeSwitch />
<Menu.Divider />
{!editModeEnabled && ( {!editModeEnabled && (
<Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}> <Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}>
{t('sections.settings')} {t('sections.settings')}

View File

@@ -0,0 +1,27 @@
import { ColorScheme } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { setCookie } from 'cookies-next';
import { Session } from 'next-auth';
import { useState } from 'react';
import { api } from '~/utils/api';
export const useColorScheme = (defaultValue: ColorScheme, session: Session) => {
const [colorScheme, setColorScheme] = useState(defaultValue);
const { mutateAsync } = api.user.changeColorScheme.useMutation();
const toggleColorScheme = async () => {
const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark';
setColorScheme(newColorScheme);
setCookie('color-scheme', newColorScheme);
if (session && new Date(session.expires) > new Date()) {
await mutateAsync({ colorScheme: newColorScheme });
}
};
useHotkeys([['mod+J', () => void toggleColorScheme()]]);
return {
colorScheme,
toggleColorScheme,
};
};

View File

@@ -1,5 +1,4 @@
import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from '@mantine/core'; import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from '@mantine/core';
import { useColorScheme, useHotkeys, useLocalStorage } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -7,7 +6,7 @@ import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persi
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import Consola from 'consola'; import Consola from 'consola';
import { getCookie } from 'cookies-next'; import { getCookie, setCookie } from 'cookies-next';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import { Session } from 'next-auth'; import { Session } from 'next-auth';
import { SessionProvider, getSession } from 'next-auth/react'; import { SessionProvider, getSession } from 'next-auth/react';
@@ -17,8 +16,10 @@ import Head from 'next/head';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import { env } from '~/env.js'; import { env } from '~/env.js';
import { useColorScheme } from '~/hooks/use-colorscheme';
import { ConfigType } from '~/types/config'; import { ConfigType } from '~/types/config';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { colorSchemeParser } from '~/validations/user';
import nextI18nextConfig from '../../next-i18next.config.js'; import nextI18nextConfig from '../../next-i18next.config.js';
import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal'; import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal';
@@ -46,7 +47,6 @@ function App(
colorScheme: ColorScheme; colorScheme: ColorScheme;
packageAttributes: ServerSidePackageAttributesType; packageAttributes: ServerSidePackageAttributesType;
editModeEnabled: boolean; editModeEnabled: boolean;
defaultColorScheme: ColorScheme;
config?: ConfigType; config?: ConfigType;
configName?: string; configName?: string;
session: Session; session: Session;
@@ -72,34 +72,20 @@ function App(
setPrimaryShade, setPrimaryShade,
}; };
// hook will return either 'dark' or 'light' on client
// and always 'light' during ssr as window.matchMedia is not available
const preferredColorScheme = useColorScheme(props.pageProps.defaultColorScheme);
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
key: 'mantine-color-scheme',
defaultValue: preferredColorScheme,
getInitialValueInEffect: true,
});
const { setInitialPackageAttributes } = usePackageAttributesStore(); const { setInitialPackageAttributes } = usePackageAttributesStore();
const { setDisabled } = useEditModeInformationStore();
useEffect(() => { useEffect(() => {
setInitialPackageAttributes(props.pageProps.packageAttributes); setInitialPackageAttributes(props.pageProps.packageAttributes);
if (!props.pageProps.editModeEnabled) {
setDisabled();
}
}, []); }, []);
const toggleColorScheme = (value?: ColorScheme) =>
setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark'));
const asyncStoragePersister = createAsyncStoragePersister({ const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage, storage: AsyncStorage,
}); });
useHotkeys([['mod+J', () => toggleColorScheme()]]); const { colorScheme, toggleColorScheme } = useColorScheme(
pageProps.colorScheme,
pageProps.session
);
return ( return (
<> <>
@@ -178,13 +164,25 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
return { return {
pageProps: { pageProps: {
colorScheme: getCookie('color-scheme', ctx) || 'light', colorScheme: getActiveColorScheme(session, ctx),
packageAttributes: getServiceSidePackageAttributes(), packageAttributes: getServiceSidePackageAttributes(),
editModeEnabled: process.env.DISABLE_EDIT_MODE !== 'true',
defaultColorScheme: env.DEFAULT_COLOR_SCHEME,
session, session,
}, },
}; };
}; };
export default appWithTranslation<any>(api.withTRPC(App), nextI18nextConfig as any); export default appWithTranslation<any>(api.withTRPC(App), nextI18nextConfig as any);
const getActiveColorScheme = (session: Session | null, ctx: GetServerSidePropsContext) => {
const environmentColorScheme = env.DEFAULT_COLOR_SCHEME ?? 'light';
const cookieValue = getCookie('color-scheme', ctx);
const activeColorScheme = colorSchemeParser.parse(
session?.user?.colorScheme ?? cookieValue ?? environmentColorScheme
);
if (cookieValue !== activeColorScheme) {
setCookie('color-scheme', activeColorScheme, ctx);
}
return activeColorScheme === 'environment' ? environmentColorScheme : activeColorScheme;
};

View File

@@ -2,9 +2,9 @@ import { TRPCError } from '@trpc/server';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword } from '~/utils/security'; import { hashPassword } from '~/utils/security';
import { signUpFormSchema } from '~/validations/user'; import { colorSchemeParser, signUpFormSchema } from '~/validations/user';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
register: publicProcedure register: publicProcedure
@@ -50,6 +50,12 @@ export const userRouter = createTRPCRouter({
name: input.username, name: input.username,
password: hashedPassword, password: hashedPassword,
salt: salt, salt: salt,
settings: {
create: {
colorScheme: colorSchemeParser.parse(ctx.cookies['color-scheme']),
language: ctx.cookies['config-locale'] ?? 'en',
},
},
}, },
}); });
await ctx.prisma.registrationToken.delete({ await ctx.prisma.registrationToken.delete({
@@ -63,4 +69,24 @@ export const userRouter = createTRPCRouter({
name: user.name, name: user.name,
}; };
}), }),
changeColorScheme: protectedProcedure
.input(
z.object({
colorScheme: colorSchemeParser,
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({
where: {
id: ctx.session?.user?.id,
},
data: {
settings: {
update: {
colorScheme: input.colorScheme,
},
},
},
});
}),
}); });

View File

@@ -25,6 +25,7 @@ import { prisma } from '../db';
interface CreateContextOptions { interface CreateContextOptions {
session: Session | null; session: Session | null;
cookies: Partial<Record<string, string>>;
} }
/** /**
@@ -39,6 +40,7 @@ interface CreateContextOptions {
*/ */
const createInnerTRPCContext = (opts: CreateContextOptions) => ({ const createInnerTRPCContext = (opts: CreateContextOptions) => ({
session: opts.session, session: opts.session,
cookies: opts.cookies,
prisma, prisma,
}); });
@@ -56,6 +58,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({ return createInnerTRPCContext({
session, session,
cookies: req.cookies,
}); });
}; };

View File

@@ -8,7 +8,7 @@ import Credentials from 'next-auth/providers/credentials';
import { prisma } from '~/server/db'; import { prisma } from '~/server/db';
import EmptyNextAuthProvider from '~/utils/empty-provider'; import EmptyNextAuthProvider from '~/utils/empty-provider';
import { fromDate, generateSessionToken } from '~/utils/session'; import { fromDate, generateSessionToken } from '~/utils/session';
import { signInSchema } from '~/validations/user'; import { colorSchemeParser, signInSchema } from '~/validations/user';
/** /**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@@ -21,6 +21,8 @@ declare module 'next-auth' {
user: DefaultSession['user'] & { user: DefaultSession['user'] & {
id: string; id: string;
isAdmin: boolean; isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
language: string;
// ...other properties // ...other properties
// role: UserRole; // role: UserRole;
}; };
@@ -28,6 +30,8 @@ declare module 'next-auth' {
interface User { interface User {
isAdmin: boolean; isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
language: string;
// ...other properties // ...other properties
// role: UserRole; // role: UserRole;
} }
@@ -64,9 +68,19 @@ export const constructAuthOptions = (
where: { where: {
id: user.id, id: user.id,
}, },
include: {
settings: {
select: {
colorScheme: true,
language: true,
},
},
},
}); });
session.user.isAdmin = userFromDatabase.isAdmin; session.user.isAdmin = userFromDatabase.isAdmin;
session.user.colorScheme = colorSchemeParser.parse(userFromDatabase.settings?.colorScheme);
session.user.language = userFromDatabase.settings?.language ?? 'en';
} }
return session; return session;
@@ -122,6 +136,14 @@ export const constructAuthOptions = (
where: { where: {
name: data.name, name: data.name,
}, },
include: {
settings: {
select: {
colorScheme: true,
language: true,
},
},
},
}); });
if (!user || !user.password) { if (!user || !user.password) {
@@ -142,6 +164,8 @@ export const constructAuthOptions = (
id: user.id, id: user.id,
name: user.name, name: user.name,
isAdmin: false, isAdmin: false,
colorScheme: colorSchemeParser.parse(user.settings?.colorScheme),
language: user.settings?.language ?? 'en',
}; };
}, },
}), }),

View File

@@ -18,3 +18,8 @@ export const signUpFormSchema = z
} satisfies CustomErrorParams, } satisfies CustomErrorParams,
path: ['passwordConfirmation'], path: ['passwordConfirmation'],
}); });
export const colorSchemeParser = z
.enum(['light', 'dark', 'environment'])
.default('environment')
.catch('environment');