diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8949aa855..ed932d718 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,16 +42,17 @@ model Session { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique emailVerified DateTime? image String? password String? salt String? - isAdmin Boolean @default(false) + isAdmin Boolean @default(false) accounts Account[] sessions Session[] + settings UserSettings? } model VerificationToken { @@ -67,3 +68,18 @@ model RegistrationToken { token String @unique 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]) +} diff --git a/src/components/layout/header/SettingsMenu.tsx b/src/components/layout/header/SettingsMenu.tsx index 01cc9c34b..514391a16 100644 --- a/src/components/layout/header/SettingsMenu.tsx +++ b/src/components/layout/header/SettingsMenu.tsx @@ -35,7 +35,6 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str - {!editModeEnabled && ( } onClick={drawer.open}> {t('sections.settings')} diff --git a/src/hooks/use-colorscheme.ts b/src/hooks/use-colorscheme.ts new file mode 100644 index 000000000..46aafa659 --- /dev/null +++ b/src/hooks/use-colorscheme.ts @@ -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, + }; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 9c40390da..a42263082 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,4 @@ import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from '@mantine/core'; -import { useColorScheme, useHotkeys, useLocalStorage } from '@mantine/hooks'; import { ModalsProvider } from '@mantine/modals'; import { Notifications } from '@mantine/notifications'; 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 { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import Consola from 'consola'; -import { getCookie } from 'cookies-next'; +import { getCookie, setCookie } from 'cookies-next'; import { GetServerSidePropsContext } from 'next'; import { Session } from 'next-auth'; import { SessionProvider, getSession } from 'next-auth/react'; @@ -17,8 +16,10 @@ import Head from 'next/head'; import { useEffect, useState } from 'react'; import 'video.js/dist/video-js.css'; import { env } from '~/env.js'; +import { useColorScheme } from '~/hooks/use-colorscheme'; import { ConfigType } from '~/types/config'; import { api } from '~/utils/api'; +import { colorSchemeParser } from '~/validations/user'; import nextI18nextConfig from '../../next-i18next.config.js'; import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal'; @@ -46,7 +47,6 @@ function App( colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType; editModeEnabled: boolean; - defaultColorScheme: ColorScheme; config?: ConfigType; configName?: string; session: Session; @@ -72,34 +72,20 @@ function App( 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({ - key: 'mantine-color-scheme', - defaultValue: preferredColorScheme, - getInitialValueInEffect: true, - }); - const { setInitialPackageAttributes } = usePackageAttributesStore(); - const { setDisabled } = useEditModeInformationStore(); useEffect(() => { setInitialPackageAttributes(props.pageProps.packageAttributes); - - if (!props.pageProps.editModeEnabled) { - setDisabled(); - } }, []); - const toggleColorScheme = (value?: ColorScheme) => - setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark')); - const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, }); - useHotkeys([['mod+J', () => toggleColorScheme()]]); + const { colorScheme, toggleColorScheme } = useColorScheme( + pageProps.colorScheme, + pageProps.session + ); return ( <> @@ -178,13 +164,25 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { return { pageProps: { - colorScheme: getCookie('color-scheme', ctx) || 'light', + colorScheme: getActiveColorScheme(session, ctx), packageAttributes: getServiceSidePackageAttributes(), - editModeEnabled: process.env.DISABLE_EDIT_MODE !== 'true', - defaultColorScheme: env.DEFAULT_COLOR_SCHEME, session, }, }; }; export default appWithTranslation(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; +}; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 50d859e95..638c4fb09 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -2,9 +2,9 @@ import { TRPCError } from '@trpc/server'; import bcrypt from 'bcrypt'; import { z } from 'zod'; 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({ register: publicProcedure @@ -50,6 +50,12 @@ export const userRouter = createTRPCRouter({ name: input.username, password: hashedPassword, salt: salt, + settings: { + create: { + colorScheme: colorSchemeParser.parse(ctx.cookies['color-scheme']), + language: ctx.cookies['config-locale'] ?? 'en', + }, + }, }, }); await ctx.prisma.registrationToken.delete({ @@ -63,4 +69,24 @@ export const userRouter = createTRPCRouter({ 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, + }, + }, + }, + }); + }), }); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 3b83ba494..cef582ff5 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -25,6 +25,7 @@ import { prisma } from '../db'; interface CreateContextOptions { session: Session | null; + cookies: Partial>; } /** @@ -39,6 +40,7 @@ interface CreateContextOptions { */ const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, + cookies: opts.cookies, prisma, }); @@ -56,6 +58,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { return createInnerTRPCContext({ session, + cookies: req.cookies, }); }; diff --git a/src/server/auth.ts b/src/server/auth.ts index 70f593c1c..b60e83905 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -8,7 +8,7 @@ import Credentials from 'next-auth/providers/credentials'; import { prisma } from '~/server/db'; import EmptyNextAuthProvider from '~/utils/empty-provider'; 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` @@ -21,6 +21,8 @@ declare module 'next-auth' { user: DefaultSession['user'] & { id: string; isAdmin: boolean; + colorScheme: 'light' | 'dark' | 'environment'; + language: string; // ...other properties // role: UserRole; }; @@ -28,6 +30,8 @@ declare module 'next-auth' { interface User { isAdmin: boolean; + colorScheme: 'light' | 'dark' | 'environment'; + language: string; // ...other properties // role: UserRole; } @@ -64,9 +68,19 @@ export const constructAuthOptions = ( where: { id: user.id, }, + include: { + settings: { + select: { + colorScheme: true, + language: true, + }, + }, + }, }); session.user.isAdmin = userFromDatabase.isAdmin; + session.user.colorScheme = colorSchemeParser.parse(userFromDatabase.settings?.colorScheme); + session.user.language = userFromDatabase.settings?.language ?? 'en'; } return session; @@ -122,6 +136,14 @@ export const constructAuthOptions = ( where: { name: data.name, }, + include: { + settings: { + select: { + colorScheme: true, + language: true, + }, + }, + }, }); if (!user || !user.password) { @@ -142,6 +164,8 @@ export const constructAuthOptions = ( id: user.id, name: user.name, isAdmin: false, + colorScheme: colorSchemeParser.parse(user.settings?.colorScheme), + language: user.settings?.language ?? 'en', }; }, }), diff --git a/src/validations/user.ts b/src/validations/user.ts index 4ce6c0848..5f05755aa 100644 --- a/src/validations/user.ts +++ b/src/validations/user.ts @@ -18,3 +18,8 @@ export const signUpFormSchema = z } satisfies CustomErrorParams, path: ['passwordConfirmation'], }); + +export const colorSchemeParser = z + .enum(['light', 'dark', 'environment']) + .default('environment') + .catch('environment');