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');