mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
✨ Add user settings, improve color scheme
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<ColorSchemeSwitch />
|
||||
<Menu.Divider />
|
||||
{!editModeEnabled && (
|
||||
<Menu.Item icon={<IconSettings strokeWidth={1.2} size={18} />} onClick={drawer.open}>
|
||||
{t('sections.settings')}
|
||||
|
||||
27
src/hooks/use-colorscheme.ts
Normal file
27
src/hooks/use-colorscheme.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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<ColorScheme>({
|
||||
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<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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { prisma } from '../db';
|
||||
|
||||
interface CreateContextOptions {
|
||||
session: Session | null;
|
||||
cookies: Partial<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -18,3 +18,8 @@ export const signUpFormSchema = z
|
||||
} satisfies CustomErrorParams,
|
||||
path: ['passwordConfirmation'],
|
||||
});
|
||||
|
||||
export const colorSchemeParser = z
|
||||
.enum(['light', 'dark', 'environment'])
|
||||
.default('environment')
|
||||
.catch('environment');
|
||||
|
||||
Reference in New Issue
Block a user