mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 15:35:55 +01:00
✨ Add user settings, improve color scheme
This commit is contained in:
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
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 { 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user