🎨 Improve color scheme logic

This commit is contained in:
Meier Lukas
2023-07-29 14:30:19 +02:00
parent c312828c79
commit c165648d5b
10 changed files with 169 additions and 115 deletions

View File

@@ -1,9 +1,10 @@
import { Menu, useMantineColorScheme } from '@mantine/core'; import { Menu } from '@mantine/core';
import { IconMoonStars, IconSun } from '@tabler/icons-react'; import { IconMoonStars, IconSun } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useColorScheme } from '~/hooks/use-colorscheme';
export const ColorSchemeSwitch = () => { export const ColorSchemeSwitch = () => {
const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const { colorScheme, toggleColorScheme } = useColorScheme();
const { t } = useTranslation('settings/general/theme-selector'); const { t } = useTranslation('settings/general/theme-selector');
const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars;

View File

@@ -1,8 +1,8 @@
const { z } = require('zod'); const { z } = require('zod');
const { createEnv } = require('@t3-oss/env-nextjs'); const { createEnv } = require('@t3-oss/env-nextjs');
const portSchema = z.string().regex(/\d+/).transform(Number).optional() const portSchema = z.string().regex(/\d+/).transform(Number).optional();
const envSchema = z.enum(["development", "test", "production"]); const envSchema = z.enum(['development', 'test', 'production']);
const env = createEnv({ const env = createEnv({
/** /**
@@ -11,22 +11,17 @@ const env = createEnv({
*/ */
server: { server: {
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
NODE_ENV: envSchema,
NEXTAUTH_SECRET: NEXTAUTH_SECRET:
process.env.NODE_ENV === "production" process.env.NODE_ENV === 'production' ? z.string().min(1) : z.string().min(1).optional(),
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess( NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present. // Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str, (str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL // VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string().min(1) : z.string().url(), process.env.VERCEL ? z.string().min(1) : z.string().url()
), ),
DEFAULT_COLOR_SCHEME: z.enum(['light', 'dark']).optional().default('light'),
DOCKER_HOST: z.string().optional(), DOCKER_HOST: z.string().optional(),
DOCKER_PORT: z.string().regex(/\d+/).transform(Number).optional(), DOCKER_PORT: z.string().regex(/\d+/).transform(Number).optional(),
PORT: portSchema
}, },
/** /**
@@ -36,8 +31,9 @@ const env = createEnv({
*/ */
client: { client: {
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1), // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: z.enum(['light', 'dark']).optional().default('light'),
NEXT_PUBLIC_PORT: portSchema, NEXT_PUBLIC_PORT: portSchema,
NEXT_PUBLIC_NODE_ENV: envSchema NEXT_PUBLIC_NODE_ENV: envSchema,
}, },
/** /**
@@ -46,21 +42,17 @@ const env = createEnv({
*/ */
runtimeEnv: { runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXT_PUBLIC_DISABLE_EDIT_MODE: process.env.DISABLE_EDIT_MODE,
DISABLE_EDIT_MODE: process.env.DISABLE_EDIT_MODE,
DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME,
DOCKER_HOST: process.env.DOCKER_HOST, DOCKER_HOST: process.env.DOCKER_HOST,
DOCKER_PORT: process.env.DOCKER_PORT, DOCKER_PORT: process.env.DOCKER_PORT,
VERCEL_URL: process.env.VERCEL_URL, VERCEL_URL: process.env.VERCEL_URL,
PORT: process.env.PORT, NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME,
NEXT_PUBLIC_PORT: process.env.PORT, NEXT_PUBLIC_PORT: process.env.PORT,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
}, },
}); });
module.exports = { module.exports = {
env env,
} };

View File

@@ -1,29 +0,0 @@
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';
import { COOKIE_COLOR_SCHEME_KEY } from '../../data/constants';
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(COOKIE_COLOR_SCHEME_KEY, newColorScheme);
if (session && new Date(session.expires) > new Date()) {
await mutateAsync({ colorScheme: newColorScheme });
}
};
useHotkeys([['mod+J', () => void toggleColorScheme()]]);
return {
colorScheme,
toggleColorScheme,
};
};

View File

@@ -0,0 +1,70 @@
import { ColorScheme as MantineColorScheme } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { setCookie } from 'cookies-next';
import { Session } from 'next-auth';
import { createContext, useContext, useState } from 'react';
import { api } from '~/utils/api';
import { COOKIE_COLOR_SCHEME_KEY } from '../../data/constants';
export type ColorScheme = 'dark' | 'light' | 'environment';
export const ColorSchemeContext = createContext<{
colorScheme: MantineColorScheme;
settings: ColorScheme;
toggleColorScheme: () => Promise<void>;
setColorScheme: (colorScheme: ColorScheme) => void;
} | null>(null);
type ColorSchemeProviderProps = {
activeColorScheme: ColorScheme;
environmentColorScheme: MantineColorScheme;
session: Session;
children: (colorScheme: MantineColorScheme) => React.ReactNode;
};
export const ColorSchemeProvider = ({
activeColorScheme,
environmentColorScheme,
session,
children,
}: ColorSchemeProviderProps) => {
const [colorScheme, setColorScheme] = useState(activeColorScheme);
const { mutateAsync } = api.user.changeColorScheme.useMutation();
const toggleColorScheme = async () => {
const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark';
setColorScheme(newColorScheme);
setCookie(COOKIE_COLOR_SCHEME_KEY, newColorScheme);
if (session && new Date(session.expires) > new Date()) {
await mutateAsync({ colorScheme: newColorScheme });
}
};
const changeColorScheme = (colorScheme: ColorScheme) => setColorScheme(colorScheme);
useHotkeys([['mod+J', () => void toggleColorScheme()]]);
const mantineColorScheme = colorScheme === 'environment' ? environmentColorScheme : colorScheme;
return (
<ColorSchemeContext.Provider
value={{
colorScheme: mantineColorScheme,
settings: colorScheme,
toggleColorScheme,
setColorScheme: changeColorScheme,
}}
>
{children(mantineColorScheme)}
</ColorSchemeContext.Provider>
);
};
export const useColorScheme = () => {
const context = useContext(ColorSchemeContext);
if (!context) {
throw new Error('useColorScheme must be used within a ColorSchemeProvider');
}
return context;
};

View File

@@ -1,4 +1,4 @@
import { ColorScheme, ColorSchemeProvider, MantineProvider, MantineTheme } from '@mantine/core'; import { ColorScheme as MantineColorScheme, MantineProvider, MantineTheme } from '@mantine/core';
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';
@@ -16,7 +16,7 @@ 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 { ColorScheme, ColorSchemeProvider } 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 { colorSchemeParser } from '~/validations/user';
@@ -45,7 +45,8 @@ import { theme } from '../tools/server/theme/theme';
function App( function App(
this: any, this: any,
props: AppProps<{ props: AppProps<{
colorScheme: ColorScheme; activeColorScheme: MantineColorScheme;
environmentColorScheme: MantineColorScheme;
packageAttributes: ServerSidePackageAttributesType; packageAttributes: ServerSidePackageAttributesType;
editModeEnabled: boolean; editModeEnabled: boolean;
config?: ConfigType; config?: ConfigType;
@@ -83,11 +84,6 @@ function App(
storage: AsyncStorage, storage: AsyncStorage,
}); });
const { colorScheme, toggleColorScheme } = useColorScheme(
pageProps.colorScheme,
pageProps.session
);
return ( return (
<> <>
<Head> <Head>
@@ -98,50 +94,52 @@ function App(
client={queryClient} client={queryClient}
persistOptions={{ persister: asyncStoragePersister }} persistOptions={{ persister: asyncStoragePersister }}
> >
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}> <ColorSchemeProvider {...pageProps}>
<ColorTheme.Provider value={colorTheme}> {(colorScheme) => (
<MantineProvider <ColorTheme.Provider value={colorTheme}>
theme={{ <MantineProvider
...theme, theme={{
components: { ...theme,
Checkbox: { components: {
styles: { Checkbox: {
input: { cursor: 'pointer' }, styles: {
label: { cursor: 'pointer' }, input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
},
Switch: {
styles: {
input: { cursor: 'pointer' },
label: { cursor: 'pointer' },
},
}, },
}, },
Switch: { primaryColor,
styles: { primaryShade,
input: { cursor: 'pointer' }, colorScheme,
label: { cursor: 'pointer' }, }}
}, withGlobalStyles
}, withNormalizeCSS
}, >
primaryColor, <ConfigProvider {...props.pageProps}>
primaryShade, <Notifications limit={4} position="bottom-left" />
colorScheme, <ModalsProvider
}} modals={{
withGlobalStyles editApp: EditAppModal,
withNormalizeCSS selectElement: SelectElementModal,
> integrationOptions: WidgetsEditModal,
<ConfigProvider {...props.pageProps}> integrationRemove: WidgetsRemoveModal,
<Notifications limit={4} position="bottom-left" /> categoryEditModal: CategoryEditModal,
<ModalsProvider changeAppPositionModal: ChangeAppPositionModal,
modals={{ changeIntegrationPositionModal: ChangeWidgetPositionModal,
editApp: EditAppModal, }}
selectElement: SelectElementModal, >
integrationOptions: WidgetsEditModal, <Component {...pageProps} />
integrationRemove: WidgetsRemoveModal, </ModalsProvider>
categoryEditModal: CategoryEditModal, </ConfigProvider>
changeAppPositionModal: ChangeAppPositionModal, </MantineProvider>
changeIntegrationPositionModal: ChangeWidgetPositionModal, </ColorTheme.Provider>
}} )}
>
<Component {...pageProps} />
</ModalsProvider>
</ConfigProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider> </ColorSchemeProvider>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider> </PersistQueryClientProvider>
@@ -151,16 +149,12 @@ function App(
} }
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
if (process.env.DISABLE_EDIT_MODE === 'true') { if (env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME !== 'light') {
Consola.warn( Consola.debug(
'EXPERIMENTAL: You have disabled the edit mode. Modifications are no longer possible and any requests on the API will be dropped. If you want to disable this, unset the DISABLE_EDIT_MODE environment variable. This behaviour may be removed in future versions of Homarr' `Overriding the default color scheme with ${env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME}`
); );
} }
if (env.DEFAULT_COLOR_SCHEME !== 'light') {
Consola.debug(`Overriding the default color scheme with ${env.DEFAULT_COLOR_SCHEME}`);
}
const session = await getSession(ctx); const session = await getSession(ctx);
// Set the cookie language to the user language if it is not set correctly // Set the cookie language to the user language if it is not set correctly
@@ -171,7 +165,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
return { return {
pageProps: { pageProps: {
colorScheme: getActiveColorScheme(session, ctx), ...getActiveColorScheme(session, ctx),
packageAttributes: getServiceSidePackageAttributes(), packageAttributes: getServiceSidePackageAttributes(),
session, session,
}, },
@@ -181,7 +175,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
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 getActiveColorScheme = (session: Session | null, ctx: GetServerSidePropsContext) => {
const environmentColorScheme = env.DEFAULT_COLOR_SCHEME ?? 'light'; const environmentColorScheme = env.NEXT_PUBLIC_DEFAULT_COLOR_SCHEME ?? 'light';
const cookieColorScheme = getCookie(COOKIE_COLOR_SCHEME_KEY, ctx); const cookieColorScheme = getCookie(COOKIE_COLOR_SCHEME_KEY, ctx);
const activeColorScheme = colorSchemeParser.parse( const activeColorScheme = colorSchemeParser.parse(
session?.user?.colorScheme ?? cookieColorScheme ?? environmentColorScheme session?.user?.colorScheme ?? cookieColorScheme ?? environmentColorScheme
@@ -191,5 +185,8 @@ const getActiveColorScheme = (session: Session | null, ctx: GetServerSidePropsCo
setCookie(COOKIE_COLOR_SCHEME_KEY, activeColorScheme, ctx); setCookie(COOKIE_COLOR_SCHEME_KEY, activeColorScheme, ctx);
} }
return activeColorScheme === 'environment' ? environmentColorScheme : activeColorScheme; return {
activeColorScheme: activeColorScheme,
environmentColorScheme,
};
}; };

View File

@@ -9,7 +9,7 @@ export default createNextApiHandler({
router: rootRouter, router: rootRouter,
createContext: createTRPCContext, createContext: createTRPCContext,
onError: onError:
env.NODE_ENV === 'development' env.NEXT_PUBLIC_NODE_ENV === 'development'
? ({ path, error }) => { ? ({ path, error }) => {
Consola.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`); Consola.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`);
} }

View File

@@ -20,7 +20,6 @@ import {
ThemeIcon, ThemeIcon,
Title, Title,
createStyles, createStyles,
useMantineColorScheme,
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { import {
@@ -37,6 +36,7 @@ import fs from 'fs';
import { GetServerSidePropsContext } from 'next'; import { GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useColorScheme } from '~/hooks/use-colorscheme';
import { Logo } from '../components/layout/Logo'; import { Logo } from '../components/layout/Logo';
import { usePrimaryGradient } from '../components/layout/useGradient'; import { usePrimaryGradient } from '../components/layout/useGradient';
@@ -229,7 +229,7 @@ export default function ServerError({ configs }: { configs: any }) {
} }
function SwitchToggle() { function SwitchToggle() {
const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const { colorScheme, toggleColorScheme } = useColorScheme();
const theme = useMantineTheme(); const theme = useMantineTheme();
return ( return (

View File

@@ -110,4 +110,27 @@ export const userRouter = createTRPCRouter({
}, },
}); });
}), }),
getWithSettings: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUnique({
where: {
id: ctx.session?.user?.id,
},
include: {
settings: true,
},
});
if (!user || !user.settings) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return {
id: user.id,
name: user.name,
settings: user.settings,
};
}),
}); });

View File

@@ -8,7 +8,7 @@ const globalForPrisma = globalThis as unknown as {
export const prisma = export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
log: env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log: env.NEXT_PUBLIC_NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
}); });
if (env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; if (env.NEXT_PUBLIC_NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -4,7 +4,7 @@ import packageJson from '../../../package.json';
const getServerPackageVersion = (): string | undefined => packageJson.version; const getServerPackageVersion = (): string | undefined => packageJson.version;
const getServerNodeEnvironment = () => env.NODE_ENV; const getServerNodeEnvironment = () => env.NEXT_PUBLIC_NODE_ENV;
const getDependencies = (): PackageJsonDependencies => packageJson.dependencies; const getDependencies = (): PackageJsonDependencies => packageJson.dependencies;